77 lines
4.3 KiB
TypeScript
77 lines
4.3 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
import { boundedBody, errorResponse, HttpError, readJson, requireSameOrigin, withUploadSlot } from "./http";
|
|
import { trustedClientAddress } from "./rate-limit";
|
|
|
|
const originalAuthUrl = process.env.BETTER_AUTH_URL;
|
|
const originalClientIpHeader = process.env.TRUSTED_CLIENT_IP_HEADER;
|
|
|
|
afterEach(() => {
|
|
if (originalAuthUrl === undefined) delete process.env.BETTER_AUTH_URL;
|
|
else process.env.BETTER_AUTH_URL = originalAuthUrl;
|
|
if (originalClientIpHeader === undefined) delete process.env.TRUSTED_CLIENT_IP_HEADER;
|
|
else process.env.TRUSTED_CLIENT_IP_HEADER = originalClientIpHeader;
|
|
});
|
|
|
|
describe("request boundaries", () => {
|
|
it("compares origin to configuration, not attacker-controlled Host", () => {
|
|
process.env.BETTER_AUTH_URL = "https://guide.example.test";
|
|
expect(() => requireSameOrigin(new Request("https://evil.test", { headers: { origin: "https://evil.test", host: "evil.test" } }))).toThrow("cross-origin");
|
|
expect(() => requireSameOrigin(new Request("http://internal", { headers: { origin: "https://guide.example.test" } }))).not.toThrow();
|
|
expect(() => requireSameOrigin(new Request("http://internal"))).toThrow("cross-origin");
|
|
});
|
|
|
|
it("rejects actual streamed bytes even with a forged Content-Length", async () => {
|
|
const body = new ReadableStream<Uint8Array>({ start(controller) {
|
|
controller.enqueue(new Uint8Array(8));
|
|
controller.enqueue(new Uint8Array(8));
|
|
controller.close();
|
|
} });
|
|
const request = new Request("https://test.invalid", {
|
|
method: "POST", body, headers: { "content-length": "1" }, duplex: "half",
|
|
} as RequestInit & { duplex: string });
|
|
await expect(boundedBody(request, 10).arrayBuffer()).rejects.toMatchObject({ status: 413 });
|
|
});
|
|
|
|
it("rejects invalid JSON and unsupported content types", async () => {
|
|
await expect(readJson(new Request("https://test.invalid", { method: "POST", body: "{}" }))).rejects.toMatchObject({ status: 415 });
|
|
await expect(readJson(new Request("https://test.invalid", { method: "POST", headers: { "content-type": "application/json" }, body: "{" }))).rejects.toMatchObject({ status: 400 });
|
|
const oversized = new Request("https://test.invalid", {
|
|
method: "POST", headers: { "content-type": "application/json", "content-length": "1" },
|
|
body: JSON.stringify({ value: "a".repeat(32) }),
|
|
});
|
|
await expect(readJson(oversized, 16)).rejects.toMatchObject({ status: 413 });
|
|
});
|
|
|
|
it("bounds concurrent upload work and releases slots after failure", async () => {
|
|
let finish!: () => void;
|
|
const blocked = new Promise<void>((resolve) => { finish = resolve; });
|
|
const first = withUploadSlot(() => blocked);
|
|
const second = withUploadSlot(() => blocked);
|
|
await expect(withUploadSlot(async () => null)).rejects.toMatchObject({ status: 429 });
|
|
finish();
|
|
await Promise.all([first, second]);
|
|
await expect(withUploadSlot(async () => { throw new Error("decode failed"); })).rejects.toThrow();
|
|
await expect(withUploadSlot(async () => "ok")).resolves.toBe("ok");
|
|
});
|
|
|
|
it("returns retry timing and hides internal errors", async () => {
|
|
const response = errorResponse(new HttpError(429, "too-many-requests", 10));
|
|
expect(response.status).toBe(429);
|
|
expect(response.headers.get("retry-after")).toBe("10");
|
|
expect(await errorResponse(new Error("secret password")).text()).not.toContain("password");
|
|
});
|
|
|
|
it("ignores spoofed forwarding headers unless explicitly configured", () => {
|
|
process.env.TRUSTED_CLIENT_IP_HEADER = "";
|
|
expect(trustedClientAddress(new Headers({ "x-forwarded-for": "1.2.3.4" }))).toBe("unknown");
|
|
process.env.TRUSTED_CLIENT_IP_HEADER = "x-real-ip";
|
|
expect(trustedClientAddress(new Headers({ "x-real-ip": "1.2.3.4, 5.6.7.8" }))).toBe("unknown");
|
|
expect(trustedClientAddress(new Headers({ "x-real-ip": "::ffff:192.0.2.1" }))).toBe("192.0.2.1");
|
|
expect(trustedClientAddress(new Headers({ "x-real-ip": "2001:db8:1:2::1234" })))
|
|
.toBe(trustedClientAddress(new Headers({ "x-real-ip": "2001:0db8:0001:0002:0:0:0:1235" })));
|
|
process.env.TRUSTED_CLIENT_IP_HEADER = "x-forwarded-for";
|
|
expect(trustedClientAddress(new Headers({ "x-forwarded-for": "192.0.2.1" }))).toBe("192.0.2.1");
|
|
expect(trustedClientAddress(new Headers({ "x-forwarded-for": "192.0.2.1, 198.51.100.2" }))).toBe("unknown");
|
|
});
|
|
});
|