Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
88005bd705 fix: HTTPS req.socket instanceof TLSSocket returns true (#16834)
Make `NodeHTTPServerSocket` instances report correct prototype chains:
- HTTPS connections: `instanceof tls.TLSSocket` and `instanceof net.Socket` return `true`
- HTTP connections: `instanceof net.Socket` returns `true`

This matches Node.js behavior where HTTPS server request sockets are
`TLSSocket` instances and HTTP server request sockets are `net.Socket`
instances.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 02:28:08 +00:00
5 changed files with 173 additions and 186 deletions

View File

@@ -57,11 +57,10 @@ static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject
for (const auto& it : vec) {
const auto& name = it.key;
const auto& value = it.value;
const auto lowercaseName = WTF::httpHeaderNameStringImpl(name);
const auto canonicalName = WTF::httpHeaderNameDefaultCaseStringImpl(name);
const auto impl = WTF::httpHeaderNameStringImpl(name);
JSString* jsValue = jsString(vm, value);
obj->putDirect(vm, Identifier::fromString(vm, lowercaseName), jsValue, 0);
array->putDirectIndex(globalObject, arrayI++, jsString(vm, canonicalName));
obj->putDirect(vm, Identifier::fromString(vm, impl), jsValue, 0);
array->putDirectIndex(globalObject, arrayI++, jsString(vm, impl));
array->putDirectIndex(globalObject, arrayI++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
}
@@ -75,10 +74,9 @@ static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject
if (count > 0) {
JSC::JSArray* setCookies = constructEmptyArray(globalObject, nullptr, count);
RETURN_IF_EXCEPTION(scope, {});
const auto setCookieHeaderStringLowercase = WTF::httpHeaderNameStringImpl(HTTPHeaderName::SetCookie);
const auto setCookieHeaderStringCanonical = WTF::httpHeaderNameDefaultCaseStringImpl(HTTPHeaderName::SetCookie);
const auto setCookieHeaderString = WTF::httpHeaderNameStringImpl(HTTPHeaderName::SetCookie);
JSString* setCookie = jsString(vm, setCookieHeaderStringCanonical);
JSString* setCookie = jsString(vm, setCookieHeaderString);
for (size_t i = 0; i < count; ++i) {
auto* out = jsString(vm, values[i]);
@@ -89,7 +87,7 @@ static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject
}
RETURN_IF_EXCEPTION(scope, {});
obj->putDirect(vm, JSC::Identifier::fromString(vm, setCookieHeaderStringLowercase), setCookies, 0);
obj->putDirect(vm, JSC::Identifier::fromString(vm, setCookieHeaderString), setCookies, 0);
}
}
@@ -156,14 +154,14 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
HTTPHeaderIdentifiers& identifiers = WebCore::clientData(vm)->httpHeaderIdentifiers();
Identifier nameIdentifier;
JSString* rawHeaderNameString = nullptr;
JSString* nameString = nullptr;
if (WebCore::findHTTPHeaderName(nameView, name)) {
rawHeaderNameString = jsString(vm, WTF::httpHeaderNameDefaultCaseStringImpl(name));
nameString = identifiers.stringFor(globalObject, name);
nameIdentifier = identifiers.identifierFor(vm, name);
} else {
WTF::String wtfString = nameView.toString();
rawHeaderNameString = jsString(vm, wtfString);
nameString = jsString(vm, wtfString);
nameIdentifier = Identifier::fromString(vm, wtfString.convertToASCIILowercase());
}
@@ -171,7 +169,7 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
if (!setCookiesHeaderArray) {
setCookiesHeaderArray = constructEmptyArray(globalObject, nullptr);
RETURN_IF_EXCEPTION(scope, );
setCookiesHeaderString = rawHeaderNameString;
setCookiesHeaderString = nameString;
headersObject->putDirect(vm, nameIdentifier, setCookiesHeaderArray, 0);
RETURN_IF_EXCEPTION(scope, void());
}
@@ -183,7 +181,7 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
} else {
headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue);
RETURN_IF_EXCEPTION(scope, void());
arrayValues.append(rawHeaderNameString);
arrayValues.append(nameString);
arrayValues.append(jsValue);
RETURN_IF_EXCEPTION(scope, void());
}
@@ -337,15 +335,15 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
memcpy(data.data(), pair.second.data(), pair.second.length());
HTTPHeaderName name;
WTF::String nameString;
WTF::String lowercasedNameString;
WTF::String canonicalNameString;
if (WebCore::findHTTPHeaderName(nameView, name)) {
lowercasedNameString = WTF::httpHeaderNameStringImpl(name);
canonicalNameString = WTF::httpHeaderNameDefaultCaseStringImpl(name);
nameString = WTF::httpHeaderNameStringImpl(name);
lowercasedNameString = nameString;
} else {
canonicalNameString = nameView.toString();
lowercasedNameString = canonicalNameString.convertToASCIILowercase();
nameString = nameView.toString();
lowercasedNameString = nameString.convertToASCIILowercase();
}
JSString* jsValue = jsString(vm, value);
@@ -354,7 +352,7 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
if (!setCookiesHeaderArray) {
setCookiesHeaderArray = constructEmptyArray(globalObject, nullptr);
RETURN_IF_EXCEPTION(scope, {});
setCookiesHeaderString = jsString(vm, canonicalNameString);
setCookiesHeaderString = jsString(vm, nameString);
headersObject->putDirect(vm, Identifier::fromString(vm, lowercasedNameString), setCookiesHeaderArray, 0);
RETURN_IF_EXCEPTION(scope, {});
}
@@ -365,7 +363,7 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
} else {
headersObject->putDirect(vm, Identifier::fromString(vm, lowercasedNameString), jsValue, 0);
array->putDirectIndex(globalObject, i++, jsString(vm, canonicalNameString));
array->putDirectIndex(globalObject, i++, jsString(vm, nameString));
array->putDirectIndex(globalObject, i++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
}

View File

@@ -34,112 +34,34 @@ const { FakeSocket } = require("internal/http/FakeSocket");
var defaultIncomingOpts = { type: "request" };
const nop = () => {};
// Map of lowercase header names to their canonical (Title-Case) form.
// Used by assignHeadersSlow to restore proper casing for rawHeaders,
// since the Fetch API normalizes all header names to lowercase.
const canonicalHeaderNames: Record<string, string> = {
"accept": "Accept",
"accept-charset": "Accept-Charset",
"accept-encoding": "Accept-Encoding",
"accept-language": "Accept-Language",
"accept-ranges": "Accept-Ranges",
"access-control-allow-credentials": "Access-Control-Allow-Credentials",
"access-control-allow-headers": "Access-Control-Allow-Headers",
"access-control-allow-methods": "Access-Control-Allow-Methods",
"access-control-allow-origin": "Access-Control-Allow-Origin",
"access-control-expose-headers": "Access-Control-Expose-Headers",
"access-control-max-age": "Access-Control-Max-Age",
"access-control-request-headers": "Access-Control-Request-Headers",
"access-control-request-method": "Access-Control-Request-Method",
"age": "Age",
"authorization": "Authorization",
"cache-control": "Cache-Control",
"connection": "Connection",
"content-disposition": "Content-Disposition",
"content-encoding": "Content-Encoding",
"content-language": "Content-Language",
"content-length": "Content-Length",
"content-location": "Content-Location",
"content-range": "Content-Range",
"content-security-policy": "Content-Security-Policy",
"content-security-policy-report-only": "Content-Security-Policy-Report-Only",
"content-type": "Content-Type",
"cookie": "Cookie",
"cookie2": "Cookie2",
"date": "Date",
"dnt": "DNT",
"etag": "ETag",
"expect": "Expect",
"expires": "Expires",
"host": "Host",
"if-match": "If-Match",
"if-modified-since": "If-Modified-Since",
"if-none-match": "If-None-Match",
"if-range": "If-Range",
"if-unmodified-since": "If-Unmodified-Since",
"keep-alive": "Keep-Alive",
"last-modified": "Last-Modified",
"link": "Link",
"location": "Location",
"origin": "Origin",
"pragma": "Pragma",
"proxy-authorization": "Proxy-Authorization",
"range": "Range",
"referer": "Referer",
"referrer-policy": "Referrer-Policy",
"refresh": "Refresh",
"sec-fetch-dest": "Sec-Fetch-Dest",
"sec-fetch-mode": "Sec-Fetch-Mode",
"sec-websocket-accept": "Sec-WebSocket-Accept",
"sec-websocket-extensions": "Sec-WebSocket-Extensions",
"sec-websocket-key": "Sec-WebSocket-Key",
"sec-websocket-protocol": "Sec-WebSocket-Protocol",
"sec-websocket-version": "Sec-WebSocket-Version",
"server-timing": "Server-Timing",
"set-cookie": "Set-Cookie",
"set-cookie2": "Set-Cookie2",
"strict-transport-security": "Strict-Transport-Security",
"te": "TE",
"trailer": "Trailer",
"transfer-encoding": "Transfer-Encoding",
"upgrade": "Upgrade",
"upgrade-insecure-requests": "Upgrade-Insecure-Requests",
"user-agent": "User-Agent",
"vary": "Vary",
"via": "Via",
"x-content-type-options": "X-Content-Type-Options",
"x-dns-prefetch-control": "X-DNS-Prefetch-Control",
"x-frame-options": "X-Frame-Options",
"x-xss-protection": "X-XSS-Protection",
};
function assignHeadersSlow(object, req) {
const headers = req.headers;
var outHeaders = Object.create(null);
const rawHeaders: string[] = [];
var i = 0;
for (let key in headers) {
var value = headers[key];
var lowercaseKey = key.toLowerCase();
var rawHeaderName = canonicalHeaderNames[lowercaseKey] || key;
var originalKey = key;
var value = headers[originalKey];
if (lowercaseKey !== "set-cookie") {
key = key.toLowerCase();
if (key !== "set-cookie") {
value = String(value);
$putByValDirect(rawHeaders, i++, rawHeaderName);
$putByValDirect(rawHeaders, i++, originalKey);
$putByValDirect(rawHeaders, i++, value);
outHeaders[lowercaseKey] = value;
outHeaders[key] = value;
} else {
if ($isJSArray(value)) {
outHeaders[lowercaseKey] = value.slice();
outHeaders[key] = value.slice();
for (let entry of value) {
$putByValDirect(rawHeaders, i++, rawHeaderName);
$putByValDirect(rawHeaders, i++, originalKey);
$putByValDirect(rawHeaders, i++, entry);
}
} else {
value = String(value);
outHeaders[lowercaseKey] = [value];
$putByValDirect(rawHeaders, i++, rawHeaderName);
outHeaders[key] = [value];
$putByValDirect(rawHeaders, i++, originalKey);
$putByValDirect(rawHeaders, i++, value);
}
}

View File

@@ -822,6 +822,13 @@ const NodeHTTPServerSocket = class Socket extends Duplex {
#pendingCallback = null;
constructor(server: Server, handle, encrypted) {
super();
// Switch prototype chain based on whether this is a TLS connection
// so that instanceof checks match Node.js behavior.
if (encrypted) {
Object.setPrototypeOf(this, getTLSSocketProto());
} else {
Object.setPrototypeOf(this, getNetSocketProto());
}
this.server = server;
this[kHandle] = handle;
this._secureEstablished = !!handle?.secureEstablished;
@@ -1107,6 +1114,42 @@ const NodeHTTPServerSocket = class Socket extends Duplex {
}
} as unknown as typeof import("node:net").Socket;
// Create alternative prototype chains so that:
// - For HTTPS connections: req.socket instanceof TLSSocket === true
// - For HTTP connections: req.socket instanceof net.Socket === true
// This matches Node.js behavior (see issue #16834).
const NodeHTTPServerSocketPrototype = NodeHTTPServerSocket.prototype;
// Lazily resolve prototypes to avoid circular dependency issues at module load time.
let _netSocketProto: object | undefined;
let _tlsSocketProto: object | undefined;
function getNetSocketProto() {
if (!_netSocketProto) {
const { Socket } = require("node:net");
// Create a new prototype that has all NodeHTTPServerSocket methods
// but inherits from net.Socket.prototype instead of Duplex.prototype
_netSocketProto = Object.create(Socket.prototype);
const descriptors = Object.getOwnPropertyDescriptors(NodeHTTPServerSocketPrototype);
delete descriptors.constructor;
Object.defineProperties(_netSocketProto, descriptors);
}
return _netSocketProto;
}
function getTLSSocketProto() {
if (!_tlsSocketProto) {
const { TLSSocket } = require("node:tls");
// Create a new prototype that has all NodeHTTPServerSocket methods
// but inherits from TLSSocket.prototype (which itself inherits from net.Socket.prototype)
_tlsSocketProto = Object.create(TLSSocket.prototype);
const descriptors = Object.getOwnPropertyDescriptors(NodeHTTPServerSocketPrototype);
delete descriptors.constructor;
Object.defineProperties(_tlsSocketProto, descriptors);
}
return _tlsSocketProto;
}
function _writeHead(statusCode, reason, obj, response) {
const originalStatusCode = statusCode;
let hasContentLength = response.hasHeader("content-length");

View File

@@ -0,0 +1,101 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tls } from "harness";
test("HTTPS req.socket instanceof TLSSocket", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const https = require("node:https");
const tls = require("node:tls");
const net = require("node:net");
const server = https.createServer(
{ cert: process.env.TLS_CERT, key: process.env.TLS_KEY },
(req, res) => {
const results = {
instanceOfTLSSocket: req.socket instanceof tls.TLSSocket,
instanceOfNetSocket: req.socket instanceof net.Socket,
encrypted: req.socket.encrypted,
};
res.end(JSON.stringify(results));
server.close();
}
);
server.listen(0, () => {
const port = server.address().port;
https.get("https://localhost:" + port, { rejectUnauthorized: false }, (res) => {
let data = "";
res.on("data", (chunk) => data += chunk);
res.on("end", () => {
console.log(data);
});
});
});
`,
],
env: { ...bunEnv, TLS_CERT: tls.cert, TLS_KEY: tls.key },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
if (exitCode !== 0) {
throw new Error(`Process exited with code ${exitCode}: ${stderr}`);
}
const results = JSON.parse(stdout.trim());
expect(results.instanceOfTLSSocket).toBe(true);
expect(results.instanceOfNetSocket).toBe(true);
expect(results.encrypted).toBe(true);
});
test("HTTP req.socket instanceof net.Socket", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const http = require("node:http");
const tls = require("node:tls");
const net = require("node:net");
const server = http.createServer((req, res) => {
const results = {
instanceOfTLSSocket: req.socket instanceof tls.TLSSocket,
instanceOfNetSocket: req.socket instanceof net.Socket,
encrypted: !!req.socket.encrypted,
};
res.end(JSON.stringify(results));
server.close();
});
server.listen(0, () => {
const port = server.address().port;
http.get("http://localhost:" + port, (res) => {
let data = "";
res.on("data", (chunk) => data += chunk);
res.on("end", () => {
console.log(data);
});
});
});
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
if (exitCode !== 0) {
throw new Error(`Process exited with code ${exitCode}: ${stderr}`);
}
const results = JSON.parse(stdout.trim());
expect(results.instanceOfTLSSocket).toBe(false);
expect(results.instanceOfNetSocket).toBe(true);
expect(results.encrypted).toBe(false);
});

View File

@@ -1,77 +0,0 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// https://github.com/oven-sh/bun/issues/20433
// req.rawHeaders on node:http server should preserve canonical header name casing
test("node:http IncomingMessage rawHeaders should have canonical-cased header names", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const http = require("node:http");
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(req.rawHeaders));
});
server.listen(0, () => {
const port = server.address().port;
fetch("http://localhost:" + port + "/", {
headers: {
"Accept-Encoding": "gzip, deflate",
"Accept": "*/*",
"Connection": "keep-alive",
"Authorization": "xxx",
"Origin": "something",
"Content-Type": "text/plain",
},
})
.then((r) => r.json())
.then((rawHeaders) => {
// rawHeaders is [name, value, name, value, ...]
const headerNames = rawHeaders.filter((_, i) => i % 2 === 0);
console.log(JSON.stringify(headerNames));
server.close();
});
});
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const headerNames: string[] = JSON.parse(stdout.trim());
// Known headers should be in canonical Title-Case form
const expectedCanonical: Record<string, string> = {
accept: "Accept",
"accept-encoding": "Accept-Encoding",
connection: "Connection",
authorization: "Authorization",
origin: "Origin",
"content-type": "Content-Type",
host: "Host",
"user-agent": "User-Agent",
};
for (const name of headerNames) {
const lower = name.toLowerCase();
if (expectedCanonical[lower]) {
expect(name).toBe(expectedCanonical[lower]);
}
}
// Verify that multi-word headers are present and properly cased
expect(headerNames).toContain("Accept-Encoding");
expect(headerNames).toContain("Authorization");
expect(headerNames).toContain("Content-Type");
expect(headerNames).toContain("Connection");
expect(headerNames).toContain("Origin");
expect(exitCode).toBe(0);
});