Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
be0bda83e1 fix(node:http): join duplicate request headers per Node.js/RFC 9110
Bun's `http.createServer` was only keeping the last value for duplicate
HTTP headers instead of joining them. This implements Node.js-compatible
behavior per RFC 9110 Section 5.3:

- Custom/unknown headers and most standard headers: joined with ", "
- Cookie: joined with "; "
- Set-Cookie: kept as array (already handled)
- Single-value headers (Content-Type, Host, etc.): first value wins

Closes #19372

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-19 09:29:29 +00:00
4 changed files with 241 additions and 297 deletions

View File

@@ -109,6 +109,45 @@ static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject
return JSValue::encode(tuple);
}
// Duplicate header handling policy, matching Node.js behavior per RFC 9110.
// See: https://github.com/nodejs/node/blob/main/lib/_http_incoming.js (matchKnownFields)
enum class DuplicateHeaderPolicy : uint8_t {
DropDuplicate, // Keep first value only (e.g., Content-Type, Host)
JoinComma, // Join with ", " (e.g., Accept, Cache-Control, unknown headers)
JoinSemicolon, // Join with "; " (Cookie only)
// Set-Cookie is handled separately as an array
};
static DuplicateHeaderPolicy duplicateHeaderPolicy(WebCore::HTTPHeaderName name)
{
switch (name) {
// Headers where only the first value should be kept:
case WebCore::HTTPHeaderName::Age:
case WebCore::HTTPHeaderName::Authorization:
case WebCore::HTTPHeaderName::ContentLength:
case WebCore::HTTPHeaderName::ContentType:
case WebCore::HTTPHeaderName::ETag:
case WebCore::HTTPHeaderName::Expires:
case WebCore::HTTPHeaderName::Host:
case WebCore::HTTPHeaderName::IfModifiedSince:
case WebCore::HTTPHeaderName::IfUnmodifiedSince:
case WebCore::HTTPHeaderName::LastModified:
case WebCore::HTTPHeaderName::Location:
case WebCore::HTTPHeaderName::ProxyAuthorization:
case WebCore::HTTPHeaderName::Referer:
case WebCore::HTTPHeaderName::UserAgent:
return DuplicateHeaderPolicy::DropDuplicate;
// Cookie is joined with "; "
case WebCore::HTTPHeaderName::Cookie:
return DuplicateHeaderPolicy::JoinSemicolon;
// All other known headers are joined with ", "
default:
return DuplicateHeaderPolicy::JoinComma;
}
}
static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSValue methodString, MarkedArgumentBuffer& args, JSC::JSGlobalObject* globalObject, JSC::VM& vm)
{
auto scope = DECLARE_THROW_SCOPE(vm);
@@ -148,7 +187,7 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
if (pair.second.length() > 0)
memcpy(data.data(), pair.second.data(), pair.second.length());
HTTPHeaderName name;
HTTPHeaderName name = WebCore::HTTPHeaderName::Age; // initialized to avoid warnings
JSString* jsValue = jsString(vm, value);
@@ -156,7 +195,8 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
Identifier nameIdentifier;
JSString* nameString = nullptr;
if (WebCore::findHTTPHeaderName(nameView, name)) {
bool isKnownHeader = WebCore::findHTTPHeaderName(nameView, name);
if (isKnownHeader) {
nameString = identifiers.stringFor(globalObject, name);
nameIdentifier = identifiers.identifierFor(vm, name);
} else {
@@ -165,7 +205,7 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
nameIdentifier = Identifier::fromString(vm, wtfString.convertToASCIILowercase());
}
if (name == WebCore::HTTPHeaderName::SetCookie) {
if (isKnownHeader && name == WebCore::HTTPHeaderName::SetCookie) {
if (!setCookiesHeaderArray) {
setCookiesHeaderArray = constructEmptyArray(globalObject, nullptr);
RETURN_IF_EXCEPTION(scope, );
@@ -179,11 +219,38 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal
RETURN_IF_EXCEPTION(scope, void());
} else {
headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue);
RETURN_IF_EXCEPTION(scope, void());
arrayValues.append(nameString);
arrayValues.append(jsValue);
RETURN_IF_EXCEPTION(scope, void());
// Check if this header already exists (duplicate header handling per Node.js/RFC 9110)
JSValue existingValue = headersObject->getDirect(vm, nameIdentifier);
if (existingValue) {
DuplicateHeaderPolicy policy = isKnownHeader ? duplicateHeaderPolicy(name) : DuplicateHeaderPolicy::JoinComma;
if (policy == DuplicateHeaderPolicy::DropDuplicate) {
// Keep first value, but still add to rawHeaders array
arrayValues.append(nameString);
arrayValues.append(jsValue);
RETURN_IF_EXCEPTION(scope, void());
} else {
// Join with separator: ", " for most headers, "; " for cookie
JSString* existingString = existingValue.toString(globalObject);
RETURN_IF_EXCEPTION(scope, void());
auto existingStringValue = existingString->value(globalObject);
RETURN_IF_EXCEPTION(scope, void());
WTF::String joinedValue = (policy == DuplicateHeaderPolicy::JoinSemicolon)
? makeString(WTF::String(existingStringValue), "; "_s, value)
: makeString(WTF::String(existingStringValue), ", "_s, value);
JSString* jsJoinedValue = jsString(vm, joinedValue);
headersObject->putDirect(vm, nameIdentifier, jsJoinedValue, 0);
arrayValues.append(nameString);
arrayValues.append(jsValue);
RETURN_IF_EXCEPTION(scope, void());
}
} else {
headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue);
RETURN_IF_EXCEPTION(scope, void());
arrayValues.append(nameString);
arrayValues.append(jsValue);
RETURN_IF_EXCEPTION(scope, void());
}
}
}
@@ -334,11 +401,12 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
if (pair.second.length() > 0)
memcpy(data.data(), pair.second.data(), pair.second.length());
HTTPHeaderName name;
HTTPHeaderName name = WebCore::HTTPHeaderName::Age; // initialized to avoid warnings
bool isKnownHeader = WebCore::findHTTPHeaderName(nameView, name);
WTF::String nameString;
WTF::String lowercasedNameString;
if (WebCore::findHTTPHeaderName(nameView, name)) {
if (isKnownHeader) {
nameString = WTF::httpHeaderNameStringImpl(name);
lowercasedNameString = nameString;
} else {
@@ -348,7 +416,7 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
JSString* jsValue = jsString(vm, value);
if (name == WebCore::HTTPHeaderName::SetCookie) {
if (isKnownHeader && name == WebCore::HTTPHeaderName::SetCookie) {
if (!setCookiesHeaderArray) {
setCookiesHeaderArray = constructEmptyArray(globalObject, nullptr);
RETURN_IF_EXCEPTION(scope, {});
@@ -362,10 +430,39 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS
RETURN_IF_EXCEPTION(scope, {});
} else {
headersObject->putDirect(vm, Identifier::fromString(vm, lowercasedNameString), jsValue, 0);
array->putDirectIndex(globalObject, i++, jsString(vm, nameString));
array->putDirectIndex(globalObject, i++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
auto identifier = Identifier::fromString(vm, lowercasedNameString);
// Check if this header already exists (duplicate header handling per Node.js/RFC 9110)
JSValue existingValue = headersObject->getDirect(vm, identifier);
if (existingValue) {
DuplicateHeaderPolicy policy = isKnownHeader ? duplicateHeaderPolicy(name) : DuplicateHeaderPolicy::JoinComma;
if (policy == DuplicateHeaderPolicy::DropDuplicate) {
// Keep first value, but still add to rawHeaders array
array->putDirectIndex(globalObject, i++, jsString(vm, nameString));
array->putDirectIndex(globalObject, i++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
} else {
// Join with separator: ", " for most headers, "; " for cookie
JSString* existingString = existingValue.toString(globalObject);
RETURN_IF_EXCEPTION(scope, {});
auto existingStringValue = existingString->value(globalObject);
RETURN_IF_EXCEPTION(scope, {});
WTF::String joinedValue = (policy == DuplicateHeaderPolicy::JoinSemicolon)
? makeString(WTF::String(existingStringValue), "; "_s, value)
: makeString(WTF::String(existingStringValue), ", "_s, value);
JSString* jsJoinedValue = jsString(vm, joinedValue);
headersObject->putDirect(vm, identifier, jsJoinedValue, 0);
array->putDirectIndex(globalObject, i++, jsString(vm, nameString));
array->putDirectIndex(globalObject, i++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
}
} else {
headersObject->putDirect(vm, identifier, jsValue, 0);
array->putDirectIndex(globalObject, i++, jsString(vm, nameString));
array->putDirectIndex(globalObject, i++, jsValue);
RETURN_IF_EXCEPTION(scope, {});
}
}
}

View File

@@ -12,79 +12,27 @@ state: union(enum) {
pub fn start(this: *Echo) Yield {
var args = this.bltn().argsSlice();
const no_newline = args.len >= 1 and std.mem.eql(u8, bun.sliceTo(args[0], 0), "-n");
// Parse flags: echo accepts -n, -e, -E in any combination.
// Flag parsing stops at the first arg that doesn't start with '-'
// or contains an invalid flag character.
var no_newline = false;
var escape_sequences = false;
var flags_done = false;
var args_start: usize = 0;
for (args) |arg| {
if (flags_done) break;
const flag = std.mem.span(arg);
if (flag.len < 2 or flag[0] != '-') {
flags_done = true;
break;
}
// Validate all characters are valid echo flags
var valid = true;
for (flag[1..]) |c| {
switch (c) {
'n', 'e', 'E' => {},
else => {
valid = false;
break;
},
}
}
if (!valid) {
flags_done = true;
break;
}
// Apply flags (last -e/-E wins)
for (flag[1..]) |c| {
switch (c) {
'n' => no_newline = true,
'e' => escape_sequences = true,
'E' => escape_sequences = false,
else => unreachable,
}
}
args_start += 1;
}
args = args[args_start..];
args = args[if (no_newline) 1 else 0..];
const args_len = args.len;
var has_leading_newline: bool = false;
var stop_output = false;
// TODO: Should flush buffer after it gets to a certain size
for (args, 0..) |arg, i| {
if (stop_output) break;
const thearg = std.mem.span(arg);
const is_last = i == args_len - 1;
if (escape_sequences) {
stop_output = appendWithEscapes(&this.output, thearg);
} else {
if (is_last) {
if (thearg.len > 0 and thearg[thearg.len - 1] == '\n') {
has_leading_newline = true;
}
bun.handleOom(this.output.appendSlice(bun.strings.trimSubsequentLeadingChars(thearg, '\n')));
} else {
bun.handleOom(this.output.appendSlice(thearg));
}
}
if (!stop_output and !is_last) {
if (i < args_len - 1) {
bun.handleOom(this.output.appendSlice(thearg));
bun.handleOom(this.output.append(' '));
} else {
if (thearg.len > 0 and thearg[thearg.len - 1] == '\n') {
has_leading_newline = true;
}
bun.handleOom(this.output.appendSlice(bun.strings.trimSubsequentLeadingChars(thearg, '\n')));
}
}
if (!stop_output and !has_leading_newline and !no_newline) bun.handleOom(this.output.append('\n'));
if (!has_leading_newline and !no_newline) bun.handleOom(this.output.append('\n'));
if (this.bltn().stdout.needsIO()) |safeguard| {
this.state = .waiting;
@@ -95,109 +43,6 @@ pub fn start(this: *Echo) Yield {
return this.bltn().done(0);
}
/// Appends `input` to `output`, interpreting backslash escape sequences.
/// Returns true if a \c escape was encountered (meaning stop all output).
fn appendWithEscapes(output: *std.array_list.Managed(u8), input: []const u8) bool {
var i: usize = 0;
while (i < input.len) {
if (input[i] == '\\' and i + 1 < input.len) {
switch (input[i + 1]) {
'\\' => {
bun.handleOom(output.append('\\'));
i += 2;
},
'a' => {
bun.handleOom(output.append('\x07'));
i += 2;
},
'b' => {
bun.handleOom(output.append('\x08'));
i += 2;
},
'c' => {
// \c: produce no further output
return true;
},
'e', 'E' => {
bun.handleOom(output.append('\x1b'));
i += 2;
},
'f' => {
bun.handleOom(output.append('\x0c'));
i += 2;
},
'n' => {
bun.handleOom(output.append('\n'));
i += 2;
},
'r' => {
bun.handleOom(output.append('\r'));
i += 2;
},
't' => {
bun.handleOom(output.append('\t'));
i += 2;
},
'v' => {
bun.handleOom(output.append('\x0b'));
i += 2;
},
'0' => {
// \0nnn: octal value (up to 3 octal digits)
i += 2; // skip \0
var val: u8 = 0;
var digits: usize = 0;
while (digits < 3 and i < input.len and input[i] >= '0' and input[i] <= '7') {
val = val *% 8 +% (input[i] - '0');
i += 1;
digits += 1;
}
bun.handleOom(output.append(val));
},
'x' => {
// \xHH: hex value (up to 2 hex digits)
i += 2; // skip \x
var val: u8 = 0;
var digits: usize = 0;
while (digits < 2 and i < input.len) {
const hex_val = hexDigitValue(input[i]);
if (hex_val) |hv| {
val = val *% 16 +% hv;
i += 1;
digits += 1;
} else {
break;
}
}
if (digits > 0) {
bun.handleOom(output.append(val));
} else {
// No valid hex digits: output \x literally
bun.handleOom(output.appendSlice("\\x"));
}
},
else => {
// Unknown escape: output backslash and the character as-is
bun.handleOom(output.append('\\'));
bun.handleOom(output.append(input[i + 1]));
i += 2;
},
}
} else {
bun.handleOom(output.append(input[i]));
i += 1;
}
}
return false;
}
fn hexDigitValue(c: u8) ?u8 {
if (c >= '0' and c <= '9') return c - '0';
if (c >= 'a' and c <= 'f') return c - 'a' + 10;
if (c >= 'A' and c <= 'F') return c - 'A' + 10;
return null;
}
pub fn onIOWriterChunk(this: *Echo, _: usize, e: ?jsc.SystemError) Yield {
if (comptime bun.Environment.allow_assert) {
assert(this.state == .waiting or this.state == .waiting_write_err);

View File

@@ -1,117 +0,0 @@
import { $ } from "bun";
import { describe, expect, test } from "bun:test";
describe("echo -e flag support", () => {
test("echo -e does not output -e as literal text", async () => {
const result = await $`echo -e hello`.text();
expect(result).toBe("hello\n");
});
test("echo -e interprets backslash-n", async () => {
const result = await $`echo -e ${"hello\\nworld"}`.text();
expect(result).toBe("hello\nworld\n");
});
test("echo -e interprets backslash-t", async () => {
const result = await $`echo -e ${"hello\\tworld"}`.text();
expect(result).toBe("hello\tworld\n");
});
test("echo -e interprets backslash-backslash", async () => {
const result = await $`echo -e ${"hello\\\\world"}`.text();
expect(result).toBe("hello\\world\n");
});
test("echo -e interprets \\a (bell)", async () => {
const result = await $`echo -e ${"\\a"}`.text();
expect(result).toBe("\x07\n");
});
test("echo -e interprets \\b (backspace)", async () => {
const result = await $`echo -e ${"a\\bb"}`.text();
expect(result).toBe("a\bb\n");
});
test("echo -e interprets \\r (carriage return)", async () => {
const result = await $`echo -e ${"hello\\rworld"}`.text();
expect(result).toBe("hello\rworld\n");
});
test("echo -e interprets \\f (form feed)", async () => {
const result = await $`echo -e ${"\\f"}`.text();
expect(result).toBe("\f\n");
});
test("echo -e interprets \\v (vertical tab)", async () => {
const result = await $`echo -e ${"\\v"}`.text();
expect(result).toBe("\v\n");
});
test("echo -e interprets \\0nnn (octal)", async () => {
// \0101 = 'A' (65 decimal)
const result = await $`echo -e ${"\\0101"}`.text();
expect(result).toBe("A\n");
});
test("echo -e interprets \\xHH (hex)", async () => {
// \x41 = 'A'
const result = await $`echo -e ${"\\x41\\x42\\x43"}`.text();
expect(result).toBe("ABC\n");
});
test("echo -e \\c stops output", async () => {
const result = await $`echo -e ${"hello\\cworld"}`.text();
expect(result).toBe("hello");
});
test("echo -e with \\e (escape character)", async () => {
const result = await $`echo -e ${"\\e"}`.text();
expect(result).toBe("\x1b\n");
});
test("echo -E disables escape interpretation", async () => {
const result = await $`echo -E ${"hello\\nworld"}`.text();
expect(result).toBe("hello\\nworld\n");
});
test("echo -eE (last wins: -E disables)", async () => {
const result = await $`echo -eE ${"hello\\tworld"}`.text();
expect(result).toBe("hello\\tworld\n");
});
test("echo -Ee (last wins: -e enables)", async () => {
const result = await $`echo -Ee ${"hello\\tworld"}`.text();
expect(result).toBe("hello\tworld\n");
});
test("echo -ne (no newline + escapes)", async () => {
const result = await $`echo -ne ${"hello\\tworld"}`.text();
expect(result).toBe("hello\tworld");
});
test("echo -en (same as -ne)", async () => {
const result = await $`echo -en ${"hello\\tworld"}`.text();
expect(result).toBe("hello\tworld");
});
test("echo -n still works (no newline)", async () => {
const result = await $`echo -n hello`.text();
expect(result).toBe("hello");
});
test("echo with invalid flag outputs literally", async () => {
const result = await $`echo -x hello`.text();
expect(result).toBe("-x hello\n");
});
test("echo -e piped to cat (original issue scenario)", async () => {
const pw = "mypassword";
const result = await $`echo -e ${pw} | cat`.text();
expect(result).toBe("mypassword\n");
});
test("echo without -e still works normally", async () => {
const result = await $`echo hello world`.text();
expect(result).toBe("hello world\n");
});
});

View File

@@ -0,0 +1,119 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
test("duplicate headers are joined per Node.js/RFC 9110 behavior", async () => {
await using server = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const http = require('http');
const server = http.createServer((req, res) => {
const result = JSON.stringify({
// Custom headers: should be joined with ", "
'x-test': req.headers['x-test'],
// Known joinable header: should be joined with ", "
'accept': req.headers['accept'],
// Cookie: should be joined with "; "
'cookie': req.headers['cookie'],
// Content-Type: should keep first value only
'content-type': req.headers['content-type'],
// Host: should keep first value only
'host': req.headers['host'],
// Set-Cookie: already tested as array (not applicable for request headers typically)
// rawHeaders should preserve all original headers
'rawHeadersLength': req.rawHeaders.length,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(result);
server.close();
});
server.listen(0, '127.0.0.1', () => {
console.log(server.address().port);
});
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const reader = server.stdout.getReader();
let portStr = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
portStr += new TextDecoder().decode(value);
if (portStr.includes("\n")) break;
}
reader.releaseLock();
const port = parseInt(portStr.trim(), 10);
// Send request with duplicate headers using raw TCP to ensure they're sent as separate lines
const socket = await Bun.connect({
hostname: "127.0.0.1",
port,
socket: {
data(socket, data) {
socket.data += new TextDecoder().decode(data);
},
open(socket) {
socket.data = "";
const request = [
"GET / HTTP/1.1",
"Host: localhost",
"Host: otherhost",
"X-Test: Hello",
"X-Test: World",
"Accept: text/html",
"Accept: application/json",
"Cookie: a=1",
"Cookie: b=2",
"Content-Type: text/plain",
"Content-Type: application/json",
"Connection: close",
"",
"",
].join("\r\n");
socket.write(request);
},
close() {},
error() {},
connectError() {},
},
});
// Wait for the response
const deadline = Date.now() + 5000;
while (!socket.data?.includes("\r\n\r\n") || !socket.data?.includes("}")) {
if (Date.now() > deadline) break;
await Bun.sleep(50);
}
socket.end();
// Parse the response body
const body = socket.data.split("\r\n\r\n").slice(1).join("\r\n\r\n");
const result = JSON.parse(body);
// Custom headers (x-*): joined with ", "
expect(result["x-test"]).toBe("Hello, World");
// Known joinable headers: joined with ", "
expect(result["accept"]).toBe("text/html, application/json");
// Cookie: joined with "; "
expect(result["cookie"]).toBe("a=1; b=2");
// Content-Type: first value wins (drop duplicate)
expect(result["content-type"]).toBe("text/plain");
// Host: first value wins (drop duplicate)
expect(result["host"]).toBe("localhost");
// rawHeaders should contain all headers (including duplicates)
// We sent 11 headers (Host x2, X-Test x2, Accept x2, Cookie x2, Content-Type x2, Connection x1)
// Each header has name+value pair = 22 entries
expect(result["rawHeadersLength"]).toBe(22);
await server.exited;
});