diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 210657e2..13336155 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,7 +130,7 @@ jobs: node-version: "21.6" steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Install Node.js ${{ matrix.node-version }} shell: bash -eo pipefail -l {0} @@ -208,7 +208,7 @@ jobs: fi - name: Upload code coverage - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 if: steps.list_env.outputs.nyc != '' with: name: coverage-${{ matrix.node-version }} @@ -222,14 +222,14 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 - name: Install lcov shell: bash run: sudo apt-get -y install lcov - name: Collect coverage reports - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: path: ./coverage @@ -238,6 +238,6 @@ jobs: run: find ./coverage -name lcov.info -exec printf '-a %q\n' {} \; | xargs lcov -o ./coverage/lcov.info - name: Upload coverage report - uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b # master + uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # master with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1a41f87b..8534ee51 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -34,11 +34,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 + uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 with: languages: javascript # If you wish to specify custom queries, you can do so here or in a config file. @@ -61,6 +61,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 + uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 with: category: "/language:javascript" \ No newline at end of file diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index d53e5871..169262af 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -33,12 +33,12 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif @@ -60,7 +60,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: SARIF file path: results.sarif @@ -68,6 +68,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 + uses: github/codeql-action/upload-sarif@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 with: sarif_file: results.sarif \ No newline at end of file diff --git a/HISTORY.md b/HISTORY.md index 9aacf124..fae1a0f1 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,10 @@ +1.19.0 / 2026-01-22 +========== + + * Add dynamic cookie options support + * Add sameSite 'auto' support for automatic SameSite attribute configuration + * deps: use tilde notation for dependencies + 1.18.2 / 2025-07-17 ========== * deps: mocha@10.8.2 diff --git a/README.md b/README.md index b880e6b4..292b2024 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,26 @@ For a list of stores, see [compatible session stores](#compatible-session-stores Settings object for the session ID cookie. The default value is `{ path: '/', httpOnly: true, secure: false, maxAge: null }`. +In addition to providing a static object, you can also pass a callback function to dynamically generate the cookie options for each request. The callback receives the `req` object as its argument and should return an object containing the cookie settings. + +```js +var app = express() +app.use(session({ + secret: 'keyboard cat', + resave: false, + saveUninitialized: true, + cookie: function(req) { + var match = req.url.match(/^\/([^/]+)/); + return { + path: match ? '/' + match[1] : '/', + httpOnly: true, + secure: req.secure || false, + maxAge: 60000 + } + } +})) +``` + The following are options that can be set in this object. ##### cookie.domain @@ -130,6 +150,7 @@ By default, this is `false`. - `'lax'` will set the `SameSite` attribute to `Lax` for lax same site enforcement. - `'none'` will set the `SameSite` attribute to `None` for an explicit cross-site cookie. - `'strict'` will set the `SameSite` attribute to `Strict` for strict same site enforcement. + - `'auto'` will set the `SameSite` attribute to `None` for secure connections and `Lax` for non-secure connections. More information about the different enforcement levels can be found in [the specification][rfc-6265bis-03-4.1.2.7]. @@ -141,6 +162,14 @@ the future. This also means many clients may ignore this attribute until they un that requires that the `Secure` attribute be set to `true` when the `SameSite` attribute has been set to `'none'`. Some web browsers or other clients may be adopting this specification. +The `cookie.sameSite` option can also be set to the special value `'auto'` to have +this setting automatically match the determined security of the connection. When the connection +is secure (HTTPS), the `SameSite` attribute will be set to `None` to enable cross-site usage. +When the connection is not secure (HTTP), the `SameSite` attribute will be set to `Lax` for +better security while maintaining functionality. This is useful when the Express `"trust proxy"` +setting is properly setup to simplify development vs production configuration, particularly +for SAML authentication scenarios. + ##### cookie.secure Specifies the `boolean` value for the `Secure` `Set-Cookie` attribute. When truthy, diff --git a/index.js b/index.js index d41b2378..1a509318 100644 --- a/index.js +++ b/index.js @@ -70,7 +70,7 @@ var defer = typeof setImmediate === 'function' * Setup session store with the given `options`. * * @param {Object} [options] - * @param {Object} [options.cookie] Options for cookie + * @param {Object|Function} [options.cookie] Options for cookie * @param {Function} [options.genid] * @param {String} [options.name=connect.sid] Session ID cookie name * @param {Boolean} [options.proxy] @@ -158,10 +158,16 @@ function session(options) { store.generate = function(req){ req.sessionID = generateId(req); req.session = new Session(req); - req.session.cookie = new Cookie(cookieOptions); + req.session.cookie = new Cookie(typeof cookieOptions === 'function' ? cookieOptions(req) : cookieOptions); + + var isSecure = issecure(req, trustProxy); if (cookieOptions.secure === 'auto') { - req.session.cookie.secure = issecure(req, trustProxy); + req.session.cookie.secure = isSecure; + } + + if (cookieOptions.sameSite === 'auto') { + req.session.cookie.sameSite = isSecure ? 'none' : 'lax'; } }; @@ -193,7 +199,8 @@ function session(options) { // pathname mismatch var originalPath = parseUrl.original(req).pathname || '/' - if (originalPath.indexOf(cookieOptions.path || '/') !== 0) { + var resolvedCookieOptions = typeof cookieOptions === 'function' ? cookieOptions(req) : cookieOptions + if (originalPath.indexOf(resolvedCookieOptions.path || '/') !== 0) { debug('pathname mismatch') next() return @@ -523,7 +530,7 @@ function session(options) { * @private */ -function generateSessionId(sess) { +function generateSessionId() { return uid(24); } diff --git a/package.json b/package.json index 7039d1d4..36387796 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "express-session", - "version": "1.18.2", + "version": "1.19.0", "description": "Simple session middleware for Express", "author": "TJ Holowaychuk (http://tjholowaychuk.com)", "contributors": [ @@ -8,15 +8,19 @@ "Joe Wagner " ], "repository": "expressjs/session", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + }, "license": "MIT", "dependencies": { - "cookie": "0.7.2", - "cookie-signature": "1.0.7", - "debug": "2.6.9", + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", "depd": "~2.0.0", "on-headers": "~1.1.0", "parseurl": "~1.3.3", - "safe-buffer": "5.2.1", + "safe-buffer": "~5.2.1", "uid-safe": "~2.1.5" }, "devDependencies": { @@ -31,7 +35,6 @@ }, "files": [ "session/", - "HISTORY.md", "index.js" ], "engines": { @@ -39,7 +42,7 @@ }, "scripts": { "lint": "eslint . && node ./scripts/lint-readme.js", - "test": "./test/support/gencert.sh && mocha --require test/support/env --check-leaks --bail --no-exit --reporter spec test/", + "test": "./test/support/gencert.sh && mocha --require test/support/env --check-leaks --no-exit --reporter spec test/", "test-ci": "nyc --reporter=lcov --reporter=text npm test", "test-cov": "nyc npm test", "version": "node scripts/version-history.js && git add HISTORY.md" diff --git a/test/session.js b/test/session.js index a7d79b70..46fed763 100644 --- a/test/session.js +++ b/test/session.js @@ -801,6 +801,225 @@ describe('session()', function(){ }) }) }) + + describe('when "cookie" is a function', function () { + it('should call custom function and apply cookie options', function (done) { + var cookieCallbackCalled = false; + var cookieCallback = function () { + cookieCallbackCalled = true; + return { path: '/test', httpOnly: true, secure: false }; + }; + var server = createServer({ cookie: cookieCallback }); + request(server) + .get('/test') + .expect( + shouldSetCookieWithAttributeAndValue('connect.sid', 'Path', '/test') + ) + .expect(shouldSetCookieWithAttribute('connect.sid', 'HttpOnly')) + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(200, function (err) { + if (err) return done(err); + assert.strictEqual( + cookieCallbackCalled, + true, + 'should have called cookie callback' + ); + done(); + }); + }); + + it('should provide req argument', function (done) { + var _path = '/test'; + var cookieCallbackCalled = false; + var cookieCallback = function (req) { + cookieCallbackCalled = true; + return { path: req.url, httpOnly: true, secure: false }; + }; + var server = createServer({ cookie: cookieCallback }); + request(server) + .get(_path) + .expect( + shouldSetCookieWithAttributeAndValue('connect.sid', 'Path', _path) + ) + .expect(shouldSetCookieWithAttribute('connect.sid', 'HttpOnly')) + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(200, function (err) { + if (err) return done(err); + assert.strictEqual( + cookieCallbackCalled, + true, + 'should have called cookie callback' + ); + done(); + }); + }); + }); + describe('when "sameSite" set to "auto"', function () { + describe('basic functionality', function () { + before(function () { + function setup (req) { + req.secure = JSON.parse(req.headers['x-secure']) + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { cookie: { sameSite: 'auto' } }, respond) + }) + + it('should set SameSite=None for secure connections', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'true') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')) + .expect(200, 'true', done) + }) + + it('should set SameSite=Lax for insecure connections', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'false') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'Lax')) + .expect(200, 'false', done) + }) + }) + + describe('with proxy settings', function () { + describe('when "proxy" is "true"', function () { + before(function () { + this.server = createServer({ proxy: true, cookie: { sameSite: 'auto' }}) + }) + + it('should set SameSite=None when X-Forwarded-Proto is https', function (done) { + request(this.server) + .get('/') + .set('X-Forwarded-Proto', 'https') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')) + .expect(200, done) + }) + + it('should set SameSite=Lax when X-Forwarded-Proto is http', function (done) { + request(this.server) + .get('/') + .set('X-Forwarded-Proto', 'http') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'Lax')) + .expect(200, done) + }) + }) + + describe('when "proxy" is "false"', function () { + before(function () { + this.server = createServer({ proxy: false, cookie: { sameSite: 'auto' }}) + }) + + it('should set SameSite=Lax when X-Forwarded-Proto is https', function (done) { + request(this.server) + .get('/') + .set('X-Forwarded-Proto', 'https') + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'Lax')) + .expect(200, done) + }) + }) + }) + + describe('combined with secure auto', function() { + describe('when "secure" is "auto"', function () { + before(function () { + function setup (req) { + req.secure = JSON.parse(req.headers['x-secure']) + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { cookie: { secure: 'auto', sameSite: 'auto' } }, respond) + }) + + it('should set both Secure and SameSite=None when secure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'true') + .expect(shouldSetCookieWithAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')) + .expect(200, 'true', done) + }) + + it('should set neither Secure nor SameSite=None when insecure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'false') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'Lax')) + .expect(200, 'false', done) + }) + }) + + describe('when "secure" is "false"', function () { + before(function () { + function setup (req) { + req.secure = JSON.parse(req.headers['x-secure']) + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { cookie: { secure: false, sameSite: 'auto' } }, respond) + }) + + it('should set SameSite=None without Secure when secure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'true') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')) + .expect(200, 'true', done) + }) + + it('should set SameSite=Lax without Secure when insecure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'false') + .expect(shouldSetCookieWithoutAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'Lax')) + .expect(200, 'false', done) + }) + }) + + describe('when "secure" is "true"', function () { + before(function () { + function setup (req) { + req.secure = JSON.parse(req.headers['x-secure']) + } + + function respond (req, res) { + res.end(String(req.secure)) + } + + this.server = createServer(setup, { cookie: { secure: true, sameSite: 'auto' } }, respond) + }) + + it('should set both Secure and SameSite=None when secure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'true') + .expect(shouldSetCookieWithAttribute('connect.sid', 'Secure')) + .expect(shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')) + .expect(200, 'true', done) + }) + + it('should not set cookie when insecure', function (done) { + request(this.server) + .get('/') + .set('X-Secure', 'false') + .expect(shouldNotHaveHeader('Set-Cookie')) + .expect(200, 'false', done) + }) + }) + }) + }) }) describe('genid option', function(){