From d5cd83729be09d305fc36167d9d46f7ed8748439 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Wed, 28 Oct 2026 20:55:31 -0500 Subject: [PATCH] fix(core): own-property lookups for CodeMode fetch response headers (#805) --- packages/core/src/codemode/web.ts | 10 ++++- packages/core/test/codemode/web.test.ts | 55 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 packages/core/test/codemode/web.test.ts diff --git a/packages/core/src/codemode/web.ts b/packages/core/src/codemode/web.ts index 0a6b5b84f8e6..5eac0a1acb9b 100644 --- a/packages/core/src/codemode/web.ts +++ b/packages/core/src/codemode/web.ts @@ -29,8 +29,14 @@ const fetch = async (input: string | URL, init: Init = {}) => { ok: response.ok, redirected: response.redirected, headers: { - get: (name: string) => headers[name.toLowerCase()] ?? null, - has: (name: string) => name.toLowerCase() in headers, + // Own-property lookups only (#805): headers is a plain record, so a bare index or + // `in` reads through Object.prototype - an absent "constructor" resolved to the + // Object function and "__proto__" to Object.prototype instead of null / false. + get: (name: string) => { + const key = name.toLowerCase() + return Object.hasOwn(headers, key) ? headers[key] : null + }, + has: (name: string) => Object.hasOwn(headers, name.toLowerCase()), entries: () => Object.entries(headers), }, text: async () => text(), diff --git a/packages/core/test/codemode/web.test.ts b/packages/core/test/codemode/web.test.ts new file mode 100644 index 000000000000..89ae41471692 --- /dev/null +++ b/packages/core/test/codemode/web.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test" +import { CodeModeWeb } from "@opencode/core/codemode/web" + +type WebResponse = { + readonly status: number + readonly headers: { + get: (name: string) => string | null + has: (name: string) => boolean + entries: () => Array<[string, string]> + } +} + +const webFetch = CodeModeWeb.extension.globals.fetch as (input: string) => Promise + +const serve = (headers: Record) => { + const server = Bun.serve({ port: 0, fetch: () => new Response("hello", { headers }) }) + return { url: `http://127.0.0.1:${server.port}/`, stop: () => server.stop(true) } +} + +describe("CodeModeWeb fetch response headers (#805)", () => { + test("absent prototype-named headers read as null / false, not inherited members", async () => { + const server = serve({ "content-type": "text/plain", "x-real": "yes" }) + try { + const response = await webFetch(server.url) + expect(response.status).toBe(200) + // Regression: the headers record is a plain object, so a bare index / `in` + // resolved "constructor" to the Object function and "__proto__" to + // Object.prototype even though the server never sent them. + expect(response.headers.get("constructor")).toBeNull() + expect(response.headers.has("constructor")).toBe(false) + expect(response.headers.get("__proto__")).toBeNull() + expect(response.headers.has("__proto__")).toBe(false) + // Present and ordinary absent headers behave as before. + expect(response.headers.get("x-real")).toBe("yes") + expect(response.headers.has("X-Real")).toBe(true) + expect(response.headers.get("content-type")).toContain("text/plain") + expect(response.headers.get("x-absent")).toBeNull() + expect(response.headers.has("x-absent")).toBe(false) + expect(response.headers.entries()).toContainEqual(["x-real", "yes"]) + } finally { + server.stop() + } + }) + + test("a header actually sent under a prototype name still resolves", async () => { + const server = serve({ constructor: "sent-value" }) + try { + const response = await webFetch(server.url) + expect(response.headers.get("constructor")).toBe("sent-value") + expect(response.headers.has("constructor")).toBe(true) + } finally { + server.stop() + } + }) +})