Skip to content
12 changes: 12 additions & 0 deletions src/jsc/bindings/NodeHTTP.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,15 @@
const auto& name = WebCore::httpHeaderNameString(header.key);
const auto& value = header.value;

// FetchHeaders strips leading/trailing HTTP whitespace, so an empty value here
// covers `""` and whitespace-only. Treat it as absent: don't write the line and
// don't set the wrote-this-header state bits, so the matching auto-header (Date,
// Content-Type from render_metadata) is emitted exactly once instead of alongside
// an empty duplicate.
if (value.isEmpty()) {
continue;
}

Check failure on line 805 in src/jsc/bindings/NodeHTTP.cpp

View check run for this annotation

Claude / Claude Code Review

Static/File routes still emit lone empty Date/Content-Type — sibling serializer not fixed

Static and file routes serialize `FetchHeaders` through a separate path (`StaticRoute::do_write_headers` / `FileRoute` via `headers_jsc::from_fetch_headers` → `WebCore__FetchHeaders__copyTo`) that this PR doesn't touch, so `Bun.serve({ static: { '/': new Response('x', { headers: { date: '' } }) } })` still emits a lone empty `Date:` with the auto-Date suppressed — the exact bug the PR body describes. Same for `content-type: ''` (`fast_has_` is key-presence, so `needs_content_type` goes false and
Comment thread
robobun marked this conversation as resolved.

// We have to tell uWS not to automatically insert a TransferEncoding or Date header.
// Otherwise, you get this when using Fastify;
//
Expand Down Expand Up @@ -1533,6 +1542,9 @@
}

for (const auto& header : internalHeaders.commonHeaders()) {
if (header.value.isEmpty()) {
continue;
}
if (header.key == WebCore::HTTPHeaderName::ContentLength) {
if (!(data->state & uWS::Http3ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER)) {
data->state |= uWS::Http3ResponseData::HTTP_WROTE_CONTENT_LENGTH_HEADER;
Expand Down
88 changes: 88 additions & 0 deletions test/js/bun/http/bun-serve-headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,94 @@ import { describe, expect, test } from "bun:test";
import { once } from "node:events";
import * as net from "node:net";

// An empty (or whitespace-only) header value must be treated as absent for the
// well-known headers Bun fills in automatically, so the response head carries
// exactly one Content-Type and exactly one Date (RFC 9110 forbids duplicate
// Content-Type; an origin server MUST send a valid Date).
describe("empty header value does not duplicate auto-headers", () => {
async function rawHead(makeResponse: () => Response): Promise<string> {
using server = Bun.serve({
port: 0,
hostname: "127.0.0.1",
development: false,
fetch: makeResponse,
});
const socket = net.connect(server.port, "127.0.0.1");
try {
socket.on("error", () => {});
await once(socket, "connect");
socket.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
let raw = "";
await new Promise<void>(resolve => {
socket.on("data", c => (raw += c.toString("latin1")));
socket.on("close", resolve);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return raw.split("\r\n\r\n")[0];
} finally {
socket.destroy();
}
}

const lines = (head: string, name: string) => head.split("\r\n").filter(l => l.toLowerCase().startsWith(name + ":"));

for (const [label, value] of [
["empty", ""],
["whitespace", " \t "],
] as const) {
test(`content-type: ${label}`, async () => {
const head = await rawHead(() => new Response("x", { headers: { "content-type": value } }));
const ct = lines(head, "content-type");
expect(ct).toHaveLength(1);
expect(ct[0].toLowerCase()).toBe("content-type: text/plain;charset=utf-8");
});

test(`date: ${label}`, async () => {
const head = await rawHead(() => new Response("x", { headers: { date: value } }));
const date = lines(head, "date");
expect(date).toHaveLength(1);
// auto Date is a valid IMF-fixdate, never an empty value
expect(date[0]).toMatch(/^Date: \S/);
expect(Number.isFinite(new Date(date[0].slice(6)).getTime())).toBe(true);
});
}

test("headers.set('content-type', '')", async () => {
const head = await rawHead(() => {
const r = new Response("x");
r.headers.set("content-type", "");
return r;
});
const ct = lines(head, "content-type");
expect(ct).toHaveLength(1);
expect(ct[0].toLowerCase()).toBe("content-type: text/plain;charset=utf-8");
});

test("Response.json with empty content-type", async () => {
const head = await rawHead(() => Response.json({ a: 1 }, { headers: { "content-type": "" } }));
expect(lines(head, "content-type")).toHaveLength(1);
});

test("both empty at once: one of each", async () => {
const head = await rawHead(() => new Response("x", { headers: { "content-type": "", "date": "" } }));
expect(lines(head, "content-type")).toHaveLength(1);
expect(lines(head, "date")).toHaveLength(1);
});

test("non-empty user values are still preserved", async () => {
const head = await rawHead(
() => new Response("x", { headers: { "content-type": "text/html", "date": "Sun, 06 Oct 2024 13:37:01 GMT" } }),
);
expect(lines(head, "content-type")).toEqual(["Content-Type: text/html"]);
expect(lines(head, "date")).toEqual(["Date: Sun, 06 Oct 2024 13:37:01 GMT"]);
});

test("uncommon header with empty value is unaffected", async () => {
const head = await rawHead(() => new Response("x", { headers: { "x-custom": "", "content-type": "text/html" } }));
expect(lines(head, "x-custom")).toEqual(["x-custom: "]);
expect(lines(head, "content-type")).toEqual(["Content-Type: text/html"]);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

// https://github.com/oven-sh/bun/issues/9180
test("weird headers", async () => {
using server = Bun.serve({
Expand Down
Loading