From 10843fefed50e7c1f692ef75f5784d31978d2d4c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 30 May 2026 05:02:02 +0000 Subject: [PATCH 01/19] transpiler: tighten cached module metadata validation --- src/bundler_jsc/analyze_jsc.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/bundler_jsc/analyze_jsc.rs b/src/bundler_jsc/analyze_jsc.rs index 831f4718f4f..c494c5b4743 100644 --- a/src/bundler_jsc/analyze_jsc.rs +++ b/src/bundler_jsc/analyze_jsc.rs @@ -40,6 +40,18 @@ pub(crate) extern "C" fn zig__ModuleInfoDeserialized__toJSModuleRecord( let buffer: &[StringID] = res.buffer(); let record_kinds: &[RecordKind] = res.record_kinds(); + let identifier_count = strings_lens.len(); + let is_valid_string_id = + |id: StringID| (id.0 as usize) < identifier_count || id.0 >= StringID::STAR_NAMESPACE.0; + if !buffer.iter().copied().all(is_valid_string_id) + || !requested_modules_keys.iter().copied().all(is_valid_string_id) + || !requested_modules_values + .iter() + .all(|&v| (v.0 as usize) < identifier_count || v.0 >= RequestedModuleValue::Json.0) + { + return core::ptr::null_mut(); + } + let identifiers = IdentifierArray::create(strings_lens.len()); // SAFETY: `identifiers` is non-null (returned by `create`); destroyed exactly once at scope exit, // mirroring Zig's `defer identifiers.destroy()` (runs on both success and early-return paths). From e4d9ec0263b716ffc82d2e9a41df3a48ba634ed9 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 30 May 2026 05:02:02 +0000 Subject: [PATCH 02/19] postgres: bound protocol message handling --- src/sql_jsc/postgres/PostgresRequest.rs | 8 ++- src/sql_jsc/postgres/PostgresSQLConnection.rs | 4 ++ .../postgres-multi-statement-fields.test.ts | 55 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/sql_jsc/postgres/PostgresRequest.rs b/src/sql_jsc/postgres/PostgresRequest.rs index 107aca6dfb6..f62e966b4c1 100644 --- a/src/sql_jsc/postgres/PostgresRequest.rs +++ b/src/sql_jsc/postgres/PostgresRequest.rs @@ -43,6 +43,7 @@ pub enum MessageType { CopyOutResponse, CopyDone, CopyBothResponse, + NotificationResponse, } /// The PostgreSQL wire protocol uses 16-bit integers for parameter and column counts. @@ -494,10 +495,15 @@ pub(crate) fn on_data( b'H' => connection.on(M::CopyOutResponse, reader.reborrow())?, b'c' => connection.on(M::CopyDone, reader.reborrow())?, b'W' => connection.on(M::CopyBothResponse, reader.reborrow())?, + b'A' => connection.on(M::NotificationResponse, reader.reborrow())?, _ => { bun_core::scoped_log!(Postgres, "Unknown message: {}", c as char); - let to_skip = reader.length()?.saturating_sub(1); + let length = reader.length()?; + if length < 4 { + return Err(AnyPostgresError::InvalidMessageLength); + } + let to_skip = length.saturating_sub(4); bun_core::scoped_log!(Postgres, "to_skip: {}", to_skip); reader.skip(usize::try_from(to_skip).expect("int cast"))?; } diff --git a/src/sql_jsc/postgres/PostgresSQLConnection.rs b/src/sql_jsc/postgres/PostgresSQLConnection.rs index 1326ed303fb..6d10f4b0062 100644 --- a/src/sql_jsc/postgres/PostgresSQLConnection.rs +++ b/src/sql_jsc/postgres/PostgresSQLConnection.rs @@ -3055,6 +3055,10 @@ impl PostgresSQLConnection { let _resp = protocol::NoticeResponse::decode_internal(reader.reborrow())?; // _resp dropped at scope end } + MessageType::NotificationResponse => { + debug!("UNSUPPORTED NotificationResponse"); + let _resp = protocol::NotificationResponse::decode_internal(reader.reborrow())?; + } MessageType::EmptyQueryResponse => { reader.eat_message(&protocol::EMPTY_QUERY_RESPONSE)?; let request = self.current().ok_or(AnyPostgresError::ExpectedRequest)?; diff --git a/test/js/sql/postgres-multi-statement-fields.test.ts b/test/js/sql/postgres-multi-statement-fields.test.ts index ce5925f9524..40cd4f5f097 100644 --- a/test/js/sql/postgres-multi-statement-fields.test.ts +++ b/test/js/sql/postgres-multi-statement-fields.test.ts @@ -110,3 +110,58 @@ test("simple query with multiple statements uses each RowDescription's column na server.close(); } }); + +// NotificationResponse ('A', sent by NOTIFY) and unknown async messages can arrive +// between result sets. The protocol reader must consume exactly the message body so +// the following messages stay correctly framed. +for (const [name, asyncMessage] of [ + ["NotificationResponse", pkt("A", Buffer.concat([int32(4321), cstr("some_channel"), cstr("some payload")]))], + // 'v' = NegotiateProtocolVersion, which the client does not handle explicitly + ["unknown message type", pkt("v", Buffer.concat([int32(0), int32(0)]))], +] as const) { + test(`${name} between result sets does not corrupt message framing`, async () => { + const server = net.createServer(socket => { + let startup = true; + socket.on("data", data => { + if (startup) { + startup = false; + socket.write(Buffer.concat([authenticationOk, readyForQuery])); + return; + } + if (data[0] !== 0x51 /* 'Q' */) return; + // End the socket after the response so a mis-framed reader stalls into a + // connection error instead of waiting for more data forever. + socket.end( + Buffer.concat([ + rowDescription(["x"]), + dataRow(["1"]), + commandComplete("SELECT 1"), + asyncMessage, + rowDescription(["y"]), + dataRow(["2"]), + commandComplete("SELECT 1"), + readyForQuery, + ]), + ); + }); + }); + + await new Promise(r => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as net.AddressInfo).port; + + const sql = new SQL({ + url: `postgres://u@127.0.0.1:${port}/db`, + max: 1, + idleTimeout: 5, + connectionTimeout: 5, + }); + + try { + const result = await sql`select 1 as x; select 2 as y`.simple(); + expect(result).toEqual([[{ x: "1" }], [{ y: "2" }]]); + } finally { + await sql.close(); + server.close(); + } + }); +} From 5566039d641ba64f0e1bde4b7a926a8bfa79479b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 30 May 2026 05:02:02 +0000 Subject: [PATCH 03/19] htmlrewriter: tighten comment mutation handling --- src/lolhtml_sys/lol_html.rs | 9 +++++-- test/js/workerd/html-rewriter.test.js | 35 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/lolhtml_sys/lol_html.rs b/src/lolhtml_sys/lol_html.rs index febbd4d974e..53ca95f248c 100644 --- a/src/lolhtml_sys/lol_html.rs +++ b/src/lolhtml_sys/lol_html.rs @@ -1091,6 +1091,12 @@ unsafe extern "C" { content_len: usize, is_html: bool, ) -> c_int; + fn lol_html_comment_replace( + comment: *mut Comment, + content: *const u8, + content_len: usize, + is_html: bool, + ) -> c_int; safe fn lol_html_comment_remove(comment: &mut Comment); safe fn lol_html_comment_is_removed(comment: &Comment) -> bool; safe fn lol_html_comment_source_location_bytes(comment: &Comment) -> SourceLocationBytes; @@ -1126,10 +1132,9 @@ impl Comment { pub fn replace(&mut self, content: &[u8], is_html: bool) -> Result<(), Error> { auto_disable(); - // PORT NOTE: Zig source calls lol_html_comment_before here (likely an upstream bug); ported faithfully // SAFETY: content ptr/len describe a valid slice match unsafe { - lol_html_comment_before(self, ptr_without_panic(content), content.len(), is_html) + lol_html_comment_replace(self, ptr_without_panic(content), content.len(), is_html) } { 0 => Ok(()), -1 => Err(Error::Fail), diff --git a/test/js/workerd/html-rewriter.test.js b/test/js/workerd/html-rewriter.test.js index 04a683b8247..833ce7ced3d 100644 --- a/test/js/workerd/html-rewriter.test.js +++ b/test/js/workerd/html-rewriter.test.js @@ -340,6 +340,41 @@ describe("HTMLRewriter", () => { remove: "

", }; + const commentMutationsMacro = async func => { + // before/after + let res = func(new HTMLRewriter(), comment => { + comment.before("before"); + comment.before("before html", { html: true }); + comment.after("after"); + comment.after("after html", { html: true }); + }).transform(new Response(commentsMutationsInput)); + expect(await res.text()).toBe(commentsMutationsExpected.beforeAfter); + + // replace + res = func(new HTMLRewriter(), comment => { + comment.replace("replace"); + }).transform(new Response(commentsMutationsInput)); + expect(await res.text()).toBe(commentsMutationsExpected.replace); + res = func(new HTMLRewriter(), comment => { + comment.replace("replace", { html: true }); + }).transform(new Response(commentsMutationsInput)); + expect(await res.text()).toBe(commentsMutationsExpected.replaceHtml); + + // remove + res = func(new HTMLRewriter(), comment => { + expect(comment.removed).toBe(false); + comment.remove(); + expect(comment.removed).toBe(true); + }).transform(new Response(commentsMutationsInput)); + expect(await res.text()).toBe(commentsMutationsExpected.remove); + }; + + it("HTMLRewriter: handles comment mutations", () => + commentMutationsMacro((rw, comments) => { + rw.on("p", { comments }); + return rw; + })); + const commentPropertiesMacro = async func => { const res = func(new HTMLRewriter(), comment => { expect(comment.removed).toBe(false); From 94564991f871a172c137e2408c8b2539d079ef23 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 30 May 2026 05:30:23 +0000 Subject: [PATCH 04/19] test: add regression coverage for input validation changes --- test/cli/run/transpiler-cache.test.ts | 113 +++++++++++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 1254fcfc234..411ff6e0499 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -1,6 +1,6 @@ import { Subprocess } from "bun"; import { beforeEach, describe, expect, test } from "bun:test"; -import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; import { bunEnv, bunExe, bunRun, tmpdirSync } from "harness"; import { join } from "path"; @@ -262,3 +262,114 @@ describe("transpiler cache", () => { expect(newCacheCount()).toBe(0); // cache hit, order doesn't matter }); }); + +test("rejects cached module records containing out-of-range string indices", () => { + // When test isolation is enabled, the runtime transpiler cache stores a + // serialized ES module record ("esm_record") alongside the transpiled + // output. The string indices inside that record are used to index an + // identifier table when the record is converted back into a JSC module + // record, so any index beyond the table length (other than the reserved + // *-default / *-namespace sentinels near u32::MAX) must be rejected. + // + // Cache entry layout (src/jsc/RuntimeTranspilerCache.rs, Metadata::encode): + // 0: cache_version u32, 4: module_type u8, 5: output_encoding u8, + // then twelve u64 fields; esm_record_byte_offset @ 78, + // esm_record_byte_length @ 86, esm_record_hash @ 94. Payload follows @ 102. + // Serialized module record layout (src/bundler/analyze_transpiled_module.rs, + // serialize()): + // [record_kinds_len u32][record_kinds, 1 byte each][pad to 4] + // [buffer_len u32][buffer: u32 string index x buffer_len] ... + const ESM_RECORD_BYTE_OFFSET_AT = 78; + const ESM_RECORD_BYTE_LENGTH_AT = 86; + const ESM_RECORD_HASH_AT = 94; + const METADATA_SIZE = 102; + + function corruptModuleRecordStringIndices(file: string): boolean { + const data = readFileSync(file); + if (data.length < METADATA_SIZE) return false; + const esmOff = Number(data.readBigUInt64LE(ESM_RECORD_BYTE_OFFSET_AT)); + const esmLen = Number(data.readBigUInt64LE(ESM_RECORD_BYTE_LENGTH_AT)); + if (esmLen === 0 || esmOff + esmLen > data.length) return false; + + const recordKindsLen = data.readUInt32LE(esmOff); + const pad = (4 - (recordKindsLen % 4)) % 4; + let off = esmOff + 4 + recordKindsLen + pad; + const bufferLen = data.readUInt32LE(off); + off += 4; + if (bufferLen === 0) return false; + + // Point every string index in the record buffer far beyond the identifier + // table (but below the reserved sentinel range near u32::MAX). + for (let i = 0; i < bufferLen; i++) { + data.writeUInt32LE(0x7fffffff, off + i * 4); + } + // The cache loader skips esm-record content verification when the stored + // hash field is zero, so whoever writes the cache file controls exactly + // what reaches the module record deserializer. + data.writeBigUInt64LE(0n, ESM_RECORD_HASH_AT); + writeFileSync(file, data); + return true; + } + + // An ES module big enough to be eligible for the transpiler cache (>= 4 KiB) + // with imports, exports and top-level variables, so its module record + // contains string indices of every record kind. + const filler = ("// " + "x".repeat(120) + "\n").repeat(120); + writeFileSync( + join(temp_dir, "big-lib.js"), + `import { join } from "node:path"; +export const value = 42; +let counter = 0; +export function next() { + counter += 1; + return join("a", String(counter)); +} +${filler}`, + ); + writeFileSync( + join(temp_dir, "uses-lib.test.js"), + `import { test, expect } from "bun:test"; +import { value, next } from "./big-lib.js"; +test("cached module still works", () => { + expect(value).toBe(42); + expect(next().length).toBeGreaterThan(0); +});`, + ); + + const run = () => + Bun.spawnSync({ + // --isolate enables the isolation source-provider cache, which is the + // code path that converts the cached module record back into a JSC + // module record. + cmd: [bunExe(), "test", "--isolate", "./uses-lib.test.js"], + cwd: temp_dir, + env, + }); + + // First run transpiles the module and writes the cache entry, including the + // serialized module record. + const first = run(); + expect(first.stderr.toString() + first.stdout.toString()).toContain("1 pass"); + expect(first.exitCode).toBe(0); + expect(existsSync(cache_dir)).toBeTrue(); + + // Second run restores from the intact cache entry: the legitimate record is + // accepted and the module still works. + const second = run(); + expect(second.stderr.toString() + second.stdout.toString()).toContain("1 pass"); + expect(second.exitCode).toBe(0); + + // Rewrite the stored module record so every string index is out of range. + let corrupted = 0; + for (const name of readdirSync(cache_dir)) { + if (corruptModuleRecordStringIndices(join(cache_dir, name))) corrupted++; + } + expect(corrupted).toBeGreaterThanOrEqual(1); + + // Third run: the corrupted record must be rejected with a clean module load + // error and a normal (non-signal) process exit. + const third = run(); + expect(third.stderr.toString() + third.stdout.toString()).toContain("parseFromSourceCode failed"); + expect(third.signalCode).toBeUndefined(); + expect(third.exitCode).toBe(1); +}, 90000); From 08bb3a66a32a928cb0d821cffaa4943aa8c1eda9 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 30 May 2026 05:31:01 +0000 Subject: [PATCH 05/19] bun-lambda: build event request URLs from the request context domain --- packages/bun-lambda/runtime.ts | 12 +- .../integration/bun-lambda/bun-lambda.test.ts | 134 ++++++++++++++++++ 2 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 test/integration/bun-lambda/bun-lambda.test.ts diff --git a/packages/bun-lambda/runtime.ts b/packages/bun-lambda/runtime.ts index 79183c6a65c..8949c4f404e 100755 --- a/packages/bun-lambda/runtime.ts +++ b/packages/bun-lambda/runtime.ts @@ -320,9 +320,10 @@ function formatHttpEventV1(event: HttpEventV1): Request { headers.append(name, value); } } - const hostname = headers.get("Host") ?? request.domainName; + const hostname = request.domainName ?? headers.get("Host"); const proto = headers.get("X-Forwarded-Proto") ?? "http"; - const url = new URL(request.path, `${proto}://${hostname}/`); + const url = new URL(`${proto}://${hostname}/`); + url.pathname = request.path; for (const [name, values] of Object.entries(event.multiValueQueryStringParameters ?? {})) { for (const value of values ?? []) { url.searchParams.append(name, value); @@ -367,9 +368,10 @@ function formatHttpEventV2(event: HttpEventV2): Request { for (const cookie of event.cookies ?? []) { headers.append("Set-Cookie", cookie); } - const hostname = headers.get("Host") ?? request.domainName; + const hostname = request.domainName ?? headers.get("Host"); const proto = headers.get("X-Forwarded-Proto") ?? "http"; - const url = new URL(request.http.path, `${proto}://${hostname}/`); + const url = new URL(`${proto}://${hostname}/`); + url.pathname = request.http.path; for (const [name, values] of Object.entries(event.queryStringParameters ?? {})) { url.searchParams.append(name, values); } @@ -431,7 +433,7 @@ function formatWebSocketUpgrade(event: WebSocketEvent): Request { headers.append(name, value); } } - const hostname = headers.get("Host") ?? request.domainName; + const hostname = request.domainName ?? headers.get("Host"); const proto = headers.get("X-Forwarded-Proto") ?? "http"; const url = new URL(`${proto}://${hostname}/${request.stage}`); return new Request(url.toString(), { diff --git a/test/integration/bun-lambda/bun-lambda.test.ts b/test/integration/bun-lambda/bun-lambda.test.ts new file mode 100644 index 00000000000..81c9aa568d6 --- /dev/null +++ b/test/integration/bun-lambda/bun-lambda.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import path from "path"; + +const runtimePath = path.join(import.meta.dir, "..", "..", "..", "packages", "bun-lambda", "runtime.ts"); + +// The runtime only uses aws4fetch for outgoing WebSocket messages, which these +// tests never send, so a stub keeps the test offline. +const aws4fetchStub = `export class AwsClient { + constructor() {} + async fetch() { + return new Response(null, { status: 200 }); + } +} +`; + +test("lambda HTTP events cannot override the request authority", async () => { + const runtimeSource = await Bun.file(runtimePath).text(); + + using dir = tempDir("bun-lambda", { + "runtime.ts": runtimeSource, + "handler.ts": `export default { + async fetch(request) { + return new Response(request.url); + }, +}; +`, + "node_modules/aws4fetch/package.json": JSON.stringify({ name: "aws4fetch", version: "1.0.0", main: "index.js" }), + "node_modules/aws4fetch/index.js": aws4fetchStub, + }); + + const events = [ + { + requestId: "req-v2", + event: { + version: "2.0", + requestContext: { + requestId: "req-v2", + domainName: "api.example.com", + http: { method: "GET", path: "//attacker.example/reset" }, + }, + headers: { "Host": "evil.example", "X-Forwarded-Proto": "https" }, + isBase64Encoded: false, + }, + }, + { + requestId: "req-v1", + event: { + requestContext: { + requestId: "req-v1", + domainName: "api.example.com", + httpMethod: "GET", + path: "//attacker.example/reset", + }, + headers: {}, + multiValueHeaders: { "Host": ["evil.example"], "X-Forwarded-Proto": ["https"] }, + isBase64Encoded: false, + }, + }, + ]; + + let nextInvocation = 0; + const resolvers = new Map void>(); + const responses = new Map>(); + for (const { requestId } of events) { + responses.set(requestId, new Promise(resolve => resolvers.set(requestId, resolve))); + } + + using server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/2018-06-01/runtime/invocation/next") { + if (nextInvocation >= events.length) { + // No more events: a non-ok status makes the runtime exit cleanly. + return new Response(null, { status: 500 }); + } + const { requestId, event } = events[nextInvocation++]; + return new Response(JSON.stringify(event), { + headers: { + "Content-Type": "application/json", + "Lambda-Runtime-Aws-Request-Id": requestId, + "Lambda-Runtime-Trace-Id": "trace-id", + "Lambda-Runtime-Invoked-Function-Arn": "arn:aws:lambda:us-east-1:123456789012:function:test", + "Lambda-Runtime-Deadline-Ms": String(Date.now() + 60_000), + }, + }); + } + const match = url.pathname.match(/^\/2018-06-01\/runtime\/invocation\/([^/]+)\/response$/); + if (match) { + resolvers.get(match[1])?.(await req.json()); + return new Response(null, { status: 202 }); + } + // Anything else (init/invocation errors) fails the assertions with useful context. + const failure = { unexpected: url.pathname, body: await req.text() }; + for (const resolve of resolvers.values()) { + resolve(failure); + } + return new Response(null, { status: 202 }); + }, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "runtime.ts"], + cwd: String(dir), + env: { + ...bunEnv, + AWS_LAMBDA_RUNTIME_API: `localhost:${server.port}`, + _HANDLER: "handler.fetch", + LAMBDA_TASK_ROOT: String(dir), + }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + const [v2Response, v1Response] = await Promise.all([responses.get("req-v2"), responses.get("req-v1")]); + + const decodeBody = (response: any): string => + response.isBase64Encoded ? Buffer.from(response.body, "base64").toString("utf8") : response.body; + + // Payload format v2: the path from the event must not be able to change the + // origin, and the authority comes from requestContext.domainName. + expect(v2Response.unexpected).toBeUndefined(); + const v2Url = new URL(decodeBody(v2Response)); + expect(v2Url.origin).toBe("https://api.example.com"); + expect(v2Url.pathname).toBe("//attacker.example/reset"); + + // Payload format v1. + expect(v1Response.unexpected).toBeUndefined(); + const v1Url = new URL(decodeBody(v1Response)); + expect(v1Url.origin).toBe("https://api.example.com"); + expect(v1Url.pathname).toBe("//attacker.example/reset"); +}); From e125f623efc7ff502795f2d4265176bdc02ac1ec Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 30 May 2026 05:31:01 +0000 Subject: [PATCH 06/19] create: pass detected dependencies as positional install arguments --- .../cli/create/SourceFileProjectGenerator.rs | 2 ++ .../__snapshots__/create-jsx.test.ts.snap | 16 +++++----- test/cli/create/create-jsx.test.ts | 32 ++++++++++++++++++- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/runtime/cli/create/SourceFileProjectGenerator.rs b/src/runtime/cli/create/SourceFileProjectGenerator.rs index a3ffb9f1037..3e4f2b52f40 100644 --- a/src/runtime/cli/create/SourceFileProjectGenerator.rs +++ b/src/runtime/cli/create/SourceFileProjectGenerator.rs @@ -336,6 +336,7 @@ pub fn generate_files( argv.push(b"bun"); argv.push(b"--only-missing"); argv.push(b"install"); + argv.push(b"--"); argv.extend(dependencies.iter().map(|d| &d[..])); run_install(&mut argv)?; } @@ -361,6 +362,7 @@ pub fn generate_files( shadcn_argv.push(b"--src-dir"); } shadcn_argv.push(b"-y"); + shadcn_argv.push(b"--"); shadcn_argv.extend(components.keys().iter().map(|k| &k[..])); // print "bun" but use bun.selfExePath() diff --git a/test/cli/create/__snapshots__/create-jsx.test.ts.snap b/test/cli/create/__snapshots__/create-jsx.test.ts.snap index 793466f6ca1..a118418d8be 100644 --- a/test/cli/create/__snapshots__/create-jsx.test.ts.snap +++ b/test/cli/create/__snapshots__/create-jsx.test.ts.snap @@ -20,7 +20,7 @@ create index.html html create index.client.tsx bun create package.json npm ๐Ÿ“ฆ Auto-installing 3 detected dependencies -$ bun --only-missing install classnames react-dom@19 react@19 +$ bun --only-missing install -- classnames react-dom@19 react@19 bun add *.*.* installed classnames@*.*.* installed react-dom@*.*.* @@ -58,7 +58,7 @@ create index.client.tsx bun create bunfig.toml bun create package.json npm ๐Ÿ“ฆ Auto-installing 4 detected dependencies -$ bun --only-missing install tailwindcss bun-plugin-tailwind react-dom@19 react@19 +$ bun --only-missing install -- tailwindcss bun-plugin-tailwind react-dom@19 react@19 bun add *.*.* installed tailwindcss@*.*.* installed bun-plugin-tailwind@*.*.* @@ -89,7 +89,7 @@ create package.json npm create tsconfig.json tsc create components.json shadcn ๐Ÿ“ฆ Auto-installing 9 detected dependencies -$ bun --only-missing install lucide-react tailwindcss bun-plugin-tailwind tailwindcss-animate class-variance-authority clsx tailwind-merge react-dom@19 react@19 +$ bun --only-missing install -- lucide-react tailwindcss bun-plugin-tailwind tailwindcss-animate class-variance-authority clsx tailwind-merge react-dom@19 react@19 bun add *.*.* installed lucide-react@*.*.* installed tailwindcss@*.*.* @@ -102,7 +102,7 @@ installed react-dom@*.*.* installed react@*.*.* 12 packages installed [*ms] ๐Ÿ˜Ž Setting up shadcn/ui components -$ bun x shadcn@canary add -y button badge card +$ bun x shadcn@canary add -y -- button badge card - components/ui/button.tsx - components/ui/badge.tsx - components/ui/card.tsx @@ -149,7 +149,7 @@ create index.html html create index.client.tsx bun create package.json npm ๐Ÿ“ฆ Auto-installing 3 detected dependencies -$ bun --only-missing install classnames react-dom@19 react@19 +$ bun --only-missing install -- classnames react-dom@19 react@19 bun add *.*.* installed classnames@*.*.* installed react-dom@*.*.* @@ -186,7 +186,7 @@ create index.client.tsx bun create bunfig.toml bun create package.json npm ๐Ÿ“ฆ Auto-installing 4 detected dependencies -$ bun --only-missing install tailwindcss bun-plugin-tailwind react-dom@19 react@19 +$ bun --only-missing install -- tailwindcss bun-plugin-tailwind react-dom@19 react@19 bun add *.*.* installed tailwindcss@*.*.* installed bun-plugin-tailwind@*.*.* @@ -217,7 +217,7 @@ create package.json npm create tsconfig.json tsc create components.json shadcn ๐Ÿ“ฆ Auto-installing 9 detected dependencies -$ bun --only-missing install lucide-react tailwindcss bun-plugin-tailwind tailwindcss-animate class-variance-authority clsx tailwind-merge react-dom@19 react@19 +$ bun --only-missing install -- lucide-react tailwindcss bun-plugin-tailwind tailwindcss-animate class-variance-authority clsx tailwind-merge react-dom@19 react@19 bun add *.*.* installed lucide-react@*.*.* installed tailwindcss@*.*.* @@ -230,7 +230,7 @@ installed react-dom@*.*.* installed react@*.*.* 12 packages installed [*ms] ๐Ÿ˜Ž Setting up shadcn/ui components -$ bun x shadcn@canary add -y button badge card +$ bun x shadcn@canary add -y -- button badge card - components/ui/button.tsx - components/ui/badge.tsx - components/ui/card.tsx diff --git a/test/cli/create/create-jsx.test.ts b/test/cli/create/create-jsx.test.ts index 41f1b312a5a..a33e1897fd1 100644 --- a/test/cli/create/create-jsx.test.ts +++ b/test/cli/create/create-jsx.test.ts @@ -1,7 +1,7 @@ import type { Subprocess } from "bun"; import { beforeEach, describe, expect, test } from "bun:test"; import { cp, readdir } from "fs/promises"; -import { bunEnv, bunExe, isCI, isWindows, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, isCI, isWindows, tempDir, tempDirWithFiles } from "harness"; import path from "path"; async function getServerUrl(process: Subprocess, all = { text: "" }) { @@ -323,6 +323,36 @@ for (const development of [true, false]) { }); } +test("auto-install passes detected dependencies as positionals", async () => { + using dir = tempDir("create-arg-separator", { + "Component.tsx": `import "--trust"; + +export default function Component() { + return
Hello
; +} +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "create", "./Component.tsx"], + cwd: String(dir), + env: { + ...bunEnv, + // Unreachable registry so the spawned install fails fast offline. + BUN_CONFIG_REGISTRY: "http://localhost:1/", + }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + const installLine = stdout.split("\n").find(line => line.includes("--only-missing install")); + expect(installLine).toBeDefined(); + expect(installLine).toContain(" install -- "); +}, 60_000); + function normalizeHTMLFn(development: boolean = true) { return (html: string) => html From 01f91d641c8c1c551b70683e277ea17ad6f5b6ea Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 30 May 2026 05:46:03 +0000 Subject: [PATCH 07/19] node:zlib: tighten write state handling --- src/bun_core/util.rs | 8 +++++ src/runtime/api/zlib.classes.ts | 2 +- src/runtime/node/node_zlib_binding.rs | 42 +++++++++++++++++---------- src/runtime/node/zlib/NativeBrotli.rs | 12 ++------ src/runtime/node/zlib/NativeZlib.rs | 8 ++--- src/runtime/node/zlib/NativeZstd.rs | 7 ++--- 6 files changed, 41 insertions(+), 38 deletions(-) diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index 9a5cb2e640c..cf7b58797c2 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -59,8 +59,13 @@ impl Unaligned { /// Reinterpret `&[Unaligned]` as `&[T]` once the caller has proven /// `ptr` is naturally aligned (Zig `@alignCast`). Panics in debug if not. + /// Empty slices need no cast: their dangling pointer has align 1, not + /// `align_of::()`, so they are returned as `&[]` directly. #[inline] pub fn slice_align_cast(slice: &[Unaligned]) -> &[T] { + if slice.is_empty() { + return &[]; + } debug_assert!( (slice.as_ptr() as usize).is_multiple_of(core::mem::align_of::()), "Unaligned::slice_align_cast: pointer is not {}-byte aligned", @@ -75,6 +80,9 @@ impl Unaligned { /// Mutable counterpart of [`slice_align_cast`]. #[inline] pub fn slice_align_cast_mut(slice: &mut [Unaligned]) -> &mut [T] { + if slice.is_empty() { + return &mut []; + } debug_assert!( (slice.as_ptr() as usize).is_multiple_of(core::mem::align_of::()), "Unaligned::slice_align_cast_mut: pointer is not {}-byte aligned", diff --git a/src/runtime/api/zlib.classes.ts b/src/runtime/api/zlib.classes.ts index 37f80fc4763..074de35a155 100644 --- a/src/runtime/api/zlib.classes.ts +++ b/src/runtime/api/zlib.classes.ts @@ -10,7 +10,7 @@ function generate(name: string) { estimatedSize: true, klass: {}, JSType: "0b11101110", - values: ["writeCallback", "errorCallback", "dictionary", "pendingInput", "pendingOutput"], + values: ["writeCallback", "errorCallback", "dictionary", "pendingInput", "pendingOutput", "writeResult"], proto: { init: { fn: "init" }, diff --git a/src/runtime/node/node_zlib_binding.rs b/src/runtime/node/node_zlib_binding.rs index a3d4e02a28a..9de778280b3 100644 --- a/src/runtime/node/node_zlib_binding.rs +++ b/src/runtime/node/node_zlib_binding.rs @@ -225,22 +225,29 @@ pub(crate) trait CompressionStreamImpl: Sized + Taskable + 'static { /// deref lives in `BackRef::get`, so callers and impls are safe. fn global_this(&self) -> &JSGlobalObject; fn stream(&self) -> &JsCell; - fn write_result_ptr(&self) -> Option<*mut u32>; /// Write `(avail_out, avail_in)` into the JS-owned 2-element `Uint32Array` - /// (`this._writeState`). Single unsafe deref site for the set-once - /// `write_result: Cell>>` field so callers stay safe. + /// (`this._writeState`), re-resolving the cached `writeResult` typed array + /// on every call so a detached, resized, or replaced backing store is + /// skipped instead of written through a stale pointer. #[inline] - fn flush_write_result(&self) { - let Some(write_result) = self.write_result_ptr() else { + fn flush_write_result(&self, global: &JSGlobalObject, this_value: JSValue) { + let Some(write_result_value) = Self::write_result_get_cached(this_value) else { return; }; - // SAFETY: `write_result` points at a 2-element `u32[]` owned by JS - // (set in each impl's `init()`); both indices are in-bounds and the - // backing buffer is kept alive by `this._writeState` / - // `_handle[owner_symbol]`. - let (r1, r0) = unsafe { (&mut *write_result.add(1), &mut *write_result) }; - self.stream().with_mut(|s| s.update_write_result(r1, r0)); + if !write_result_value.is_cell() { + return; + } + let Some(mut write_result_buf) = write_result_value.as_array_buffer(global) else { + return; + }; + let write_result = write_result_buf.as_u32(); + if write_result.len() < 2 { + return; + } + let (r0, r1) = write_result.split_at_mut(1); + self.stream() + .with_mut(|s| s.update_write_result(&mut r1[0], &mut r0[0])); } fn poll_ref(&self) -> &JsCell; @@ -275,6 +282,7 @@ pub(crate) trait CompressionStreamImpl: Sized + Taskable + 'static { unsafe fn deref(this: *mut Self); // Per-class codegen (`T.js.*` cached-property accessors). + fn write_result_get_cached(this_value: JSValue) -> Option; fn write_callback_get_cached(this_value: JSValue) -> Option; fn error_callback_get_cached(this_value: JSValue) -> Option; fn error_callback_set_cached(this_value: JSValue, global: &JSGlobalObject, cb: JSValue); @@ -546,7 +554,7 @@ impl CompressionStream { return; } - this.flush_write_result(); + this.flush_write_result(global, this_value); this_value.ensure_still_alive(); let write_callback: JSValue = T::write_callback_get_cached(this_value).unwrap(); @@ -697,7 +705,7 @@ impl CompressionStream { this.stream().with_mut(|s| s.do_work()); if Self::check_error(this, global_this, this_value) { - this.flush_write_result(); + this.flush_write_result(global_this, this_value); this.write_in_progress().set(false); } // SAFETY: matching `ref_()` above. The bracketed `ref_()`/`deref()` @@ -980,7 +988,7 @@ pub(crate) fn native_zstd(global: &JSGlobalObject) -> JSValue { /// comptime duck-typed `CompressionStream(T)` mixin). /// /// All three `Native{Zlib,Brotli,Zstd}` structs share the exact field layout -/// (`global_this`, `stream`, `write_result`, `poll_ref`, `this_value`, +/// (`global_this`, `stream`, `poll_ref`, `this_value`, /// `write_in_progress`, `pending_close`, `pending_reset`, `closed`, `task`, /// `ref_count`), so the macro can stamp the impls uniformly. /// @@ -1001,7 +1009,7 @@ macro_rules! __impl_compression_stream { /// `generate-classes.ts` for the `values:` list in `zlib.classes.ts`. #[allow(unused)] pub(crate) mod js { - ::bun_jsc::codegen_cached_accessors!($type_name; writeCallback, errorCallback, dictionary, pendingInput, pendingOutput); + ::bun_jsc::codegen_cached_accessors!($type_name; writeCallback, errorCallback, dictionary, pendingInput, pendingOutput, writeResult); } impl $crate::node::node_zlib_binding::CompressionContext for $ctx { @@ -1019,7 +1027,6 @@ macro_rules! __impl_compression_stream { #[inline] fn global_this(&self) -> &::bun_jsc::JSGlobalObject { self.global_this.get() } #[inline] fn stream(&self) -> &::bun_jsc::JsCell { &self.stream } - #[inline] fn write_result_ptr(&self) -> Option<*mut u32> { self.write_result.get().map(|p| p.cast::()) } #[inline] fn poll_ref(&self) -> &::bun_jsc::JsCell<$crate::node::node_zlib_binding::CountedKeepAlive> { &self.poll_ref } #[inline] fn this_value(&self) -> &::bun_jsc::JsCell<::bun_jsc::StrongOptional> { &self.this_value } #[inline] fn task(&self) -> &::bun_jsc::JsCell<::bun_jsc::WorkPoolTask> { &self.task } @@ -1048,6 +1055,9 @@ macro_rules! __impl_compression_stream { unsafe { ::deref(this) } } + #[inline] fn write_result_get_cached(this_value: ::bun_jsc::JSValue) -> Option<::bun_jsc::JSValue> { + js::write_result_get_cached(this_value) + } #[inline] fn write_callback_get_cached(this_value: ::bun_jsc::JSValue) -> Option<::bun_jsc::JSValue> { js::write_callback_get_cached(this_value) } diff --git a/src/runtime/node/zlib/NativeBrotli.rs b/src/runtime/node/zlib/NativeBrotli.rs index 8bfe10b4c10..ef9761d7663 100644 --- a/src/runtime/node/zlib/NativeBrotli.rs +++ b/src/runtime/node/zlib/NativeBrotli.rs @@ -85,9 +85,6 @@ mod _impl { // centralises the single unsafe deref so the trait impl is safe. pub global_this: bun_ptr::BackRef, pub stream: JsCell, - /// Points into a JS `Uint32Array` (`this._writeState`). Kept alive because - /// the JS object is tied to the native handle as `_handle[owner_symbol]`. - pub write_result: Cell>, pub poll_ref: JsCell, // TODO(port): Strong on m_ctx self-ref โ†’ JsRef per PORTING.md ยงJSC (Strong back-ref to own wrapper leaks) pub this_value: JsCell, // Strong.Optional โ€” empty-initialised @@ -145,7 +142,6 @@ mod _impl { // JSC_BORROW backref โ€” the global outlives this m_ctx payload. global_this: bun_ptr::BackRef::new(global_this), stream: JsCell::new(stream), - write_result: Cell::new(None), poll_ref: JsCell::new(CountedKeepAlive::default()), this_value: JsCell::new(StrongOptional::empty()), write_in_progress: Cell::new(false), @@ -188,10 +184,7 @@ mod _impl { .throw()); } - // this does not get gc'd because it is stored in the JS object's - // `this._writeState`. and the JS object is tied to the native handle - // as `_handle[owner_symbol]`. - // `flush_write_result` writes two u32s through this pointer, so the + // `flush_write_result` writes two u32s into this array, so the // caller-supplied array must hold at least 2 elements. let write_result_value = arguments.ptr[1]; let Some(mut write_result_buf) = write_result_value.as_array_buffer(global_this) else { @@ -217,7 +210,6 @@ mod _impl { ) .throw()); } - let write_result = write_result_slice.as_mut_ptr(); let write_callback = validators::validate_function(global_this, "writeCallback", arguments.ptr[2])?; @@ -240,7 +232,7 @@ mod _impl { )); } - self.write_result.set(Some(write_result)); + js::write_result_set_cached(this_value, global_this, write_result_value); js::write_callback_set_cached( this_value, diff --git a/src/runtime/node/zlib/NativeZlib.rs b/src/runtime/node/zlib/NativeZlib.rs index eef9eeb4a13..eafe4afc869 100644 --- a/src/runtime/node/zlib/NativeZlib.rs +++ b/src/runtime/node/zlib/NativeZlib.rs @@ -44,7 +44,6 @@ mod _impl { // centralises the single unsafe deref so the trait impl is safe. pub global_this: bun_ptr::BackRef, pub stream: JsCell, - pub write_result: Cell>, pub poll_ref: JsCell, pub this_value: JsCell, // jsc.Strong.Optional pub write_in_progress: Cell, @@ -97,7 +96,6 @@ mod _impl { // JSC_BORROW backref โ€” the global outlives this m_ctx payload. global_this: bun_ptr::BackRef::new(global), stream: JsCell::new(stream), - write_result: Cell::new(None), poll_ref: JsCell::new(CountedKeepAlive::default()), this_value: JsCell::new(StrongOptional::empty()), write_in_progress: Cell::new(false), @@ -141,8 +139,7 @@ mod _impl { validators::validate_int32(global, arguments.ptr[2], "memLevel", None, None)?; let strategy = validators::validate_int32(global, arguments.ptr[3], "strategy", None, None)?; - // this does not get gc'd because it is stored in the JS object's `this._writeState`. and the JS object is tied to the native handle as `_handle[owner_symbol]`. - // `flush_write_result` writes two u32s through this pointer, so the + // `flush_write_result` writes two u32s into this array, so the // caller-supplied array must hold at least 2 elements. let write_result_value = arguments.ptr[4]; let Some(mut write_result_buf) = write_result_value.as_array_buffer(global) else { @@ -168,7 +165,6 @@ mod _impl { ) .throw()); } - let write_result = write_result_slice.as_mut_ptr(); let write_callback = validators::validate_function(global, "writeCallback", arguments.ptr[5])?; // Bind the ArrayBuffer view to a local so the borrowed byte_slice() outlives @@ -191,7 +187,7 @@ mod _impl { Some(dictionary_buf.byte_slice()) }; - self.write_result.set(Some(write_result)); + js::write_result_set_cached(this_value, global, write_result_value); js::write_callback_set_cached( this_value, global, diff --git a/src/runtime/node/zlib/NativeZstd.rs b/src/runtime/node/zlib/NativeZstd.rs index d3a7aefd4c4..abebacec68f 100644 --- a/src/runtime/node/zlib/NativeZstd.rs +++ b/src/runtime/node/zlib/NativeZstd.rs @@ -42,8 +42,6 @@ mod _impl { // `BackRef` centralises the single unsafe deref so the trait impl is safe. pub global_this: bun_ptr::BackRef, pub stream: JsCell, - // LIFETIMES.tsv: BORROW_PARAM โ†’ Option<*mut u32> (points into JS Uint32Array backing store) - pub write_result: Cell>, pub poll_ref: JsCell, pub this_value: JsCell, // jsc.Strong.Optional pub write_in_progress: Cell, @@ -101,7 +99,6 @@ mod _impl { // wrapper is owned by that global's heap). global_this: bun_ptr::BackRef::new(global), stream: JsCell::new(stream), - write_result: Cell::new(None), poll_ref: JsCell::new(CountedKeepAlive::default()), this_value: JsCell::new(StrongOptional::empty()), write_in_progress: Cell::new(false), @@ -160,7 +157,7 @@ mod _impl { write_state_value, )); } - // `flush_write_result` writes two u32s through this pointer, so the + // `flush_write_result` writes two u32s into this array, so the // caller-supplied array must hold at least 2 elements. let write_state_slice = write_state.as_u32(); if write_state_slice.len() < 2 { @@ -171,7 +168,7 @@ mod _impl { ) .throw()); } - self.write_result.set(Some(write_state_slice.as_mut_ptr())); + js::write_result_set_cached(this_value, global, write_state_value); let write_js_callback = validators::validate_function(global, "processCallback", process_callback_value)?; From d0b16a8b158d3578479c4478469d25519cb20cde Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 30 May 2026 05:46:03 +0000 Subject: [PATCH 08/19] node:net: tighten subnet rule matching --- src/runtime/node/net/BlockList.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/runtime/node/net/BlockList.rs b/src/runtime/node/net/BlockList.rs index b00731a852f..321e7a7e20f 100644 --- a/src/runtime/node/net/BlockList.rs +++ b/src/runtime/node/net/BlockList.rs @@ -305,7 +305,7 @@ impl BlockList { } Rule::Subnet { network, prefix } => { if let Some(ip_addr) = address.as_v4() { - if let Some(subnet_addr) = network.as_v4() { + if let Some(subnet_addr) = network.as_sin().map(|s| s.addr) { if *prefix == 32 { if ip_addr == subnet_addr { return Ok(JSValue::TRUE); @@ -313,6 +313,9 @@ impl BlockList { continue; } } + if *prefix == 0 { + return Ok(JSValue::TRUE); + } let one: u32 = 1; let mask_addr: u32 = ((one << (*prefix as u32)) - 1) << (32 - *prefix as u32); @@ -323,8 +326,18 @@ impl BlockList { } } } - if let (Some(addr6), Some(net6)) = (address.as_sin6(), network.as_sin6()) { - let ip_addr: u128 = u128::from_ne_bytes(addr6.addr); + if let Some(net6) = network.as_sin6() { + let ip_addr: u128 = if let Some(addr6) = address.as_sin6() { + u128::from_ne_bytes(addr6.addr) + } else if let Some(ip4) = address.as_v4() { + let mut mapped = [0u8; 16]; + mapped[10] = 255; + mapped[11] = 255; + mapped[12..16].copy_from_slice(&ip4.to_ne_bytes()); + u128::from_ne_bytes(mapped) + } else { + continue; + }; let subnet_addr: u128 = u128::from_ne_bytes(net6.addr); if *prefix == 128 { if ip_addr == subnet_addr { @@ -333,6 +346,9 @@ impl BlockList { continue; } } + if *prefix == 0 { + return Ok(JSValue::TRUE); + } let one: u128 = 1; let mask_addr = ((one << (*prefix as u32)) - 1) << (128 - *prefix as u32); let ip_net: u128 = ip_addr.swap_bytes() & mask_addr; From 18b747aded96c527edf8ff53c1b560bf6c09a911 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 30 May 2026 05:46:03 +0000 Subject: [PATCH 09/19] node:http: bound duplicate header handling --- src/jsc/bindings/NodeHTTP.cpp | 141 +++++++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 4 deletions(-) diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 37959b9a913..98418f4cae8 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -110,6 +110,112 @@ static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject return JSValue::encode(tuple); } +static bool isSingleValueHeader(WebCore::HTTPHeaderName name) +{ + switch (name) { + 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 true; + default: + return false; + } +} + +static bool isSingleValueHeader(const WTF::String& lowercasedName) +{ + return lowercasedName == "from"_s || lowercasedName == "max-forwards"_s || lowercasedName == "retry-after"_s || lowercasedName == "server"_s; +} + +static JSC::JSObject* buildHeadersObjectHandlingDuplicates(uWS::HttpRequest* request, JSObject* prototype, JSC::JSGlobalObject* globalObject, JSC::VM& vm) +{ + auto scope = DECLARE_THROW_SCOPE(vm); + + JSC::JSObject* headersObject = JSC::constructEmptyObject(globalObject, prototype, JSFinalObject::defaultInlineCapacity); + RETURN_IF_EXCEPTION(scope, nullptr); + JSC::JSArray* setCookiesHeaderArray = nullptr; + HTTPHeaderIdentifiers& identifiers = WebCore::clientData(vm)->httpHeaderIdentifiers(); + + for (auto it = request->begin(); it != request->end(); ++it) { + auto pair = *it; + StringView nameView = StringView(std::span { reinterpret_cast(pair.first.data()), pair.first.length() }); + std::span data; + auto value = String::tryCreateUninitialized(pair.second.length(), data); + if (value.isNull()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return nullptr; + } + if (pair.second.length() > 0) + memcpy(data.data(), pair.second.data(), pair.second.length()); + + JSString* jsValue = jsString(vm, value); + + HTTPHeaderName name; + Identifier nameIdentifier; + bool isSetCookie = false; + bool isSingleValue = false; + bool isCookie = false; + + if (WebCore::findHTTPHeaderName(nameView, name)) { + nameIdentifier = identifiers.identifierFor(vm, name); + isSetCookie = name == WebCore::HTTPHeaderName::SetCookie; + isSingleValue = isSingleValueHeader(name); + isCookie = name == WebCore::HTTPHeaderName::Cookie; + } else { + WTF::String lowercasedNameString = nameView.toString().convertToASCIILowercase(); + nameIdentifier = Identifier::fromString(vm, lowercasedNameString); + isSingleValue = isSingleValueHeader(lowercasedNameString); + } + + if (isSetCookie) { + if (!setCookiesHeaderArray) { + setCookiesHeaderArray = constructEmptyArray(globalObject, nullptr); + RETURN_IF_EXCEPTION(scope, nullptr); + headersObject->putDirect(vm, nameIdentifier, setCookiesHeaderArray, 0); + RETURN_IF_EXCEPTION(scope, nullptr); + } + setCookiesHeaderArray->push(globalObject, jsValue); + RETURN_IF_EXCEPTION(scope, nullptr); + continue; + } + + JSValue existing; + if (std::optional index = parseIndex(nameIdentifier)) { + existing = headersObject->getDirectIndex(globalObject, index.value()); + RETURN_IF_EXCEPTION(scope, nullptr); + } else { + existing = headersObject->getDirect(vm, nameIdentifier); + } + + if (!existing) { + headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue); + RETURN_IF_EXCEPTION(scope, nullptr); + continue; + } + + if (isSingleValue) + continue; + + JSString* joined = jsString(globalObject, asString(existing), jsString(vm, String(isCookie ? "; "_s : ", "_s)), jsValue); + RETURN_IF_EXCEPTION(scope, nullptr); + headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, joined); + RETURN_IF_EXCEPTION(scope, nullptr); + } + + return headersObject; +} + static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSValue methodString, MarkedArgumentBuffer& args, JSC::JSGlobalObject* globalObject, JSC::VM& vm) { auto scope = DECLARE_THROW_SCOPE(vm); @@ -142,8 +248,7 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal JSC::JSString* setCookiesHeaderString = nullptr; MarkedArgumentBuffer arrayValues; HTTPHeaderIdentifiers& identifiers = WebCore::clientData(vm)->httpHeaderIdentifiers(); - - args.append(headersObject); + bool sawDuplicateHeader = false; for (auto it = request->begin(); it != request->end(); ++it) { auto pair = *it; @@ -189,7 +294,14 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal RETURN_IF_EXCEPTION(scope, void()); } else { - headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue); + if (parseIndex(nameIdentifier)) [[unlikely]] { + sawDuplicateHeader = true; + headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue); + } else { + PutPropertySlot slot(headersObject); + headersObject->putDirect(vm, nameIdentifier, jsValue, 0, slot); + sawDuplicateHeader |= slot.type() == PutPropertySlot::ExistingProperty; + } RETURN_IF_EXCEPTION(scope, void()); arrayValues.append(nameString); arrayValues.append(jsValue); @@ -197,6 +309,12 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal } } + if (sawDuplicateHeader) [[unlikely]] { + headersObject = buildHeadersObjectHandlingDuplicates(request, globalObject->objectPrototype(), globalObject, vm); + RETURN_IF_EXCEPTION(scope, void()); + } + args.append(headersObject); + JSC::JSArray* array; { @@ -329,6 +447,7 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS RETURN_IF_EXCEPTION(scope, {}); JSC::JSArray* setCookiesHeaderArray = nullptr; JSC::JSString* setCookiesHeaderString = nullptr; + bool sawDuplicateHeader = false; unsigned i = 0; @@ -374,13 +493,27 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS RETURN_IF_EXCEPTION(scope, {}); } else { - headersObject->putDirect(vm, Identifier::fromString(vm, lowercasedNameString), jsValue, 0); + Identifier nameIdentifier = Identifier::fromString(vm, lowercasedNameString); + if (parseIndex(nameIdentifier)) [[unlikely]] { + sawDuplicateHeader = true; + headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue); + } else { + PutPropertySlot slot(headersObject); + headersObject->putDirect(vm, nameIdentifier, jsValue, 0, slot); + sawDuplicateHeader |= slot.type() == PutPropertySlot::ExistingProperty; + } + RETURN_IF_EXCEPTION(scope, {}); array->putDirectIndex(globalObject, i++, jsString(vm, nameString)); array->putDirectIndex(globalObject, i++, jsValue); RETURN_IF_EXCEPTION(scope, {}); } } + if (sawDuplicateHeader) [[unlikely]] { + headersObject = buildHeadersObjectHandlingDuplicates(request, prototype, globalObject, vm); + RETURN_IF_EXCEPTION(scope, {}); + } + tuple->putInternalField(vm, 0, headersObject); tuple->putInternalField(vm, 1, array); From a4b7c5b69968a8c73ed10d0899acf80f92d2d278 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 30 May 2026 05:46:03 +0000 Subject: [PATCH 10/19] glob: tighten cwd validation --- src/runtime/api/glob.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/runtime/api/glob.rs b/src/runtime/api/glob.rs index 94cd2db9616..c251fbf8e5f 100644 --- a/src/runtime/api/glob.rs +++ b/src/runtime/api/glob.rs @@ -47,6 +47,13 @@ impl ScanOpts { let cwd_str: Box<[u8]> = 'cwd_str: { let cwd_utf8 = cwd_string.to_utf8_without_ref(); + if cwd_utf8.slice().len() > MAX_PATH_BYTES { + return Err(global_this.throw(format_args!( + "{}: invalid `cwd`, longer than {} bytes", + fn_name, MAX_PATH_BYTES + ))); + } + // If its absolute return as is if resolve_path::Platform::AUTO.is_absolute(cwd_utf8.slice()) { break 'cwd_str Box::<[u8]>::from(cwd_utf8.slice()); From a6f3f97e197b351192e505398c17c156dba12b45 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 30 May 2026 07:27:19 +0000 Subject: [PATCH 11/19] test: add regression coverage for input validation changes --- test/js/bun/glob/scan.test.ts | 23 ++++++ test/js/node/http/node-http.test.ts | 63 ++++++++++++++++ test/js/node/net/node-net.test.ts | 73 ++++++++++++++++++- .../zlib/zlib-handle-bounds-check.test.ts | 58 +++++++++++++++ 4 files changed, 216 insertions(+), 1 deletion(-) diff --git a/test/js/bun/glob/scan.test.ts b/test/js/bun/glob/scan.test.ts index 5c427e43ea8..13cfae83c8b 100644 --- a/test/js/bun/glob/scan.test.ts +++ b/test/js/bun/glob/scan.test.ts @@ -188,6 +188,29 @@ describe("glob.match", async () => { return undefined; } }); + + test("oversized cwd throws instead of crashing", async () => { + const glob = new Glob("*.ts"); + const tooLong = "x".repeat(10_000); + // relative cwd + expect(returnError(() => [...glob.scanSync({ cwd: tooLong })])).toBeDefined(); + expect(returnError(() => glob.scan({ cwd: tooLong }))).toBeDefined(); + // relative cwd that would be resolved against process.cwd() + expect(returnError(() => [...glob.scanSync({ cwd: tooLong, absolute: true })])).toBeDefined(); + // absolute cwd + expect(returnError(() => [...glob.scanSync({ cwd: "/" + tooLong })])).toBeDefined(); + expect(returnError(() => glob.scan({ cwd: "/" + tooLong }))).toBeDefined(); + + function returnError(cb: () => any): Error | undefined { + try { + cb(); + } catch (err) { + // @ts-expect-error + return err; + } + return undefined; + } + }); }); // From fast-glob regular.e2e.tes diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index a78115a5e00..7d056db4daf 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -1628,6 +1628,69 @@ describe("HTTP Server Security Tests - Advanced", () => { await promise; expect(mockHandler).not.toHaveBeenCalled(); }); + + test("duplicate request headers follow Node.js precedence rules", async () => { + // Expected values verified against Node.js v24: singleton headers keep + // the first value, joinable headers are comma-joined, Cookie joins with + // "; ", and Set-Cookie becomes an array. + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("request", (req, res) => { + try { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); + resolve({ + host: req.headers.host, + contentType: req.headers["content-type"], + authorization: req.headers.authorization, + accept: req.headers.accept, + xCustom: req.headers["x-custom"], + cookie: req.headers.cookie, + setCookie: req.headers["set-cookie"], + rawHostCount: req.rawHeaders.filter(h => h.toLowerCase() === "host").length, + }); + } catch (err) { + reject(err); + } + }); + + const msg = [ + "GET / HTTP/1.1", + "Host: first.example.com", + "Host: second.example.com", + "Content-Type: text/plain", + "Content-Type: text/html", + "Authorization: token1", + "Authorization: token2", + "Accept: application/json", + "Accept: text/html", + "X-Custom: one", + "X-Custom: two", + "Cookie: a=1", + "Cookie: b=2", + "Set-Cookie: x=1", + "Set-Cookie: y=2", + "Connection: close", + "", + "", + ].join("\r\n"); + + const response = await sendRequest(msg); + expect(response).toInclude("200"); + const headers: any = await promise; + // Singleton headers keep the first value. + expect(headers.host).toBe("first.example.com"); + expect(headers.contentType).toBe("text/plain"); + expect(headers.authorization).toBe("token1"); + // Other headers are joined with ", ". + expect(headers.accept).toBe("application/json, text/html"); + expect(headers.xCustom).toBe("one, two"); + // Cookie is joined with "; ". + expect(headers.cookie).toBe("a=1; b=2"); + // Set-Cookie is collected into an array. + expect(headers.setCookie).toEqual(["x=1", "y=2"]); + // rawHeaders still reports every received header. + expect(headers.rawHostCount).toBe(2); + }); }); describe("HTTP Protocol Violations", () => { diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index 27a15e38ab2..73ed0dffd45 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -3,7 +3,18 @@ import { heapStats } from "bun:jsc"; import { describe, expect, it } from "bun:test"; import { bunEnv, bunExe, expectMaxObjectTypeCount, isASAN, isDebug, isWindows, tmpdirSync } from "harness"; import { randomUUID } from "node:crypto"; -import { connect, createConnection, createServer, isIP, isIPv4, isIPv6, Server, Socket, Stream } from "node:net"; +import { + BlockList, + connect, + createConnection, + createServer, + isIP, + isIPv4, + isIPv6, + Server, + Socket, + Stream, +} from "node:net"; import { join } from "node:path"; const socket_domain = tmpdirSync(); @@ -37,6 +48,66 @@ it("should support net.isIPv6()", () => { expect(isIPv6("127.000.000.001")).toBe(false); }); +describe("net.BlockList subnet rules", () => { + // Expected values verified against Node.js v24. + it("matches IPv4-mapped IPv6 subnet rules against IPv4 and mapped addresses", () => { + const blockList = new BlockList(); + blockList.addSubnet("::ffff:1.1.1.0", 120, "ipv6"); + expect(blockList.check("1.1.1.1", "ipv4")).toBe(true); + expect(blockList.check("1.1.2.1", "ipv4")).toBe(false); + expect(blockList.check("::ffff:1.1.1.1", "ipv6")).toBe(true); + expect(blockList.check("::ffff:1.1.2.1", "ipv6")).toBe(false); + }); + + it("matches IPv4 subnet rules against IPv4-mapped IPv6 addresses", () => { + const blockList = new BlockList(); + blockList.addSubnet("1.1.1.0", 24, "ipv4"); + expect(blockList.check("::ffff:1.1.1.1", "ipv6")).toBe(true); + expect(blockList.check("::ffff:1.1.2.1", "ipv6")).toBe(false); + expect(blockList.check("::1", "ipv6")).toBe(false); + expect(blockList.check("1.1.1.255", "ipv4")).toBe(true); + expect(blockList.check("1.1.2.0", "ipv4")).toBe(false); + }); + + it("does not match IPv4 addresses against non-mapped IPv6 subnet rules", () => { + const blockList = new BlockList(); + blockList.addSubnet("8592:757c:efae:4e45::", 64, "ipv6"); + expect(blockList.check("1.1.1.1", "ipv4")).toBe(false); + expect(blockList.check("8592:757c:efae:4e45::f", "ipv6")).toBe(true); + expect(blockList.check("8592:757c:efaf:4e45::f", "ipv6")).toBe(false); + }); + + it("matches exact-prefix subnet rules", () => { + const v4 = new BlockList(); + v4.addSubnet("10.0.0.1", 32, "ipv4"); + expect(v4.check("10.0.0.1", "ipv4")).toBe(true); + expect(v4.check("10.0.0.2", "ipv4")).toBe(false); + expect(v4.check("::ffff:10.0.0.1", "ipv6")).toBe(true); + + const v6 = new BlockList(); + v6.addSubnet("::1", 128, "ipv6"); + expect(v6.check("::1", "ipv6")).toBe(true); + expect(v6.check("::2", "ipv6")).toBe(false); + + const mapped = new BlockList(); + mapped.addSubnet("::ffff:10.0.0.1", 128, "ipv6"); + expect(mapped.check("10.0.0.1", "ipv4")).toBe(true); + expect(mapped.check("10.0.0.2", "ipv4")).toBe(false); + }); + + it("matches zero-prefix subnet rules", () => { + const v4 = new BlockList(); + v4.addSubnet("0.0.0.0", 0, "ipv4"); + expect(v4.check("255.255.255.255", "ipv4")).toBe(true); + expect(v4.check("::1", "ipv6")).toBe(false); + + const v6 = new BlockList(); + v6.addSubnet("::", 0, "ipv6"); + expect(v6.check("8592:757c:efae:4e45::f", "ipv6")).toBe(true); + expect(v6.check("1.2.3.4", "ipv4")).toBe(true); + }); +}); + describe("net.Socket read", () => { var unix_servers = 0; for (let [message, label] of [ diff --git a/test/js/node/zlib/zlib-handle-bounds-check.test.ts b/test/js/node/zlib/zlib-handle-bounds-check.test.ts index 217c84d52fa..d92c83f4bc0 100644 --- a/test/js/node/zlib/zlib-handle-bounds-check.test.ts +++ b/test/js/node/zlib/zlib-handle-bounds-check.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; // Tests for bounds checking on native zlib handle write/writeSync methods. // These verify that user-controlled offset/length parameters are validated @@ -87,3 +88,60 @@ describe("zlib native handle bounds checking", () => { }).not.toThrow(); }); }); + +describe("zlib native handle writeState", () => { + test("writeSync updates the writeState array", () => { + const zlib = require("zlib"); + const deflate = zlib.createDeflateRaw(); + const handle = deflate._handle; + const ws = deflate._writeState; + const inBuf = Buffer.from("hello world ".repeat(10)); + const outBuf = Buffer.alloc(1024); + + ws[0] = 0; + ws[1] = 0xffffffff; + handle.writeSync(2 /* Z_SYNC_FLUSH */, inBuf, 0, inBuf.length, outBuf, 0, outBuf.length); + + // writeState receives (availOut, availIn) after the write completes. + expect(ws[0]).toBeGreaterThan(0); + expect(ws[0]).toBeLessThan(outBuf.length); + expect(ws[1]).toBe(0); + }); + + test("write completion with a detached writeState backing store does not crash", async () => { + // The native handle caches the writeState array passed to init(). If its + // backing ArrayBuffer is detached mid-stream, completing a write must + // re-resolve the array and skip the update rather than write through a + // stale pointer into freed/transferred memory. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const zlib = require("zlib"); + const deflate = zlib.createDeflateRaw(); + const handle = deflate._handle; + const ws = deflate._writeState; + const input = Buffer.from("hello world ".repeat(10)); + const out = Buffer.alloc(1024); + handle.writeSync(2, input, 0, input.length, out, 0, out.length); + // Detach the writeState backing store; the transferred copy is + // dropped immediately and collected. + structuredClone(ws.buffer, { transfer: [ws.buffer] }); + Bun.gc(true); + // This write completion must not touch the detached store. + handle.writeSync(2, Buffer.from("more data here"), 0, 14, out, 0, out.length); + // A fresh stream still works end-to-end. + const compressed = zlib.deflateRawSync("still works"); + console.log(zlib.inflateRawSync(compressed).toString()); + `, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout.trim()).toBe("still works"); + expect(exitCode).toBe(0); + }); +}); From 2f944497257e39a340d662771ea91d6a31b3e188 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 07:32:32 +0000 Subject: [PATCH 12/19] [autofix.ci] apply automated fixes --- src/bundler_jsc/analyze_jsc.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/bundler_jsc/analyze_jsc.rs b/src/bundler_jsc/analyze_jsc.rs index c494c5b4743..d4313d4eb65 100644 --- a/src/bundler_jsc/analyze_jsc.rs +++ b/src/bundler_jsc/analyze_jsc.rs @@ -44,7 +44,10 @@ pub(crate) extern "C" fn zig__ModuleInfoDeserialized__toJSModuleRecord( let is_valid_string_id = |id: StringID| (id.0 as usize) < identifier_count || id.0 >= StringID::STAR_NAMESPACE.0; if !buffer.iter().copied().all(is_valid_string_id) - || !requested_modules_keys.iter().copied().all(is_valid_string_id) + || !requested_modules_keys + .iter() + .copied() + .all(is_valid_string_id) || !requested_modules_values .iter() .all(|&v| (v.0 as usize) < identifier_count || v.0 >= RequestedModuleValue::Json.0) From 493d44409105f49aae4a29134d25fa8a2b084e6a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 30 May 2026 07:39:05 +0000 Subject: [PATCH 13/19] create: build install argv with vec macro --- src/runtime/cli/create/SourceFileProjectGenerator.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/runtime/cli/create/SourceFileProjectGenerator.rs b/src/runtime/cli/create/SourceFileProjectGenerator.rs index 3e4f2b52f40..634f1a385a5 100644 --- a/src/runtime/cli/create/SourceFileProjectGenerator.rs +++ b/src/runtime/cli/create/SourceFileProjectGenerator.rs @@ -332,11 +332,7 @@ pub fn generate_files( } if !dependencies.is_empty() { - let mut argv: Vec<&[u8]> = Vec::new(); - argv.push(b"bun"); - argv.push(b"--only-missing"); - argv.push(b"install"); - argv.push(b"--"); + let mut argv: Vec<&[u8]> = vec![b"bun", b"--only-missing", b"install", b"--"]; argv.extend(dependencies.iter().map(|d| &d[..])); run_install(&mut argv)?; } From e0056255fc514f83ca8be7bd67f7707dabb24356 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 30 May 2026 08:31:54 +0000 Subject: [PATCH 14/19] test: tighten assertions and platform coverage in new regression tests --- test/cli/create/create-jsx.test.ts | 2 +- test/cli/run/transpiler-cache.test.ts | 4 ++-- test/js/bun/glob/scan.test.ts | 2 +- test/js/node/zlib/zlib-handle-bounds-check.test.ts | 3 ++- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/test/cli/create/create-jsx.test.ts b/test/cli/create/create-jsx.test.ts index a33e1897fd1..8612ea74469 100644 --- a/test/cli/create/create-jsx.test.ts +++ b/test/cli/create/create-jsx.test.ts @@ -351,7 +351,7 @@ export default function Component() { const installLine = stdout.split("\n").find(line => line.includes("--only-missing install")); expect(installLine).toBeDefined(); expect(installLine).toContain(" install -- "); -}, 60_000); +}); function normalizeHTMLFn(development: boolean = true) { return (html: string) => diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 411ff6e0499..d9a09d5163d 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -350,8 +350,8 @@ test("cached module still works", () => { // serialized module record. const first = run(); expect(first.stderr.toString() + first.stdout.toString()).toContain("1 pass"); - expect(first.exitCode).toBe(0); expect(existsSync(cache_dir)).toBeTrue(); + expect(first.exitCode).toBe(0); // Second run restores from the intact cache entry: the legitimate record is // accepted and the module still works. @@ -372,4 +372,4 @@ test("cached module still works", () => { expect(third.stderr.toString() + third.stdout.toString()).toContain("parseFromSourceCode failed"); expect(third.signalCode).toBeUndefined(); expect(third.exitCode).toBe(1); -}, 90000); +}); diff --git a/test/js/bun/glob/scan.test.ts b/test/js/bun/glob/scan.test.ts index 13cfae83c8b..c4fa1714f8b 100644 --- a/test/js/bun/glob/scan.test.ts +++ b/test/js/bun/glob/scan.test.ts @@ -191,7 +191,7 @@ describe("glob.match", async () => { test("oversized cwd throws instead of crashing", async () => { const glob = new Glob("*.ts"); - const tooLong = "x".repeat(10_000); + const tooLong = Buffer.alloc(100_000, "x").toString(); // relative cwd expect(returnError(() => [...glob.scanSync({ cwd: tooLong })])).toBeDefined(); expect(returnError(() => glob.scan({ cwd: tooLong }))).toBeDefined(); diff --git a/test/js/node/zlib/zlib-handle-bounds-check.test.ts b/test/js/node/zlib/zlib-handle-bounds-check.test.ts index d92c83f4bc0..ffac59ab607 100644 --- a/test/js/node/zlib/zlib-handle-bounds-check.test.ts +++ b/test/js/node/zlib/zlib-handle-bounds-check.test.ts @@ -140,7 +140,8 @@ describe("zlib native handle writeState", () => { stderr: "pipe", }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); expect(stdout.trim()).toBe("still works"); expect(exitCode).toBe(0); }); From 3e0ef3df55ea2d0482c862dd0dc4a1ec835ff62b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 1 Jun 2026 23:14:11 +0000 Subject: [PATCH 15/19] node:http: restructure request header object construction --- src/jsc/bindings/NodeHTTP.cpp | 123 ++++++++++++++++++++-------------- 1 file changed, 74 insertions(+), 49 deletions(-) diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 98418f4cae8..00978eaa605 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -110,9 +110,20 @@ static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject return JSValue::encode(tuple); } -static bool isSingleValueHeader(WebCore::HTTPHeaderName name) +enum class RequestHeaderKind : uint8_t { + Joinable, + Singleton, + Cookie, + SetCookie, +}; + +static RequestHeaderKind requestHeaderKind(WebCore::HTTPHeaderName name) { switch (name) { + case WebCore::HTTPHeaderName::SetCookie: + return RequestHeaderKind::SetCookie; + case WebCore::HTTPHeaderName::Cookie: + return RequestHeaderKind::Cookie; case WebCore::HTTPHeaderName::Age: case WebCore::HTTPHeaderName::Authorization: case WebCore::HTTPHeaderName::ContentLength: @@ -127,89 +138,103 @@ static bool isSingleValueHeader(WebCore::HTTPHeaderName name) case WebCore::HTTPHeaderName::ProxyAuthorization: case WebCore::HTTPHeaderName::Referer: case WebCore::HTTPHeaderName::UserAgent: - return true; + return RequestHeaderKind::Singleton; default: - return false; + return RequestHeaderKind::Joinable; } } -static bool isSingleValueHeader(const WTF::String& lowercasedName) +static RequestHeaderKind requestHeaderKind(const WTF::String& lowercasedName) { - return lowercasedName == "from"_s || lowercasedName == "max-forwards"_s || lowercasedName == "retry-after"_s || lowercasedName == "server"_s; + if (lowercasedName == "from"_s || lowercasedName == "max-forwards"_s || lowercasedName == "retry-after"_s || lowercasedName == "server"_s) + return RequestHeaderKind::Singleton; + return RequestHeaderKind::Joinable; +} + +struct RequestHeaderIdentity { + Identifier identifier; + RequestHeaderKind kind; +}; + +static RequestHeaderIdentity requestHeaderIdentity(JSC::VM& vm, StringView nameView) +{ + HTTPHeaderName name; + if (WebCore::findHTTPHeaderName(nameView, name)) + return { WebCore::clientData(vm)->httpHeaderIdentifiers().identifierFor(vm, name), requestHeaderKind(name) }; + + WTF::String lowercasedName = nameView.toString().convertToASCIILowercase(); + return { Identifier::fromString(vm, lowercasedName), requestHeaderKind(lowercasedName) }; +} + +static JSString* jsStringForRequestHeaderValue(JSC::JSGlobalObject* globalObject, JSC::VM& vm, std::string_view headerValue) +{ + auto scope = DECLARE_THROW_SCOPE(vm); + std::span data; + auto value = String::tryCreateUninitialized(headerValue.length(), data); + if (value.isNull()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return nullptr; + } + if (headerValue.length() > 0) + memcpy(data.data(), headerValue.data(), headerValue.length()); + return jsString(vm, value); } static JSC::JSObject* buildHeadersObjectHandlingDuplicates(uWS::HttpRequest* request, JSObject* prototype, JSC::JSGlobalObject* globalObject, JSC::VM& vm) { auto scope = DECLARE_THROW_SCOPE(vm); - JSC::JSObject* headersObject = JSC::constructEmptyObject(globalObject, prototype, JSFinalObject::defaultInlineCapacity); + size_t headerCount = 0; + for (auto it = request->begin(); it != request->end(); ++it) + headerCount++; + + JSC::JSObject* headersObject = JSC::constructEmptyObject(globalObject, prototype, std::min(headerCount, static_cast(JSFinalObject::maxInlineCapacity))); RETURN_IF_EXCEPTION(scope, nullptr); JSC::JSArray* setCookiesHeaderArray = nullptr; - HTTPHeaderIdentifiers& identifiers = WebCore::clientData(vm)->httpHeaderIdentifiers(); + unsigned setCookieCount = 0; for (auto it = request->begin(); it != request->end(); ++it) { auto pair = *it; StringView nameView = StringView(std::span { reinterpret_cast(pair.first.data()), pair.first.length() }); - std::span data; - auto value = String::tryCreateUninitialized(pair.second.length(), data); - if (value.isNull()) [[unlikely]] { - throwOutOfMemoryError(globalObject, scope); - return nullptr; - } - if (pair.second.length() > 0) - memcpy(data.data(), pair.second.data(), pair.second.length()); - - JSString* jsValue = jsString(vm, value); + JSString* jsValue = jsStringForRequestHeaderValue(globalObject, vm, pair.second); + RETURN_IF_EXCEPTION(scope, nullptr); - HTTPHeaderName name; - Identifier nameIdentifier; - bool isSetCookie = false; - bool isSingleValue = false; - bool isCookie = false; + auto [nameIdentifier, kind] = requestHeaderIdentity(vm, nameView); - if (WebCore::findHTTPHeaderName(nameView, name)) { - nameIdentifier = identifiers.identifierFor(vm, name); - isSetCookie = name == WebCore::HTTPHeaderName::SetCookie; - isSingleValue = isSingleValueHeader(name); - isCookie = name == WebCore::HTTPHeaderName::Cookie; - } else { - WTF::String lowercasedNameString = nameView.toString().convertToASCIILowercase(); - nameIdentifier = Identifier::fromString(vm, lowercasedNameString); - isSingleValue = isSingleValueHeader(lowercasedNameString); - } - - if (isSetCookie) { + if (kind == RequestHeaderKind::SetCookie) { if (!setCookiesHeaderArray) { setCookiesHeaderArray = constructEmptyArray(globalObject, nullptr); RETURN_IF_EXCEPTION(scope, nullptr); headersObject->putDirect(vm, nameIdentifier, setCookiesHeaderArray, 0); RETURN_IF_EXCEPTION(scope, nullptr); } - setCookiesHeaderArray->push(globalObject, jsValue); + setCookiesHeaderArray->putDirectIndex(globalObject, setCookieCount++, jsValue); RETURN_IF_EXCEPTION(scope, nullptr); continue; } + std::optional index = parseIndex(nameIdentifier); JSValue existing; - if (std::optional index = parseIndex(nameIdentifier)) { + if (index) { existing = headersObject->getDirectIndex(globalObject, index.value()); RETURN_IF_EXCEPTION(scope, nullptr); } else { existing = headersObject->getDirect(vm, nameIdentifier); } - if (!existing) { - headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue); - RETURN_IF_EXCEPTION(scope, nullptr); + if (existing && kind == RequestHeaderKind::Singleton) continue; - } - if (isSingleValue) - continue; + JSValue valueToPut = jsValue; + if (existing) { + valueToPut = jsString(globalObject, asString(existing), jsString(vm, String(kind == RequestHeaderKind::Cookie ? "; "_s : ", "_s)), jsValue); + RETURN_IF_EXCEPTION(scope, nullptr); + } - JSString* joined = jsString(globalObject, asString(existing), jsString(vm, String(isCookie ? "; "_s : ", "_s)), jsValue); - RETURN_IF_EXCEPTION(scope, nullptr); - headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, joined); + if (index) + headersObject->putDirectIndex(globalObject, index.value(), valueToPut); + else + headersObject->putDirect(vm, nameIdentifier, valueToPut, 0); RETURN_IF_EXCEPTION(scope, nullptr); } @@ -294,9 +319,9 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal RETURN_IF_EXCEPTION(scope, void()); } else { - if (parseIndex(nameIdentifier)) [[unlikely]] { + if (std::optional index = parseIndex(nameIdentifier)) [[unlikely]] { sawDuplicateHeader = true; - headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue); + headersObject->putDirectIndex(globalObject, index.value(), jsValue); } else { PutPropertySlot slot(headersObject); headersObject->putDirect(vm, nameIdentifier, jsValue, 0, slot); @@ -494,9 +519,9 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS } else { Identifier nameIdentifier = Identifier::fromString(vm, lowercasedNameString); - if (parseIndex(nameIdentifier)) [[unlikely]] { + if (std::optional index = parseIndex(nameIdentifier)) [[unlikely]] { sawDuplicateHeader = true; - headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue); + headersObject->putDirectIndex(globalObject, index.value(), jsValue); } else { PutPropertySlot slot(headersObject); headersObject->putDirect(vm, nameIdentifier, jsValue, 0, slot); From a35bf0dbaceb7197e08488139c16238b869013cd Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 1 Jun 2026 23:39:25 +0000 Subject: [PATCH 16/19] verify-baseline: treat hint-space cldemote as a nop --- scripts/verify-baseline-static/src/main.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/verify-baseline-static/src/main.rs b/scripts/verify-baseline-static/src/main.rs index 3756c0c5b9b..1963814bcde 100644 --- a/scripts/verify-baseline-static/src/main.rs +++ b/scripts/verify-baseline-static/src/main.rs @@ -152,6 +152,14 @@ fn is_harmless_on_nehalem(insn: &Instruction) -> bool { return true; } + // CLDEMOTE encodes in hint/NOP space (0f 1c /0) and is architecturally + // treated as a NOP on CPUs that don't enumerate it (SDM vol. 2A). Newer + // UCRT string routines (e.g. strpbrk) emit it unconditionally as a cache + // hint; on Nehalem it NOPs and the routine behaves identically. + if insn.mnemonic() == Mnemonic::Cldemote { + return true; + } + false } From 1ffc4cafe0e84479f3258d9912e4b078d04ce912 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:34:38 +0000 Subject: [PATCH 17/19] node:http: merge duplicate request headers in place via PutPropertySlot offset When putDirect reports ExistingProperty, the slot has already recorded the property's offset, so the duplicate can be fixed up with putDirectOffset at that offset instead of flagging the request and rebuilding the whole headers object with a second pass of property lookups. The merged value is re-derived from the raw header list, which also handles names that differ only by case. Index-shaped names, which bypass PutPropertySlot, check getDirectIndex directly instead of forcing a rebuild. --- src/jsc/bindings/NodeHTTP.cpp | 175 ++++++++++++---------------- test/js/node/http/node-http.test.ts | 63 ++++++++++ 2 files changed, 139 insertions(+), 99 deletions(-) diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 00978eaa605..ec6b7c9b8f6 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -21,6 +21,7 @@ #include "ZigGeneratedClasses.h" #include #include +#include #include "JSSocketAddressDTO.h" #include "node/JSNodeHTTPServerSocket.h" #include "node/JSNodeHTTPServerSocketPrototype.h" @@ -151,94 +152,39 @@ static RequestHeaderKind requestHeaderKind(const WTF::String& lowercasedName) return RequestHeaderKind::Joinable; } -struct RequestHeaderIdentity { - Identifier identifier; - RequestHeaderKind kind; -}; - -static RequestHeaderIdentity requestHeaderIdentity(JSC::VM& vm, StringView nameView) -{ - HTTPHeaderName name; - if (WebCore::findHTTPHeaderName(nameView, name)) - return { WebCore::clientData(vm)->httpHeaderIdentifiers().identifierFor(vm, name), requestHeaderKind(name) }; - - WTF::String lowercasedName = nameView.toString().convertToASCIILowercase(); - return { Identifier::fromString(vm, lowercasedName), requestHeaderKind(lowercasedName) }; -} - -static JSString* jsStringForRequestHeaderValue(JSC::JSGlobalObject* globalObject, JSC::VM& vm, std::string_view headerValue) -{ - auto scope = DECLARE_THROW_SCOPE(vm); - std::span data; - auto value = String::tryCreateUninitialized(headerValue.length(), data); - if (value.isNull()) [[unlikely]] { - throwOutOfMemoryError(globalObject, scope); - return nullptr; - } - if (headerValue.length() > 0) - memcpy(data.data(), headerValue.data(), headerValue.length()); - return jsString(vm, value); -} - -static JSC::JSObject* buildHeadersObjectHandlingDuplicates(uWS::HttpRequest* request, JSObject* prototype, JSC::JSGlobalObject* globalObject, JSC::VM& vm) +// Called after putDirect reported PutPropertySlot::ExistingProperty for a +// request header name. The first pass stores every header with a single put, +// so by the time a duplicate is detected the earlier value has already been +// replaced. Re-derive the value Node.js would produce for this name from the +// raw header list: the first value wins for singleton headers, Cookie joins +// with "; ", and everything else joins with ", ". Names that map to the same +// property key are exactly the names that are ASCII-case-insensitively equal. +static JSString* duplicateRequestHeaderValue(uWS::HttpRequest* request, StringView nameView, RequestHeaderKind kind, JSC::JSGlobalObject* globalObject, JSC::VM& vm) { auto scope = DECLARE_THROW_SCOPE(vm); - size_t headerCount = 0; - for (auto it = request->begin(); it != request->end(); ++it) - headerCount++; - - JSC::JSObject* headersObject = JSC::constructEmptyObject(globalObject, prototype, std::min(headerCount, static_cast(JSFinalObject::maxInlineCapacity))); - RETURN_IF_EXCEPTION(scope, nullptr); - JSC::JSArray* setCookiesHeaderArray = nullptr; - unsigned setCookieCount = 0; + WTF::StringBuilder builder; + ASCIILiteral separator = kind == RequestHeaderKind::Cookie ? "; "_s : ", "_s; + bool seenAny = false; for (auto it = request->begin(); it != request->end(); ++it) { auto pair = *it; - StringView nameView = StringView(std::span { reinterpret_cast(pair.first.data()), pair.first.length() }); - JSString* jsValue = jsStringForRequestHeaderValue(globalObject, vm, pair.second); - RETURN_IF_EXCEPTION(scope, nullptr); - - auto [nameIdentifier, kind] = requestHeaderIdentity(vm, nameView); - - if (kind == RequestHeaderKind::SetCookie) { - if (!setCookiesHeaderArray) { - setCookiesHeaderArray = constructEmptyArray(globalObject, nullptr); - RETURN_IF_EXCEPTION(scope, nullptr); - headersObject->putDirect(vm, nameIdentifier, setCookiesHeaderArray, 0); - RETURN_IF_EXCEPTION(scope, nullptr); - } - setCookiesHeaderArray->putDirectIndex(globalObject, setCookieCount++, jsValue); - RETURN_IF_EXCEPTION(scope, nullptr); + StringView candidateName(std::span { reinterpret_cast(pair.first.data()), pair.first.length() }); + if (!equalIgnoringASCIICase(candidateName, nameView)) continue; - } - - std::optional index = parseIndex(nameIdentifier); - JSValue existing; - if (index) { - existing = headersObject->getDirectIndex(globalObject, index.value()); - RETURN_IF_EXCEPTION(scope, nullptr); - } else { - existing = headersObject->getDirect(vm, nameIdentifier); - } - - if (existing && kind == RequestHeaderKind::Singleton) - continue; - - JSValue valueToPut = jsValue; - if (existing) { - valueToPut = jsString(globalObject, asString(existing), jsString(vm, String(kind == RequestHeaderKind::Cookie ? "; "_s : ", "_s)), jsValue); - RETURN_IF_EXCEPTION(scope, nullptr); - } - - if (index) - headersObject->putDirectIndex(globalObject, index.value(), valueToPut); - else - headersObject->putDirect(vm, nameIdentifier, valueToPut, 0); - RETURN_IF_EXCEPTION(scope, nullptr); + if (seenAny) + builder.append(separator); + seenAny = true; + builder.append(StringView(std::span { reinterpret_cast(pair.second.data()), pair.second.length() })); + if (kind == RequestHeaderKind::Singleton) + break; } - return headersObject; + if (builder.hasOverflowed()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return nullptr; + } + return jsString(vm, builder.toString()); } static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSValue methodString, MarkedArgumentBuffer& args, JSC::JSGlobalObject* globalObject, JSC::VM& vm) @@ -273,7 +219,6 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal JSC::JSString* setCookiesHeaderString = nullptr; MarkedArgumentBuffer arrayValues; HTTPHeaderIdentifiers& identifiers = WebCore::clientData(vm)->httpHeaderIdentifiers(); - bool sawDuplicateHeader = false; for (auto it = request->begin(); it != request->end(); ++it) { auto pair = *it; @@ -289,20 +234,23 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal Identifier nameIdentifier; JSString* nameString = nullptr; + WTF::String lowercasedName; // `findHTTPHeaderName` only writes `name` when it returns true, so the // SetCookie check must be gated on a successful lookup rather than on the // (otherwise indeterminate) `name` value. set-cookie is always a known // header name, so an unrecognized header is never set-cookie. + bool knownHeader = WebCore::findHTTPHeaderName(nameView, name); bool isSetCookie = false; - if (WebCore::findHTTPHeaderName(nameView, name)) { + if (knownHeader) { nameString = identifiers.stringFor(globalObject, name); nameIdentifier = identifiers.identifierFor(vm, name); isSetCookie = name == WebCore::HTTPHeaderName::SetCookie; } else { WTF::String wtfString = nameView.toString(); nameString = jsString(vm, wtfString); - nameIdentifier = Identifier::fromString(vm, wtfString.convertToASCIILowercase()); + lowercasedName = wtfString.convertToASCIILowercase(); + nameIdentifier = Identifier::fromString(vm, lowercasedName); } if (isSetCookie) { @@ -320,12 +268,31 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal } else { if (std::optional index = parseIndex(nameIdentifier)) [[unlikely]] { - sawDuplicateHeader = true; - headersObject->putDirectIndex(globalObject, index.value(), jsValue); + // Index-shaped names can't report back through PutPropertySlot, + // so check for an existing value directly. A numeric name is + // never a known header name, so duplicates always comma-join. + JSValue existing = headersObject->getDirectIndex(globalObject, index.value()); + RETURN_IF_EXCEPTION(scope, void()); + JSValue valueToPut = jsValue; + if (existing) { + valueToPut = jsString(globalObject, asString(existing), jsString(vm, String(", "_s)), jsValue); + RETURN_IF_EXCEPTION(scope, void()); + } + headersObject->putDirectIndex(globalObject, index.value(), valueToPut); } else { PutPropertySlot slot(headersObject); headersObject->putDirect(vm, nameIdentifier, jsValue, 0, slot); - sawDuplicateHeader |= slot.type() == PutPropertySlot::ExistingProperty; + if (slot.type() == PutPropertySlot::ExistingProperty) [[unlikely]] { + // Duplicate header name. putDirect already located the + // property and replaced its value, and the slot recorded + // the offset it lives at, so apply Node's merge rules in + // place without looking the name up again. + RequestHeaderKind kind = knownHeader ? requestHeaderKind(name) : requestHeaderKind(lowercasedName); + JSString* merged = duplicateRequestHeaderValue(request, nameView, kind, globalObject, vm); + RETURN_IF_EXCEPTION(scope, void()); + headersObject->structure()->didReplaceProperty(slot.cachedOffset()); + headersObject->putDirectOffset(vm, slot.cachedOffset(), merged); + } } RETURN_IF_EXCEPTION(scope, void()); arrayValues.append(nameString); @@ -334,10 +301,6 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal } } - if (sawDuplicateHeader) [[unlikely]] { - headersObject = buildHeadersObjectHandlingDuplicates(request, globalObject->objectPrototype(), globalObject, vm); - RETURN_IF_EXCEPTION(scope, void()); - } args.append(headersObject); JSC::JSArray* array; @@ -472,7 +435,6 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS RETURN_IF_EXCEPTION(scope, {}); JSC::JSArray* setCookiesHeaderArray = nullptr; JSC::JSString* setCookiesHeaderString = nullptr; - bool sawDuplicateHeader = false; unsigned i = 0; @@ -491,9 +453,10 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS HTTPHeaderName name; WTF::String nameString; WTF::String lowercasedNameString; + bool knownHeader = WebCore::findHTTPHeaderName(nameView, name); bool isSetCookie = false; - if (WebCore::findHTTPHeaderName(nameView, name)) { + if (knownHeader) { nameString = WTF::httpHeaderNameStringImpl(name); lowercasedNameString = nameString; isSetCookie = name == WebCore::HTTPHeaderName::SetCookie; @@ -520,12 +483,31 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS } else { Identifier nameIdentifier = Identifier::fromString(vm, lowercasedNameString); if (std::optional index = parseIndex(nameIdentifier)) [[unlikely]] { - sawDuplicateHeader = true; - headersObject->putDirectIndex(globalObject, index.value(), jsValue); + // Index-shaped names can't report back through PutPropertySlot, + // so check for an existing value directly. A numeric name is + // never a known header name, so duplicates always comma-join. + JSValue existing = headersObject->getDirectIndex(globalObject, index.value()); + RETURN_IF_EXCEPTION(scope, {}); + JSValue valueToPut = jsValue; + if (existing) { + valueToPut = jsString(globalObject, asString(existing), jsString(vm, String(", "_s)), jsValue); + RETURN_IF_EXCEPTION(scope, {}); + } + headersObject->putDirectIndex(globalObject, index.value(), valueToPut); } else { PutPropertySlot slot(headersObject); headersObject->putDirect(vm, nameIdentifier, jsValue, 0, slot); - sawDuplicateHeader |= slot.type() == PutPropertySlot::ExistingProperty; + if (slot.type() == PutPropertySlot::ExistingProperty) [[unlikely]] { + // Duplicate header name. putDirect already located the + // property and replaced its value, and the slot recorded + // the offset it lives at, so apply Node's merge rules in + // place without looking the name up again. + RequestHeaderKind kind = knownHeader ? requestHeaderKind(name) : requestHeaderKind(lowercasedNameString); + JSString* merged = duplicateRequestHeaderValue(request, nameView, kind, globalObject, vm); + RETURN_IF_EXCEPTION(scope, {}); + headersObject->structure()->didReplaceProperty(slot.cachedOffset()); + headersObject->putDirectOffset(vm, slot.cachedOffset(), merged); + } } RETURN_IF_EXCEPTION(scope, {}); array->putDirectIndex(globalObject, i++, jsString(vm, nameString)); @@ -534,11 +516,6 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS } } - if (sawDuplicateHeader) [[unlikely]] { - headersObject = buildHeadersObjectHandlingDuplicates(request, prototype, globalObject, vm); - RETURN_IF_EXCEPTION(scope, {}); - } - tuple->putInternalField(vm, 0, headersObject); tuple->putInternalField(vm, 1, array); diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 7d056db4daf..6c3b2a1d25a 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -1691,6 +1691,69 @@ describe("HTTP Server Security Tests - Advanced", () => { // rawHeaders still reports every received header. expect(headers.rawHostCount).toBe(2); }); + + test("duplicate request header edge cases follow Node.js precedence rules", async () => { + // Expected values verified against Node.js v24. + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("request", (req, res) => { + try { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); + resolve({ + xTriple: req.headers["x-triple"], + xMixed: req.headers["x-mixed"], + xEmpty: req.headers["x-empty"], + server: req.headers.server, + retryAfter: req.headers["retry-after"], + numeric: req.headers["123"], + rawHeaderCount: req.rawHeaders.length, + }); + } catch (err) { + reject(err); + } + }); + + const msg = [ + "GET / HTTP/1.1", + "Host: localhost", + "X-Triple: one", + "X-Triple: two", + "X-Triple: three", + "x-MIXED: a", + "X-Mixed: b", + "X-Empty:", + "X-Empty: b", + "Server: apache", + "Server: nginx", + "Retry-After: 10", + "Retry-After: 20", + "123: a", + "123: b", + "Connection: close", + "", + "", + ].join("\r\n"); + + const response = await sendRequest(msg); + expect(response).toInclude("200"); + const headers: any = await promise; + expect(headers).toEqual({ + // Three or more occurrences are all joined, in order. + xTriple: "one, two, three", + // Names that differ only by case are the same header. + xMixed: "a, b", + // An empty first value still participates in the join. + xEmpty: ", b", + // Singleton headers keep the first value, including the ones WebCore + // has no HTTPHeaderName for (server, retry-after). + server: "apache", + retryAfter: "10", + // A header whose name parses as an array index joins like any other. + numeric: "a, b", + // rawHeaders still reports every received header (15 names + values). + rawHeaderCount: 30, + }); + }); }); describe("HTTP Protocol Violations", () => { From a82c15de03f8c49f160108c7f775861d25c02984 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:09:18 +0000 Subject: [PATCH 18/19] ci: retrigger From 054a9fc458c134f15ee74cb5f803bad2725ddcff Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:45:44 +0000 Subject: [PATCH 19/19] node:http: check for existing header before storing, join as flat strings Replace the put-then-fixup pattern: each header name is looked up with getDirectOffset (the same structure lookup putDirect's replace path uses) before anything is stored. On a duplicate the first value is still intact at the returned offset, so singleton headers keep it with no write at all and joinable/cookie headers merge through putDirectOffset. The merged value is built with tryMakeString as a single flat string instead of a rope, and the temporary separator JSString in the index-name path is gone. --- src/jsc/bindings/NodeHTTP.cpp | 111 +++++++++++++++------------------- 1 file changed, 50 insertions(+), 61 deletions(-) diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index ec6b7c9b8f6..287742270eb 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -21,7 +21,7 @@ #include "ZigGeneratedClasses.h" #include #include -#include +#include #include "JSSocketAddressDTO.h" #include "node/JSNodeHTTPServerSocket.h" #include "node/JSNodeHTTPServerSocketPrototype.h" @@ -152,39 +152,20 @@ static RequestHeaderKind requestHeaderKind(const WTF::String& lowercasedName) return RequestHeaderKind::Joinable; } -// Called after putDirect reported PutPropertySlot::ExistingProperty for a -// request header name. The first pass stores every header with a single put, -// so by the time a duplicate is detected the earlier value has already been -// replaced. Re-derive the value Node.js would produce for this name from the -// raw header list: the first value wins for singleton headers, Cookie joins -// with "; ", and everything else joins with ", ". Names that map to the same -// property key are exactly the names that are ASCII-case-insensitively equal. -static JSString* duplicateRequestHeaderValue(uWS::HttpRequest* request, StringView nameView, RequestHeaderKind kind, JSC::JSGlobalObject* globalObject, JSC::VM& vm) +// Builds the value for a duplicated, non-singleton request header: the +// existing value, the kind's separator, and the new value as one flat +// string โ€” never a rope. +static JSString* joinedRequestHeaderValue(JSC::JSGlobalObject* globalObject, JSC::VM& vm, JSString* existing, RequestHeaderKind kind, const WTF::String& value) { auto scope = DECLARE_THROW_SCOPE(vm); - - WTF::StringBuilder builder; - ASCIILiteral separator = kind == RequestHeaderKind::Cookie ? "; "_s : ", "_s; - bool seenAny = false; - - for (auto it = request->begin(); it != request->end(); ++it) { - auto pair = *it; - StringView candidateName(std::span { reinterpret_cast(pair.first.data()), pair.first.length() }); - if (!equalIgnoringASCIICase(candidateName, nameView)) - continue; - if (seenAny) - builder.append(separator); - seenAny = true; - builder.append(StringView(std::span { reinterpret_cast(pair.second.data()), pair.second.length() })); - if (kind == RequestHeaderKind::Singleton) - break; - } - - if (builder.hasOverflowed()) [[unlikely]] { + auto existingValue = existing->value(globalObject); + RETURN_IF_EXCEPTION(scope, nullptr); + String merged = tryMakeString(existingValue.data, kind == RequestHeaderKind::Cookie ? "; "_s : ", "_s, value); + if (merged.isNull()) [[unlikely]] { throwOutOfMemoryError(globalObject, scope); return nullptr; } - return jsString(vm, builder.toString()); + return jsString(vm, merged); } static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSValue methodString, MarkedArgumentBuffer& args, JSC::JSGlobalObject* globalObject, JSC::VM& vm) @@ -268,30 +249,34 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal } else { if (std::optional index = parseIndex(nameIdentifier)) [[unlikely]] { - // Index-shaped names can't report back through PutPropertySlot, - // so check for an existing value directly. A numeric name is - // never a known header name, so duplicates always comma-join. + // Index-shaped names store through the indexed path. A numeric + // name is never a known header name, so duplicates comma-join. JSValue existing = headersObject->getDirectIndex(globalObject, index.value()); RETURN_IF_EXCEPTION(scope, void()); JSValue valueToPut = jsValue; - if (existing) { - valueToPut = jsString(globalObject, asString(existing), jsString(vm, String(", "_s)), jsValue); + if (existing) [[unlikely]] { + valueToPut = joinedRequestHeaderValue(globalObject, vm, asString(existing), RequestHeaderKind::Joinable, value); RETURN_IF_EXCEPTION(scope, void()); } headersObject->putDirectIndex(globalObject, index.value(), valueToPut); } else { - PutPropertySlot slot(headersObject); - headersObject->putDirect(vm, nameIdentifier, jsValue, 0, slot); - if (slot.type() == PutPropertySlot::ExistingProperty) [[unlikely]] { - // Duplicate header name. putDirect already located the - // property and replaced its value, and the slot recorded - // the offset it lives at, so apply Node's merge rules in - // place without looking the name up again. + // Locate the property the same way putDirect's replace path + // would, before storing anything: on a duplicate the first + // value is still intact at the returned offset. + PropertyOffset offset = headersObject->getDirectOffset(vm, nameIdentifier); + if (offset != invalidOffset) [[unlikely]] { + // Duplicate header name, Node's rules: singleton headers + // keep the first value (nothing to store), Cookie joins + // with "; ", everything else joins with ", ". RequestHeaderKind kind = knownHeader ? requestHeaderKind(name) : requestHeaderKind(lowercasedName); - JSString* merged = duplicateRequestHeaderValue(request, nameView, kind, globalObject, vm); - RETURN_IF_EXCEPTION(scope, void()); - headersObject->structure()->didReplaceProperty(slot.cachedOffset()); - headersObject->putDirectOffset(vm, slot.cachedOffset(), merged); + if (kind != RequestHeaderKind::Singleton) { + JSString* merged = joinedRequestHeaderValue(globalObject, vm, asString(headersObject->getDirect(offset)), kind, value); + RETURN_IF_EXCEPTION(scope, void()); + headersObject->structure()->didReplaceProperty(offset); + headersObject->putDirectOffset(vm, offset, merged); + } + } else { + headersObject->putDirect(vm, nameIdentifier, jsValue, 0); } } RETURN_IF_EXCEPTION(scope, void()); @@ -483,30 +468,34 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS } else { Identifier nameIdentifier = Identifier::fromString(vm, lowercasedNameString); if (std::optional index = parseIndex(nameIdentifier)) [[unlikely]] { - // Index-shaped names can't report back through PutPropertySlot, - // so check for an existing value directly. A numeric name is - // never a known header name, so duplicates always comma-join. + // Index-shaped names store through the indexed path. A numeric + // name is never a known header name, so duplicates comma-join. JSValue existing = headersObject->getDirectIndex(globalObject, index.value()); RETURN_IF_EXCEPTION(scope, {}); JSValue valueToPut = jsValue; - if (existing) { - valueToPut = jsString(globalObject, asString(existing), jsString(vm, String(", "_s)), jsValue); + if (existing) [[unlikely]] { + valueToPut = joinedRequestHeaderValue(globalObject, vm, asString(existing), RequestHeaderKind::Joinable, value); RETURN_IF_EXCEPTION(scope, {}); } headersObject->putDirectIndex(globalObject, index.value(), valueToPut); } else { - PutPropertySlot slot(headersObject); - headersObject->putDirect(vm, nameIdentifier, jsValue, 0, slot); - if (slot.type() == PutPropertySlot::ExistingProperty) [[unlikely]] { - // Duplicate header name. putDirect already located the - // property and replaced its value, and the slot recorded - // the offset it lives at, so apply Node's merge rules in - // place without looking the name up again. + // Locate the property the same way putDirect's replace path + // would, before storing anything: on a duplicate the first + // value is still intact at the returned offset. + PropertyOffset offset = headersObject->getDirectOffset(vm, nameIdentifier); + if (offset != invalidOffset) [[unlikely]] { + // Duplicate header name, Node's rules: singleton headers + // keep the first value (nothing to store), Cookie joins + // with "; ", everything else joins with ", ". RequestHeaderKind kind = knownHeader ? requestHeaderKind(name) : requestHeaderKind(lowercasedNameString); - JSString* merged = duplicateRequestHeaderValue(request, nameView, kind, globalObject, vm); - RETURN_IF_EXCEPTION(scope, {}); - headersObject->structure()->didReplaceProperty(slot.cachedOffset()); - headersObject->putDirectOffset(vm, slot.cachedOffset(), merged); + if (kind != RequestHeaderKind::Singleton) { + JSString* merged = joinedRequestHeaderValue(globalObject, vm, asString(headersObject->getDirect(offset)), kind, value); + RETURN_IF_EXCEPTION(scope, {}); + headersObject->structure()->didReplaceProperty(offset); + headersObject->putDirectOffset(vm, offset, merged); + } + } else { + headersObject->putDirect(vm, nameIdentifier, jsValue, 0); } } RETURN_IF_EXCEPTION(scope, {});