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',