Repository: nodejs/node
Pull request: http: validate headers in `writeEarlyHints`

`writeEarlyHints()` constructs the 103 Early Hints response by directly concatenating user-supplied header names and values into the raw HTTP response. Unlike `setHeader()`, `appendHeader()`, and `writeHead()`, it never calls `validateHeaderName()` or `validateHeaderValue()`, allowing CRLF sequences to pass through unchecked.

This means user supplied data passed to `writeEarlyHints()` can inject arbitrary HTTP response headers (or entire responses) via CRLF injection in:

* Non-link header values
* Non-link header names
* Link header URLs (the linkValueRegExp regex uses `[^>]*` which matches `\r\n`)

This PR adds the same validation used by `setHeader()`:

Call `validateHeaderName()` and `validateHeaderValue()` for non-link headers in the `writeEarlyHints` loop
Call `checkInvalidHeaderChar()` on the assembled Link header value
Tighten `linkValueRegExp` from `[^>]*` to `[^>\r\n]*` to reject CRLF inside the `<...>` URL portion

The HTTP/2 compatibility layer (lib/internal/http2/compat.js) has the same gap but nghttp2 rejects CRLF in HTTP/2 binary frame headers at the lower level. Though, it could be open for discussion whether similar validation should also be applied?

Changed files:
filename: lib/_http_server.js
patch: @@ -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';
     }
   }
 

filename: lib/internal/validators.js
patch: @@ -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

filename: test/parallel/test-http-early-hints-invalid-argument.js
patch: @@ -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': '</styles.css>; rel=preload; as=style',
+        'X-Custom': 'valid\r\nSet-Cookie: session=evil',
+      });
+    }, (err) => err.code === 'ERR_INVALID_CHAR');
+
+    assert.throws(() => {
+      res.writeEarlyHints({
+        'link': '</styles.css>; rel=preload; as=style',
+        'X-Custom\r\nSet-Cookie: session=evil': 'value',
+      });
+    }, (err) => err.code === 'ERR_INVALID_HTTP_TOKEN');
+
+    assert.throws(() => {
+      res.writeEarlyHints({
+        link: '</styles.css\r\nSet-Cookie: session=evil>; 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());
+  }));
+}