fix: use RFC 6265 path matching for cookie path option (#1114)

* test: add specific RFC 6265 5.1.4 compliance tests

* feat: add specific RFC 6265 5.1.4 handler

* docs: note RFC 6265 5.1.4 compliance in cookie.path documentation

* feat: enhance cookie handling for RFC 6265 compliance with path matching and auto secure/sameSite options

* feat: enforce RFC 6265 section 5.1.4 path matching for session cookie path

---------

Co-authored-by: Sebastian Beltran <[email protected]>
This commit is contained in:
Ulises Gascón
2026-07-05 21:07:01 -05:00
committed by GitHub
co-authored by Sebastian Beltran
parent 143d26eb9f
commit 672216dd12
4 changed files with 200 additions and 4 deletions
+6
View File
@@ -1,3 +1,9 @@
unreleased
==========
* Enforce RFC 6265 section 5.1.4 path matching for the session cookie path
* Fix `secure: 'auto'` and `sameSite: 'auto'` not being resolved when `cookie` is a function
1.19.0 / 2026-01-22
==========
+7
View File
@@ -126,6 +126,12 @@ More information about can be found in [the proposal](https://github.com/privacy
Specifies the value for the `Path` `Set-Cookie`. By default, this is set to `'/'`, which
is the root path of the domain.
Since 1.19.1, path matching follows [RFC 6265 section 5.1.4][rfc-6265-5.1.4]. This means
the session middleware will only activate when the request path is an exact match or falls
under a segment boundary of the cookie path. For example, a cookie path of `/admin` will
match `/admin` and `/admin/users` but will **not** match `/administrator`. Prior versions
used a simple prefix check that did not enforce segment boundaries.
##### cookie.priority
Specifies the `string` to be the value for the [`Priority` `Set-Cookie` attribute][rfc-west-cookie-priority-00-4.1].
@@ -1038,6 +1044,7 @@ On Windows, use the corresponding command;
[MIT](LICENSE)
[rfc-6265-5.1.4]: https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.4
[rfc-6265bis-03-4.1.2.7]: https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7
[rfc-cutler-httpbis-partitioned-cookies]: https://tools.ietf.org/html/draft-cutler-httpbis-partitioned-cookies/
[rfc-west-cookie-priority-00-4.1]: https://tools.ietf.org/html/draft-west-cookie-priority-00#section-4.1
+44 -4
View File
@@ -158,15 +158,16 @@ function session(options) {
store.generate = function(req){
req.sessionID = generateId(req);
req.session = new Session(req);
req.session.cookie = new Cookie(typeof cookieOptions === 'function' ? cookieOptions(req) : cookieOptions);
var resolvedCookieOptions = typeof cookieOptions === 'function' ? cookieOptions(req) : cookieOptions;
req.session.cookie = new Cookie(resolvedCookieOptions);
var isSecure = issecure(req, trustProxy);
if (cookieOptions.secure === 'auto') {
if (resolvedCookieOptions.secure === 'auto') {
req.session.cookie.secure = isSecure;
}
if (cookieOptions.sameSite === 'auto') {
if (resolvedCookieOptions.sameSite === 'auto') {
req.session.cookie.sameSite = isSecure ? 'none' : 'lax';
}
};
@@ -200,12 +201,15 @@ function session(options) {
// pathname mismatch
var originalPath = parseUrl.original(req).pathname || '/'
var resolvedCookieOptions = typeof cookieOptions === 'function' ? cookieOptions(req) : cookieOptions
if (originalPath.indexOf(resolvedCookieOptions.path || '/') !== 0) {
var cfgPath = resolvedCookieOptions.path || '/'
if (!rfcPathMatch(originalPath, cfgPath)) {
debug('pathname mismatch')
next()
return
}
// ensure a secret is available or bail
if (!secret && !req.secret) {
next(new Error('secret option required for sessions'));
@@ -523,6 +527,42 @@ function session(options) {
};
};
/**
* Check if the cookiePath matches the requestPath following the
* rules in RFC 6265 section 5.1.4.
*
* @param {String} requestPath
* @param {String} cookiePath
* @return {Boolean}
* @private
*/
function rfcPathMatch(requestPath, cookiePath) {
// Normalize inputs (Node 0.8-safe)
requestPath = (typeof requestPath === 'string' && requestPath.length) ? requestPath : '/';
cookiePath = (typeof cookiePath === 'string' && cookiePath.length) ? cookiePath : '/';
// Root cookie matches everything
if (cookiePath === '/') return true;
// Exact match
if (requestPath === cookiePath) return true;
// Prefix match
if (requestPath.indexOf(cookiePath) === 0) {
// If cookiePath ends with '/', any longer requestPath is OK
if (cookiePath.charAt(cookiePath.length - 1) === '/') return true;
// Otherwise the next char after the prefix must be '/'
var nextChar = requestPath.length > cookiePath.length
? requestPath.charAt(cookiePath.length)
: '';
return nextChar === '/';
}
return false;
}
/**
* Generate a session ID for a new session.
*
+143
View File
@@ -853,6 +853,40 @@ describe('session()', function(){
done();
});
});
it('should not set cookie when request-path does not match returned path', function (done) {
var cookieCallback = function () {
return { path: '/admin', httpOnly: true, secure: false };
};
var server = createServer({ cookie: cookieCallback });
request(server)
.get('/administrator')
.expect(shouldNotHaveHeader('Set-Cookie'))
.expect(200, done);
});
it('should resolve "secure" and "sameSite" set to "auto"', function (done) {
function setup (req) {
req.secure = JSON.parse(req.headers['x-secure'])
}
function respond (req, res) {
res.end(String(req.secure))
}
var cookieCallback = function () {
return { path: '/', secure: 'auto', sameSite: 'auto' };
};
var server = createServer(setup, { cookie: cookieCallback }, respond);
request(server)
.get('/')
.set('X-Secure', 'true')
.expect(shouldSetCookieWithAttribute('connect.sid', 'Secure'))
.expect(
shouldSetCookieWithAttributeAndValue('connect.sid', 'SameSite', 'None')
)
.expect(200, 'true', done);
});
});
describe('when "sameSite" set to "auto"', function () {
describe('basic functionality', function () {
@@ -2620,6 +2654,115 @@ describe('session()', function(){
})
})
describe('path matching (RFC 6265)', function () {
describe('when "path" is "/" (root path)', function () {
before(function () {
this.server = createServer({ cookie: { path: '/' } })
})
it('should set cookie when request-path is "/" (root path)', function (done) {
// RFC 6265 5.1.4: "The cookie-path and the request-path are identical."
request(this.server)
.get('/')
.expect(shouldSetCookie('connect.sid'))
.expect(200, done)
})
it('should set cookie when request-path is any path ("/foo")', function (done) {
// RFC 6265 5.1.4: "The cookie-path is a prefix of the request-path, and the last
// character of the cookie-path is %x2F ("/")."
request(this.server)
.get('/foo')
.expect(shouldSetCookie('connect.sid'))
.expect(200, done)
})
it('should set cookie when request-path has multiple segments ("/foo/bar/baz")', function (done) {
// RFC 6265 5.1.4: "The cookie-path is a prefix of the request-path, and the last
// character of the cookie-path is %x2F ("/")."
request(this.server)
.get('/foo/bar/baz')
.expect(shouldSetCookie('connect.sid'))
.expect(200, done)
})
})
describe('when "path" is "/admin"', function () {
before(function () {
this.server = createServer({ cookie: { path: '/admin' } })
})
it('should set cookie when request-path and cookie-path are identical ("/admin")', function (done) {
// RFC 6265 5.1.4: "The cookie-path and the request-path are identical."
request(this.server)
.get('/admin')
.expect(shouldSetCookie('connect.sid'))
.expect(200, done)
})
it('should set cookie when cookie-path is prefix and last char is "/" ("/admin/")', function (done) {
// RFC 6265 5.1.4: "The cookie-path is a prefix of the request-path, and the last
// character of the cookie-path is %x2F ("/")."
request(this.server)
.get('/admin/')
.expect(shouldSetCookie('connect.sid'))
.expect(200, done)
})
it('should set cookie when cookie-path is prefix and next char is "/" ("/admin/users")', function (done) {
// RFC 6265 5.1.4: "The cookie-path is a prefix of the request-path, and the first
// character of the request-path that is not included in the cookie-path is a %x2F ("/") character."
request(this.server)
.get('/admin/users')
.expect(shouldSetCookie('connect.sid'))
.expect(200, done)
})
it('should NOT set cookie when cookie-path is not a prefix ("/administrator")', function (done) {
// RFC 6265 5.1.4: None of the path-match conditions are met
request(this.server)
.get('/administrator')
.expect(shouldNotHaveHeader('Set-Cookie'))
.expect(200, done)
})
})
describe('when "path" is "/admin/" (trailing slash)', function () {
before(function () {
this.server = createServer({ cookie: { path: '/admin/' } })
})
it('should set cookie when cookie-path is prefix and last char is "/" ("/admin/x")', function (done) {
// RFC 6265 5.1.4: "The cookie-path is a prefix of the request-path, and the last
// character of the cookie-path is %x2F ("/")."
request(this.server)
.get('/admin/x')
.expect(shouldSetCookie('connect.sid'))
.expect(200, done)
})
it('should NOT set cookie when request-path is not prefixed by cookie-path ("/admin")', function (done) {
// RFC 6265 5.1.4: cookie-path "/admin/" is not a prefix of request-path "/admin"
request(this.server)
.get('/admin')
.expect(shouldNotHaveHeader('Set-Cookie'))
.expect(200, done)
})
it('should NOT set cookie when cookie-path is not a prefix ("/administrator")', function (done) {
// RFC 6265 5.1.4: None of the path-match conditions are met:
// 1. The paths are not identical
// 2. "/admin/" is not a prefix of "/administrator"
// 3. The prefix condition with next character "/" is not applicable
request(this.server)
.get('/administrator')
.expect(shouldNotHaveHeader('Set-Cookie'))
.expect(200, done)
})
})
})
function cookie(res) {
var setCookie = res.headers['set-cookie'];
return (setCookie && setCookie[0]) || undefined;