Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/better-call/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 4 additions & 4 deletions packages/better-call/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}

Expand Down
120 changes: 120 additions & 0 deletions packages/better-call/src/to-response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 76 additions & 4 deletions packages/better-call/src/to-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Expand All @@ -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);
}
}
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/better-call/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["esnext", "dom", "dom.iterable"],
"types": ["node", "bun"]
"types": ["node"]
}
}
35 changes: 13 additions & 22 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading