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"); +});