-
-
Notifications
You must be signed in to change notification settings - Fork 21.8k
feat: add CORS-aware ETag modes and configurable query parser options #6908
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
Conversation
Add support for including response headers in ETag calculation to prevent
cache conflicts when serving content to multiple origins through CDNs.
This addresses an issue where CDNs return 304 Not Modified responses that
omit CORS headers, causing browsers to apply cached CORS headers from a
different origin, resulting in CORS errors.
New ETag modes:
- 'weak-cors': Weak ETag including Access-Control-Allow-Origin header
- 'strong-cors': Strong ETag including Access-Control-Allow-Origin header
The implementation:
- Extends createETagGenerator to accept includeHeaders option
- Updates res.send() to pass response headers to ETag function
- Maintains full backward compatibility with existing ETag modes
- Falls back to body-only hashing when CORS headers are not present
Usage:
app.set('etag', 'weak-cors');
app.use(function(req, res) {
res.set('Access-Control-Allow-Origin', req.get('Origin'));
res.send('content');
});
Test coverage:
- 13 new unit tests in test/utils.js
- 10 new integration tests in test/res.send.cors.js
- All existing tests pass (1269 total)
Fixes expressjs#5986
Add ability to configure qs library options when using the extended
query parser, addressing silent parameter truncation and providing
better security controls.
Previously, the extended query parser used hardcoded qs defaults with
no way to customize behavior. This caused issues when query strings
exceeded the default 1000 parameter limit, resulting in silent data
loss. Additionally, the default allowPrototypes: true setting poses
a prototype pollution security risk.
New API:
app.set('query parser', 'extended');
app.set('query parser options', {
parameterLimit: 5000, // Increase from default 1000
arrayLimit: 50, // Increase from default 20
depth: 10, // Increase from default 5
allowPrototypes: false // Prevent prototype pollution
});
Changes:
- compileQueryParser now accepts optional qsOptions parameter
- createExtendedQueryParser factory function replaces parseExtendedQueryString
- app.set('query parser options') triggers parser recompilation
- Explicit defaults documented: parameterLimit=1000, arrayLimit=20, depth=5
Backward compatibility:
- Default behavior unchanged (same qs defaults apply)
- Works without setting options
- Options can be set before or after parser mode
- Does not affect 'simple' parser mode
Security note:
The default allowPrototypes: true is maintained for backward compatibility
but developers are encouraged to set allowPrototypes: false to prevent
prototype pollution attacks.
Test coverage:
- 11 new tests in test/req.query.options.js
- Tests cover limits, security, and backward compatibility
- All existing tests pass (1269 total)
Fixes expressjs#5878
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR introduces two independent enhancements to Express.js: CORS-aware ETag generation modes and configurable query parser options. The CORS-aware ETags (weak-cors and strong-cors) include the Access-Control-Allow-Origin response header in the ETag calculation to prevent CDN cache conflicts when serving content to multiple origins. The configurable query parser options allow developers to customize the qs library behavior (e.g., parameterLimit, arrayLimit, depth, allowPrototypes) when using the extended query parser, addressing silent parameter truncation and providing security controls.
Key changes:
- New ETag modes
'weak-cors'and'strong-cors'that include CORS headers in hash calculation - New
'query parser options'setting to configureqsparsing behavior - Extensions to
createETagGeneratorandcompileQueryParserfunctions to support new options
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/utils.js | Implements CORS-aware ETag generators (etagCors, wetagCors) and configurable extended query parser factory; adds new compilation modes in compileETag and compileQueryParser |
| lib/response.js | Updates res.send() to pass response headers to ETag function for CORS-aware ETag calculation |
| lib/application.js | Adds handling for 'query parser options' setting with automatic parser recompilation |
| test/utils.js | Unit tests for new CORS-aware ETag functions (etagCors, wetagCors) covering different origins, headers, and backward compatibility |
| test/res.send.cors.js | Integration tests for CORS-aware ETags in res.send(), testing different origins, 304 responses, fallback behavior, and JSON support |
| test/req.query.options.js | Tests for configurable query parser options covering parameter limits, array limits, depth limits, prototype pollution prevention, and backward compatibility |
| .gitignore | Adds development documentation files to ignore list |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var headerBuf = Buffer.from(headerParts.join('|'), 'utf8'); | ||
| var combined = Buffer.concat([buf, Buffer.from('|'), headerBuf]); |
Copilot
AI
Dec 10, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The implementation uses a pipe character (|) as a delimiter between body and headers (line 317). If the body itself ends with a pipe character and a header value starts with the same content, there's a theoretical (though extremely unlikely) hash collision risk. Consider using a more robust delimiter strategy, such as including the length of each component, or using a null byte (\x00) which is invalid in HTTP headers.
| var headerBuf = Buffer.from(headerParts.join('|'), 'utf8'); | |
| var combined = Buffer.concat([buf, Buffer.from('|'), headerBuf]); | |
| var headerBuf = Buffer.from(headerParts.join('\x00'), 'utf8'); | |
| var combined = Buffer.concat([buf, Buffer.from('\x00'), headerBuf]); |
| describe('with depth option', function(){ | ||
| it('should respect nesting depth limit', function(done){ | ||
| var app = express(); | ||
| app.set('query parser', 'extended'); | ||
| app.set('query parser options', { | ||
| depth: 2 | ||
| }); | ||
|
|
||
| app.get('/', function(req, res){ | ||
| res.json(req.query); | ||
| }); | ||
|
|
||
| request(app) | ||
| .get('/?a[b][c][d]=value') | ||
| .expect(200) | ||
| .end(function(err, res){ | ||
| if (err) return done(err); | ||
| // With depth 2, should only parse a[b] | ||
| assert.ok(res.body.a); | ||
| assert.ok(res.body.a.b); | ||
| // Further nesting should be flattened or ignored | ||
| done(); | ||
| }); | ||
| }); |
Copilot
AI
Dec 10, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test doesn't adequately verify the depth limit behavior. The comment on line 146 states "should only parse a[b]" and "Further nesting should be flattened or ignored", but the test doesn't verify this. It should check the actual structure of res.body.a.b to confirm that deeper nesting (c and d) was not parsed as expected, for example by verifying that res.body.a.b.c is undefined or has a different structure than expected.
| it('should allow prototype pollution with allowPrototypes:true (default for backward compat)', function(done){ | ||
| var app = express(); | ||
| app.set('query parser', 'extended'); | ||
|
|
||
| app.get('/', function(req, res){ | ||
| res.json({ success: true }); | ||
| }); | ||
|
|
||
| request(app) | ||
| .get('/?__proto__[test]=polluted') | ||
| .expect(200) | ||
| .end(function(err, res){ | ||
| if (err) return done(err); | ||
| // With allowPrototypes:true, this would work (but is dangerous) | ||
| done(); | ||
| }); | ||
| }); |
Copilot
AI
Dec 10, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test doesn't actually verify that prototype pollution is allowed with allowPrototypes:true. It just expects a 200 response, which doesn't prove anything about whether the prototype was actually polluted. The test should verify that the pollution actually occurred, similar to what the second test attempts to do (though that one also has issues as noted in another comment).
| }); | ||
| function createExtendedQueryParser(options) { | ||
| var qsOptions = Object.assign({ | ||
| allowPrototypes: true, // Backward compatibility (consider changing to false in v6) |
Copilot
AI
Dec 10, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Setting allowPrototypes: true as the default (line 333) maintains backward compatibility but leaves applications vulnerable to prototype pollution attacks unless developers explicitly set it to false. While the comment mentions "consider changing to false in v6", this is a security risk in the current version. Consider documenting this security concern prominently in the PR description and migration guide, or at minimum add a deprecation warning when this default is used.
| case 'query parser options': | ||
| // Re-compile the query parser with new options | ||
| var currentParser = this.get('query parser'); | ||
| if (currentParser) { | ||
| this.set('query parser fn', compileQueryParser(currentParser, val)); | ||
| } | ||
| break; |
Copilot
AI
Dec 10, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] When 'query parser options' is set before 'query parser' mode is set, the options will be stored in settings but won't be used until the parser mode is set. However, when 'query parser' is subsequently set, it will correctly retrieve and use these options via this.get('query parser options') on line 368. This behavior should be documented or the code should handle this edge case more explicitly to avoid confusion.
| it('should prevent prototype pollution with allowPrototypes:false', function(done){ | ||
| var app = express(); | ||
| app.set('query parser', 'extended'); | ||
| app.set('query parser options', { | ||
| allowPrototypes: false | ||
| }); | ||
|
|
||
| app.get('/', function(req, res){ | ||
| var testObj = {}; | ||
| // Check if prototype was polluted | ||
| var isPolluted = testObj.hasOwnProperty('__proto__'); | ||
| res.json({ polluted: isPolluted }); | ||
| }); | ||
|
|
||
| request(app) | ||
| .get('/?__proto__[test]=polluted') | ||
| .expect(200) | ||
| .end(function(err, res){ | ||
| if (err) return done(err); | ||
| assert.strictEqual(res.body.polluted, false); | ||
| done(); | ||
| }); |
Copilot
AI
Dec 10, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The prototype pollution test is checking the wrong condition. testObj.hasOwnProperty('__proto__') will always return false because __proto__ is not an own property - it's an accessor property on Object.prototype. To properly test if prototype pollution was prevented, you should check if a property set via __proto__[test]=polluted actually polluted the prototype, e.g., by checking testObj.test === undefined or by creating a clean object and checking ({}).test === undefined.
| var headerParts = includeHeaders | ||
| .map(function(name) { | ||
| var value = headers[name.toLowerCase()]; | ||
| return value ? String(value) : ''; | ||
| }) | ||
| .filter(Boolean); | ||
|
|
||
| if (headerParts.length === 0) { | ||
| // No headers present, fall back to body-only | ||
| return etag(buf, { weak: weak }); | ||
| } | ||
|
|
||
| // Create combined buffer: body + header values | ||
| var headerBuf = Buffer.from(headerParts.join('|'), 'utf8'); |
Copilot
AI
Dec 10, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] If a header value contains invalid UTF-8 sequences or other non-string data that can't be properly converted to a string, the String(value) conversion on line 306 might produce unexpected results, and Buffer.from(headerParts.join('|'), 'utf8') on line 316 could potentially fail or produce incorrect results. Consider adding validation or error handling for header values to ensure they're valid strings before including them in the ETag calculation.
| it('should respect array limit for indexed arrays', function(done){ | ||
| var app = express(); | ||
| app.set('query parser', 'extended'); | ||
| app.set('query parser options', { | ||
| arrayLimit: 3 | ||
| }); | ||
|
|
||
| app.get('/', function(req, res){ | ||
| res.json(req.query); | ||
| }); | ||
|
|
||
| // qs arrayLimit applies to indexed arrays like a[0]=1&a[1]=2&a[2]=3 | ||
| request(app) | ||
| .get('/?ids[0]=a&ids[1]=b&ids[2]=c&ids[3]=d&ids[4]=e') | ||
| .expect(200) | ||
| .end(function(err, res){ | ||
| if (err) return done(err); | ||
| // With arrayLimit of 3, indices above 3 become object keys | ||
| assert.ok(res.body.ids); | ||
| done(); | ||
| }); | ||
| }); |
Copilot
AI
Dec 10, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test doesn't adequately verify that the arrayLimit option is working correctly. It only checks that res.body.ids exists, but doesn't verify the actual structure of the result. According to the comment on line 122, indices above the limit should become object keys, so the test should assert the actual structure, for example by checking if res.body.ids is an array or an object, and verifying the specific structure that results from exceeding the array limit.
|
I've written and deleted a comment enough times here that I need to move on. Before a PR is reviewable please ensure that at least
|
Overview
This PR introduces two independent enhancements to Express.js that address long-standing issues with CDN caching and query string handling.
Changes Included
1. CORS-Aware ETag Generation (Fixes #5986)
Adds new ETag modes that include response headers in ETag calculation to prevent cache conflicts when serving content to multiple origins through CDNs.
New ETag modes:
'weak-cors': Weak ETag including Access-Control-Allow-Origin header'strong-cors': Strong ETag including Access-Control-Allow-Origin headerProblem solved: CDNs returning 304 Not Modified responses that omit CORS headers, causing browsers to apply cached CORS headers from different origins, resulting in CORS errors.
Usage:
`app.set('etag', 'weak-cors');
app.use(function(req, res) {
res.set('Access-Control-Allow-Origin', req.get('Origin'));
res.send('content');
});`
Implementation:
createETagGeneratorto acceptincludeHeadersoptionres.send()to pass response headers to ETag function2. Configurable Query Parser Options (Fixes #5878)
Adds ability to configure
qslibrary options when using the extended query parser, addressing silent parameter truncation and providing better security controls.New API:
app.set('query parser', 'extended'); app.set('query parser options', { parameterLimit: 5000, // Increase from default 1000 arrayLimit: 50, // Increase from default 20 depth: 10, // Increase from default 5 allowPrototypes: false // Prevent prototype pollution });Problem solved: Query strings exceeding the default 1000 parameter limit resulted in silent data loss. The default
allowPrototypes: truesetting poses a prototype pollution security risk.Implementation:
compileQueryParsernow accepts optionalqsOptionsparametercreateExtendedQueryParserfactory function replacesparseExtendedQueryStringapp.set('query parser options')triggers parser recompilationBackward Compatibility
Both features maintain full backward compatibility:
Test Coverage
test/utils.js+ 10 integration tests intest/res.send.cors.jstest/req.query.options.jsSecurity Considerations
The query parser feature helps prevent prototype pollution attacks by allowing developers to set
allowPrototypes: false. The default remainstruefor backward compatibility, but documentation encourages the secure option.