feat: migrate from Next.js to pure Bun relay proxy
- Replace Next.js app router with standalone Bun.serve() entry point - Add middleware stack: rate limiter, body limiter, structured logger, SSRF protection - Add WebSocket bidirectional relay via x-relay-target header - Implement error classification (DNS→502, timeouts→504, SSRF→403, rate→429) - Remove all Next.js dependencies and config files - Update deploy workflow, tsconfig, wrangler config for Bun deployment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
124dab98a2
commit
f58a92e2aa
+836
-65
@@ -1,85 +1,856 @@
|
||||
/**
|
||||
* Relay utilities test suite.
|
||||
*
|
||||
* Covers URL normalisation, SSRF protection, header filtering,
|
||||
* request building, error classification, and response creation.
|
||||
*/
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import {
|
||||
normalizeTargetUrl,
|
||||
filterHeaders,
|
||||
filterRequestHeaders,
|
||||
filterResponseHeaders,
|
||||
shouldSendBody,
|
||||
isAllowedTarget,
|
||||
isPrivateIp,
|
||||
classifyFetchError,
|
||||
createErrorResponse,
|
||||
createCorsPreflightResponse,
|
||||
buildRelayRequest,
|
||||
createRelayResponse,
|
||||
RelayError,
|
||||
} from "./relay-utils";
|
||||
|
||||
describe("relay-utils", () => {
|
||||
describe("normalizeTargetUrl", () => {
|
||||
test("should combine target and path", () => {
|
||||
expect(normalizeTargetUrl("https://example.com", "/api/v1")).toBe("https://example.com/api/v1");
|
||||
});
|
||||
// ─── NormalizeTargetUrl ────────────────────────────────────────────────────
|
||||
|
||||
test("should handle trailing slash in target", () => {
|
||||
expect(normalizeTargetUrl("https://example.com/", "/api/v1")).toBe("https://example.com/api/v1");
|
||||
});
|
||||
|
||||
test("should return null if target is missing", () => {
|
||||
expect(normalizeTargetUrl(null, "/api/v1")).toBe(null);
|
||||
});
|
||||
describe("normalizeTargetUrl", () => {
|
||||
test("should combine target and path — returns URL object", () => {
|
||||
const result = normalizeTargetUrl("https://example.com", "/api/v1");
|
||||
expect(result).toBeInstanceOf(URL);
|
||||
expect(result!.href).toBe("https://example.com/api/v1");
|
||||
});
|
||||
|
||||
describe("filterHeaders", () => {
|
||||
test("should remove blocked headers", () => {
|
||||
const headers = new Headers({
|
||||
"content-type": "application/json",
|
||||
"cookie": "secret=123",
|
||||
"x-vercel-id": "123",
|
||||
"cf-ray": "123",
|
||||
"host": "localhost",
|
||||
"x-relay-target": "test",
|
||||
});
|
||||
const filtered = filterHeaders(headers);
|
||||
expect(filtered.has("content-type")).toBe(true);
|
||||
expect(filtered.has("cookie")).toBe(false);
|
||||
expect(filtered.has("x-vercel-id")).toBe(false);
|
||||
expect(filtered.has("cf-ray")).toBe(false);
|
||||
expect(filtered.has("host")).toBe(false);
|
||||
expect(filtered.has("x-relay-target")).toBe(false);
|
||||
});
|
||||
|
||||
test("should remove headers starting with blocked prefixes", () => {
|
||||
const headers = new Headers({
|
||||
"x-vercel-custom": "val",
|
||||
"cf-custom": "val",
|
||||
"x-forwarded-for": "1.1.1.1",
|
||||
});
|
||||
const filtered = filterHeaders(headers);
|
||||
expect(filtered.has("x-vercel-custom")).toBe(false);
|
||||
expect(filtered.has("cf-custom")).toBe(false);
|
||||
expect(filtered.has("x-forwarded-for")).toBe(false);
|
||||
});
|
||||
test("should handle trailing slash in target", () => {
|
||||
const result = normalizeTargetUrl("https://example.com/", "/api/v1");
|
||||
expect(result!.href).toBe("https://example.com/api/v1");
|
||||
});
|
||||
|
||||
describe("shouldSendBody", () => {
|
||||
test("should return false for GET and HEAD", () => {
|
||||
expect(shouldSendBody("GET")).toBe(false);
|
||||
expect(shouldSendBody("HEAD")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for POST, PUT, DELETE, PATCH", () => {
|
||||
expect(shouldSendBody("POST")).toBe(true);
|
||||
expect(shouldSendBody("PUT")).toBe(true);
|
||||
expect(shouldSendBody("DELETE")).toBe(true);
|
||||
expect(shouldSendBody("PATCH")).toBe(true);
|
||||
});
|
||||
test("should handle trailing slashes in target", () => {
|
||||
const result = normalizeTargetUrl("https://example.com///", "/api/v1");
|
||||
expect(result!.href).toBe("https://example.com/api/v1");
|
||||
});
|
||||
|
||||
describe("isAllowedTarget", () => {
|
||||
test("should allow http and https", () => {
|
||||
expect(isAllowedTarget("https://example.com")).toBe(true);
|
||||
expect(isAllowedTarget("http://example.com")).toBe(true);
|
||||
});
|
||||
test("should return null if target is missing", () => {
|
||||
expect(normalizeTargetUrl(null, "/api/v1")).toBe(null);
|
||||
});
|
||||
|
||||
test("should reject other protocols", () => {
|
||||
expect(isAllowedTarget("ftp://example.com")).toBe(false);
|
||||
expect(isAllowedTarget("javascript:alert(1)")).toBe(false);
|
||||
});
|
||||
test("should return null if target is empty string", () => {
|
||||
expect(normalizeTargetUrl("", "/api/v1")).toBe(null);
|
||||
});
|
||||
|
||||
test("should reject invalid URLs", () => {
|
||||
expect(isAllowedTarget("not-a-url")).toBe(false);
|
||||
test("should return null if target is whitespace-only", () => {
|
||||
expect(normalizeTargetUrl(" ", "/api/v1")).toBe(null);
|
||||
});
|
||||
|
||||
test("should keep target without trailing slash", () => {
|
||||
const result = normalizeTargetUrl("https://httpbin.org", "/get");
|
||||
expect(result!.href).toBe("https://httpbin.org/get");
|
||||
});
|
||||
|
||||
test("should append relay path without leading slash", () => {
|
||||
const result = normalizeTargetUrl("https://example.com", "api/users");
|
||||
expect(result!.href).toBe("https://example.com/api/users");
|
||||
});
|
||||
|
||||
test("should handle nested paths", () => {
|
||||
const result = normalizeTargetUrl(
|
||||
"https://api.example.com",
|
||||
"/v1/users/123/profile",
|
||||
);
|
||||
expect(result!.href).toBe(
|
||||
"https://api.example.com/v1/users/123/profile",
|
||||
);
|
||||
});
|
||||
|
||||
test("should merge query parameters from target URL", () => {
|
||||
const result = normalizeTargetUrl(
|
||||
"https://example.com?source=proxy",
|
||||
"/path",
|
||||
);
|
||||
expect(result!.href).toBe("https://example.com/path?source=proxy");
|
||||
});
|
||||
|
||||
test("should merge query parameters from relay path", () => {
|
||||
const result = normalizeTargetUrl(
|
||||
"https://example.com",
|
||||
"/path?format=json",
|
||||
);
|
||||
expect(result!.href).toBe("https://example.com/path?format=json");
|
||||
});
|
||||
|
||||
test("should merge query params from both target and relay path", () => {
|
||||
const result = normalizeTargetUrl(
|
||||
"https://example.com?source=proxy",
|
||||
"/path?format=json",
|
||||
);
|
||||
const href = result!.href;
|
||||
expect(href).toContain("source=proxy");
|
||||
expect(href).toContain("format=json");
|
||||
});
|
||||
|
||||
test("should return null for invalid target URL", () => {
|
||||
expect(normalizeTargetUrl("not-a-valid-url", "/path")).toBe(null);
|
||||
});
|
||||
|
||||
test("should handle target with existing path", () => {
|
||||
const result = normalizeTargetUrl("https://example.com/base", "/new");
|
||||
expect(result!.href).toBe("https://example.com/base/new");
|
||||
});
|
||||
|
||||
test("should handle target with trailing path", () => {
|
||||
const result = normalizeTargetUrl(
|
||||
"https://api.example.com/v1/",
|
||||
"/users",
|
||||
);
|
||||
expect(result!.href).toBe("https://api.example.com/v1/users");
|
||||
});
|
||||
|
||||
test("should preserve port in target", () => {
|
||||
const result = normalizeTargetUrl(
|
||||
"https://localhost:8443",
|
||||
"/api/test",
|
||||
);
|
||||
expect(result!.port).toBe("8443");
|
||||
expect(result!.href).toBe("https://localhost:8443/api/test");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IsPrivateIp ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("isPrivateIp", () => {
|
||||
test("should return true for IPv4 loopback", () => {
|
||||
expect(isPrivateIp("127.0.0.1")).toBe(true);
|
||||
expect(isPrivateIp("127.255.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for 10.x.x.x (private class A)", () => {
|
||||
expect(isPrivateIp("10.0.0.1")).toBe(true);
|
||||
expect(isPrivateIp("10.255.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for 192.168.x.x (private class C)", () => {
|
||||
expect(isPrivateIp("192.168.0.1")).toBe(true);
|
||||
expect(isPrivateIp("192.168.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for 172.16-31.x.x (private class B)", () => {
|
||||
expect(isPrivateIp("172.16.0.1")).toBe(true);
|
||||
expect(isPrivateIp("172.31.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for 172.15.x.x (outside private range)", () => {
|
||||
expect(isPrivateIp("172.15.0.1")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false for 172.32.x.x (outside private range)", () => {
|
||||
expect(isPrivateIp("172.32.0.1")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for link-local 169.254.x.x", () => {
|
||||
expect(isPrivateIp("169.254.1.1")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for IPv6 loopback", () => {
|
||||
expect(isPrivateIp("::1")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for IPv6 unspecified", () => {
|
||||
expect(isPrivateIp("::")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for IPv6 link-local", () => {
|
||||
expect(isPrivateIp("fe80::1")).toBe(true);
|
||||
expect(isPrivateIp("FE80::")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for IPv6 unique local", () => {
|
||||
expect(isPrivateIp("fd00::1")).toBe(true);
|
||||
expect(isPrivateIp("fc00::1")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for public IPs", () => {
|
||||
expect(isPrivateIp("8.8.8.8")).toBe(false);
|
||||
expect(isPrivateIp("1.1.1.1")).toBe(false);
|
||||
expect(isPrivateIp("93.184.216.34")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false for public hostnames", () => {
|
||||
expect(isPrivateIp("example.com")).toBe(false);
|
||||
expect(isPrivateIp("google.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for localhost", () => {
|
||||
expect(isPrivateIp("localhost")).toBe(true);
|
||||
expect(isPrivateIp("LOCALHOST")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for metadata endpoints", () => {
|
||||
expect(isPrivateIp("169.254.169.254")).toBe(true);
|
||||
expect(isPrivateIp("metadata.google.internal")).toBe(true);
|
||||
expect(isPrivateIp("metadata.internal")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for .local and .internal hostnames", () => {
|
||||
expect(isPrivateIp("myhost.local")).toBe(true);
|
||||
expect(isPrivateIp("service.internal")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for 0.0.0.0", () => {
|
||||
expect(isPrivateIp("0.0.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle empty string", () => {
|
||||
expect(isPrivateIp("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IsAllowedTarget ───────────────────────────────────────────────────────
|
||||
|
||||
describe("isAllowedTarget", () => {
|
||||
test("should allow https and http URLs", () => {
|
||||
expect(isAllowedTarget(new URL("https://example.com"))).toBe(true);
|
||||
expect(isAllowedTarget(new URL("http://example.com"))).toBe(true);
|
||||
});
|
||||
|
||||
test("should reject other protocols", () => {
|
||||
expect(isAllowedTarget(new URL("ftp://example.com"))).toBe(false);
|
||||
expect(isAllowedTarget(new URL("javascript:alert(1)"))).toBe(false);
|
||||
expect(isAllowedTarget(new URL("file:///etc/passwd"))).toBe(false);
|
||||
});
|
||||
|
||||
test("should reject private IPs", () => {
|
||||
expect(isAllowedTarget(new URL("http://127.0.0.1:8080/api"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isAllowedTarget(new URL("http://192.168.1.1"))).toBe(false);
|
||||
expect(isAllowedTarget(new URL("http://10.0.0.1"))).toBe(false);
|
||||
});
|
||||
|
||||
test("should reject localhost", () => {
|
||||
expect(isAllowedTarget(new URL("http://localhost:3000"))).toBe(false);
|
||||
});
|
||||
|
||||
test("should reject metadata endpoints", () => {
|
||||
expect(
|
||||
isAllowedTarget(new URL("http://169.254.169.254/latest/meta-data/")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("should allow public hosts on standard ports", () => {
|
||||
expect(isAllowedTarget(new URL("https://api.github.com"))).toBe(true);
|
||||
expect(isAllowedTarget(new URL("https://httpbin.org/get"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FilterRequestHeaders ──────────────────────────────────────────────────
|
||||
|
||||
describe("filterRequestHeaders", () => {
|
||||
test("should keep allowed headers and remove blocked ones", () => {
|
||||
const headers = new Headers({
|
||||
"content-type": "application/json",
|
||||
cookie: "secret=123",
|
||||
"x-vercel-id": "abc123",
|
||||
"cf-ray": "def456",
|
||||
host: "localhost",
|
||||
"x-relay-target": "test",
|
||||
});
|
||||
const filtered = filterRequestHeaders(headers);
|
||||
expect(filtered.get("content-type")).toBe("application/json");
|
||||
expect(filtered.has("cookie")).toBe(false);
|
||||
expect(filtered.has("x-vercel-id")).toBe(false);
|
||||
expect(filtered.has("cf-ray")).toBe(false);
|
||||
expect(filtered.has("host")).toBe(false);
|
||||
expect(filtered.has("x-relay-target")).toBe(false);
|
||||
});
|
||||
|
||||
test("should remove headers with blocked prefixes", () => {
|
||||
const headers = new Headers({
|
||||
"x-vercel-custom": "val",
|
||||
"cf-custom": "val",
|
||||
"x-forwarded-for": "1.1.1.1",
|
||||
"x-forwarded-host": "example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
});
|
||||
const filtered = filterRequestHeaders(headers);
|
||||
expect(filtered.has("x-vercel-custom")).toBe(false);
|
||||
expect(filtered.has("cf-custom")).toBe(false);
|
||||
expect(filtered.has("x-forwarded-for")).toBe(false);
|
||||
expect(filtered.has("x-forwarded-host")).toBe(false);
|
||||
expect(filtered.has("x-forwarded-proto")).toBe(false);
|
||||
});
|
||||
|
||||
test("should preserve non-blocked headers", () => {
|
||||
const headers = new Headers({
|
||||
authorization: "Bearer token-123",
|
||||
"x-custom": "custom-value",
|
||||
"x-request-id": "req-abc",
|
||||
});
|
||||
const filtered = filterRequestHeaders(headers);
|
||||
expect(filtered.get("authorization")).toBe("Bearer token-123");
|
||||
expect(filtered.get("x-custom")).toBe("custom-value");
|
||||
expect(filtered.get("x-request-id")).toBe("req-abc");
|
||||
});
|
||||
|
||||
test("should handle case-insensitive header matching", () => {
|
||||
const headers = new Headers({
|
||||
Host: "example.com",
|
||||
"X-Vercel-Id": "abc123",
|
||||
"CF-Ray": "def456",
|
||||
});
|
||||
const filtered = filterRequestHeaders(headers);
|
||||
expect(filtered.has("Host")).toBe(false);
|
||||
expect(filtered.has("X-Vercel-Id")).toBe(false);
|
||||
expect(filtered.has("CF-Ray")).toBe(false);
|
||||
});
|
||||
|
||||
test("should not mutate the original headers", () => {
|
||||
const headers = new Headers({ cookie: "secret", "x-custom": "val" });
|
||||
const filtered = filterRequestHeaders(headers);
|
||||
expect(headers.has("cookie")).toBe(true);
|
||||
expect(filtered.has("cookie")).toBe(false);
|
||||
});
|
||||
|
||||
test("should not add any new headers", () => {
|
||||
const headers = new Headers({ "x-custom": "val" });
|
||||
const filtered = filterRequestHeaders(headers);
|
||||
expect(filtered.get("x-custom")).toBe("val");
|
||||
expect(Array.from(filtered).length).toBe(1);
|
||||
});
|
||||
|
||||
test("should block hop-by-hop headers", () => {
|
||||
const headers = new Headers({
|
||||
connection: "close",
|
||||
"transfer-encoding": "chunked",
|
||||
"proxy-authorization": "basic xyz",
|
||||
});
|
||||
const filtered = filterRequestHeaders(headers);
|
||||
expect(filtered.has("connection")).toBe(false);
|
||||
expect(filtered.has("transfer-encoding")).toBe(false);
|
||||
expect(filtered.has("proxy-authorization")).toBe(false);
|
||||
});
|
||||
|
||||
test("should block x-real-ip and forwarded and via", () => {
|
||||
const headers = new Headers({
|
||||
"x-real-ip": "10.0.0.1",
|
||||
forwarded: "for=192.0.2.60",
|
||||
via: "1.1 proxy",
|
||||
});
|
||||
const filtered = filterRequestHeaders(headers);
|
||||
expect(filtered.has("x-real-ip")).toBe(false);
|
||||
expect(filtered.has("forwarded")).toBe(false);
|
||||
expect(filtered.has("via")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── filterHeaders (backward compatibility alias) ──────────────────────────
|
||||
|
||||
describe("filterHeaders (deprecated alias)", () => {
|
||||
test("should be the same function as filterRequestHeaders", () => {
|
||||
expect(filterHeaders).toBe(filterRequestHeaders);
|
||||
});
|
||||
|
||||
test("should work identically to filterRequestHeaders", () => {
|
||||
const headers = new Headers({
|
||||
"content-type": "application/json",
|
||||
cookie: "secret",
|
||||
});
|
||||
expect(filterHeaders(headers).get("content-type")).toBe(
|
||||
"application/json",
|
||||
);
|
||||
expect(filterHeaders(headers).has("cookie")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FilterResponseHeaders ─────────────────────────────────────────────────
|
||||
|
||||
describe("filterResponseHeaders", () => {
|
||||
test("should remove blocked response headers", () => {
|
||||
const headers = new Headers({
|
||||
"content-type": "application/json",
|
||||
"set-cookie": "session=abc",
|
||||
"transfer-encoding": "chunked",
|
||||
"keep-alive": "timeout=5",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
const filtered = filterResponseHeaders(headers);
|
||||
expect(filtered.get("content-type")).toBe("application/json");
|
||||
expect(filtered.has("set-cookie")).toBe(false);
|
||||
expect(filtered.has("transfer-encoding")).toBe(false);
|
||||
expect(filtered.has("keep-alive")).toBe(false);
|
||||
expect(filtered.has("connection")).toBe(false);
|
||||
});
|
||||
|
||||
test("should add CORS headers", () => {
|
||||
const headers = new Headers();
|
||||
const filtered = filterResponseHeaders(headers);
|
||||
expect(filtered.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
expect(filtered.get("Access-Control-Allow-Methods")).toBeTruthy();
|
||||
expect(filtered.get("Access-Control-Allow-Headers")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ShouldSendBody ────────────────────────────────────────────────────────
|
||||
|
||||
describe("shouldSendBody", () => {
|
||||
test("should return false for GET", () => {
|
||||
expect(shouldSendBody("GET")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false for HEAD", () => {
|
||||
expect(shouldSendBody("HEAD")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false for CONNECT", () => {
|
||||
expect(shouldSendBody("CONNECT")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for POST", () => {
|
||||
expect(shouldSendBody("POST")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for PUT", () => {
|
||||
expect(shouldSendBody("PUT")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for PATCH", () => {
|
||||
expect(shouldSendBody("PATCH")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for DELETE", () => {
|
||||
expect(shouldSendBody("DELETE")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for OPTIONS", () => {
|
||||
expect(shouldSendBody("OPTIONS")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle lowercase methods", () => {
|
||||
expect(shouldSendBody("get")).toBe(false);
|
||||
expect(shouldSendBody("head")).toBe(false);
|
||||
expect(shouldSendBody("connect")).toBe(false);
|
||||
expect(shouldSendBody("post")).toBe(true);
|
||||
expect(shouldSendBody("put")).toBe(true);
|
||||
expect(shouldSendBody("patch")).toBe(true);
|
||||
expect(shouldSendBody("delete")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── BuildRelayRequest ─────────────────────────────────────────────────────
|
||||
|
||||
describe("buildRelayRequest", () => {
|
||||
test("should set correct method", () => {
|
||||
const req = new Request("http://test.com", { method: "POST" });
|
||||
const headers = new Headers();
|
||||
const result = buildRelayRequest(req, headers);
|
||||
expect(result.method).toBe("POST");
|
||||
});
|
||||
|
||||
test("should set provided headers", () => {
|
||||
const req = new Request("http://test.com");
|
||||
const headers = new Headers({ "x-custom": "value" });
|
||||
const result = buildRelayRequest(req, headers);
|
||||
expect(result.headers).toBe(headers);
|
||||
});
|
||||
|
||||
test("should omit body for GET", () => {
|
||||
const req = new Request("http://test.com", { method: "GET" });
|
||||
const headers = new Headers();
|
||||
const result = buildRelayRequest(req, headers);
|
||||
expect(result.body).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should omit body for HEAD", () => {
|
||||
const req = new Request("http://test.com", { method: "HEAD" });
|
||||
const headers = new Headers();
|
||||
const result = buildRelayRequest(req, headers);
|
||||
expect(result.body).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should omit body for CONNECT", () => {
|
||||
const req = new Request("http://test.com", { method: "CONNECT" });
|
||||
const headers = new Headers();
|
||||
const result = buildRelayRequest(req, headers);
|
||||
expect(result.body).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should include body and duplex for POST", () => {
|
||||
const body = JSON.stringify({ test: true });
|
||||
const req = new Request("http://test.com", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
const headers = new Headers();
|
||||
const result = buildRelayRequest(req, headers) as RequestInit & {
|
||||
duplex?: string;
|
||||
};
|
||||
expect(result.body).toBeDefined();
|
||||
expect(result.duplex).toBe("half");
|
||||
});
|
||||
|
||||
test("should include body for PUT", () => {
|
||||
const body = JSON.stringify({ update: true });
|
||||
const req = new Request("http://test.com", { method: "PUT", body });
|
||||
const headers = new Headers();
|
||||
const result = buildRelayRequest(req, headers);
|
||||
expect(result.body).toBeDefined();
|
||||
});
|
||||
|
||||
test("should include body for PATCH", () => {
|
||||
const body = JSON.stringify({ patch: true });
|
||||
const req = new Request("http://test.com", { method: "PATCH", body });
|
||||
const headers = new Headers();
|
||||
const result = buildRelayRequest(req, headers);
|
||||
expect(result.body).toBeDefined();
|
||||
});
|
||||
|
||||
test("should include body for DELETE", () => {
|
||||
const body = JSON.stringify({ delete: true });
|
||||
const req = new Request("http://test.com", { method: "DELETE", body });
|
||||
const headers = new Headers();
|
||||
const result = buildRelayRequest(req, headers);
|
||||
expect(result.body).toBeDefined();
|
||||
});
|
||||
|
||||
test("should include AbortSignal with default timeout", () => {
|
||||
const req = new Request("http://test.com", { method: "GET" });
|
||||
const headers = new Headers();
|
||||
const result = buildRelayRequest(req, headers);
|
||||
expect(result.signal).toBeDefined();
|
||||
expect(result.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
test("should use provided timeout", () => {
|
||||
const req = new Request("http://test.com", { method: "GET" });
|
||||
const headers = new Headers();
|
||||
const result = buildRelayRequest(req, headers, 5000);
|
||||
expect(result.signal).toBeDefined();
|
||||
});
|
||||
|
||||
test("should handle lowercase method correctly", () => {
|
||||
const req = new Request("http://test.com", { method: "post", body: "data" });
|
||||
const headers = new Headers();
|
||||
const result = buildRelayRequest(req, headers) as RequestInit & {
|
||||
duplex?: string;
|
||||
};
|
||||
expect(result.method).toBe("POST");
|
||||
expect(result.body).toBeDefined();
|
||||
expect(result.duplex).toBe("half");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ClassifyFetchError ────────────────────────────────────────────────────
|
||||
|
||||
describe("classifyFetchError", () => {
|
||||
test("should classify AbortError as TIMEOUT / 504", () => {
|
||||
const error = new DOMException("The operation was aborted", "AbortError");
|
||||
const result = classifyFetchError(error);
|
||||
expect(result.code).toBe("TIMEOUT");
|
||||
expect(result.status).toBe(504);
|
||||
expect(result.message).toBe("Upstream timed out");
|
||||
});
|
||||
|
||||
test("should classify TimeoutError as TIMEOUT / 504", () => {
|
||||
const error = new DOMException("Timeout", "TimeoutError");
|
||||
const result = classifyFetchError(error);
|
||||
expect(result.code).toBe("TIMEOUT");
|
||||
expect(result.status).toBe(504);
|
||||
});
|
||||
|
||||
test("should classify DNS resolution failures", () => {
|
||||
const error = new TypeError(
|
||||
"fetch failed: DNS resolution failed for host",
|
||||
);
|
||||
const result = classifyFetchError(error);
|
||||
expect(result.code).toBe("DNS_FAILURE");
|
||||
expect(result.status).toBe(502);
|
||||
});
|
||||
|
||||
test("should classify ENOTFOUND as DNS failure", () => {
|
||||
const error = new TypeError("getaddrinfo ENOTFOUND example.com");
|
||||
const result = classifyFetchError(error);
|
||||
expect(result.code).toBe("DNS_FAILURE");
|
||||
});
|
||||
|
||||
test("should classify hostname resolution failures", () => {
|
||||
const error = new TypeError("fetch failed: hostname not found");
|
||||
const result = classifyFetchError(error);
|
||||
expect(result.code).toBe("DNS_FAILURE");
|
||||
});
|
||||
|
||||
test("should classify connection refused", () => {
|
||||
const error = new TypeError("fetch failed: connection refused");
|
||||
const result = classifyFetchError(error);
|
||||
expect(result.code).toBe("CONNECTION_REFUSED");
|
||||
expect(result.status).toBe(502);
|
||||
});
|
||||
|
||||
test("should classify ECONNREFUSED", () => {
|
||||
const error = new TypeError(
|
||||
"connect ECONNREFUSED 127.0.0.1:8080",
|
||||
);
|
||||
const result = classifyFetchError(error);
|
||||
expect(result.code).toBe("CONNECTION_REFUSED");
|
||||
});
|
||||
|
||||
test("should classify generic network errors", () => {
|
||||
const error = new TypeError("fetch failed: network error");
|
||||
const result = classifyFetchError(error);
|
||||
expect(result.code).toBe("NETWORK_ERROR");
|
||||
expect(result.status).toBe(502);
|
||||
});
|
||||
|
||||
test("should classify ECONNRESET as network error", () => {
|
||||
const error = new TypeError("read ECONNRESET");
|
||||
const result = classifyFetchError(error);
|
||||
expect(result.code).toBe("NETWORK_ERROR");
|
||||
});
|
||||
|
||||
test("should classify ECONNABORTED as network error", () => {
|
||||
const error = new TypeError("write ECONNABORTED");
|
||||
const result = classifyFetchError(error);
|
||||
expect(result.code).toBe("NETWORK_ERROR");
|
||||
});
|
||||
|
||||
test("should classify ENETUNREACH as network error", () => {
|
||||
const error = new TypeError("connect ENETUNREACH 10.0.0.1:80");
|
||||
const result = classifyFetchError(error);
|
||||
expect(result.code).toBe("NETWORK_ERROR");
|
||||
});
|
||||
|
||||
test("should pass through RelayError unchanged", () => {
|
||||
const relayError = new RelayError(
|
||||
"SSRF_BLOCKED",
|
||||
403,
|
||||
"Target is not allowed",
|
||||
);
|
||||
const result = classifyFetchError(relayError);
|
||||
expect(result.code).toBe("SSRF_BLOCKED");
|
||||
expect(result.status).toBe(403);
|
||||
expect(result.message).toBe("Target is not allowed");
|
||||
});
|
||||
|
||||
test("should handle RelayError with TIMEOUT code", () => {
|
||||
const relayError = new RelayError("TIMEOUT", 504, "Custom timeout");
|
||||
const result = classifyFetchError(relayError);
|
||||
expect(result.code).toBe("TIMEOUT");
|
||||
expect(result.status).toBe(504);
|
||||
});
|
||||
|
||||
test("should handle unknown Error objects", () => {
|
||||
const error = new Error("Something completely unexpected");
|
||||
const result = classifyFetchError(error);
|
||||
expect(result.code).toBe("NETWORK_ERROR");
|
||||
expect(result.status).toBe(502);
|
||||
expect(result.message).toBe("Unknown upstream error");
|
||||
});
|
||||
|
||||
test("should handle non-Error thrown values", () => {
|
||||
const result = classifyFetchError("just a string");
|
||||
expect(result.code).toBe("NETWORK_ERROR");
|
||||
expect(result.status).toBe(502);
|
||||
});
|
||||
|
||||
test("should handle null thrown value", () => {
|
||||
const result = classifyFetchError(null);
|
||||
expect(result.code).toBe("NETWORK_ERROR");
|
||||
expect(result.status).toBe(502);
|
||||
});
|
||||
|
||||
test("should handle undefined thrown value", () => {
|
||||
const result = classifyFetchError(undefined);
|
||||
expect(result.code).toBe("NETWORK_ERROR");
|
||||
expect(result.status).toBe(502);
|
||||
});
|
||||
|
||||
test("should handle generic TypeError that doesn't match known patterns", () => {
|
||||
const error = new TypeError("some random type error");
|
||||
const result = classifyFetchError(error);
|
||||
expect(result.code).toBe("NETWORK_ERROR");
|
||||
expect(result.status).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CreateErrorResponse ───────────────────────────────────────────────────
|
||||
|
||||
describe("createErrorResponse", () => {
|
||||
test("should return correct status and JSON body", async () => {
|
||||
const result = createErrorResponse({
|
||||
code: "TIMEOUT",
|
||||
status: 504,
|
||||
message: "Upstream timed out",
|
||||
});
|
||||
expect(result.status).toBe(504);
|
||||
expect(result.headers.get("Content-Type")).toBe("application/json");
|
||||
const body = await result.json();
|
||||
expect(body.error).toBe(true);
|
||||
expect(body.code).toBe("TIMEOUT");
|
||||
expect(body.message).toBe("Upstream timed out");
|
||||
});
|
||||
|
||||
test("should include CORS headers in error response", async () => {
|
||||
const result = createErrorResponse({
|
||||
code: "SSRF_BLOCKED",
|
||||
status: 403,
|
||||
message: "Blocked",
|
||||
});
|
||||
expect(result.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
|
||||
test("should handle different error types", async () => {
|
||||
const result = createErrorResponse({
|
||||
code: "DNS_FAILURE",
|
||||
status: 502,
|
||||
message: "DNS resolution failed",
|
||||
});
|
||||
expect(result.status).toBe(502);
|
||||
const body = await result.json();
|
||||
expect(body.code).toBe("DNS_FAILURE");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CreateCorsPreflightResponse ───────────────────────────────────────────
|
||||
|
||||
describe("createCorsPreflightResponse", () => {
|
||||
test("should return 204 with CORS headers", () => {
|
||||
const result = createCorsPreflightResponse();
|
||||
expect(result.status).toBe(204);
|
||||
expect(result.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
expect(result.headers.get("Access-Control-Allow-Methods")).toBe(
|
||||
"GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
||||
);
|
||||
expect(result.headers.get("Access-Control-Allow-Headers")).toBe("*");
|
||||
expect(result.headers.get("Access-Control-Max-Age")).toBe("86400");
|
||||
});
|
||||
|
||||
test("should have no body", async () => {
|
||||
const result = createCorsPreflightResponse();
|
||||
const text = await result.text();
|
||||
expect(text).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CreateRelayResponse ───────────────────────────────────────────────────
|
||||
|
||||
describe("createRelayResponse", () => {
|
||||
test("should preserve status", async () => {
|
||||
const mockResponse = new Response("body", { status: 418 });
|
||||
const result = createRelayResponse(mockResponse);
|
||||
expect(result.status).toBe(418);
|
||||
});
|
||||
|
||||
test("should preserve allowed headers", async () => {
|
||||
const mockResponse = new Response("body", {
|
||||
headers: { "x-custom": "value" },
|
||||
});
|
||||
const result = createRelayResponse(mockResponse);
|
||||
expect(result.headers.get("x-custom")).toBe("value");
|
||||
});
|
||||
|
||||
test("should preserve body", async () => {
|
||||
const mockResponse = new Response("test body content");
|
||||
const result = createRelayResponse(mockResponse);
|
||||
expect(await result.text()).toBe("test body content");
|
||||
});
|
||||
|
||||
test("should handle different status codes", async () => {
|
||||
const mockResponse = new Response(null, { status: 404 });
|
||||
const result = createRelayResponse(mockResponse);
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
test("should handle 500 status", async () => {
|
||||
const mockResponse = new Response("error", { status: 500 });
|
||||
const result = createRelayResponse(mockResponse);
|
||||
expect(result.status).toBe(500);
|
||||
});
|
||||
|
||||
test("should add CORS headers to relayed response", () => {
|
||||
const mockResponse = new Response("ok");
|
||||
const result = createRelayResponse(mockResponse);
|
||||
expect(result.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
|
||||
test("should strip blocked response headers", () => {
|
||||
const mockResponse = new Response("ok", {
|
||||
headers: {
|
||||
"set-cookie": "session=secret",
|
||||
"transfer-encoding": "chunked",
|
||||
},
|
||||
});
|
||||
const result = createRelayResponse(mockResponse);
|
||||
expect(result.headers.has("set-cookie")).toBe(false);
|
||||
expect(result.headers.has("transfer-encoding")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration: Full relay flow with mocked components ───────────────────
|
||||
|
||||
describe("integration: full relay flow", () => {
|
||||
test("complete flow with target URL construction", () => {
|
||||
const target = "https://httpbin.org";
|
||||
const relayPath = "/get";
|
||||
const targetUrl = normalizeTargetUrl(target, relayPath);
|
||||
|
||||
expect(targetUrl).toBeInstanceOf(URL);
|
||||
expect(targetUrl!.href).toBe("https://httpbin.org/get");
|
||||
});
|
||||
|
||||
test("SSRF protection blocks private targets", () => {
|
||||
const targetUrl = normalizeTargetUrl("http://localhost:8080", "/admin");
|
||||
expect(targetUrl).toBeInstanceOf(URL);
|
||||
expect(isAllowedTarget(targetUrl!)).toBe(false);
|
||||
});
|
||||
|
||||
test("request header filtering strips sensitive headers", () => {
|
||||
const originalHeaders = new Headers({
|
||||
"x-relay-target": "https://example.com",
|
||||
"x-relay-path": "/api",
|
||||
host: "localhost",
|
||||
"x-custom": "preserved",
|
||||
});
|
||||
|
||||
const filteredHeaders = filterRequestHeaders(originalHeaders);
|
||||
expect(filteredHeaders.get("x-relay-target")).toBeNull();
|
||||
expect(filteredHeaders.get("x-relay-path")).toBeNull();
|
||||
expect(filteredHeaders.get("host")).toBeNull();
|
||||
expect(filteredHeaders.get("x-custom")).toBe("preserved");
|
||||
});
|
||||
|
||||
test("buildRelayRequest produces correct options for POST", () => {
|
||||
const req = new Request("http://test.com", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ data: "test" }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const options = buildRelayRequest(req, new Headers());
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.body).toBeDefined();
|
||||
});
|
||||
|
||||
test("error case: missing target returns null", () => {
|
||||
const result = normalizeTargetUrl(null, "/test");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("error case: classifyFetchError and createErrorResponse work together", async () => {
|
||||
const error = new DOMException("timeout", "TimeoutError");
|
||||
const classified = classifyFetchError(error);
|
||||
const response = createErrorResponse(classified);
|
||||
expect(response.status).toBe(504);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("TIMEOUT");
|
||||
expect(body.error).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+493
-37
@@ -1,52 +1,508 @@
|
||||
const BLOCKED_HEADERS = new Set([
|
||||
"host",
|
||||
"x-relay-target",
|
||||
"x-relay-path",
|
||||
/**
|
||||
* Relay utilities for the edge proxy.
|
||||
* Handles URL normalization, header filtering, SSRF protection,
|
||||
* request building, and error handling.
|
||||
*/
|
||||
|
||||
// ─── Types & Classes ───────────────────────────────────────────────────────────
|
||||
|
||||
export class RelayError extends Error {
|
||||
public readonly name = 'RelayError' as const;
|
||||
|
||||
constructor(
|
||||
public readonly code:
|
||||
| 'TIMEOUT'
|
||||
| 'DNS_FAILURE'
|
||||
| 'CONNECTION_REFUSED'
|
||||
| 'NETWORK_ERROR'
|
||||
| 'INVALID_TARGET'
|
||||
| 'SSRF_BLOCKED'
|
||||
| 'BODY_TOO_LARGE'
|
||||
| 'UPSTREAM_ERROR',
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── URL Handling ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Combines a relay target URL with a path, returning a URL object.
|
||||
*
|
||||
* - If `target` is null, empty, or whitespace-only, returns null.
|
||||
* - Merges query parameters from both `target` and `relayPath`.
|
||||
* - Returns a `URL` object (call `.toString()` or `.href` for a string).
|
||||
*/
|
||||
export function normalizeTargetUrl(
|
||||
target: string | null,
|
||||
relayPath: string,
|
||||
): URL | null {
|
||||
if (!target || target.trim().length === 0) return null;
|
||||
|
||||
const normalizedTarget = target.replace(/\/+$/, '');
|
||||
const cleanRelayPath = relayPath.startsWith('/')
|
||||
? relayPath
|
||||
: '/' + relayPath;
|
||||
|
||||
try {
|
||||
const baseUrl = new URL(normalizedTarget);
|
||||
const baseOrigin = baseUrl.origin;
|
||||
const basePathname = baseUrl.pathname;
|
||||
|
||||
// Strip query string from relayPath before concatenating
|
||||
const relayPathOnly = cleanRelayPath.includes('?')
|
||||
? cleanRelayPath.slice(0, cleanRelayPath.indexOf('?'))
|
||||
: cleanRelayPath;
|
||||
|
||||
const combinedPath =
|
||||
basePathname === '/'
|
||||
? relayPathOnly
|
||||
: basePathname.replace(/\/$/, '') + relayPathOnly;
|
||||
|
||||
const combined = new URL(combinedPath, baseOrigin);
|
||||
|
||||
// Preserve query parameters from the target URL
|
||||
const targetParams = Array.from(baseUrl.searchParams);
|
||||
for (const [key, value] of targetParams) {
|
||||
combined.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
// Merge query parameters from relayPath
|
||||
if (cleanRelayPath.includes('?')) {
|
||||
const relayQueryStr = cleanRelayPath.slice(
|
||||
cleanRelayPath.indexOf('?') + 1,
|
||||
);
|
||||
if (relayQueryStr.length > 0) {
|
||||
const relayParams = Array.from(new URLSearchParams(relayQueryStr));
|
||||
for (const [key, value] of relayParams) {
|
||||
combined.searchParams.append(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return combined;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SSRF Protection ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Regex patterns for private / loopback / link-local IP ranges. */
|
||||
const PRIVATE_IP_PATTERNS: RegExp[] = [
|
||||
// IPv4
|
||||
/^127\./, // loopback
|
||||
/^10\./, // private class A
|
||||
/^172\.(?:1[6-9]|2\d|3[01])\./, // private class B
|
||||
/^192\.168\./, // private class C
|
||||
/^169\.254\./, // link-local
|
||||
/^0\./, // current network
|
||||
/^0\.0\.0\.0$/, // unspecified
|
||||
// IPv6
|
||||
/^::$/, // unspecified
|
||||
/^::1$/, // loopback
|
||||
/^fe80:/i, // link-local
|
||||
/^fd00:/i, // unique local
|
||||
/^fc00:/i, // unique local
|
||||
];
|
||||
|
||||
const PRIVATE_HOSTNAMES = new Set([
|
||||
'localhost',
|
||||
'localhost.localdomain',
|
||||
'localhost6',
|
||||
'localhost6.localdomain6',
|
||||
'metadata.google.internal',
|
||||
'metadata.internal',
|
||||
'169.254.169.254',
|
||||
]);
|
||||
|
||||
export function normalizeTargetUrl(target: string | null, relayPath: string): string | null {
|
||||
if (!target) return null;
|
||||
return target.replace(/\/$/, "") + relayPath;
|
||||
const PRIVATE_HOSTNAME_SUFFIXES = ['.local', '.internal'];
|
||||
|
||||
/**
|
||||
* Returns `true` when `hostname` is a private / loopback / link-local IP
|
||||
* or a well-known private hostname string.
|
||||
*/
|
||||
export function isPrivateIp(hostname: string): boolean {
|
||||
const lower = hostname.toLowerCase();
|
||||
|
||||
// Check known private hostnames
|
||||
if (PRIVATE_HOSTNAMES.has(lower)) return true;
|
||||
|
||||
// Check hostname suffixes (e.g. *.local, *.internal)
|
||||
for (const suffix of PRIVATE_HOSTNAME_SUFFIXES) {
|
||||
if (lower.endsWith(suffix)) return true;
|
||||
}
|
||||
|
||||
// Check IP patterns
|
||||
for (const pattern of PRIVATE_IP_PATTERNS) {
|
||||
if (pattern.test(lower)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterHeaders(headers: Headers): Headers {
|
||||
const filtered = new Headers(headers);
|
||||
for (const key of BLOCKED_HEADERS) {
|
||||
filtered.delete(key);
|
||||
}
|
||||
return filtered;
|
||||
/**
|
||||
* Validates a parsed URL is allowed for proxying.
|
||||
*
|
||||
* - Only `http:` and `https:` protocols are permitted.
|
||||
* - Hostname must not resolve to a private / internal IP (SSRF protection).
|
||||
*/
|
||||
export function isAllowedTarget(url: URL): boolean {
|
||||
// Protocol check
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// SSRF check — block private / internal hosts
|
||||
if (isPrivateIp(url.hostname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Header Filtering ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Set of exact header names (lower-case) to strip from **outgoing** relay
|
||||
* requests.
|
||||
*/
|
||||
export const BLOCKED_REQUEST_HEADERS = new Set([
|
||||
// Relay control headers
|
||||
'host',
|
||||
'x-relay-target',
|
||||
'x-relay-path',
|
||||
// Hop-by-hop headers (should never be forwarded)
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailers',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
// Security-sensitive — strip by default
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
// Vercel platform headers
|
||||
'x-vercel-id',
|
||||
'x-vercel-deployment-url',
|
||||
'x-vercel-oidc-token',
|
||||
'x-vercel-signature',
|
||||
'x-vercel-edgified',
|
||||
'x-vercel-proxy-signature',
|
||||
'x-vercel-ip-city',
|
||||
'x-vercel-ip-country',
|
||||
'x-vercel-ip-country-region',
|
||||
'x-vercel-ip-latency',
|
||||
'x-vercel-ip-longitude',
|
||||
'x-vercel-ip-timezone',
|
||||
'x-vercel-forwarded-for',
|
||||
'x-vercel-set-bucket',
|
||||
// Cloudflare platform headers
|
||||
'cf-ray',
|
||||
'cf-connecting-ip',
|
||||
'cf-ipcountry',
|
||||
'cf-visitor',
|
||||
'cf-worker',
|
||||
'cf-edge',
|
||||
// Forwarded-for metadata (privacy)
|
||||
'x-forwarded-for',
|
||||
'x-forwarded-host',
|
||||
'x-forwarded-proto',
|
||||
'x-real-ip',
|
||||
'forwarded',
|
||||
'via',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Header name prefixes that cause a header to be stripped from **outgoing**
|
||||
* relay requests. Matching is case-insensitive.
|
||||
*/
|
||||
export const BLOCKED_REQUEST_PREFIXES = [
|
||||
'x-vercel-',
|
||||
'cf-',
|
||||
'x-forwarded-',
|
||||
'x-envoy-',
|
||||
];
|
||||
|
||||
// Pre-computed lower-case versions for efficient matching
|
||||
const BLOCKED_REQUEST_PREFIXES_LOWER = BLOCKED_REQUEST_PREFIXES.map((p) =>
|
||||
p.toLowerCase(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Strips sensitive / unnecessary headers from an outgoing relay request.
|
||||
*
|
||||
* Removes:
|
||||
* 1. Exact matches against `BLOCKED_REQUEST_HEADERS` (case-insensitive).
|
||||
* 2. Any header whose lower-case key starts with an entry in
|
||||
* `BLOCKED_REQUEST_PREFIXES`.
|
||||
*
|
||||
* Returns a **new** `Headers` instance — the original is not mutated.
|
||||
*/
|
||||
export function filterRequestHeaders(headers: Headers): Headers {
|
||||
const filtered = new Headers();
|
||||
|
||||
const headerEntries = Array.from(headers);
|
||||
for (const [key, value] of headerEntries) {
|
||||
const lower = key.toLowerCase();
|
||||
|
||||
// Check exact blocked headers
|
||||
if (BLOCKED_REQUEST_HEADERS.has(lower)) continue;
|
||||
|
||||
// Check blocked prefixes
|
||||
let blockedByPrefix = false;
|
||||
for (const prefix of BLOCKED_REQUEST_PREFIXES_LOWER) {
|
||||
if (lower.startsWith(prefix)) {
|
||||
blockedByPrefix = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (blockedByPrefix) continue;
|
||||
|
||||
filtered.set(key, value);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers to strip from **incoming** relay responses before sending back to
|
||||
* the caller.
|
||||
*/
|
||||
export const BLOCKED_RESPONSE_HEADERS = new Set([
|
||||
'set-cookie',
|
||||
'transfer-encoding',
|
||||
'keep-alive',
|
||||
'connection',
|
||||
]);
|
||||
|
||||
const CORS_HEADERS: Record<string, string> = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
};
|
||||
|
||||
/**
|
||||
* Strips sensitive headers from a relay **response** and attaches standard
|
||||
* CORS headers.
|
||||
*
|
||||
* Returns a **new** `Headers` instance — the original is not mutated.
|
||||
*/
|
||||
export function filterResponseHeaders(headers: Headers): Headers {
|
||||
const filtered = new Headers(headers);
|
||||
|
||||
const blockedKeys = Array.from(BLOCKED_RESPONSE_HEADERS);
|
||||
for (const key of blockedKeys) {
|
||||
filtered.delete(key);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(CORS_HEADERS)) {
|
||||
filtered.set(key, value);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// ─── Backward Compatibility ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @deprecated Use `filterRequestHeaders` instead. Kept for compatibility
|
||||
* with existing callers (`route.ts`, tests).
|
||||
*/
|
||||
export const filterHeaders = filterRequestHeaders;
|
||||
|
||||
// ─── Request Building ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns `true` when the HTTP method typically carries a request body.
|
||||
*
|
||||
* `GET`, `HEAD`, and `CONNECT` are the only common methods that never carry
|
||||
* a body. Everything else (POST, PUT, PATCH, DELETE, OPTIONS, etc.) may.
|
||||
*/
|
||||
export function shouldSendBody(method: string): boolean {
|
||||
return method !== "GET" && method !== "HEAD";
|
||||
const upper = method.toUpperCase();
|
||||
return upper !== 'GET' && upper !== 'HEAD' && upper !== 'CONNECT';
|
||||
}
|
||||
|
||||
export function buildRelayRequest(req: Request, headers: Headers): RequestInit {
|
||||
return {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: shouldSendBody(req.method) ? req.body : undefined,
|
||||
duplex: "half",
|
||||
} as any;
|
||||
/**
|
||||
* Constructs a `RequestInit` suitable for passing to `fetch()`.
|
||||
*
|
||||
* - Applies the (already-filtered) headers.
|
||||
* - Attaches a `ReadableStream` body when the method permits it (with
|
||||
* `duplex: 'half'` as required by the spec for streaming bodies).
|
||||
* - Attaches an `AbortSignal.timeout()` signal.
|
||||
*/
|
||||
export function buildRelayRequest(
|
||||
req: Request,
|
||||
headers: Headers,
|
||||
timeoutMs?: number,
|
||||
): RequestInit {
|
||||
const timeout = timeoutMs ?? 30_000;
|
||||
const method = req.method;
|
||||
const body = shouldSendBody(method) ? req.body : undefined;
|
||||
|
||||
const init: RequestInit & { duplex?: 'half' } = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
};
|
||||
|
||||
if (body) {
|
||||
init.body = body;
|
||||
init.duplex = 'half';
|
||||
}
|
||||
|
||||
return init;
|
||||
}
|
||||
|
||||
export function isAllowedTarget(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return ["http:", "https:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// ─── Response Building ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a relay-friendly `Response` by passing through the upstream status,
|
||||
* status text, and body while sanitising headers via `filterResponseHeaders`.
|
||||
*/
|
||||
export function createRelayResponse(response: Response): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Access-Control-Allow-Origin", "*");
|
||||
headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
|
||||
headers.set("Access-Control-Allow-Headers", "*");
|
||||
const headers = filterResponseHeaders(response.headers);
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers,
|
||||
});
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Error Handling ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ErrorClassification {
|
||||
code: string;
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies a caught `unknown` into a structured error with an HTTP status
|
||||
* code and a user-facing message suitable for JSON error responses.
|
||||
*/
|
||||
export function classifyFetchError(error: unknown): ErrorClassification {
|
||||
// RelayError passes through its own classification
|
||||
if (error instanceof RelayError) {
|
||||
return {
|
||||
code: error.code,
|
||||
status: error.status,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
// AbortError from AbortSignal.timeout or controller.abort()
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
(error.name === 'AbortError' || error.name === 'TimeoutError')
|
||||
) {
|
||||
return {
|
||||
code: 'TIMEOUT',
|
||||
status: 504,
|
||||
message: 'Upstream timed out',
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof TypeError) {
|
||||
const msg = error.message.toLowerCase();
|
||||
|
||||
if (
|
||||
msg.includes('dns') ||
|
||||
msg.includes('resolve') ||
|
||||
msg.includes('hostname') ||
|
||||
msg.includes('enotfound')
|
||||
) {
|
||||
return {
|
||||
code: 'DNS_FAILURE',
|
||||
status: 502,
|
||||
message: 'DNS resolution failed',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
msg.includes('refused') ||
|
||||
msg.includes('econnrefused') ||
|
||||
msg.includes('connection refused')
|
||||
) {
|
||||
return {
|
||||
code: 'CONNECTION_REFUSED',
|
||||
status: 502,
|
||||
message: 'Connection refused',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
msg.includes('fetch failed') ||
|
||||
msg.includes('network') ||
|
||||
msg.includes('econnreset') ||
|
||||
msg.includes('econnaborted') ||
|
||||
msg.includes('enetunreach')
|
||||
) {
|
||||
return {
|
||||
code: 'NETWORK_ERROR',
|
||||
status: 502,
|
||||
message: 'Network error',
|
||||
};
|
||||
}
|
||||
|
||||
// Generic TypeError that doesn't match known patterns
|
||||
return {
|
||||
code: 'NETWORK_ERROR',
|
||||
status: 502,
|
||||
message: 'Network error',
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return {
|
||||
code: 'NETWORK_ERROR',
|
||||
status: 502,
|
||||
message: 'Unknown upstream error',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a JSON `Response` from a structured error classification.
|
||||
*
|
||||
* Body includes `error`, `code`, and `message` fields. CORS headers are
|
||||
* attached so the caller can read the error from a browser.
|
||||
*/
|
||||
export function createErrorResponse(error: ErrorClassification): Response {
|
||||
const body = JSON.stringify({
|
||||
error: true,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
status: error.status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...CORS_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── CORS Preflight ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns a 204 No Content response with CORS preflight headers.
|
||||
*
|
||||
* This is a convenience so the relay server does not need to manually
|
||||
* construct CORS OPTIONS responses.
|
||||
*/
|
||||
export function createCorsPreflightResponse(): Response {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
...CORS_HEADERS,
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user