GeezerVison is showing github.com — http: add httpValidation option to configure header value validation · nodejs/node@49462ec
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: doc/api/http.md +26Lines changed: 26 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -3662,6 +3662,9 @@ Found'`. | 3662 | 3662 | <!-- YAML | 3663 | 3663 | added: v0.1.13 | 3664 | 3664 | changes: | | 3665 | + - version: REPLACEME | | 3666 | + pr-url: https://github.com/nodejs/node/pull/61597 | | 3667 | + description: The `httpValidation` option is supported now. | 3665 | 3668 | - version: | 3666 | 3669 | - v25.1.0 | 3667 | 3670 | - v24.12.0 | @@ -3718,6 +3721,16 @@ changes: | 3718 | 3721 | `readableHighWaterMark` and `writableHighWaterMark`. This affects | 3719 | 3722 | `highWaterMark` property of both `IncomingMessage` and `ServerResponse`. | 3720 | 3723 | **Default:** See [`stream.getDefaultHighWaterMark()`][]. | | 3724 | + * `httpValidation` {string} Controls HTTP header value validation strictness | | 3725 | + for incoming requests. Accepted values are: | | 3726 | + * `'strict'`: Strictest validation; rejects any non-ASCII or control | | 3727 | + characters in header values. | | 3728 | + * `'relaxed'`: Allows a limited set of non-ASCII characters in header | | 3729 | + values, aligning with the | | 3730 | + [Fetch specification](https://fetch.spec.whatwg.org/). | | 3731 | + * `'insecure'`: Disables all header value validation (equivalent to | | 3732 | + `insecureHTTPParser: true`). | | 3733 | + Cannot be used together with `insecureHTTPParser`. **Default:** `'strict'`. | 3721 | 3734 | * `insecureHTTPParser` {boolean} If set to `true`, it will use an HTTP parser | 3722 | 3735 | with leniency flags enabled. Using the insecure parser should be avoided. | 3723 | 3736 | See [`--insecure-http-parser`][] for more information. | @@ -3974,6 +3987,9 @@ This can be overridden for servers and client requests by passing the | 3974 | 3987 | <!-- YAML | 3975 | 3988 | added: v0.3.6 | 3976 | 3989 | changes: | | 3990 | + - version: REPLACEME | | 3991 | + pr-url: https://github.com/nodejs/node/pull/61597 | | 3992 | + description: The `httpValidation` option is supported now. | 3977 | 3993 | - version: | 3978 | 3994 | - v16.7.0 | 3979 | 3995 | - v14.18.0 | @@ -4029,6 +4045,16 @@ changes: | 4029 | 4045 | request to. **Default:** `'localhost'`. | 4030 | 4046 | * `hostname` {string} Alias for `host`. To support [`url.parse()`][], | 4031 | 4047 | `hostname` will be used if both `host` and `hostname` are specified. | | 4048 | + * `httpValidation` {string} Controls HTTP header value validation strictness | | 4049 | + for outgoing requests. Accepted values are: | | 4050 | + * `'strict'`: Strictest validation; rejects any non-ASCII or control | | 4051 | + characters in header values. | | 4052 | + * `'relaxed'`: Allows a limited set of non-ASCII characters in header | | 4053 | + values, aligning with the | | 4054 | + [Fetch specification](https://fetch.spec.whatwg.org/). | | 4055 | + * `'insecure'`: Disables all header value validation (equivalent to | | 4056 | + `insecureHTTPParser: true`). | | 4057 | + Cannot be used together with `insecureHTTPParser`. **Default:** `'strict'`. | 4032 | 4058 | * `insecureHTTPParser` {boolean} If set to `true`, it will use an HTTP parser | 4033 | 4059 | with leniency flags enabled. Using the insecure parser should be avoided. | 4034 | 4060 | See [`--insecure-http-parser`][] for more information. | |
Collapse file Copy file name to clipboardExpand all lines: lib/_http_client.js +20-7Lines changed: 20 additions & 7 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -46,7 +46,7 @@ const { | 46 | 46 | freeParser, | 47 | 47 | parsers, | 48 | 48 | HTTPParser, | 49 | | - isLenient, | | 49 | + calculateLenientFlags, | 50 | 50 | prepareError, | 51 | 51 | kSkipPendingData, | 52 | 52 | } = require('_http_common'); | @@ -74,6 +74,7 @@ const { | 74 | 74 | codes: { | 75 | 75 | ERR_HTTP_HEADERS_SENT, | 76 | 76 | ERR_INVALID_ARG_TYPE, | | 77 | + ERR_INVALID_ARG_VALUE, | 77 | 78 | ERR_INVALID_HTTP_TOKEN, | 78 | 79 | ERR_INVALID_PROTOCOL, | 79 | 80 | ERR_UNESCAPED_CHARACTERS, | @@ -82,6 +83,7 @@ const { | 82 | 83 | const { | 83 | 84 | validateInteger, | 84 | 85 | validateBoolean, | | 86 | + validateOneOf, | 85 | 87 | validateString, | 86 | 88 | } = require('internal/validators'); | 87 | 89 | const { getTimerDuration } = require('internal/timers'); | @@ -119,9 +121,6 @@ const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/; | 119 | 121 | const kError = Symbol('kError'); | 120 | 122 | const kPath = Symbol('kPath'); | 121 | 123 |
| 122 | | -const kLenientAll = HTTPParser.kLenientAll | 0; | 123 | | -const kLenientNone = HTTPParser.kLenientNone | 0; | 124 | | - | 125 | 124 | const HTTP_CLIENT_TRACE_EVENT_NAME = 'http.client.request'; | 126 | 125 |
| 127 | 126 | function validateHost(host, name) { | @@ -299,6 +298,21 @@ function ClientRequest(input, options, cb) { | 299 | 298 |
| 300 | 299 | this.insecureHTTPParser = insecureHTTPParser; | 301 | 300 |
| | 301 | + const httpValidation = options.httpValidation; | | 302 | + if (httpValidation !== undefined) { | | 303 | + validateOneOf(httpValidation, 'options.httpValidation', | | 304 | + ['strict', 'relaxed', 'insecure']); | | 305 | + if (insecureHTTPParser !== undefined) { | | 306 | + throw new ERR_INVALID_ARG_VALUE( | | 307 | + 'options.httpValidation', | | 308 | + httpValidation, | | 309 | + 'cannot be used together with options.insecureHTTPParser', | | 310 | + ); | | 311 | + } | | 312 | + } | | 313 | + | | 314 | + this.httpValidation = httpValidation; | | 315 | + | 302 | 316 | if (options.joinDuplicateHeaders !== undefined) { | 303 | 317 | validateBoolean(options.joinDuplicateHeaders, 'options.joinDuplicateHeaders'); | 304 | 318 | } | @@ -907,12 +921,11 @@ function emitFreeNT(req) { | 907 | 921 | function tickOnSocket(req, socket) { | 908 | 922 | const parser = parsers.alloc(); | 909 | 923 | req.socket = socket; | 910 | | - const lenient = req.insecureHTTPParser === undefined ? | 911 | | - isLenient() : req.insecureHTTPParser; | | 924 | + const lenientFlags = calculateLenientFlags(req.httpValidation, req.insecureHTTPParser); | 912 | 925 | parser.initialize(HTTPParser.RESPONSE, | 913 | 926 | new HTTPClientAsyncResource('HTTPINCOMINGMESSAGE', req), | 914 | 927 | req.maxHeaderSize || 0, | 915 | | - lenient ? kLenientAll : kLenientNone); | | 928 | + lenientFlags); | 916 | 929 | parser.socket = socket; | 917 | 930 | parser.outgoing = req; | 918 | 931 | req.parser = parser; | |
Collapse file Copy file name to clipboardExpand all lines: lib/_http_common.js +35-7Lines changed: 35 additions & 7 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -256,17 +256,31 @@ function checkIsHttpToken(val) { | 256 | 256 | return true; | 257 | 257 | } | 258 | 258 |
| 259 | | -const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/; | | 259 | +// Strict header value regex per RFC 7230 (original/default behavior): | | 260 | +// field-value = *( field-content / obs-fold ) | | 261 | +// field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] | | 262 | +// field-vchar = VCHAR / obs-text | | 263 | +// This rejects control characters (0x00-0x1f except HTAB) and DEL (0x7f). | | 264 | +const strictHeaderCharRegex = /[^\t\x20-\x7e\x80-\xff]/; | | 265 | + | | 266 | +// Lenient header value regex per Fetch spec (https://fetch.spec.whatwg.org/#header-value): | | 267 | +// - Must contain no 0x00 (NUL) or HTTP newline bytes (0x0a LF, 0x0d CR) | | 268 | +// - Must be byte sequences (0x00-0xff), not arbitrary unicode | | 269 | +// This allows most control characters except NUL, CR, and LF. | | 270 | +// eslint-disable-next-line no-control-regex | | 271 | +const lenientHeaderCharRegex = /[\x00\x0a\x0d]|[^\x00-\xff]/; | | 272 | + | 260 | 273 | /** | 261 | | - * True if val contains an invalid field-vchar | 262 | | - * field-value = *( field-content / obs-fold ) | 263 | | - * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] | 264 | | - * field-vchar = VCHAR / obs-text | | 274 | + * True if val contains an invalid header value character. | | 275 | + * By default uses strict validation per RFC 7230. | | 276 | + * When lenient=true, uses relaxed validation per Fetch spec. | 265 | 277 | * @param {string} val | | 278 | + * @param {boolean} [lenient] - Use lenient validation (Fetch spec rules) | 266 | 279 | * @returns {boolean} | 267 | 280 | */ | 268 | | -function checkInvalidHeaderChar(val) { | 269 | | - return headerCharRegex.test(val); | | 281 | +function checkInvalidHeaderChar(val, lenient = false) { | | 282 | + const regex = lenient ? lenientHeaderCharRegex : strictHeaderCharRegex; | | 283 | + return regex.test(val); | 270 | 284 | } | 271 | 285 |
| 272 | 286 | function cleanParser(parser) { | @@ -300,6 +314,19 @@ function isLenient() { | 300 | 314 | return insecureHTTPParser; | 301 | 315 | } | 302 | 316 |
| | 317 | +function calculateLenientFlags(httpValidation, insecureHTTPParserOption) { | | 318 | + if (httpValidation === 'strict') { | | 319 | + return HTTPParser.kLenientNone | 0; | | 320 | + } else if (httpValidation === 'relaxed') { | | 321 | + return HTTPParser.kLenientHeaderValueRelaxed | 0; | | 322 | + } else if (httpValidation === 'insecure') { | | 323 | + return HTTPParser.kLenientAll | 0; | | 324 | + } | | 325 | + const lenient = insecureHTTPParserOption === undefined ? | | 326 | + isLenient() : insecureHTTPParserOption; | | 327 | + return lenient ? HTTPParser.kLenientAll | 0 : HTTPParser.kLenientNone | 0; | | 328 | +} | | 329 | + | 303 | 330 | module.exports = { | 304 | 331 | _checkInvalidHeaderChar: checkInvalidHeaderChar, | 305 | 332 | _checkIsHttpToken: checkIsHttpToken, | @@ -312,6 +339,7 @@ module.exports = { | 312 | 339 | kIncomingMessage, | 313 | 340 | HTTPParser, | 314 | 341 | isLenient, | | 342 | + calculateLenientFlags, | 315 | 343 | prepareError, | 316 | 344 | kSkipPendingData, | 317 | 345 | }; |
Collapse file Copy file name to clipboardExpand all lines: lib/_http_outgoing.js +57-15Lines changed: 57 additions & 15 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -44,6 +44,7 @@ const { | 44 | 44 | _checkIsHttpToken: checkIsHttpToken, | 45 | 45 | _checkInvalidHeaderChar: checkInvalidHeaderChar, | 46 | 46 | chunkExpression: RE_TE_CHUNKED, | | 47 | + isLenient, | 47 | 48 | } = require('_http_common'); | 48 | 49 | const { | 49 | 50 | defaultTriggerAsyncIdScope, | @@ -158,6 +159,33 @@ function OutgoingMessage(options) { | 158 | 159 | ObjectSetPrototypeOf(OutgoingMessage.prototype, Stream.prototype); | 159 | 160 | ObjectSetPrototypeOf(OutgoingMessage, Stream); | 160 | 161 |
| | 162 | +// Check if lenient header validation should be used. | | 163 | +// For ClientRequest: checks this.httpValidation or this.insecureHTTPParser | | 164 | +// For ServerResponse: checks the server's httpValidation or insecureHTTPParser | | 165 | +// Falls back to global --insecure-http-parser flag. | | 166 | +OutgoingMessage.prototype._isLenientHeaderValidation = function() { | | 167 | + // New httpValidation option takes priority (ClientRequest case) | | 168 | + if (this.httpValidation !== undefined) { | | 169 | + return this.httpValidation !== 'strict'; | | 170 | + } | | 171 | + // ServerResponse: check server's httpValidation option | | 172 | + const serverHttpValidation = this.req?.socket?.server?.httpValidation; | | 173 | + if (serverHttpValidation !== undefined) { | | 174 | + return serverHttpValidation !== 'strict'; | | 175 | + } | | 176 | + // Legacy insecureHTTPParser - ClientRequest has it directly | | 177 | + if (typeof this.insecureHTTPParser === 'boolean') { | | 178 | + return this.insecureHTTPParser; | | 179 | + } | | 180 | + // ServerResponse can access via req.socket.server | | 181 | + const serverOption = this.req?.socket?.server?.insecureHTTPParser; | | 182 | + if (typeof serverOption === 'boolean') { | | 183 | + return serverOption; | | 184 | + } | | 185 | + // Fall back to global option | | 186 | + return isLenient(); | | 187 | +}; | | 188 | + | 161 | 189 | ObjectDefineProperty(OutgoingMessage.prototype, 'errored', { | 162 | 190 | __proto__: null, | 163 | 191 | get() { | @@ -411,32 +439,33 @@ function _storeHeader(firstLine, headers) { | 411 | 439 | trailer: false, | 412 | 440 | header: firstLine, | 413 | 441 | }; | | 442 | + const lenient = this._isLenientHeaderValidation(); | 414 | 443 |
| 415 | 444 | if (headers) { | 416 | 445 | if (headers === this[kOutHeaders]) { | 417 | 446 | for (const key in headers) { | 418 | 447 | const entry = headers[key]; | 419 | | - processHeader(this, state, entry[0], entry[1], false); | | 448 | + processHeader(this, state, entry[0], entry[1], false, lenient); | 420 | 449 | } | 421 | 450 | } else if (ArrayIsArray(headers)) { | 422 | 451 | if (headers.length && ArrayIsArray(headers[0])) { | 423 | 452 | for (let i = 0; i < headers.length; i++) { | 424 | 453 | const entry = headers[i]; | 425 | | - processHeader(this, state, entry[0], entry[1], true); | | 454 | + processHeader(this, state, entry[0], entry[1], true, lenient); | 426 | 455 | } | 427 | 456 | } else { | 428 | 457 | if (headers.length % 2 !== 0) { | 429 | 458 | throw new ERR_INVALID_ARG_VALUE('headers', headers); | 430 | 459 | } | 431 | 460 |
| 432 | 461 | for (let n = 0; n < headers.length; n += 2) { | 433 | | - processHeader(this, state, headers[n + 0], headers[n + 1], true); | | 462 | + processHeader(this, state, headers[n + 0], headers[n + 1], true, lenient); | 434 | 463 | } | 435 | 464 | } | 436 | 465 | } else { | 437 | 466 | for (const key in headers) { | 438 | 467 | if (ObjectHasOwn(headers, key)) { | 439 | | - processHeader(this, state, key, headers[key], true); | | 468 | + processHeader(this, state, key, headers[key], true, lenient); | 440 | 469 | } | 441 | 470 | } | 442 | 471 | } | @@ -535,7 +564,7 @@ function _storeHeader(firstLine, headers) { | 535 | 564 | if (state.expect) this._send(''); | 536 | 565 | } | 537 | 566 |
| 538 | | -function processHeader(self, state, key, value, validate) { | | 567 | +function processHeader(self, state, key, value, validate, lenient) { | 539 | 568 | if (validate) | 540 | 569 | validateHeaderName(key); | 541 | 570 |
| @@ -562,17 +591,17 @@ function processHeader(self, state, key, value, validate) { | 562 | 591 | // Retain for(;;) loop for performance reasons | 563 | 592 | // Refs: https://github.com/nodejs/node/pull/30958 | 564 | 593 | for (let i = 0; i < value.length; i++) | 565 | | - storeHeader(self, state, key, value[i], validate); | | 594 | + storeHeader(self, state, key, value[i], validate, lenient); | 566 | 595 | return; | 567 | 596 | } | 568 | 597 | value = value.join('; '); | 569 | 598 | } | 570 | | - storeHeader(self, state, key, value, validate); | | 599 | + storeHeader(self, state, key, value, validate, lenient); | 571 | 600 | } | 572 | 601 |
| 573 | | -function storeHeader(self, state, key, value, validate) { | | 602 | +function storeHeader(self, state, key, value, validate, lenient) { | 574 | 603 | if (validate) | 575 | | - validateHeaderValue(key, value); | | 604 | + validateHeaderValue(key, value, lenient); | 576 | 605 | state.header += key + ': ' + value + '\r\n'; | 577 | 606 | matchHeader(self, state, key, value); | 578 | 607 | } | @@ -618,11 +647,11 @@ const validateHeaderName = assignFunctionName('validateHeaderName', hideStackFra | 618 | 647 | } | 619 | 648 | })); | 620 | 649 |
| 621 | | -const validateHeaderValue = assignFunctionName('validateHeaderValue', hideStackFrames((name, value) => { | | 650 | +const validateHeaderValue = assignFunctionName('validateHeaderValue', hideStackFrames((name, value, lenient) => { | 622 | 651 | if (value === undefined) { | 623 | 652 | throw new ERR_HTTP_INVALID_HEADER_VALUE.HideStackFramesError(value, name); | 624 | 653 | } | 625 | | - if (checkInvalidHeaderChar(value)) { | | 654 | + if (checkInvalidHeaderChar(value, lenient)) { | 626 | 655 | debug('Header "%s" contains invalid characters', name); | 627 | 656 | throw new ERR_INVALID_CHAR.HideStackFramesError('header content', name); | 628 | 657 | } | @@ -647,7 +676,13 @@ OutgoingMessage.prototype.setHeader = function setHeader(name, value) { | 647 | 676 | throw new ERR_HTTP_HEADERS_SENT('set'); | 648 | 677 | } | 649 | 678 | validateHeaderName(name); | 650 | | - validateHeaderValue(name, value); | | 679 | + if (value === undefined) { | | 680 | + throw new ERR_HTTP_INVALID_HEADER_VALUE(value, name); | | 681 | + } | | 682 | + if (checkInvalidHeaderChar(value, this._isLenientHeaderValidation())) { | | 683 | + debug('Header "%s" contains invalid characters', name); | | 684 | + throw new ERR_INVALID_CHAR('header content', name); | | 685 | + } | 651 | 686 |
| 652 | 687 | let headers = this[kOutHeaders]; | 653 | 688 | if (headers === null) | @@ -705,7 +740,13 @@ OutgoingMessage.prototype.appendHeader = function appendHeader(name, value) { | 705 | 740 | throw new ERR_HTTP_HEADERS_SENT('append'); | 706 | 741 | } | 707 | 742 | validateHeaderName(name); | 708 | | - validateHeaderValue(name, value); | | 743 | + if (value === undefined) { | | 744 | + throw new ERR_HTTP_INVALID_HEADER_VALUE(value, name); | | 745 | + } | | 746 | + if (checkInvalidHeaderChar(value, this._isLenientHeaderValidation())) { | | 747 | + debug('Header "%s" contains invalid characters', name); | | 748 | + throw new ERR_INVALID_CHAR('header content', name); | | 749 | + } | 709 | 750 |
| 710 | 751 | const field = name.toLowerCase(); | 711 | 752 | const headers = this[kOutHeaders]; | @@ -1001,12 +1042,13 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) { | 1001 | 1042 |
| 1002 | 1043 | // Check if the field must be sent several times | 1003 | 1044 | const isArrayValue = ArrayIsArray(value); | | 1045 | + const lenient = this._isLenientHeaderValidation(); | 1004 | 1046 | if ( | 1005 | 1047 | isArrayValue && value.length > 1 && | 1006 | 1048 | (!this[kUniqueHeaders] || !this[kUniqueHeaders].has(field.toLowerCase())) | 1007 | 1049 | ) { | 1008 | 1050 | for (let j = 0, l = value.length; j < l; j++) { | 1009 | | - if (checkInvalidHeaderChar(value[j])) { | | 1051 | + if (checkInvalidHeaderChar(value[j], lenient)) { | 1010 | 1052 | debug('Trailer "%s"[%d] contains invalid characters', field, j); | 1011 | 1053 | throw new ERR_INVALID_CHAR('trailer content', field); | 1012 | 1054 | } | @@ -1017,7 +1059,7 @@ OutgoingMessage.prototype.addTrailers = function addTrailers(headers) { | 1017 | 1059 | value = value.join('; '); | 1018 | 1060 | } | 1019 | 1061 |
| 1020 | | - if (checkInvalidHeaderChar(value)) { | | 1062 | + if (checkInvalidHeaderChar(value, lenient)) { | 1021 | 1063 | debug('Trailer "%s" contains invalid characters', field); | 1022 | 1064 | throw new ERR_INVALID_CHAR('trailer content', field); | 1023 | 1065 | } | |
|