From 0b2892f94a472dcb54fd257cb9c4e392ae7d69d8 Mon Sep 17 00:00:00 2001 From: Taesu Date: Sat, 4 Apr 2026 07:29:13 +0900 Subject: [PATCH] chore: backport fixes from v2 to v1.3.x --- package.json | 1 - packages/better-call/package.json | 2 +- packages/better-call/src/middleware.test.ts | 13 ++ packages/better-call/src/middleware.ts | 2 +- packages/better-call/src/openapi.ts | 8 +- packages/better-call/src/to-response.test.ts | 120 +++++++++++++++++++ packages/better-call/src/to-response.ts | 80 ++++++++++++- packages/better-call/tsconfig.json | 2 +- pnpm-lock.yaml | 35 ++---- tsconfig.base.json | 2 +- 10 files changed, 230 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index f634674..88b1e07 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", "@biomejs/biome": "^2.4.2", - "@types/bun": "^1.3.9", "@types/node": "^25.2.3", "@vitest/coverage-v8": "4.0.18", "bumpp": "^10.4.1", diff --git a/packages/better-call/package.json b/packages/better-call/package.json index b7669e1..376507b 100644 --- a/packages/better-call/package.json +++ b/packages/better-call/package.json @@ -31,7 +31,7 @@ "supertest": "^7.1.4" }, "dependencies": { - "@better-auth/utils": "^0.3.1", + "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" diff --git a/packages/better-call/src/middleware.test.ts b/packages/better-call/src/middleware.test.ts index bfd2353..9985b42 100644 --- a/packages/better-call/src/middleware.test.ts +++ b/packages/better-call/src/middleware.test.ts @@ -161,6 +161,19 @@ describe("creator", () => { }); }); + it("should not crash when APIError flows through nested middleware", async () => { + const inner = createMiddleware(async () => { + throw new APIError("FORBIDDEN", { message: "blocked" }); + }); + + const outer = createMiddleware(async (ctx) => { + return await inner(ctx); + }); + + // Should throw APIError, NOT TypeError: Cannot redefine property + await expect(outer({})).rejects.toThrow(APIError); + }); + it("should get header set in middleware when error is thrown", async () => { const middleware = createMiddleware(async (ctx) => { ctx.setHeader("X-Test", "test"); diff --git a/packages/better-call/src/middleware.ts b/packages/better-call/src/middleware.ts index 3d2e6ca..eb590b3 100644 --- a/packages/better-call/src/middleware.ts +++ b/packages/better-call/src/middleware.ts @@ -174,7 +174,7 @@ export function createMiddleware(optionsOrHandler: any, handler?: any) { if (isAPIError(e)) { Object.defineProperty(e, kAPIErrorHeaderSymbol, { enumerable: false, - configurable: false, + configurable: true, get() { return internalContext.responseHeaders; }, diff --git a/packages/better-call/src/openapi.ts b/packages/better-call/src/openapi.ts index a40880c..3727a52 100644 --- a/packages/better-call/src/openapi.ts +++ b/packages/better-call/src/openapi.ts @@ -16,15 +16,15 @@ export interface OpenAPIParameter { required?: boolean; schema?: { type: OpenAPISchemaType; - format?: string; + format?: string | undefined; items?: { type: OpenAPISchemaType; }; enum?: string[]; minLength?: number; - description?: string; - default?: string; - example?: string; + description?: string | undefined; + default?: string | undefined; + example?: string | undefined; }; } diff --git a/packages/better-call/src/to-response.test.ts b/packages/better-call/src/to-response.test.ts index 33a12ea..e00fc68 100644 --- a/packages/better-call/src/to-response.test.ts +++ b/packages/better-call/src/to-response.test.ts @@ -411,6 +411,126 @@ describe("toResponse", () => { }); }); + describe("Request header stripping", () => { + const REQUEST_ONLY_HEADERS = [ + "host", + "user-agent", + "referer", + "from", + "expect", + "authorization", + "proxy-authorization", + "cookie", + "origin", + "accept-charset", + "accept-encoding", + "accept-language", + "if-match", + "if-none-match", + "if-modified-since", + "if-unmodified-since", + "if-range", + "range", + "max-forwards", + "connection", + "keep-alive", + "transfer-encoding", + "te", + "upgrade", + "trailer", + "proxy-connection", + "content-length", + ]; + + it("should strip request-only headers from init when building JSON response", async () => { + const requestHeaders = new Headers({ + "content-length": "42", + host: "example.com", + accept: "text/html", + "user-agent": "TestAgent/1.0", + cookie: "session=abc", + "x-custom": "keep-me", + }); + const response = toResponse( + { message: "ok" }, + { headers: requestHeaders }, + ); + for (const h of REQUEST_ONLY_HEADERS) { + expect(response.headers.has(h)).toBe(false); + } + expect(response.headers.get("x-custom")).toBe("keep-me"); + }); + + it("should strip request-only headers from init on fallback path", async () => { + const requestHeaders = new Headers({ + "content-length": "100", + "transfer-encoding": "chunked", + origin: "http://evil.com", + "x-request-id": "keep-me", + }); + const response = toResponse(undefined, { headers: requestHeaders }); + for (const h of REQUEST_ONLY_HEADERS) { + expect(response.headers.has(h)).toBe(false); + } + expect(response.headers.get("x-request-id")).toBe("keep-me"); + }); + + it("should strip request-only headers when merging into existing Response", async () => { + const existingResponse = new Response("body", { + headers: { "x-existing": "yes" }, + }); + const requestHeaders = new Headers({ + "content-length": "999", + host: "attacker.com", + "x-custom": "also-keep", + }); + const response = toResponse(existingResponse, { + headers: requestHeaders, + }); + expect(response.headers.has("content-length")).toBe(false); + expect(response.headers.has("host")).toBe(false); + expect(response.headers.get("x-existing")).toBe("yes"); + expect(response.headers.get("x-custom")).toBe("also-keep"); + }); + + it("should strip request-only headers when merging plain-object headers into existing Response", async () => { + const existingResponse = new Response("body", { + headers: { "x-existing": "yes" }, + }); + const response = toResponse(existingResponse, { + headers: { + "content-length": "999", + host: "attacker.com", + authorization: "Bearer secret", + "x-custom": "also-keep", + }, + }); + expect(response.headers.has("content-length")).toBe(false); + expect(response.headers.has("host")).toBe(false); + expect(response.headers.has("authorization")).toBe(false); + expect(response.headers.get("x-existing")).toBe("yes"); + expect(response.headers.get("x-custom")).toBe("also-keep"); + }); + + it("should strip request-only headers from APIError responses", async () => { + const error = new APIError("BAD_REQUEST", { + message: "bad request", + }); + const requestHeaders = new Headers({ + "content-length": "42", + cookie: "session=abc", + authorization: "Bearer secret", + "x-custom": "keep-me", + }); + const response = toResponse(error, { headers: requestHeaders }); + expect(response.status).toBe(400); + expect(response.headers.has("content-length")).toBe(false); + expect(response.headers.has("cookie")).toBe(false); + expect(response.headers.has("authorization")).toBe(false); + expect(response.headers.get("x-custom")).toBe("keep-me"); + }); + }); + describe("Circular reference handling", () => { it("should handle ORM-like circular references", async () => { // Types representing common ORM entities diff --git a/packages/better-call/src/to-response.ts b/packages/better-call/src/to-response.ts index 563f67b..5078137 100644 --- a/packages/better-call/src/to-response.ts +++ b/packages/better-call/src/to-response.ts @@ -72,10 +72,79 @@ function isJSONResponse(value: any): value is JSONResponse { return "_flag" in value && value._flag === "json"; } +/** + * Headers that MUST be stripped when building an HTTP response from + * arbitrary header input. These are request-only, hop-by-hop, or + * transport-managed headers that cause protocol violations when present + * on responses (e.g. Content-Length mismatch → net::ERR_CONTENT_LENGTH_MISMATCH). + * + * Sources: + * - RFC 9110 §10.1 (Request Context Fields) + * - RFC 9110 §7.6.1 (Connection / hop-by-hop) + * - RFC 9110 §11.6-7 (Authentication credentials) + * - RFC 9110 §12.5 (Content negotiation) + * - RFC 9110 §13.1 (Conditional request headers) + * - RFC 9110 §14.2 (Range requests) + * - RFC 6265 §5.4 (Cookie) + * - RFC 6454 (Origin) + */ +const REQUEST_ONLY_HEADERS = new Set([ + // Request context (RFC 9110 §10.1) + "host", // §7.2 + "user-agent", // §10.1.5 + "referer", // §10.1.3 + "from", // §10.1.2 + "expect", // §10.1.1 + + // Authentication credentials (RFC 9110 §11.6-7) + "authorization", // §11.6.2 + "proxy-authorization", // §11.7.2 + "cookie", // RFC 6265 §5.4 + "origin", // RFC 6454 + + // Content negotiation (RFC 9110 §12.5) + "accept-charset", // §12.5.2 (deprecated) + "accept-encoding", // §12.5.3 + "accept-language", // §12.5.4 + + // Conditional requests (RFC 9110 §13.1) + "if-match", // §13.1.1 + "if-none-match", // §13.1.2 + "if-modified-since", // §13.1.3 + "if-unmodified-since", // §13.1.4 + "if-range", // §13.1.5 + + // Range requests (RFC 9110 §14.2) + "range", // §14.2 + + // Forwarding control (RFC 9110 §7.6) + "max-forwards", // §7.6.2 + + // Hop-by-hop (RFC 9110 §7.6.1) + "connection", // §7.6.1 + "keep-alive", + "transfer-encoding", + "te", // §10.1.4 + "upgrade", + "trailer", + "proxy-connection", // non-standard + + // Valid on responses but WRONG if copied from request (RFC 9110 §8.6) + "content-length", +]); + +function stripRequestOnlyHeaders(headers: Headers): void { + for (const name of REQUEST_ONLY_HEADERS) { + headers.delete(name); + } +} + export function toResponse(data?: any, init?: ResponseInit): Response { if (data instanceof Response) { - if (init?.headers instanceof Headers) { - init.headers.forEach((value, key) => { + if (init?.headers) { + const safeHeaders = new Headers(init.headers); + stripRequestOnlyHeaders(safeHeaders); + safeHeaders.forEach((value, key) => { data.headers.set(key, value); }); } @@ -101,7 +170,9 @@ export function toResponse(data?: any, init?: ResponseInit): Response { } } if (init?.headers) { - for (const [key, value] of new Headers(init.headers).entries()) { + const safeHeaders = new Headers(init.headers); + stripRequestOnlyHeaders(safeHeaders); + for (const [key, value] of safeHeaders.entries()) { headers.set(key, value); } } @@ -122,7 +193,8 @@ export function toResponse(data?: any, init?: ResponseInit): Response { }); } let body = data; - let headers = new Headers(init?.headers); + const headers = new Headers(init?.headers); + stripRequestOnlyHeaders(headers); if (!data) { if (data === null) { body = JSON.stringify(null); diff --git a/packages/better-call/tsconfig.json b/packages/better-call/tsconfig.json index f375097..76de03e 100644 --- a/packages/better-call/tsconfig.json +++ b/packages/better-call/tsconfig.json @@ -2,6 +2,6 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": ["esnext", "dom", "dom.iterable"], - "types": ["node", "bun"] + "types": ["node"] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3afb38..71743f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ importers: '@biomejs/biome': specifier: ^2.4.2 version: 2.4.2 - '@types/bun': - specifier: ^1.3.9 - version: 1.3.9 '@types/node': specifier: ^25.2.3 version: 25.2.3 @@ -63,8 +60,8 @@ importers: packages/better-call: dependencies: '@better-auth/utils': - specifier: ^0.3.1 - version: 0.3.1 + specifier: ^0.4.0 + version: 0.4.0 '@better-fetch/fetch': specifier: ^1.1.21 version: 1.1.21 @@ -156,8 +153,8 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@better-auth/utils@0.3.1': - resolution: {integrity: sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==} + '@better-auth/utils@0.4.0': + resolution: {integrity: sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==} '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} @@ -648,6 +645,10 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1016,9 +1017,6 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - '@types/bun@1.3.9': - resolution: {integrity: sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1180,9 +1178,6 @@ packages: engines: {node: '>=18'} hasBin: true - bun-types@1.3.9: - resolution: {integrity: sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==} - bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -2366,7 +2361,9 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/utils@0.3.1': {} + '@better-auth/utils@0.4.0': + dependencies: + '@noble/hashes': 2.0.1 '@better-fetch/fetch@1.1.21': {} @@ -2753,6 +2750,8 @@ snapshots: '@noble/hashes@1.8.0': {} + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2971,10 +2970,6 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 25.2.3 - '@types/bun@1.3.9': - dependencies: - bun-types: 1.3.9 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -3176,10 +3171,6 @@ snapshots: transitivePeerDependencies: - magicast - bun-types@1.3.9: - dependencies: - '@types/node': 25.2.3 - bytes@3.1.2: {} c12@3.3.3(magicast@0.5.2): diff --git a/tsconfig.base.json b/tsconfig.base.json index fa866e4..bd66635 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -14,7 +14,7 @@ "composite": true, "declaration": true, "emitDeclarationOnly": true, - "types": ["node", "bun"], + "types": ["node"], // Put the .d.ts files output and cache file (tsbuildinfo) in a directory // that will be ignored by other tools. "outDir": "${configDir}/node_modules/.cache/ts/out",