diff --git a/.gitignore b/.gitignore index 207febba..5b3ace39 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ coverage node_modules npm-debug.log package-lock.json +.npmrc diff --git a/README.md b/README.md index 38b75c7c..06a00e91 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,22 @@ app.use(session({ })) ``` +##### maxDuration + +The maximum amount of time that a session can stay open *even* if there are continuous +requests that keep the session alive. This is to minimize the total footprint of a session +replay attack in the case where a session identifier is stolen. This will treat the session as +expired and generate a new session. Rolling sessions do not update this behavior. + +The default is none. + +```js +app.use(session({ + maxDuration: 28800, // duration in seconds (this would be 8 hours) + secret: 'keyboard cat' +})) +``` + ##### name The name of the session ID cookie to set in the response (and read from in the diff --git a/index.js b/index.js index d7efeab9..a2a31900 100644 --- a/index.js +++ b/index.js @@ -80,6 +80,7 @@ var defer = typeof setImmediate === 'function' * @param {String|Array} [options.secret] Secret for signing session ID * @param {Object} [options.store=MemoryStore] Session store * @param {String} [options.unset] + * @param {Number} [options.maxDuration] Sets the maximum total age in seconds for a session to minimize session replay duration * @return {Function} middleware * @public */ @@ -143,6 +144,10 @@ function session(options) { secret = [secret]; } + if (opts.maxDuration && typeof opts.maxDuration !== 'number') { + throw new TypeError('maxDuration needs to be specified as a number'); + } + if (!secret) { deprecate('req.secret; provide secret option'); } @@ -163,6 +168,10 @@ function session(options) { if (cookieOptions.secure === 'auto') { req.session.cookie.secure = issecure(req, trustProxy); } + + if (opts.maxDuration > 0) { + req.session.cookie.createdAt = new Date(); + } }; var storeImplementsTouch = typeof store.touch === 'function'; @@ -472,6 +481,25 @@ function session(options) { : rollingSessions || req.session.cookie.expires != null && isModified(req.session); } + // if opts.maxDuration is set, check to see if the createdAt value of the cookie is has + // expired. + function hasReachedMaxDuration (sess, opts) { + if (!opts || !opts.maxDuration || opts.maxDuration <= 0) { + return false; + } + if (!sess || !sess.cookie || !sess.cookie.createdAt) { + debug('session should be timed out, but the createdAt value is not saved') + return true; + } + var createdDate = new Date(sess.cookie.createdAt); + var nowDate = new Date(); + + if ((nowDate.getTime() - createdDate.getTime()) / 1000 < opts.maxDuration) { + return false; + } + return true; + } + // generate a session if the browser doesn't send a sessionID if (!req.sessionID) { debug('no SID sent, generating session'); @@ -494,6 +522,9 @@ function session(options) { if (err || !sess) { debug('no session found') generate() + } else if (hasReachedMaxDuration(sess, opts)) { + debug('session has reached the max duration'); + generate(); } else { debug('session found') inflate(req, sess) diff --git a/package.json b/package.json index e3f988f7..8d7a8696 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "express-session", + "name": "@varedis/express-session", "version": "1.17.1", "description": "Simple session middleware for Express", "author": "TJ Holowaychuk (http://tjholowaychuk.com)", @@ -7,11 +7,11 @@ "Douglas Christopher Wilson ", "Joe Wagner " ], - "repository": "expressjs/session", + "repository": "varedis/session", "license": "MIT", "dependencies": { - "cookie": "0.4.1", - "cookie-signature": "1.0.6", + "cookie": "0.5.0", + "cookie-signature": "1.2.0", "debug": "2.6.9", "depd": "~2.0.0", "on-headers": "~1.0.2", @@ -21,10 +21,10 @@ }, "devDependencies": { "after": "0.8.2", - "cookie-parser": "1.4.5", + "cookie-parser": "1.4.6", "eslint": "3.19.0", "eslint-plugin-markdown": "1.0.2", - "express": "4.17.1", + "express": "4.18.2", "mocha": "8.1.1", "nyc": "15.1.0", "supertest": "4.0.2" diff --git a/session/cookie.js b/session/cookie.js index a8b4e570..6b4546ee 100644 --- a/session/cookie.js +++ b/session/cookie.js @@ -123,6 +123,7 @@ Cookie.prototype = { , domain: this.domain , path: this.path , sameSite: this.sameSite + , createdAt: this.createdAt } }, diff --git a/test/session.js b/test/session.js index 8d796f91..f61d9ee2 100644 --- a/test/session.js +++ b/test/session.js @@ -1309,6 +1309,141 @@ describe('session()', function(){ }) }) + describe('maxDuration option', function () { + it('should reject unknown values', function () { + assert.throws(createServer.bind(null, { maxDuration: "baddata" }), /maxDuration needs to be specified as a number/); + }) + it('should keep session in maxDuration', function(done) { + var store = new session.MemoryStore(); + var server = createServer({store: store, maxDuration: 10}, function(req, res) { + req.session.user = 'bob'; + res.write('hello, world'); + res.end(); + }); + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, function(err, res) { + if (err) return done(err); + var originalExpires = expires(res); + setTimeout(function() { + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, done); + }, 100); + }); + }); + + it('should start a new session if createdAt is corrupt', function(done) { + var store = new session.MemoryStore(); + var server = createServer({store: store, maxDuration: 10}, function(req, res) { + req.session.user = 'bob'; + req.session.cookie.createdAt = null; + res.write('hello, world'); + res.end(); + }); + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, function(err, res) { + if (err) return done(err); + var originalCookie = cookie(res); + setTimeout(function() { + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetCookie('connect.sid')) + .expect(function (res) { assert.notEqual(originalCookie, cookie(res)); }) + .expect(200, done); + }, 100); + }); + }); + + it('it should handle a user mucking up the createdAt date', function(done) { + var store = new session.MemoryStore(); + var server = createServer({store: store, maxDuration: 10}, function(req, res) { + req.session.user = 'bob'; + req.session.cookie.createdAt = 'some really bad data'; + res.write('hello, world'); + res.end(); + }); + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, function(err, res) { + if (err) return done(err); + var originalCookie = cookie(res); + setTimeout(function() { + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetCookie('connect.sid')) + .expect(function (res) { assert.notEqual(originalCookie, cookie(res)); }) + .expect(200, done); + }, 100); + }); + }); + + it('should destroy session after maxDuration', function(done) { + var store = new session.MemoryStore(); + var server = createServer({ store: store, maxDuration: .1 }, function(req, res) { + req.session.user = 'bob'; + res.write('hello, world'); + res.end(); + }); + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, function (err, res) { + if (err) return done(err); + var originalCookie = cookie(res); + setTimeout(function () { + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetCookie('connect.sid')) + .expect(function (res) { assert.notEqual(originalCookie, cookie(res)); }) + .expect(200, done); + }, 200); + }); + }); + + it('should destroy session after maxDuration even with rolling sessions', function (done) { + var store = new session.MemoryStore(); + var server = createServer({ store: store, maxDuration: .1, rolling: true }, function(req, res) { + req.session.user = 'bob'; + res.write('hello, world'); + res.end(); + }); + request(server) + .get('/') + .expect(shouldSetCookie('connect.sid')) + .expect(200, function (err, res) { + if (err) return done(err); + var originalCookie = cookie(res); + setTimeout(function () { + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetCookie('connect.sid')) + .expect(200, function (err, res) { + if (err) return done(err); + setTimeout(function () { + request(server) + .get('/') + .set('Cookie', cookie(res)) + .expect(shouldSetCookie('connect.sid')) + .expect(function (res) { assert.notEqual(originalCookie, cookie(res)); }) + .expect(200, done); + }, 100); + }); + }, 100); + }); + }); + }); + describe('unset option', function () { it('should reject unknown values', function(){ assert.throws(session.bind(null, { unset: 'bogus!' }), /unset.*must/)