Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

crypto: transparent encryption of session data #166

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
19 changes: 18 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"license": "MIT",
"dependencies": {
"@types/pg": "^7.14.1",
"kruptein": "^2.0.6",
"pg": "^7.4.3"
},
"engines": {
Expand Down
146 changes: 146 additions & 0 deletions test/integration/crypto.express.spec.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
115 changes: 115 additions & 0 deletions test/integration/crypto.pgpromise.spec.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});