diff --git a/README.md b/README.md index 99237d6..cb2bbab 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ app.use(session({ * **conString** - If you don't specify a pool object, use this option or `conObject` to specify a PostgreSQL connection [string](https://github.com/brianc/node-postgres/wiki/Client#new-clientstring-url-client) and this module will create a new pool for you. If the connection string is in the `DATABASE_URL` environment variable (as you do by default on eg. Heroku) – then this module fallback to that if this option is not specified. * **conObject** - If you don't specify a pool object, use this option or `conString` to specify a PostgreSQL Pool connection [object](https://github.com/brianc/node-postgres#pooling-example) and this module will create a new pool for you. * **ttl** - the time to live for the session in the database – specified in seconds. Defaults to the cookie maxAge if the cookie has a maxAge defined and otherwise defaults to one day. +* **secret** - a secret to enable transparent encryption of session data in accordance with [OWASP sessions management](https://owasp.org/www-project-cheat-sheets/cheatsheets/Session_Management_Cheat_Sheet.html). Defaults to false. * **schemaName** - if your session table is in another Postgres schema than the default (it normally isn't), then you can specify that here. * **tableName** - if your session table is named something else than `session`, then you can specify that here. * **pruneSessionInterval** - sets the delay in seconds at which expired sessions are pruned from the database. Default is `60` seconds. If set to `false` no automatic pruning will happen. By default every delay is randomized between 50% and 150% of set value, resulting in an average delay equal to the set value, but spread out to even the load on the database. Automatic pruning will happen `pruneSessionInterval` seconds after the last pruning (includes manual prunes). diff --git a/index.js b/index.js index 7765daa..6f06e71 100644 --- a/index.js +++ b/index.js @@ -49,6 +49,7 @@ const errorToCallbackAndReject = (err, fn) => { * @property {string} [schemaName] * @property {string} [tableName] * @property {number} [ttl] + * @property {false|string} [secret] * @property {typeof console.error} [errorLog] * @property {Pool} [pool] * @property {*} [pgPromise] @@ -86,6 +87,11 @@ module.exports = function (session) { this.ttl = options.ttl; + if (options.secret) { + this.secret = options.secret; + this.kruptein = require('kruptein')(options); + } + this.errorLog = options.errorLog || console.error.bind(console); if (options.pool !== undefined) { @@ -281,6 +287,12 @@ module.exports = function (session) { if (err) { return fn(err); } if (!data) { return fn(); } try { + if (this.secret) { + this.kruptein.get(this.secret, JSON.stringify(data.sess), (err, pt) => { + if (err) return fn(err); + data.sess = JSON.parse(pt); + }); + } return fn(null, (typeof data.sess === 'string') ? JSON.parse(data.sess) : data.sess); } catch (e) { return this.destroy(sid, fn); @@ -299,8 +311,13 @@ module.exports = function (session) { set (sid, sess, fn) { const expireTime = this.getExpireTime(sess.cookie.maxAge); + if (this.secret) { + this.kruptein.set(this.secret, sess, (err, ct) => { + if (err) return fn(err); + sess = ct; + }); + } const query = 'INSERT INTO ' + this.quotedTable() + ' (sess, expire, sid) SELECT $1, to_timestamp($2), $3 ON CONFLICT (sid) DO UPDATE SET sess=$1, expire=to_timestamp($2) RETURNING sid'; - this.query(query, [sess, expireTime, sid], function (err) { if (fn) { fn(err); } fn(); diff --git a/package.json b/package.json index 83344ee..fc4458b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "license": "MIT", "dependencies": { "@types/pg": "^7.14.1", + "kruptein": "^2.0.6", "pg": "^7.4.3" }, "engines": { diff --git a/test/integration/crypto.express.spec.js b/test/integration/crypto.express.spec.js new file mode 100644 index 0000000..0be3727 --- /dev/null +++ b/test/integration/crypto.express.spec.js @@ -0,0 +1,146 @@ +// @ts-check + +'use strict'; + +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); +const sinon = require('sinon'); +const request = require('supertest'); + +chai.use(chaiAsPromised); +chai.should(); + +describe('Express w/ crypto', function () { + const express = require('express'); + const session = require('express-session'); + const Cookie = require('cookiejar').Cookie; + const signature = require('cookie-signature'); + + const connectPgSimple = require('../../'); + const dbUtils = require('../db-utils'); + const conObject = dbUtils.conObject; + const queryPromise = dbUtils.queryPromise; + + const secret = 'abc123'; + const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days + + const appSetup = (store) => { + const app = express(); + + app.use(session({ + store, + secret, + resave: false, + saveUninitialized: true, + cookie: { maxAge } + })); + + app.get('/', (req, res) => { + res.send('Hello World!'); + }); + + return app; + }; + + beforeEach(() => { + return dbUtils.removeTables() + .then(() => dbUtils.initTables()); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('main', function () { + it('should generate a token', () => { + const store = new (connectPgSimple(session))({ conObject, secret: 'squirrel' }); + const app = appSetup(store); + + return queryPromise('SELECT COUNT(sid) FROM session') + .should.eventually.have.nested.property('rows[0].count', '0') + .then(() => request(app) + .get('/') + .expect(200) + ) + .then(() => queryPromise('SELECT COUNT(sid) FROM session')) + .should.eventually.have.nested.property('rows[0].count', '1'); + }); + + it('should return the token it generates', () => { + const store = new (connectPgSimple(session))({ conObject, secret: 'squirrel' }); + const app = appSetup(store); + + return request(app) + .get('/') + .then(res => { + const sessionCookie = new Cookie(res.header['set-cookie'][0]); + const cookieValue = decodeURIComponent(sessionCookie.value); + + cookieValue.substr(0, 2).should.equal('s:'); + + return signature.unsign(cookieValue.substr(2), secret); + }) + .then(decodedCookie => queryPromise('SELECT sid FROM session WHERE sid = $1', [decodedCookie])) + .should.eventually.have.nested.property('rowCount', 1); + }); + + it('should reuse existing session when given a cookie', () => { + const store = new (connectPgSimple(session))({ conObject, secret: 'squirrel' }); + const app = appSetup(store); + const agent = request.agent(app); + + return queryPromise('SELECT COUNT(sid) FROM session') + .should.eventually.have.nested.property('rows[0].count', '0') + .then(() => agent.get('/')) + .then(() => queryPromise('SELECT COUNT(sid) FROM session')) + .should.eventually.have.nested.property('rows[0].count', '1') + .then(() => agent.get('/').expect(200)) + .then(() => queryPromise('SELECT COUNT(sid) FROM session')) + .should.eventually.have.nested.property('rows[0].count', '1'); + }); + + it('should not reuse existing session when not given a cookie', () => { + const store = new (connectPgSimple(session))({ conObject, secret: 'squirrel' }); + const app = appSetup(store); + + return queryPromise('SELECT COUNT(sid) FROM session') + .should.eventually.have.nested.property('rows[0].count', '0') + .then(() => request(app).get('/')) + .then(() => queryPromise('SELECT COUNT(sid) FROM session')) + .should.eventually.have.nested.property('rows[0].count', '1') + .then(() => request(app).get('/').expect(200)) + .then(() => queryPromise('SELECT COUNT(sid) FROM session')) + .should.eventually.have.nested.property('rows[0].count', '2'); + }); + + it('should invalidate a too old token', () => { + const store = new (connectPgSimple(session))({ conObject, pruneSessionInterval: false, secret: 'squirrel' }); + const app = appSetup(store); + const agent = request.agent(app); + + const clock = sinon.useFakeTimers(Date.now()); + + return queryPromise('SELECT COUNT(sid) FROM session') + .should.eventually.have.nested.property('rows[0].count', '0') + .then(() => Promise.all([ + request(app).get('/'), + agent.get('/') + ])) + .then(() => queryPromise('SELECT COUNT(sid) FROM session')) + .should.eventually.have.nested.property('rows[0].count', '2') + .then(() => { + clock.tick(maxAge * 0.6); + return new Promise((resolve, reject) => store.pruneSessions(err => err ? reject(err) : resolve())); + }) + .then(() => queryPromise('SELECT COUNT(sid) FROM session')) + .should.eventually.have.nested.property('rows[0].count', '2') + .then(() => agent.get('/').expect(200)) + .then(() => { + clock.tick(maxAge * 0.6); + return new Promise((resolve, reject) => store.pruneSessions(err => err ? reject(err) : resolve())); + }) + .then(() => queryPromise('SELECT COUNT(sid) FROM session')) + .should.eventually.have.nested.property('rows[0].count', '1'); + }); + }); +}); diff --git a/test/integration/crypto.pgpromise.spec.js b/test/integration/crypto.pgpromise.spec.js new file mode 100644 index 0000000..e88e63d --- /dev/null +++ b/test/integration/crypto.pgpromise.spec.js @@ -0,0 +1,115 @@ +// @ts-check + +'use strict'; + +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); +const sinon = require('sinon'); +const request = require('supertest'); + +chai.use(chaiAsPromised); +chai.should(); + +describe('pgPromise w/ crypto', function () { + const express = require('express'); + const session = require('express-session'); + const pgp = require('pg-promise')(); + + const connectPgSimple = require('../../'); + const dbUtils = require('../db-utils'); + const conObject = dbUtils.conObject; + const queryPromise = dbUtils.queryPromise; + + const secret = 'abc123'; + const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days + + const pgPromise = pgp(conObject); + + const appSetup = (store) => { + const app = express(); + + app.use(session({ + store, + secret, + resave: false, + saveUninitialized: true, + cookie: { maxAge } + })); + + app.get('/', (req, res) => { + res.send('Hello World!'); + }); + + return app; + }; + + beforeEach(() => { + return dbUtils.removeTables() + .then(() => dbUtils.initTables()); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('main', function () { + it('should generate a token', () => { + const store = new (connectPgSimple(session))({ pgPromise, secret: 'squirrel' }); + const app = appSetup(store); + + return queryPromise('SELECT COUNT(sid) FROM session') + .should.eventually.have.nested.property('rows[0].count', '0') + .then(() => request(app) + .get('/') + .expect(200) + ) + .then(() => queryPromise('SELECT COUNT(sid) FROM session')) + .should.eventually.have.nested.property('rows[0].count', '1'); + }); + + it('should reuse existing session when given a cookie', () => { + const store = new (connectPgSimple(session))({ pgPromise, secret: 'squirrel' }); + const app = appSetup(store); + const agent = request.agent(app); + + return queryPromise('SELECT COUNT(sid) FROM session') + .should.eventually.have.nested.property('rows[0].count', '0') + .then(() => agent.get('/')) + .then(() => queryPromise('SELECT COUNT(sid) FROM session')) + .should.eventually.have.nested.property('rows[0].count', '1') + .then(() => agent.get('/').expect(200)) + .then(() => queryPromise('SELECT COUNT(sid) FROM session')) + .should.eventually.have.nested.property('rows[0].count', '1'); + }); + + it('should invalidate a too old token', () => { + const store = new (connectPgSimple(session))({ pgPromise, pruneSessionInterval: false, secret: 'squirrel' }); + const app = appSetup(store); + const agent = request.agent(app); + + const clock = sinon.useFakeTimers(Date.now()); + + return queryPromise('SELECT COUNT(sid) FROM session') + .should.eventually.have.nested.property('rows[0].count', '0') + .then(() => Promise.all([ + request(app).get('/'), + agent.get('/') + ])) + .then(() => queryPromise('SELECT COUNT(sid) FROM session')) + .should.eventually.have.nested.property('rows[0].count', '2') + .then(() => { + clock.tick(maxAge * 0.6); + return new Promise((resolve, reject) => store.pruneSessions(err => err ? reject(err) : resolve())); + }) + .then(() => queryPromise('SELECT COUNT(sid) FROM session')) + .should.eventually.have.nested.property('rows[0].count', '2') + .then(() => agent.get('/').expect(200)) + .then(() => { + clock.tick(maxAge * 0.6); + return new Promise((resolve, reject) => store.pruneSessions(err => err ? reject(err) : resolve())); + }) + .then(() => queryPromise('SELECT COUNT(sid) FROM session')) + .should.eventually.have.nested.property('rows[0].count', '1'); + }); + }); +});