From 79bd80c5dcbadcd06c5325369794c04378184c92 Mon Sep 17 00:00:00 2001 From: rsclarke Date: Fri, 20 Feb 2026 14:03:28 +0800 Subject: [PATCH] http: validate headers in writeEarlyHints Add validateHeaderName/validateHeaderValue checks for non-link headers and checkInvalidHeaderChar for the Link value in HTTP/1.1 writeEarlyHints, closing a CRLF injection gap where header names and values were concatenated into the raw response without validation. Also tighten linkValueRegExp to reject CR/LF inside the <...> URL portion of Link header values. --- lib/_http_server.js | 11 ++++- lib/internal/validators.js | 2 +- .../test-http-early-hints-invalid-argument.js | 41 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/lib/_http_server.js b/lib/_http_server.js index 1d1bc37c47e109..b042106d2e14da 100644 --- a/lib/_http_server.js +++ b/lib/_http_server.js @@ -55,6 +55,8 @@ const { kUniqueHeaders, parseUniqueHeadersOption, OutgoingMessage, + validateHeaderName, + validateHeaderValue, } = require('_http_outgoing'); const { kOutHeaders, @@ -333,13 +335,20 @@ ServerResponse.prototype.writeEarlyHints = function writeEarlyHints(hints, cb) { return; } + if (checkInvalidHeaderChar(link)) { + throw new ERR_INVALID_CHAR('header content', 'Link'); + } + head += 'Link: ' + link + '\r\n'; const keys = ObjectKeys(hints); for (let i = 0; i < keys.length; i++) { const key = keys[i]; if (key !== 'link') { - head += key + ': ' + hints[key] + '\r\n'; + validateHeaderName(key); + const value = hints[key]; + validateHeaderValue(key, value); + head += key + ': ' + value + '\r\n'; } } diff --git a/lib/internal/validators.js b/lib/internal/validators.js index b9845c538bb98f..110b045a063460 100644 --- a/lib/internal/validators.js +++ b/lib/internal/validators.js @@ -509,7 +509,7 @@ function validateUnion(value, name, union) { (not necessarily a valid URI reference) followed by zero or more link-params separated by semicolons. */ -const linkValueRegExp = /^(?:<[^>]*>)(?:\s*;\s*[^;"\s]+(?:=(")?[^;"\s]*\1)?)*$/; +const linkValueRegExp = /^(?:<[^>\r\n]*>)(?:\s*;\s*[^;"\s]+(?:=(")?[^;"\s]*\1)?)*$/; /** * @param {any} value diff --git a/test/parallel/test-http-early-hints-invalid-argument.js b/test/parallel/test-http-early-hints-invalid-argument.js index f776bcafa40ed3..4204d74c67a18d 100644 --- a/test/parallel/test-http-early-hints-invalid-argument.js +++ b/test/parallel/test-http-early-hints-invalid-argument.js @@ -47,3 +47,44 @@ const testResBody = 'response content\n'; req.on('information', common.mustNotCall()); })); } + +{ + const server = http.createServer(common.mustCall((req, res) => { + debug('Server sending early hints with CRLF injection...'); + + assert.throws(() => { + res.writeEarlyHints({ + link: '; rel=preload; as=style', + 'X-Custom': 'valid\r\nSet-Cookie: session=evil', + }); + }, (err) => err.code === 'ERR_INVALID_CHAR'); + + assert.throws(() => { + res.writeEarlyHints({ + link: '; rel=preload; as=style', + 'X-Custom\r\nSet-Cookie: session=evil': 'value', + }); + }, (err) => err.code === 'ERR_INVALID_HTTP_TOKEN'); + + assert.throws(() => { + res.writeEarlyHints({ + link: '; rel=preload; as=style', + }); + }, (err) => err.code === 'ERR_INVALID_ARG_VALUE'); + + debug('Server sending full response...'); + res.end(testResBody); + server.close(); + })); + + server.listen(0, common.mustCall(() => { + const req = http.request({ + port: server.address().port, path: '/' + }); + + req.end(); + debug('Client sending request...'); + + req.on('information', common.mustNotCall()); + })); +}