diff --git a/README.md b/README.md index 57c5361..ea7f4c3 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ app.use(session({ get a stale entry, as if it had already been deleted. * `noDisposeOnSet` By default, if you set a `dispose()` method, then it'll be called whenever a `set()` operation overwrites an existing key. If you set this option, `dispose()` will only be called when a key falls out of the cache, not when it is overwritten. * `serializer` An object containing `stringify` and `parse` methods compatible with Javascript's `JSON` to override the serializer used. +* `secret` Enables transparent encryption of sessions to support [OWASP session management](https://owasp.org/www-project-cheat-sheets/cheatsheets/Session_Management_Cheat_Sheet.html) recommendations ## Methods diff --git a/lib/memorystore.js b/lib/memorystore.js index 1bdba87..9640b67 100644 --- a/lib/memorystore.js +++ b/lib/memorystore.js @@ -74,6 +74,11 @@ module.exports = function (session) { this.options.stale = options.stale this.options.noDisposeOnSet = options.noDisposeOnSet + if (options.secret) { + this.secret = options.secret + this.kruptein = require('kruptein')(options) + } + this.serializer = options.serializer || JSON this.store = LRU(this.options) debug('Init MemoryStore') @@ -106,6 +111,16 @@ module.exports = function (session) { debug('GOT %s', data) var err = null var result + + if (this.secret) { + this.kruptein.get(this.secret, data, function(err, ct) { + if (err) + return fn(err) + + data = JSON.parse(ct) + }) + } + try { result = this.serializer.parse(data) } catch (er) { @@ -134,6 +149,15 @@ module.exports = function (session) { fn && defer(fn, err) } + if (this.secret) { + this.kruptein.set(this.secret, jsess, function(err, ct) { + if (err) + return fn(err) + + jsess = ct + }) + } + store.set(sid, jsess, ttl) debug('SET "%s" %s ttl:%s', sid, jsess, ttl) fn && defer(fn, null) @@ -176,16 +200,44 @@ module.exports = function (session) { var ttl = getTTL(this.options, sess, sid) debug('EXPIRE "%s" ttl:%s', sid, ttl) + + var data = store.get(sid) + if (!data) return fn() + var err = null - if (store.get(sid) !== undefined) { - try { - var s = this.serializer.parse(store.get(sid)) - s.cookie = sess.cookie - store.set(sid, this.serializer.stringify(s), ttl) - } catch (e) { - err = e - } + + if (store.secret) { + store.kruptein.get(store.secret, data, function(err, ct) { + if (err) + return fn(err) + + data = ct + }) } + + try { + var s = this.serializer.parse(data) + s.cookie = sess.cookie + store.set(sid, this.serializer.stringify(s), ttl) + } catch (e) { + err = e + } + + if (store.secret) { + store.kruptein.set(store.secret, s, function(err, ct) { + if (err) + return fn(err) + + s = ct + }) + } + + try { + store.set(sid, this.serializer.stringify(s), ttl) + } catch (e) { + err = e + } + fn && defer(fn, err) } @@ -220,6 +272,15 @@ module.exports = function (session) { var result = {} try { store.forEach(function (val, key) { + if (self.secret) { + self.kruptein.get(self.secret, val, function(err, ct) { + if (err) + return fn(err) + + val = JSON.parse(ct) + }) + } + result[key] = self.serializer.parse(val) }) } catch (e) { diff --git a/package.json b/package.json index 3448900..b54393f 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "homepage": "https://github.com/roccomuso/memorystore#readme", "dependencies": { "debug": "3.1.0", + "kruptein": "^2.0.5", "lru-cache": "^4.0.3" }, "devDependencies": { diff --git a/test/crypto.js b/test/crypto.js new file mode 100644 index 0000000..a275c91 --- /dev/null +++ b/test/crypto.js @@ -0,0 +1,307 @@ +var assert = require('assert') + +// express-session way +var MemoryStore = require('../')({Store: function () {}}) +var session = {MemoryStore: MemoryStore} + +describe('MemoryStore (crypto)', function (done) { + afterEach(function () { + // runs after each test in this block + this.store.stopInterval() + }) + + it('constructor should use default options', function (done) { + this.store = new session.MemoryStore() + var store = this.store + assert.ok(store.options, 'should have an option object') + assert.equal(store.options.max, Infinity, 'max option should be Infinity') + assert.equal(store.options.checkPeriod, undefined, 'checkPeriod undefined by default') + assert.ok(store.store, 'should have the LRU cache store') + assert.equal(store._checkInterval, undefined, 'should not have the pruning loop') + done() + }) + + it('should set options', function (done) { + this.store = new session.MemoryStore({ + max: 10, + checkPeriod: 10 * 1000, + ttl: 36000, + dispose: null, + stale: true, + secret: "squirrel" + }) + var store = this.store + assert.equal(store.options.max, 10, 'should set the max option') + assert.equal(store.options.checkPeriod, 10 * 1000, 'should set checkPeriod') + assert.equal(store.options.ttl, 36000, 'should set the TTL') + assert.equal(store.options.dispose, null, 'should set dispose') + assert.equal(store.options.stale, true, 'should set stale') + assert.equal(store.secret, 'squirrel', 'should set secret') + done() + }) + + it('should not set the interval to check for expired entries by default', function (done) { + this.store = new session.MemoryStore({secret: "squirrel"}) + var store = this.store + assert.equal(store._checkInterval, undefined, 'should not exists') + done() + }) + + it('should only contain 10 items', function (done) { + this.store = new session.MemoryStore({secret: "squirrel", max: 10}) + var store = this.store + + for (var i = 0; i < 15; i++) { + store.set(i, {cookie: { expires: new Date((new Date()).valueOf() + 60 * 10 * 1000) }}) + } + + store.length(function (err, length) { + if (err) return done(err) + assert.equal(length, 10) + done() + }) + }) + + it('should delete the first item', function () { + this.store = new session.MemoryStore({secret: "squirrel", max: 10}) + var store = this.store + + for (var i = 0; i < 15; i++) { + store.set(i, {cookie: { expires: new Date((new Date()).valueOf() + 60 * 10 * 1000) }}) + } + + store.destroy(14) + + store.length(function (err, length) { + if (err) return done(err) + assert.equal(length, 9) + done() + }) + }) + + it('should delete the last item', function () { + this.store = new session.MemoryStore({secret: "squirrel", max: 10}) + var store = this.store + + for (var i = 0; i < 10; i++) { + store.set(i, {cookie: { expires: new Date((new Date()).valueOf() + 60 * 10 * 1000) }}) + } + + store.destroy(0) + store.destroy(1) + + store.length(function (err, length) { + if (err) return done(err) + assert.equal(length, 8) + done() + }) + + for (i = 10; i < 12; i++) { + store.set(i, {cookie: { expires: new Date((new Date()).valueOf() + 60 * 10 * 1000) }}) + } + + store.length(function (err, length) { + if (err) return done(err) + assert.equal(length, 10) + done() + }) + }) + + it('should set and get a sample entry', function (done) { + this.store = new session.MemoryStore({secret: "squirrel"}) + var store = this.store + + store.set('hello', {cookie: {}, sample: true}) + store.get('hello', function (err, val) { + if (err) return done(err) + assert.equal(val.sample, true, 'set and got expected value') + done() + }) + }) + + it('should set TTL from cookie.maxAge', function (done) { + this.store = new session.MemoryStore({secret: "squirrel"}) + var store = this.store + + store.set('hello', {cookie: {maxAge: 400}, sample: true}) + store.get('hello', function (err, val) { + if (err) return done(err) + assert.equal(val.sample, true, 'entry should be valid') + }) + setTimeout(function () { + store.get('hello', function (err, val) { + if (err) return done(err) + assert.equal(val, undefined, 'entry should be expired') + done() + }) + }, 500) + }) + + it('should not get empty entry', function (done) { + this.store = new session.MemoryStore({secret: "squirrel"}) + var store = this.store + + store.get('', function (err, val) { + if (err) return done(err) + assert.equal(val, undefined) + done() + }) + }) + + it('should not get a deleted entry', function (done) { + this.store = new session.MemoryStore({secret: "squirrel"}) + var store = this.store + + store.set('foo', {cookie: {}}) + store.get('foo', function (err, val) { + if (err) return done(err) + assert.ok(val, 'entry exists') + store.destroy('foo') + store.get('foo', function (err, val) { + if (err) return done(err) + assert.equal(val, undefined, 'entry actually deleted') + done() + }) + }) + }) + + it('should not get an expired entry', function (done) { + this.store = new session.MemoryStore({secret: "squirrel"}) + var store = this.store + + store.set('hello', {cookie: {maxAge: 200}, sample: true}) + setTimeout(function () { + store.get('hello', function (err, val) { + if (err) return done(err) + assert.equal(val, undefined, 'entry should be expired') + done() + }) + }, 300) + }) + + it('should enable automatic prune for expired entries', function (done) { + this.store = new session.MemoryStore({secret: "squirrel", checkPeriod: 300}) + var store = this.store + + store.set('foo', {cookie: {maxAge: 150}}) + store.set('bar', {cookie: {maxAge: 150}}) + store.length(function (err, count) { + if (err) return done(err) + assert.equal(count, 2, 'should count 2 entries') + }) + setTimeout(function () { + store.length(function (err, count) { + if (err) return done(err) + assert.equal(count, 0, 'expired entries should be pruned') + done() + }) + }, 500) + }) + + it('automatic check for expired entries should be disabled', function (done) { + this.store = new session.MemoryStore({secret: "squirrel"}) + var store = this.store + + store.set('foo', {cookie: {maxAge: 150}}) + store.set('bar', {cookie: {maxAge: 150}}) + store.length(function (err, count) { + if (err) return done(err) + assert.equal(count, 2, 'should count 2 entries') + }) + setTimeout(function () { + store.length(function (err, count) { + if (err) return done(err) + assert.equal(count, 2, 'expired entries should not be pruned') + done() + }) + }, 500) + }) + + it('should touch a given entry', function (done) { + this.store = new session.MemoryStore({secret: "squirrel"}) + var store = this.store + + store.set('hei', {cookie: {maxAge: 50}}) + store.touch('hei', {cookie: {maxAge: 300}}) + setTimeout(function () { + store.get('hei', function (err, val) { + if (err) return done(err) + assert.ok(val, 'entry should be touched') + done() + }) + }, 200) + }) + + it('should fetch all entries Ids', function (done) { + this.store = new session.MemoryStore({secret: "squirrel"}) + var store = this.store + + var k = 10 + var i = 0 + for (i = 0; i < k; i++) { store.set('sess' + i, {cookie: {maxAge: 1000}}) } + + store.ids(function (err, ids) { + if (err) return done(err) + assert.ok(Array.isArray(ids), 'ids should be an Array') + i = 10 + ids.forEach(function (sid) { + assert.equal(sid, 'sess' + (--i), 'got expected key') + }) + done() + }) + }) + + it('should fetch all entries values', function (done) { + this.store = new session.MemoryStore({secret: "squirrel"}) + var store = this.store + + var k = 10 + var i = 0 + for (i = 0; i < k; i++) { store.set('sess-' + i, {cookie: {maxAge: 1000}, i: i}) } + + store.all(function (err, all) { + if (err) return done(err) + assert.equal(typeof all, 'object', 'all should be an Object') + Object.keys(all).forEach(function (sid) { + var v = sid.split('-')[1] + assert.equal(all[sid].i, v, 'got expected value') + }) + done() + }) + }) + + it('should count all entries in the store', function (done) { + this.store = new session.MemoryStore({secret: "squirrel"}) + var store = this.store + + var k = 10 + var i = 0 + for (i = 0; i < k; i++) { store.set(i, {cookie: {maxAge: 1000}}) } + + store.length(function (err, n) { + if (err) return done(err) + assert.equal(n, k, 'Got expected lenght') + done() + }) + }) + + it('should delete all entries from the store', function (done) { + this.store = new session.MemoryStore({secret: "squirrel"}) + var store = this.store + + var k = 10 + var i = 0 + for (i = 0; i < k; i++) { store.set(i, {cookie: {maxAge: 1000}}) } + + store.length(function (err, n) { + if (err) return done(err) + assert.equal(n, k, 'store is not empty') + }) + store.clear() + store.length(function (err, n) { + if (err) return done(err) + assert.equal(n, 0, 'store should be empty') + done() + }) + }) +})