@fastify/secure-session
Advanced tools
Comparing version
190
index.js
@@ -11,3 +11,3 @@ 'use strict' | ||
get (target, prop) { | ||
// Calling functions eg request.session.get('key') or request.session.set('key', 'value') | ||
// Calling functions eg request[sessionName].get('key') or request[sessionName].set('key', 'value') | ||
if (typeof target[prop] === 'function') { | ||
@@ -21,3 +21,3 @@ return new Proxy(target[prop], { | ||
// accessing own properties, eg request.session.changed | ||
// accessing own properties, eg request[sessionName].changed | ||
if (Object.prototype.hasOwnProperty.call(target, prop)) { | ||
@@ -31,3 +31,3 @@ return target[prop] | ||
set (target, prop, value) { | ||
// modifying own properties, eg request.session.changed | ||
// modifying own properties, eg request[sessionName].changed | ||
if (Object.prototype.hasOwnProperty.call(target, prop)) { | ||
@@ -45,67 +45,87 @@ target[prop] = value | ||
function fastifySecureSession (fastify, options, next) { | ||
let key | ||
if (options.secret) { | ||
if (Buffer.byteLength(options.secret) < 32) { | ||
return next(new Error('secret must be at least 32 bytes')) | ||
} | ||
if (!(options instanceof Array)) { | ||
options = [options] | ||
} | ||
key = Buffer.allocUnsafe(sodium.crypto_secretbox_KEYBYTES) | ||
let defaultSessionName | ||
const sessionNames = new Map() | ||
// static salt to be used for key derivation, not great for security, | ||
// but better than nothing | ||
let salt = Buffer.from('mq9hDxBVDbspDR6nLfFT1g==', 'base64') | ||
for (const sessionOptions of options) { | ||
const sessionName = sessionOptions.sessionName || 'session' | ||
const cookieName = sessionOptions.cookieName || sessionName | ||
const cookieOptions = sessionOptions.cookieOptions || sessionOptions.cookie || {} | ||
if (options.salt) { | ||
salt = (Buffer.isBuffer(options.salt)) ? options.salt : Buffer.from(options.salt, 'ascii') | ||
} | ||
let key | ||
if (sessionOptions.secret) { | ||
if (Buffer.byteLength(sessionOptions.secret) < 32) { | ||
return next(new Error('secret must be at least 32 bytes')) | ||
} | ||
if (Buffer.byteLength(salt) !== sodium.crypto_pwhash_SALTBYTES) { | ||
return next(new Error('salt must be length ' + sodium.crypto_pwhash_SALTBYTES)) | ||
key = Buffer.allocUnsafe(sodium.crypto_secretbox_KEYBYTES) | ||
// static salt to be used for key derivation, not great for security, | ||
// but better than nothing | ||
let salt = Buffer.from('mq9hDxBVDbspDR6nLfFT1g==', 'base64') | ||
if (sessionOptions.salt) { | ||
salt = (Buffer.isBuffer(sessionOptions.salt)) ? sessionOptions.salt : Buffer.from(sessionOptions.salt, 'ascii') | ||
} | ||
if (Buffer.byteLength(salt) !== sodium.crypto_pwhash_SALTBYTES) { | ||
return next(new Error('salt must be length ' + sodium.crypto_pwhash_SALTBYTES)) | ||
} | ||
sodium.crypto_pwhash(key, | ||
Buffer.from(sessionOptions.secret), | ||
salt, | ||
sodium.crypto_pwhash_OPSLIMIT_MODERATE, | ||
sodium.crypto_pwhash_MEMLIMIT_MODERATE, | ||
sodium.crypto_pwhash_ALG_DEFAULT) | ||
} | ||
sodium.crypto_pwhash(key, | ||
Buffer.from(options.secret), | ||
salt, | ||
sodium.crypto_pwhash_OPSLIMIT_MODERATE, | ||
sodium.crypto_pwhash_MEMLIMIT_MODERATE, | ||
sodium.crypto_pwhash_ALG_DEFAULT) | ||
} | ||
if (sessionOptions.key) { | ||
key = sessionOptions.key | ||
if (typeof key === 'string') { | ||
key = Buffer.from(key, 'base64') | ||
} else if (key instanceof Array) { | ||
try { | ||
key = key.map(ensureBufferKey) | ||
} catch (error) { | ||
return next(error) | ||
} | ||
} else if (!(key instanceof Buffer)) { | ||
return next(new Error('key must be a string or a Buffer')) | ||
} | ||
if (options.key) { | ||
key = options.key | ||
if (typeof key === 'string') { | ||
key = Buffer.from(key, 'base64') | ||
} else if (key instanceof Array) { | ||
try { | ||
key = key.map(ensureBufferKey) | ||
} catch (error) { | ||
return next(error) | ||
if (!(key instanceof Array) && isBufferKeyLengthInvalid(key)) { | ||
return next(new Error(`key must be ${sodium.crypto_secretbox_KEYBYTES} bytes`)) | ||
} else if (key instanceof Array && key.every(isBufferKeyLengthInvalid)) { | ||
return next(new Error(`key lengths must be ${sodium.crypto_secretbox_KEYBYTES} bytes`)) | ||
} | ||
} else if (!(key instanceof Buffer)) { | ||
return next(new Error('key must be a string or a Buffer')) | ||
} | ||
if (!(key instanceof Array) && isBufferKeyLengthInvalid(key)) { | ||
return next(new Error(`key must be ${sodium.crypto_secretbox_KEYBYTES} bytes`)) | ||
} else if (key instanceof Array && key.every(isBufferKeyLengthInvalid)) { | ||
return next(new Error(`key lengths must be ${sodium.crypto_secretbox_KEYBYTES} bytes`)) | ||
if (!key) { | ||
return next(new Error('key or secret must specified')) | ||
} | ||
} | ||
if (!key) { | ||
return next(new Error('key or secret must specified')) | ||
} | ||
if (!(key instanceof Array)) { | ||
key = [key] | ||
} | ||
if (!(key instanceof Array)) { | ||
key = [key] | ||
} | ||
// just to add something to the shape | ||
// TODO verify if it helps the perf | ||
fastify.decorateRequest(sessionName, null) | ||
const cookieName = options.cookieName || 'session' | ||
const cookieOptions = options.cookieOptions || options.cookie || {} | ||
sessionNames.set(sessionName, { | ||
cookieName, | ||
cookieOptions, | ||
key | ||
}) | ||
// just to add something to the shape | ||
// TODO verify if it helps the perf | ||
fastify.decorateRequest('session', null) | ||
if (!defaultSessionName) { | ||
defaultSessionName = sessionName | ||
} | ||
} | ||
fastify.decorate('decodeSecureSession', (cookie, log = fastify.log) => { | ||
fastify.decorate('decodeSecureSession', (cookie, log = fastify.log, sessionName = defaultSessionName) => { | ||
if (cookie === undefined) { | ||
@@ -117,2 +137,8 @@ // there is no cookie | ||
if (!sessionNames.has(sessionName)) { | ||
throw new Error('Unknown session key.') | ||
} | ||
const { key } = sessionNames.get(sessionName) | ||
// do not use destructuring or it will deopt | ||
@@ -168,3 +194,9 @@ const split = cookie.split(';') | ||
fastify.decorate('encodeSecureSession', (session) => { | ||
fastify.decorate('encodeSecureSession', (session, sessionName = defaultSessionName) => { | ||
if (!sessionNames.has(sessionName)) { | ||
throw new Error('Unknown session key.') | ||
} | ||
const { key } = sessionNames.get(sessionName) | ||
const nonce = genNonce() | ||
@@ -194,6 +226,8 @@ const msg = Buffer.from(JSON.stringify(session[kObj])) | ||
fastify.addHook('onRequest', (request, reply, next) => { | ||
const cookie = request.cookies[cookieName] | ||
const result = fastify.decodeSecureSession(cookie, request.log) | ||
for (const [sessionName, { cookieName }] of sessionNames.entries()) { | ||
const cookie = request.cookies[cookieName] | ||
const result = fastify.decodeSecureSession(cookie, request.log, sessionName) | ||
request.session = new Proxy((result || new Session({})), sessionProxyHandler) | ||
request[sessionName] = new Proxy((result || new Session({})), sessionProxyHandler) | ||
} | ||
@@ -204,29 +238,29 @@ next() | ||
fastify.addHook('onSend', (request, reply, payload, next) => { | ||
const session = request.session | ||
for (const [sessionName, { cookieName, cookieOptions }] of sessionNames.entries()) { | ||
const session = request[sessionName] | ||
if (!session || !session.changed) { | ||
if (!session || !session.changed) { | ||
// nothing to do | ||
request.log.trace('@fastify/secure-session: there is no session or the session didn\'t change, leaving it as is') | ||
next() | ||
return | ||
} else if (session.deleted) { | ||
request.log.debug('@fastify/secure-session: deleting session') | ||
const tmpCookieOptions = Object.assign( | ||
{}, | ||
cookieOptions, | ||
session[kCookieOptions], | ||
{ expires: new Date(0), maxAge: 0 } | ||
request.log.trace('@fastify/secure-session: there is no session or the session didn\'t change, leaving it as is') | ||
continue | ||
} else if (session.deleted) { | ||
request.log.debug('@fastify/secure-session: deleting session') | ||
const tmpCookieOptions = Object.assign( | ||
{}, | ||
cookieOptions, | ||
session[kCookieOptions], | ||
{ expires: new Date(0), maxAge: 0 } | ||
) | ||
reply.setCookie(cookieName, '', tmpCookieOptions) | ||
continue | ||
} | ||
request.log.trace('@fastify/secure-session: setting session') | ||
reply.setCookie( | ||
cookieName, | ||
fastify.encodeSecureSession(session, sessionName), | ||
Object.assign({}, cookieOptions, session[kCookieOptions]) | ||
) | ||
reply.setCookie(cookieName, '', tmpCookieOptions) | ||
next() | ||
return | ||
} | ||
request.log.trace('@fastify/secure-session: setting session') | ||
reply.setCookie( | ||
cookieName, | ||
fastify.encodeSecureSession(session), | ||
Object.assign({}, cookieOptions, session[kCookieOptions]) | ||
) | ||
next() | ||
@@ -233,0 +267,0 @@ }) |
{ | ||
"name": "@fastify/secure-session", | ||
"version": "6.0.0", | ||
"version": "6.1.0", | ||
"description": "Create a secure stateless cookie session for Fastify", | ||
@@ -39,3 +39,3 @@ "main": "index.js", | ||
"tap": "^16.1.0", | ||
"tsd": "^0.25.0" | ||
"tsd": "^0.28.0" | ||
}, | ||
@@ -42,0 +42,0 @@ "dependencies": { |
@@ -34,2 +34,8 @@ # @fastify/secure-session | ||
If you don't want to use `npx`, you can still generate the `secret-key` installing the `@fastify/secure-session` library with your choice package manager, and then: | ||
```sh | ||
./node_modules/@fastify/secure-session/genkey.js > secret_key | ||
``` | ||
Then, register the plugin as follows: | ||
@@ -45,3 +51,5 @@ | ||
fastify.register(require('@fastify/secure-session'), { | ||
// the name of the session cookie, defaults to 'session' | ||
// the name of the attribute decorated on the request-object, defaults to 'session' | ||
sessionName: 'session', | ||
// the name of the session cookie, defaults to value of sessionName | ||
cookieName: 'my-session-cookie', | ||
@@ -58,2 +66,6 @@ // adapt this to point to the directory where secret-key is located | ||
request.session.set('data', request.body) | ||
// or when using a custom sessionName: | ||
request.customSessionName.set('data', request.body) | ||
reply.send('hello world') | ||
@@ -84,2 +96,31 @@ }) | ||
### Multiple sessions | ||
If you want to use multiple sessions, you have to supply an array of options when registering the plugin. It supports the same options as a single session but in this case, the `sessionName` name is mandatory. | ||
```js | ||
fastify.register(require('@fastify/secure-session'), [{ | ||
sessionName: 'mySession', | ||
cookieName: 'my-session-cookie', | ||
key: fs.readFileSync(path.join(__dirname, 'secret-key')), | ||
cookie: { | ||
path: '/' | ||
} | ||
}, { | ||
sessionName: 'myOtherSession', | ||
key: fs.readFileSync(path.join(__dirname, 'another-secret-key')), | ||
cookie: { | ||
path: '/path', | ||
maxAge: 100 | ||
} | ||
}]) | ||
fastify.post('/', (request, reply) => { | ||
request.mySession.set('data', request.body) | ||
request.myOtherSession.set('data', request.body) | ||
reply.send('hello world') | ||
}) | ||
``` | ||
### Using keys as strings | ||
@@ -258,3 +299,3 @@ | ||
// .options takes any parameter that you can pass to setCookie | ||
request.session.options({ maxAge: 1000 * 60 * 60 }) | ||
request.session.options({ maxAge: 60 * 60 }); // 3600 seconds => maxAge is always passed in seconds | ||
reply.send('hello world') | ||
@@ -279,2 +320,10 @@ }) | ||
When using multiple sessions, you will have to provide the sessionName when encoding and decoding the session. | ||
```js | ||
fastify.encodeSecureSession(request.session, 'mySecondSession') | ||
fastify.decodeSecureSession(request.cookies['session'], undefined, 'mySecondSession') | ||
``` | ||
## Add TypeScript types | ||
@@ -297,2 +346,21 @@ | ||
When using a custom sessionName or using multiple sessions the types should be configured as follows: | ||
```ts | ||
interface FooSessionData { | ||
foo: string; | ||
} | ||
declare module "fastify" { | ||
interface FastifyRequest { | ||
foo: Session<FooSessionData>; | ||
} | ||
} | ||
fastify.get('/', (request, reply) => { | ||
request.foo.get('foo'); // typed `string | undefined` | ||
reply.send('hello world') | ||
}) | ||
``` | ||
## TODO | ||
@@ -299,0 +367,0 @@ |
@@ -9,3 +9,3 @@ 'use strict' | ||
tap.test('it exposes encode and decode decorators for other libraries to use', async t => { | ||
t.plan(8) | ||
t.plan(10) | ||
@@ -29,2 +29,5 @@ const fastify = require('fastify')({ | ||
t.throws(() => fastify.encodeSecureSession(fastify.createSecureSession({ foo: 'bar' }), 'key-does-not-exist'), {}, 'Unknown session key.') | ||
t.throws(() => fastify.decodeSecureSession('bogus', undefined, 'key-does-not-exist'), {}, 'Unknown session key.') | ||
const cookie = fastify.encodeSecureSession(fastify.createSecureSession({ foo: 'bar' })) | ||
@@ -31,0 +34,0 @@ const decoded = fastify.decodeSecureSession(cookie) |
@@ -8,4 +8,4 @@ /// <reference types="node" /> | ||
createSecureSession(data?: Record<string, any>): fastifySecureSession.Session | ||
decodeSecureSession(cookie: string, log?: FastifyBaseLogger): fastifySecureSession.Session | null | ||
encodeSecureSession(session: fastifySecureSession.Session): string | ||
decodeSecureSession(cookie: string, log?: FastifyBaseLogger, sessionName?: string): fastifySecureSession.Session | null | ||
encodeSecureSession(session: fastifySecureSession.Session, sessionName?: string): string | ||
} | ||
@@ -18,11 +18,12 @@ | ||
type FastifySecureSession = FastifyPluginCallback<fastifySecureSession.SecureSessionPluginOptions>; | ||
type FastifySecureSession = FastifyPluginCallback<fastifySecureSession.SecureSessionPluginOptions | (fastifySecureSession.SecureSessionPluginOptions & Required<Pick<fastifySecureSession.SecureSessionPluginOptions, 'sessionName'>>)[]>; | ||
declare namespace fastifySecureSession { | ||
export type Session = Partial<SessionData> & { | ||
export type Session<T = SessionData> = Partial<T> & { | ||
changed: boolean; | ||
deleted: boolean; | ||
get<Key extends keyof SessionData>(key: Key): SessionData[Key] | undefined; | ||
set<Key extends keyof SessionData>(key: Key, value: SessionData[Key] | undefined): void; | ||
data(): SessionData | undefined; | ||
get<Key extends keyof T>(key: Key): T[Key] | undefined; | ||
get(key: string): any | undefined; | ||
set<Key extends keyof T>(key: Key, value: T[Key] | undefined): void; | ||
data(): T | undefined; | ||
delete(): void; | ||
@@ -39,2 +40,3 @@ options(opts: CookieSerializeOptions): void; | ||
cookieName?: string | ||
sessionName?: string | ||
} & ({ key: string | Buffer | (string | Buffer)[] } | { | ||
@@ -41,0 +43,0 @@ secret: string | Buffer, |
@@ -15,2 +15,4 @@ import SecureSessionPlugin, { Session, SessionData } from ".."; | ||
app.register(SecureSessionPlugin, { secret: "foo", salt: "bar" }); | ||
app.register(SecureSessionPlugin, { sessionName: "foo", key: "bar" }); | ||
app.register(SecureSessionPlugin, [{ sessionName: "foo", key: "bar" }, { sessionName: "bar", key: "bar" }]); | ||
@@ -23,2 +25,12 @@ declare module ".." { | ||
interface FooSessionData { | ||
foo: string; | ||
} | ||
declare module "fastify" { | ||
interface FastifyRequest { | ||
foo: Session<FooSessionData>; | ||
} | ||
} | ||
app.get("/not-websockets", async (request, reply) => { | ||
@@ -35,3 +47,9 @@ expectType<FastifyRequest>(request); | ||
request.session.delete(); | ||
request.session.options({ maxAge: 42 }); | ||
request.session.options({ maxAge: 42 }) | ||
request.foo.set("foo", "bar"); | ||
expectType<string | undefined>(request.foo.get("foo")); | ||
expectType<any>(request.foo.get("baz")); | ||
request.foo.delete(); | ||
request.foo.options({ maxAge: 42 }); | ||
}); | ||
@@ -38,0 +56,0 @@ |
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Found 1 instance in 1 package
67229
15.87%29
7.41%1736
13.46%366
22.82%0
-100%