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:
MythEclipse
2026-06-10 19:54:55 +07:00
co-authored by Claude Fable 5
parent 124dab98a2
commit f58a92e2aa
43 changed files with 2981 additions and 1659 deletions
+836 -65
View File
@@ -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
View File
@@ -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',
},
});
}
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}