Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
…ssion into next
  • Loading branch information
gurgunday committed Apr 10, 2024
2 parents 28ce6c7 + 49252b0 commit 047c973
Show file tree
Hide file tree
Showing 8 changed files with 517 additions and 13 deletions.
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ fastify.register(require('@fastify/secure-session'), {
cookieName: 'my-session-cookie',
// adapt this to point to the directory where secret-key is located
key: fs.readFileSync(path.join(__dirname, 'secret-key')),
// the amount of time the session is considered valid; this is different from the cookie options
// and based on value wihin the session.
expiry: 24 * 60 * 60, // Default 1 day
cookie: {
path: '/'
// options for setCookie, see https://github.com/fastify/fastify-cookie
Expand Down Expand Up @@ -365,9 +368,12 @@ fastify.get('/', (request, reply) => {
})
```

## TODO
## Security Notice

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

## License

Expand Down
49 changes: 40 additions & 9 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,20 @@ function fastifySecureSession (fastify, options, next) {
for (const sessionOptions of options) {
const sessionName = sessionOptions.sessionName || 'session'
const cookieName = sessionOptions.cookieName || sessionName
const expiry = sessionOptions.expiry || 86401 // 24 hours
const cookieOptions = sessionOptions.cookieOptions || sessionOptions.cookie || {}

if (cookieOptions.httpOnly === undefined) {
cookieOptions.httpOnly = true
}

let key
if (sessionOptions.secret) {

if (sessionOptions.secret && !sessionOptions.key) {
if (Buffer.byteLength(sessionOptions.secret) < 32) {
return next(new Error('secret must be at least 32 bytes'))
}

if (!defaultSecret) {
defaultSecret = sessionOptions.secret
}

key = Buffer.allocUnsafe(sodium.crypto_secretbox_KEYBYTES)

// static salt to be used for key derivation, not great for security,
Expand All @@ -82,6 +84,8 @@ function fastifySecureSession (fastify, options, next) {
sodium.crypto_pwhash_OPSLIMIT_MODERATE,
sodium.crypto_pwhash_MEMLIMIT_MODERATE,
sodium.crypto_pwhash_ALG_DEFAULT)

defaultSecret = sessionOptions.secret
}

if (sessionOptions.key) {
Expand All @@ -103,6 +107,16 @@ function fastifySecureSession (fastify, options, next) {
} else if (Array.isArray(key) && key.every(isBufferKeyLengthInvalid)) {
return next(new Error(`key lengths must be ${sodium.crypto_secretbox_KEYBYTES} bytes`))
}

const outputHash = Buffer.alloc(sodium.crypto_generichash_BYTES)

if (Array.isArray(key)) {
sodium.crypto_generichash(outputHash, key[0])
} else {
sodium.crypto_generichash(outputHash, key)
}

defaultSecret = outputHash.toString('hex')
}

if (!key) {
Expand All @@ -120,7 +134,8 @@ function fastifySecureSession (fastify, options, next) {
sessionNames.set(sessionName, {
cookieName,
cookieOptions,
key
key,
expiry
})

if (!defaultSessionName) {
Expand All @@ -139,7 +154,7 @@ function fastifySecureSession (fastify, options, next) {
throw new Error('Unknown session key.')
}

const { key } = sessionNames.get(sessionName)
const { key, expiry } = sessionNames.get(sessionName)

// do not use destructuring or it will deopt
const split = cookie.split(';')
Expand Down Expand Up @@ -184,8 +199,15 @@ function fastifySecureSession (fastify, options, next) {
return null
}

const parsed = JSON.parse(msg)
if ((parsed.__ts + expiry) * 1000 - Date.now() <= 0) {
// maximum validity is reached, resetting
log.debug('@fastify/secure-session: expiry reached')
return null
}
const session = new Proxy(new Session(JSON.parse(msg)), sessionProxyHandler)
session.changed = signingKeyRotated

return session
})

Expand Down Expand Up @@ -228,7 +250,7 @@ function fastifySecureSession (fastify, options, next) {
const cookie = request.cookies[cookieName]
const result = fastify.decodeSecureSession(cookie, request.log, sessionName)

request[sessionName] = new Proxy((result || new Session({})), sessionProxyHandler)
request[sessionName] = result || new Proxy(new Session({}), sessionProxyHandler)
}

next()
Expand Down Expand Up @@ -275,6 +297,10 @@ class Session {
this[kCookieOptions] = null
this.changed = false
this.deleted = false

if (this[kObj].__ts === undefined) {
this[kObj].__ts = Math.round(Date.now() / 1000)
}
}

get (key) {
Expand All @@ -296,7 +322,12 @@ class Session {
}

data () {
return this[kObj]
const copy = {
...this[kObj]
}

delete copy.__ts
return copy
}

touch () {
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fastify/secure-session",
"version": "7.2.0",
"version": "7.4.0",
"description": "Create a secure stateless cookie session for Fastify",
"main": "index.js",
"type": "commonjs",
Expand Down Expand Up @@ -32,12 +32,13 @@
"homepage": "https://github.com/fastify/fastify-secure-session#readme",
"devDependencies": {
"@fastify/pre-commit": "^2.1.0",
"@sinonjs/fake-timers": "^11.2.2",
"@types/node": "^20.11.30",
"cookie": "^0.6.0",
"fastify": "^4.26.2",
"standard": "^17.1.0",
"tap": "^18.7.1",
"tsd": "^0.30.7"
"tsd": "^0.31.0"
},
"dependencies": {
"@fastify/cookie": "^9.3.1",
Expand Down
62 changes: 62 additions & 0 deletions test/anti-reuse-15-min.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use strict'

const t = require('tap')
const fastify = require('fastify')({ logger: false })
const sodium = require('sodium-native')
const FakeTimers = require('@sinonjs/fake-timers')
const clock = FakeTimers.install({
shouldAdvanceTime: true,
now: Date.now()
})

const key = Buffer.alloc(sodium.crypto_secretbox_KEYBYTES)
sodium.randombytes_buf(key)

fastify.register(require('../'), {
key,
expiry: 15 * 60 // 15 minutes
})

fastify.post('/', (request, reply) => {
request.session.set('some', request.body.some)
request.session.set('some2', request.body.some2)
reply.send('hello world')
})

t.teardown(fastify.close.bind(fastify))
t.plan(5)

fastify.get('/', (request, reply) => {
const some = request.session.get('some')
const some2 = request.session.get('some2')
reply.send({ some, some2 })
})

fastify.inject({
method: 'POST',
url: '/',
payload: {
some: 'someData',
some2: { a: 1, b: undefined, c: 3 }
}
}, (error, response) => {
t.error(error)
t.equal(response.statusCode, 200)
t.ok(response.headers['set-cookie'])

clock.jump('00:15:01') // default validity is 24 hours

fastify.inject({
method: 'GET',
url: '/',
headers: {
cookie: response.headers['set-cookie']
}
}, (error, response) => {
t.error(error)
t.same(JSON.parse(response.payload), {})
clock.reset()
clock.uninstall()
fastify.close()
})
})
62 changes: 62 additions & 0 deletions test/anti-reuse.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use strict'

const t = require('tap')
const fastify = require('fastify')({ logger: false })
const sodium = require('sodium-native')
const FakeTimers = require('@sinonjs/fake-timers')
const clock = FakeTimers.install({
shouldAdvanceTime: true,
now: Date.now()
})

const key = Buffer.alloc(sodium.crypto_secretbox_KEYBYTES)

sodium.randombytes_buf(key)

fastify.register(require('../'), {
key
})

fastify.post('/', (request, reply) => {
request.session.set('some', request.body.some)
request.session.set('some2', request.body.some2)
reply.send('hello world')
})

t.teardown(fastify.close.bind(fastify))
t.plan(6)

fastify.get('/', (request, reply) => {
const some = request.session.get('some')
const some2 = request.session.get('some2')
reply.send({ some, some2 })
})

fastify.inject({
method: 'POST',
url: '/',
payload: {
some: 'someData',
some2: { a: 1, b: undefined, c: 3 }
}
}, (error, response) => {
t.error(error)
t.equal(response.statusCode, 200)
t.ok(response.headers['set-cookie'])
t.equal(response.headers['set-cookie'].split(';')[1].trim(), 'HttpOnly')

clock.jump('24:01:00') // default validity is 24 hours

fastify.inject({
method: 'GET',
url: '/',
headers: {
cookie: response.headers['set-cookie']
}
}, (error, response) => {
t.error(error)
t.same(JSON.parse(response.payload), {})
clock.reset()
clock.uninstall()
})
})
96 changes: 96 additions & 0 deletions test/http-only.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
'use strict'

const tap = require('tap')
const Fastify = require('fastify')
const SecureSessionPlugin = require('../')
const sodium = require('sodium-native')
const key = Buffer.alloc(sodium.crypto_secretbox_KEYBYTES)
sodium.randombytes_buf(key)

tap.test('http-only override', async t => {
const fastify = Fastify({ logger: false })
t.teardown(fastify.close.bind(fastify))
t.plan(3)

await fastify.register(SecureSessionPlugin, {
key,
cookie: {
path: '/',
httpOnly: false
}
})

fastify.post('/login', (request, reply) => {
request.session.set('user', request.body.email)
reply.send('Welcome back!')
})

const loginResponse = await fastify.inject({
method: 'POST',
url: '/login',
payload: {
email: 'me@here.fine'
}
})

t.equal(loginResponse.statusCode, 200)
t.ok(loginResponse.headers['set-cookie'])
t.not(loginResponse.headers['set-cookie'].split(';')[1].trim(), 'HttpOnly')
})

tap.test('Override global options does not change httpOnly default', t => {
t.plan(8)
const fastify = Fastify()
fastify.register(SecureSessionPlugin, {
key,
cookieOptions: {
maxAge: 42,
path: '/'
}
})

fastify.post('/', (request, reply) => {
request.session.set('data', request.body)
request.session.options({ maxAge: 1000 * 60 * 60 })
reply.send('hello world')
})

t.teardown(fastify.close.bind(fastify))

fastify.get('/', (request, reply) => {
const data = request.session.get('data')

if (!data) {
reply.code(404).send()
return
}
reply.send(data)
})

fastify.inject({
method: 'POST',
url: '/',
payload: {
some: 'data'
}
}, (error, response) => {
t.error(error)
t.equal(response.statusCode, 200)
t.ok(response.headers['set-cookie'])
const { maxAge, path } = response.cookies[0]
t.equal(maxAge, 1000 * 60 * 60)
t.equal(response.headers['set-cookie'].split(';')[3].trim(), 'HttpOnly')
t.equal(path, '/')

fastify.inject({
method: 'GET',
url: '/',
headers: {
cookie: response.headers['set-cookie']
}
}, (error, response) => {
t.error(error)
t.same(JSON.parse(response.payload), { some: 'data' })
})
})
})

0 comments on commit 047c973

Please sign in to comment.