Commit 9aa379e22fa for nodejs

commit 9aa379e22fafbd2521430ab2013c63f4d706d757
Author: Matteo Collina <hello@matteocollina.com>
Date:   Mon Nov 9 13:05:43 2026 +0100

    tls: reject hostnames with forbidden code points

    domainToASCII() is implemented with the URL host parser, which stops at
    path, query and fragment delimiters and percent-decodes. Passing a
    hostname through it can therefore silently shorten the name, so
    checkServerIdentity() validates a certificate against a host the
    application never asked for: name resolution and SNI use the original
    string, while the certificate is matched after normalization. Reject
    hostnames containing a WHATWG forbidden host code point before matching.
    None of these code points is valid in a DNS hostname, so failing closed
    is safe.

    Signed-off-by: Matteo Collina <hello@matteocollina.com>
    PR-URL: https://github.com/nodejs/node/pull/67254
    Reviewed-By: Robert Nagy <ronagy@icloud.com>
    Reviewed-By: Filip Skokan <panva.ip@gmail.com>

diff --git a/lib/tls.js b/lib/tls.js
index ba395be5eb3..288d783721d 100644
--- a/lib/tls.js
+++ b/lib/tls.js
@@ -427,6 +427,17 @@ function splitEscapedAltNames(altNames) {
   return result;
 }

+// domainToASCII() is implemented on top of the URL host parser, which stops at
+// path, query and fragment delimiters ("/", "?", "#" and backslash),
+// percent-decodes and drops a few code points. Passing a hostname through it
+// can therefore yield a different host than the one the application actually
+// connects to (the original string is used for name resolution and SNI), so
+// the certificate would be checked against a host the application never asked
+// for. Every one of these code points (the WHATWG "forbidden host code point"
+// set) is invalid in a DNS hostname, so failing closed is safe.
+// eslint-disable-next-line no-control-regex
+const forbiddenHostCodePoint = /[\u0000\u0009\u000A\u000D\u0020#%/:<>?@[\]\\^|]/;
+
 exports.checkServerIdentity = function checkServerIdentity(hostname, cert) {
   const subject = cert.subject;
   const altNames = cert.subjectaltname;
@@ -442,6 +453,17 @@ exports.checkServerIdentity = function checkServerIdentity(hostname, cert) {
   const hostnameIsIP = net.isIP(hostname);
   const hostnameASCIIIsIP = net.isIP(hostnameASCIIWithoutFQDN);

+  // The application's hostname is only meaningful for the exact string it
+  // supplied, but domainToASCII() can silently shorten it. Reject hostnames
+  // containing a forbidden host code point so the certificate is not validated
+  // against a host that was never requested (e.g. "agent1/.bank.example" must
+  // not be verified as "agent1"). IP literals are exempt because IPv6 uses
+  // "[", "]" and ":" legitimately.
+  if (!hostnameIsIP && forbiddenHostCodePoint.test(hostname)) {
+    return new ERR_TLS_CERT_ALTNAME_INVALID(
+      'Host contains a forbidden host code point', hostname, cert);
+  }
+
   if (altNames) {
     const splitAltNames = altNames.includes('"') ?
       splitEscapedAltNames(altNames) :
diff --git a/test/parallel/test-tls-check-server-identity.js b/test/parallel/test-tls-check-server-identity.js
index 2097081816e..78ba9caf584 100644
--- a/test/parallel/test-tls-check-server-identity.js
+++ b/test/parallel/test-tls-check-server-identity.js
@@ -138,10 +138,13 @@ const tests = [
     cert: { subject: { CN: '*.a.com' } },
     error: 'Host: ba.com. is not cert\'s CN: *.a.com'
   },
+  // A hostname containing a forbidden host code point (here a line feed) is
+  // rejected instead of being silently normalized and matched: domainToASCII()
+  // would drop the control character, producing a host that was never asked for.
   {
     host: '\n.b.com',
     cert: { subject: { CN: '*n.b.com' } },
-    error: 'Host: \n.b.com. is not cert\'s CN: *n.b.com'
+    error: 'Host contains a forbidden host code point'
   },
   { host: 'b.a.com',
     cert: {
@@ -432,6 +435,28 @@ const tests = [
       subject: {},
     },
   },
+  // domainToASCII() is implemented on top of the URL host parser, which stops at
+  // path/query/fragment delimiters and percent-decodes. Reject hostnames that
+  // would be silently truncated or otherwise transformed, so the certificate is
+  // never validated against a host the application did not ask for (e.g.
+  // "agent1/.bank.example" must not be verified as "agent1").
+  ...[
+    ['agent1/.bank.example'],
+    ['agent1?.bank.example'],
+    ['agent1#.bank.example'],
+    ['agent1\\.bank.example'],
+    ['ag%65nt1'],
+    ['ag@ent1'],
+    ['ag:ent1'],
+    ['ag ent1'],
+  ].map(([host]) => ({
+    host,
+    cert: {
+      subjectaltname: 'DNS:agent1',
+      subject: {},
+    },
+    error: 'Host contains a forbidden host code point',
+  })),
   {
     host: 'localhost', cert: {
       subjectaltname: 'DNS:a.com',