Skip to content

Commit 56d6664

Browse files
mcollinajsumnersEomm
authored
Merge pull request from GHSA-9wwp-q7wq-jx35
* Add anti session theft provisions Signed-off-by: Matteo Collina <hello@matteocollina.com> * Update README.md Co-authored-by: James Sumners <321201+jsumners@users.noreply.github.com> Signed-off-by: Matteo Collina <matteo.collina@gmail.com> * Apply suggestions from code review Signed-off-by: Manuel Spigolon <behemoth89@gmail.com> --------- Signed-off-by: Matteo Collina <hello@matteocollina.com> Signed-off-by: Matteo Collina <matteo.collina@gmail.com> Signed-off-by: Manuel Spigolon <behemoth89@gmail.com> Co-authored-by: James Sumners <321201+jsumners@users.noreply.github.com> Co-authored-by: Manuel Spigolon <behemoth89@gmail.com>
1 parent 7b6eebe commit 56d6664

File tree

6 files changed

+255
-6
lines changed

6 files changed

+255
-6
lines changed

README.md

+8-2
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ fastify.register(require('@fastify/secure-session'), {
5353
cookieName: 'my-session-cookie',
5454
// adapt this to point to the directory where secret-key is located
5555
key: fs.readFileSync(path.join(__dirname, 'secret-key')),
56+
// the amount of time the session is considered valid; this is different from the cookie options
57+
// and based on value wihin the session.
58+
expiry: 24 * 60 * 60, // Default 1 day
5659
cookie: {
5760
path: '/'
5861
// options for setCookie, see https://github.com/fastify/fastify-cookie
@@ -365,9 +368,12 @@ fastify.get('/', (request, reply) => {
365368
})
366369
```
367370

368-
## TODO
371+
## Security Notice
369372

370-
- [ ] add an option to just sign, and do not encrypt
373+
`@fastify/secure-session` stores the session within a cookie, and as a result an attacker could impersonate a user
374+
if the cookie is leaked. The maximum expiration time of the session is set by the `expiry` option, which has default
375+
1 day. Adjust this parameter accordingly.
376+
Moreover, to protect users from further attacks, all cookies are created as "http only" if not specified otherwise.
371377

372378
## License
373379

index.js

+26-4
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,13 @@ function fastifySecureSession (fastify, options, next) {
5050
for (const sessionOptions of options) {
5151
const sessionName = sessionOptions.sessionName || 'session'
5252
const cookieName = sessionOptions.cookieName || sessionName
53+
const expiry = sessionOptions.expiry || 86401 // 24 hours
5354
const cookieOptions = sessionOptions.cookieOptions || sessionOptions.cookie || {}
5455

56+
if (cookieOptions.httpOnly === undefined) {
57+
cookieOptions.httpOnly = true
58+
}
59+
5560
let key
5661
if (sessionOptions.secret) {
5762
if (Buffer.byteLength(sessionOptions.secret) < 32) {
@@ -120,7 +125,8 @@ function fastifySecureSession (fastify, options, next) {
120125
sessionNames.set(sessionName, {
121126
cookieName,
122127
cookieOptions,
123-
key
128+
key,
129+
expiry
124130
})
125131

126132
if (!defaultSessionName) {
@@ -139,7 +145,7 @@ function fastifySecureSession (fastify, options, next) {
139145
throw new Error('Unknown session key.')
140146
}
141147

142-
const { key } = sessionNames.get(sessionName)
148+
const { key, expiry } = sessionNames.get(sessionName)
143149

144150
// do not use destructuring or it will deopt
145151
const split = cookie.split(';')
@@ -184,8 +190,15 @@ function fastifySecureSession (fastify, options, next) {
184190
return null
185191
}
186192

193+
const parsed = JSON.parse(msg)
194+
if ((parsed.__ts + expiry) * 1000 - Date.now() <= 0) {
195+
// maximum validity is reached, resetting
196+
log.debug('@fastify/secure-session: expiry reached')
197+
return null
198+
}
187199
const session = new Proxy(new Session(JSON.parse(msg)), sessionProxyHandler)
188200
session.changed = signingKeyRotated
201+
189202
return session
190203
})
191204

@@ -228,7 +241,7 @@ function fastifySecureSession (fastify, options, next) {
228241
const cookie = request.cookies[cookieName]
229242
const result = fastify.decodeSecureSession(cookie, request.log, sessionName)
230243

231-
request[sessionName] = new Proxy((result || new Session({})), sessionProxyHandler)
244+
request[sessionName] = result || new Proxy(new Session({}), sessionProxyHandler)
232245
}
233246

234247
next()
@@ -275,6 +288,10 @@ class Session {
275288
this[kCookieOptions] = null
276289
this.changed = false
277290
this.deleted = false
291+
292+
if (this[kObj].__ts === undefined) {
293+
this[kObj].__ts = Math.round(Date.now() / 1000)
294+
}
278295
}
279296

280297
get (key) {
@@ -296,7 +313,12 @@ class Session {
296313
}
297314

298315
data () {
299-
return this[kObj]
316+
const copy = {
317+
...this[kObj]
318+
}
319+
320+
delete copy.__ts
321+
return copy
300322
}
301323

302324
touch () {

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"homepage": "https://github.com/fastify/fastify-secure-session#readme",
3333
"devDependencies": {
3434
"@fastify/pre-commit": "^2.0.2",
35+
"@sinonjs/fake-timers": "^11.2.2",
3536
"@types/node": "^20.1.0",
3637
"cookie": "^0.6.0",
3738
"fastify": "^4.0.0",

test/anti-reuse-15-min.js

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
'use strict'
2+
3+
const t = require('tap')
4+
const fastify = require('fastify')({ logger: false })
5+
const sodium = require('sodium-native')
6+
const FakeTimers = require('@sinonjs/fake-timers')
7+
const clock = FakeTimers.install({
8+
shouldAdvanceTime: true,
9+
now: Date.now()
10+
})
11+
12+
const key = Buffer.alloc(sodium.crypto_secretbox_KEYBYTES)
13+
sodium.randombytes_buf(key)
14+
15+
fastify.register(require('../'), {
16+
key,
17+
expiry: 15 * 60 // 15 minutes
18+
})
19+
20+
fastify.post('/', (request, reply) => {
21+
request.session.set('some', request.body.some)
22+
request.session.set('some2', request.body.some2)
23+
reply.send('hello world')
24+
})
25+
26+
t.teardown(fastify.close.bind(fastify))
27+
t.plan(5)
28+
29+
fastify.get('/', (request, reply) => {
30+
const some = request.session.get('some')
31+
const some2 = request.session.get('some2')
32+
reply.send({ some, some2 })
33+
})
34+
35+
fastify.inject({
36+
method: 'POST',
37+
url: '/',
38+
payload: {
39+
some: 'someData',
40+
some2: { a: 1, b: undefined, c: 3 }
41+
}
42+
}, (error, response) => {
43+
t.error(error)
44+
t.equal(response.statusCode, 200)
45+
t.ok(response.headers['set-cookie'])
46+
47+
clock.jump('00:15:01') // default validity is 24 hours
48+
49+
fastify.inject({
50+
method: 'GET',
51+
url: '/',
52+
headers: {
53+
cookie: response.headers['set-cookie']
54+
}
55+
}, (error, response) => {
56+
t.error(error)
57+
t.same(JSON.parse(response.payload), {})
58+
clock.reset()
59+
clock.uninstall()
60+
fastify.close()
61+
})
62+
})

test/anti-reuse.js

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
'use strict'
2+
3+
const t = require('tap')
4+
const fastify = require('fastify')({ logger: false })
5+
const sodium = require('sodium-native')
6+
const FakeTimers = require('@sinonjs/fake-timers')
7+
const clock = FakeTimers.install({
8+
shouldAdvanceTime: true,
9+
now: Date.now()
10+
})
11+
12+
const key = Buffer.alloc(sodium.crypto_secretbox_KEYBYTES)
13+
14+
sodium.randombytes_buf(key)
15+
16+
fastify.register(require('../'), {
17+
key
18+
})
19+
20+
fastify.post('/', (request, reply) => {
21+
request.session.set('some', request.body.some)
22+
request.session.set('some2', request.body.some2)
23+
reply.send('hello world')
24+
})
25+
26+
t.teardown(fastify.close.bind(fastify))
27+
t.plan(6)
28+
29+
fastify.get('/', (request, reply) => {
30+
const some = request.session.get('some')
31+
const some2 = request.session.get('some2')
32+
reply.send({ some, some2 })
33+
})
34+
35+
fastify.inject({
36+
method: 'POST',
37+
url: '/',
38+
payload: {
39+
some: 'someData',
40+
some2: { a: 1, b: undefined, c: 3 }
41+
}
42+
}, (error, response) => {
43+
t.error(error)
44+
t.equal(response.statusCode, 200)
45+
t.ok(response.headers['set-cookie'])
46+
t.equal(response.headers['set-cookie'].split(';')[1].trim(), 'HttpOnly')
47+
48+
clock.jump('24:01:00') // default validity is 24 hours
49+
50+
fastify.inject({
51+
method: 'GET',
52+
url: '/',
53+
headers: {
54+
cookie: response.headers['set-cookie']
55+
}
56+
}, (error, response) => {
57+
t.error(error)
58+
t.same(JSON.parse(response.payload), {})
59+
clock.reset()
60+
clock.uninstall()
61+
})
62+
})

test/http-only.js

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
'use strict'
2+
3+
const tap = require('tap')
4+
const Fastify = require('fastify')
5+
const SecureSessionPlugin = require('../')
6+
const sodium = require('sodium-native')
7+
const key = Buffer.alloc(sodium.crypto_secretbox_KEYBYTES)
8+
sodium.randombytes_buf(key)
9+
10+
tap.test('http-only override', async t => {
11+
const fastify = Fastify({ logger: false })
12+
t.teardown(fastify.close.bind(fastify))
13+
t.plan(3)
14+
15+
await fastify.register(SecureSessionPlugin, {
16+
key,
17+
cookie: {
18+
path: '/',
19+
httpOnly: false
20+
}
21+
})
22+
23+
fastify.post('/login', (request, reply) => {
24+
request.session.set('user', request.body.email)
25+
reply.send('Welcome back!')
26+
})
27+
28+
const loginResponse = await fastify.inject({
29+
method: 'POST',
30+
url: '/login',
31+
payload: {
32+
email: 'me@here.fine'
33+
}
34+
})
35+
36+
t.equal(loginResponse.statusCode, 200)
37+
t.ok(loginResponse.headers['set-cookie'])
38+
t.not(loginResponse.headers['set-cookie'].split(';')[1].trim(), 'HttpOnly')
39+
})
40+
41+
tap.test('Override global options does not change httpOnly default', t => {
42+
t.plan(8)
43+
const fastify = Fastify()
44+
fastify.register(SecureSessionPlugin, {
45+
key,
46+
cookieOptions: {
47+
maxAge: 42,
48+
path: '/'
49+
}
50+
})
51+
52+
fastify.post('/', (request, reply) => {
53+
request.session.set('data', request.body)
54+
request.session.options({ maxAge: 1000 * 60 * 60 })
55+
reply.send('hello world')
56+
})
57+
58+
t.teardown(fastify.close.bind(fastify))
59+
60+
fastify.get('/', (request, reply) => {
61+
const data = request.session.get('data')
62+
63+
if (!data) {
64+
reply.code(404).send()
65+
return
66+
}
67+
reply.send(data)
68+
})
69+
70+
fastify.inject({
71+
method: 'POST',
72+
url: '/',
73+
payload: {
74+
some: 'data'
75+
}
76+
}, (error, response) => {
77+
t.error(error)
78+
t.equal(response.statusCode, 200)
79+
t.ok(response.headers['set-cookie'])
80+
const { maxAge, path } = response.cookies[0]
81+
t.equal(maxAge, 1000 * 60 * 60)
82+
t.equal(response.headers['set-cookie'].split(';')[3].trim(), 'HttpOnly')
83+
t.equal(path, '/')
84+
85+
fastify.inject({
86+
method: 'GET',
87+
url: '/',
88+
headers: {
89+
cookie: response.headers['set-cookie']
90+
}
91+
}, (error, response) => {
92+
t.error(error)
93+
t.same(JSON.parse(response.payload), { some: 'data' })
94+
})
95+
})
96+
})

0 commit comments

Comments
 (0)