From d904e7e598a6d754be4452725ca6605b1b6f8563 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Tue, 27 Jan 2026 07:11:49 +0000 Subject: [PATCH] fix(http): preserve original header name casing in Node.js HTTP server When using `res.setHeader('location', ...)` in Bun's Node.js-compatible HTTP server, the header was being sent as `Location` (title-case) instead of preserving the original lowercase `location`. This is because known HTTP headers were being normalized to enum values, losing the original casing. The fix adds an `originalName` field to `CommonHeader` that stores the original header name when set via the Node.js HTTP API. When writing response headers, the original name is used if available, otherwise falling back to the default casing. This makes Bun's behavior match Node.js, which preserves the original casing of header names set via `setHeader()`. Fixes #15578 Co-Authored-By: Claude Opus 4.5 --- src/bun.js/bindings/NodeHTTP.cpp | 9 +- src/bun.js/bindings/webcore/FetchHeaders.cpp | 18 ++ src/bun.js/bindings/webcore/FetchHeaders.h | 1 + src/bun.js/bindings/webcore/HTTPHeaderMap.cpp | 33 +++- src/bun.js/bindings/webcore/HTTPHeaderMap.h | 12 +- test/regression/issue/15578.test.ts | 158 ++++++++++++++++++ 6 files changed, 221 insertions(+), 10 deletions(-) create mode 100644 test/regression/issue/15578.test.ts diff --git a/src/bun.js/bindings/NodeHTTP.cpp b/src/bun.js/bindings/NodeHTTP.cpp index a13cba6591..db908de2c7 100644 --- a/src/bun.js/bindings/NodeHTTP.cpp +++ b/src/bun.js/bindings/NodeHTTP.cpp @@ -538,7 +538,8 @@ static void writeFetchHeadersToUWSResponse(WebCore::FetchHeaders& headers, uWS:: for (const auto& header : internalHeaders.commonHeaders()) { - const auto& name = WebCore::httpHeaderNameString(header.key); + // Use the original name if it was preserved, otherwise use the default header name string + const auto& name = header.originalName.isEmpty() ? WebCore::httpHeaderNameString(header.key) : StringView(header.originalName); const auto& value = header.value; // We have to tell uWS not to automatically insert a TransferEncoding or Date header. @@ -930,7 +931,8 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetHeader, (JSGlobalObject * globalObject, CallFr RETURN_IF_EXCEPTION(scope, {}); auto value = item.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); - impl->set(name, value); + // Use setPreservingOriginalName to preserve the original header name casing + impl->setPreservingOriginalName(name, value); RETURN_IF_EXCEPTION(scope, {}); } for (unsigned i = 1; i < length; ++i) { @@ -947,7 +949,8 @@ JSC_DEFINE_HOST_FUNCTION(jsHTTPSetHeader, (JSGlobalObject * globalObject, CallFr auto value = valueValue.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); - impl->set(name, value); + // Use setPreservingOriginalName to preserve the original header name casing + impl->setPreservingOriginalName(name, value); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); } diff --git a/src/bun.js/bindings/webcore/FetchHeaders.cpp b/src/bun.js/bindings/webcore/FetchHeaders.cpp index f241e7e0e8..cde43d896c 100644 --- a/src/bun.js/bindings/webcore/FetchHeaders.cpp +++ b/src/bun.js/bindings/webcore/FetchHeaders.cpp @@ -287,6 +287,24 @@ ExceptionOr FetchHeaders::set(const String& name, const String& value) return {}; } +ExceptionOr FetchHeaders::setPreservingOriginalName(const String& name, const String& value) +{ + String normalizedValue = value.trim(isHTTPSpace); + auto canWriteResult = canWriteHeader(name, normalizedValue, normalizedValue, m_guard); + if (canWriteResult.hasException()) + return canWriteResult.releaseException(); + if (!canWriteResult.releaseReturnValue()) + return {}; + + ++m_updateCounter; + m_headers.setPreservingOriginalName(name, normalizedValue); + + if (m_guard == FetchHeaders::Guard::RequestNoCors) + removePrivilegedNoCORSRequestHeaders(m_headers); + + return {}; +} + void FetchHeaders::filterAndFill(const HTTPHeaderMap& headers, Guard guard) { for (auto& header : headers) { diff --git a/src/bun.js/bindings/webcore/FetchHeaders.h b/src/bun.js/bindings/webcore/FetchHeaders.h index 1785f66984..7546510fdb 100644 --- a/src/bun.js/bindings/webcore/FetchHeaders.h +++ b/src/bun.js/bindings/webcore/FetchHeaders.h @@ -64,6 +64,7 @@ public: ExceptionOr has(const StringView) const; ExceptionOr set(const String& name, const String& value); ExceptionOr set(const HTTPHeaderName name, const String& value); + ExceptionOr setPreservingOriginalName(const String& name, const String& value); ExceptionOr fill(const Init&); ExceptionOr fill(const FetchHeaders&); diff --git a/src/bun.js/bindings/webcore/HTTPHeaderMap.cpp b/src/bun.js/bindings/webcore/HTTPHeaderMap.cpp index 6ef789490b..cb8cf28f2a 100644 --- a/src/bun.js/bindings/webcore/HTTPHeaderMap.cpp +++ b/src/bun.js/bindings/webcore/HTTPHeaderMap.cpp @@ -128,6 +128,31 @@ void HTTPHeaderMap::set(const String& name, const String& value) setUncommonHeader(name, value); } +void HTTPHeaderMap::setPreservingOriginalName(const String& name, const String& value) +{ + HTTPHeaderName headerName; + if (findHTTPHeaderName(name, headerName)) { + if (headerName == HTTPHeaderName::SetCookie) { + m_setCookieHeaders.clear(); + m_setCookieHeaders.append(value); + return; + } + + auto index = m_commonHeaders.findIf([&](auto& header) { + return header.key == headerName; + }); + if (index == notFound) + m_commonHeaders.append(CommonHeader { headerName, value, name }); + else { + m_commonHeaders[index].value = value; + m_commonHeaders[index].originalName = name; + } + return; + } + + setUncommonHeader(name, value); +} + void HTTPHeaderMap::setUncommonHeader(const String& name, const String& value) { auto index = m_uncommonHeaders.findIf([&](auto& header) { @@ -178,7 +203,7 @@ void HTTPHeaderMap::append(const String& name, const String& value) if (headerName == HTTPHeaderName::SetCookie) m_setCookieHeaders.append(value); else - m_commonHeaders.append(CommonHeader { headerName, value }); + m_commonHeaders.append(CommonHeader { headerName, value, String() }); } else { m_uncommonHeaders.append(UncommonHeader { name, value }); } @@ -189,7 +214,7 @@ bool HTTPHeaderMap::addIfNotPresent(HTTPHeaderName headerName, const String& val if (contains(headerName)) return false; - m_commonHeaders.append(CommonHeader { headerName, value }); + m_commonHeaders.append(CommonHeader { headerName, value, String() }); return true; } @@ -290,7 +315,7 @@ void HTTPHeaderMap::set(HTTPHeaderName name, const String& value) return header.key == name; }); if (index == notFound) - m_commonHeaders.append(CommonHeader { name, value }); + m_commonHeaders.append(CommonHeader { name, value, String() }); else m_commonHeaders[index].value = value; } @@ -344,7 +369,7 @@ void HTTPHeaderMap::add(HTTPHeaderName name, const String& value) if (index != notFound) m_commonHeaders[index].value = makeString(m_commonHeaders[index].value, name == HTTPHeaderName::Cookie ? "; "_s : ", "_s, value); else - m_commonHeaders.append(CommonHeader { name, value }); + m_commonHeaders.append(CommonHeader { name, value, String() }); } } // namespace WebCore diff --git a/src/bun.js/bindings/webcore/HTTPHeaderMap.h b/src/bun.js/bindings/webcore/HTTPHeaderMap.h index 6b834f8451..edc7b35626 100644 --- a/src/bun.js/bindings/webcore/HTTPHeaderMap.h +++ b/src/bun.js/bindings/webcore/HTTPHeaderMap.h @@ -39,9 +39,10 @@ public: struct CommonHeader { HTTPHeaderName key; String value; + String originalName; // Original header name with preserved casing (may be empty) - CommonHeader isolatedCopy() const & { return { key, value.isolatedCopy() }; } - CommonHeader isolatedCopy() && { return { key, WTF::move(value).isolatedCopy() }; } + CommonHeader isolatedCopy() const & { return { key, value.isolatedCopy(), originalName.isolatedCopy() }; } + CommonHeader isolatedCopy() && { return { key, WTF::move(value).isolatedCopy(), WTF::move(originalName).isolatedCopy() }; } template void encode(Encoder &) const; template static std::optional decode(Decoder &); @@ -184,6 +185,7 @@ public: WEBCORE_EXPORT String get(const StringView name) const; WEBCORE_EXPORT void set(const String &name, const String &value); + WEBCORE_EXPORT void setPreservingOriginalName(const String &name, const String &value); WEBCORE_EXPORT void add(const String &name, const String &value); WEBCORE_EXPORT void append(const String &name, const String &value); WEBCORE_EXPORT bool contains(const StringView) const; @@ -278,6 +280,7 @@ void HTTPHeaderMap::CommonHeader::encode(Encoder &encoder) const { encoder << key; encoder << value; + encoder << originalName; } template @@ -289,8 +292,11 @@ auto HTTPHeaderMap::CommonHeader::decode(Decoder &decoder) -> std::optional diff --git a/test/regression/issue/15578.test.ts b/test/regression/issue/15578.test.ts new file mode 100644 index 0000000000..5dcb950f46 --- /dev/null +++ b/test/regression/issue/15578.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// https://github.com/oven-sh/bun/issues/15578 +// Node.js HTTP server should preserve the original casing of header names +// when using res.setHeader() + +test("res.setHeader preserves original header name casing", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + import { createServer } from 'node:http'; + import { connect } from 'node:net'; + + const http = createServer((req, res) => { + res.setHeader('location', 'http://test.com'); + res.setHeader('content-type', 'text/plain'); + res.setHeader('X-Custom-Header', 'custom-value'); + res.setHeader('X-UPPERCASE', 'value1'); + res.setHeader('x-lowercase', 'value2'); + res.end('test'); + }); + + http.listen(0, () => { + const port = http.address().port; + + // Use raw socket to see actual header casing + const client = connect(port, 'localhost', () => { + client.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n'); + }); + + client.on('data', (data) => { + const response = data.toString(); + console.log(response); + client.end(); + http.close(); + }); + }); + `, + ], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + + // Check that lowercase headers are preserved as lowercase + expect(stdout).toContain("location: http://test.com"); + expect(stdout).toContain("content-type: text/plain"); + + // Check that the original casing is preserved + expect(stdout).toContain("X-Custom-Header: custom-value"); + expect(stdout).toContain("X-UPPERCASE: value1"); + expect(stdout).toContain("x-lowercase: value2"); + + // Make sure title-case versions are NOT present + expect(stdout).not.toContain("Location:"); + expect(stdout).not.toContain("Content-Type:"); +}); + +test("res.setHeader with array values preserves header name casing", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + import { createServer } from 'node:http'; + import { connect } from 'node:net'; + + const http = createServer((req, res) => { + // Set a header with multiple values (array) + res.setHeader('x-multi-value', ['value1', 'value2']); + res.end('test'); + }); + + http.listen(0, () => { + const port = http.address().port; + + const client = connect(port, 'localhost', () => { + client.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n'); + }); + + client.on('data', (data) => { + const response = data.toString(); + console.log(response); + client.end(); + http.close(); + }); + }); + `, + ], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + + // x-multi-value should appear with original lowercase casing + expect(stdout).toContain("x-multi-value:"); +}); + +test("writeHead preserves header name casing", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + import { createServer } from 'node:http'; + import { connect } from 'node:net'; + + const http = createServer((req, res) => { + res.writeHead(302, { + 'location': '/redirect', + 'cache-control': 'no-cache' + }); + res.end(); + }); + + http.listen(0, () => { + const port = http.address().port; + + const client = connect(port, 'localhost', () => { + client.write('GET / HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n'); + }); + + client.on('data', (data) => { + const response = data.toString(); + console.log(response); + client.end(); + http.close(); + }); + }); + `, + ], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + + // Headers passed to writeHead should preserve their casing + expect(stdout).toContain("location: /redirect"); + expect(stdout).toContain("cache-control: no-cache"); +});