refactor: migrate to Next.js with edge functions and update relay utilities
- Removed old relay utilities and tests, replacing them with a new structure under `src/lib/relay-utils.ts`. - Implemented a new edge function in `app/api/proxy/route.ts` to handle proxy requests. - Created a new Next.js layout and page structure for the frontend, including a user interface for testing the proxy. - Added Tailwind CSS for styling and configured PostCSS. - Updated TypeScript configuration for better compatibility with Next.js. - Removed unused files and configurations related to the previous setup.
This commit is contained in:
@@ -1,257 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
buildRelayRequest,
|
||||
createRelayResponse,
|
||||
normalizeTargetUrl,
|
||||
shouldSendBody,
|
||||
stripRelayHeaders,
|
||||
} from "~/relay-utils";
|
||||
|
||||
describe("normalizeTargetUrl", () => {
|
||||
test("returns null when target is null", () => {
|
||||
expect(normalizeTargetUrl(null, "/")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when target is empty string", () => {
|
||||
expect(normalizeTargetUrl("", "/")).toBeNull();
|
||||
});
|
||||
|
||||
test("removes trailing slash from target", () => {
|
||||
expect(normalizeTargetUrl("https://httpbin.org/", "/get")).toBe(
|
||||
"https://httpbin.org/get",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps target without trailing slash", () => {
|
||||
expect(normalizeTargetUrl("https://httpbin.org", "/get")).toBe(
|
||||
"https://httpbin.org/get",
|
||||
);
|
||||
});
|
||||
|
||||
test("appends relay path", () => {
|
||||
expect(normalizeTargetUrl("https://example.com", "/api/users")).toBe(
|
||||
"https://example.com/api/users",
|
||||
);
|
||||
});
|
||||
|
||||
test("handles nested paths", () => {
|
||||
expect(
|
||||
normalizeTargetUrl("https://api.example.com", "/v1/users/123/profile"),
|
||||
).toBe("https://api.example.com/v1/users/123/profile");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripRelayHeaders", () => {
|
||||
test("removes x-relay-target header", () => {
|
||||
const headers = new Headers({ "x-relay-target": "https://test.com" });
|
||||
const stripped = stripRelayHeaders(headers);
|
||||
expect(stripped.get("x-relay-target")).toBeNull();
|
||||
});
|
||||
|
||||
test("removes x-relay-path header", () => {
|
||||
const headers = new Headers({ "x-relay-path": "/test" });
|
||||
const stripped = stripRelayHeaders(headers);
|
||||
expect(stripped.get("x-relay-path")).toBeNull();
|
||||
});
|
||||
|
||||
test("removes host header", () => {
|
||||
const headers = new Headers({ host: "localhost:3000" });
|
||||
const stripped = stripRelayHeaders(headers);
|
||||
expect(stripped.get("host")).toBeNull();
|
||||
});
|
||||
|
||||
test("preserves other headers", () => {
|
||||
const headers = new Headers({
|
||||
"x-relay-target": "https://test.com",
|
||||
"x-custom-header": "value",
|
||||
authorization: "Bearer token",
|
||||
});
|
||||
const stripped = stripRelayHeaders(headers);
|
||||
expect(stripped.get("x-custom-header")).toBe("value");
|
||||
expect(stripped.get("authorization")).toBe("Bearer token");
|
||||
});
|
||||
|
||||
test("handles multiple relay headers", () => {
|
||||
const headers = new Headers({
|
||||
"x-relay-target": "https://test.com",
|
||||
"x-relay-path": "/path",
|
||||
host: "test.com",
|
||||
"content-type": "application/json",
|
||||
});
|
||||
const stripped = stripRelayHeaders(headers);
|
||||
expect(stripped.get("content-type")).toBe("application/json");
|
||||
expect(stripped.get("x-relay-target")).toBeNull();
|
||||
expect(stripped.get("x-relay-path")).toBeNull();
|
||||
expect(stripped.get("host")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldSendBody", () => {
|
||||
test("returns false for GET", () => {
|
||||
expect(shouldSendBody("GET")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for HEAD", () => {
|
||||
expect(shouldSendBody("HEAD")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for POST", () => {
|
||||
expect(shouldSendBody("POST")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for PUT", () => {
|
||||
expect(shouldSendBody("PUT")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for PATCH", () => {
|
||||
expect(shouldSendBody("PATCH")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for DELETE", () => {
|
||||
expect(shouldSendBody("DELETE")).toBe(true);
|
||||
});
|
||||
|
||||
test("is case-sensitive", () => {
|
||||
expect(shouldSendBody("get")).toBe(true);
|
||||
expect(shouldSendBody("post")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRelayRequest", () => {
|
||||
test("sets 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("sets 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("omits 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("omits 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("includes body 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);
|
||||
expect(result.body).toBeDefined();
|
||||
expect(result.duplex).toBe("half");
|
||||
});
|
||||
|
||||
test("includes 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("includes 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("includes 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRelayResponse", () => {
|
||||
test("preserves status", async () => {
|
||||
const mockResponse = new Response("body", { status: 418 });
|
||||
const result = createRelayResponse(mockResponse);
|
||||
expect(result.status).toBe(418);
|
||||
});
|
||||
|
||||
test("preserves headers", async () => {
|
||||
const mockResponse = new Response("body", {
|
||||
headers: { "x-custom": "value" },
|
||||
});
|
||||
const result = createRelayResponse(mockResponse);
|
||||
expect(result.headers.get("x-custom")).toBe("value");
|
||||
});
|
||||
|
||||
test("preserves body", async () => {
|
||||
const mockResponse = new Response("test body content");
|
||||
const result = createRelayResponse(mockResponse);
|
||||
expect(await result.text()).toBe("test body content");
|
||||
});
|
||||
|
||||
test("handles different status codes", async () => {
|
||||
const mockResponse = new Response(null, { status: 404 });
|
||||
const result = createRelayResponse(mockResponse);
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
test("handles 500 status", async () => {
|
||||
const mockResponse = new Response("error", { status: 500 });
|
||||
const result = createRelayResponse(mockResponse);
|
||||
expect(result.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration: full relay flow", () => {
|
||||
test("complete flow with mocked fetch", async () => {
|
||||
const target = "https://httpbin.org";
|
||||
const relayPath = "/get";
|
||||
const targetUrl = normalizeTargetUrl(target, relayPath);
|
||||
|
||||
expect(targetUrl).toBe("https://httpbin.org/get");
|
||||
|
||||
const originalHeaders = new Headers({
|
||||
"x-relay-target": target,
|
||||
"x-relay-path": relayPath,
|
||||
host: "localhost",
|
||||
"x-custom": "preserved",
|
||||
});
|
||||
|
||||
const strippedHeaders = stripRelayHeaders(originalHeaders);
|
||||
expect(strippedHeaders.get("x-relay-target")).toBeNull();
|
||||
expect(strippedHeaders.get("x-relay-path")).toBeNull();
|
||||
expect(strippedHeaders.get("host")).toBeNull();
|
||||
expect(strippedHeaders.get("x-custom")).toBe("preserved");
|
||||
});
|
||||
|
||||
test("POST flow with body preservation", () => {
|
||||
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", () => {
|
||||
const target = null;
|
||||
const relayPath = "/test";
|
||||
const result = normalizeTargetUrl(target, relayPath);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
buildRelayRequest,
|
||||
createRelayResponse,
|
||||
normalizeTargetUrl,
|
||||
stripRelayHeaders,
|
||||
isAllowedTarget,
|
||||
} from "~/relay-utils";
|
||||
|
||||
export const config = { runtime: "edge" };
|
||||
|
||||
// Only allow safe methodsa
|
||||
const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]);
|
||||
|
||||
export default async function handler(req: Request): Promise<Response> {
|
||||
// Method validation
|
||||
if (!ALLOWED_METHODS.has(req.method)) {
|
||||
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
||||
status: 405,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const target = req.headers.get("x-relay-target");
|
||||
const relayPath = req.headers.get("x-relay-path") || "/";
|
||||
|
||||
const targetUrl = normalizeTargetUrl(target, relayPath);
|
||||
if (!targetUrl) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing x-relay-target header" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Target validation (SSRF prevention)
|
||||
if (!isAllowedTarget(targetUrl)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Target domain not allowed" }),
|
||||
{
|
||||
status: 403,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const headers = stripRelayHeaders(new Headers(req.headers));
|
||||
const fetchOptions = buildRelayRequest(req, headers);
|
||||
|
||||
const response = await fetch(targetUrl, fetchOptions);
|
||||
return createRelayResponse(response);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface RelayOptions {
|
||||
stripHeaders?: string[];
|
||||
}
|
||||
|
||||
export function normalizeTargetUrl(target: string | null, relayPath: string): string | null {
|
||||
if (!target) return null;
|
||||
return target.replace(/\/$/, "") + relayPath;
|
||||
}
|
||||
|
||||
export function stripRelayHeaders(headers: Headers): Headers {
|
||||
const stripped = new Headers(headers);
|
||||
stripped.delete("x-relay-target");
|
||||
stripped.delete("x-relay-path");
|
||||
stripped.delete("host");
|
||||
return stripped;
|
||||
}
|
||||
|
||||
export function shouldSendBody(method: string): boolean {
|
||||
return method !== "GET" && method !== "HEAD";
|
||||
}
|
||||
|
||||
export function buildRelayRequest(
|
||||
req: Request,
|
||||
targetUrl: string,
|
||||
headers: Headers
|
||||
): RequestInit {
|
||||
return {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: shouldSendBody(req.method) ? req.body : undefined,
|
||||
duplex: "half" as const,
|
||||
};
|
||||
}
|
||||
|
||||
export function isAllowedTarget(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return ["http:", "https:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createRelayResponse(response: Response): Response {
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
// Allowlist: only these headers are forwarded to prevent leaking
|
||||
// Vercel internal metadata, credentials, and infrastructure info
|
||||
const ALLOWED_HEADERS = new Set([
|
||||
// Standard request headers
|
||||
"content-type",
|
||||
"accept",
|
||||
"accept-encoding",
|
||||
"accept-language",
|
||||
"user-agent",
|
||||
"referer",
|
||||
"origin",
|
||||
// Auth headers (but NOT cookies)
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
// Content negotiation
|
||||
"cache-control",
|
||||
// Custom headers (no prefix restriction, but Vercel-specific are blocked)
|
||||
]);
|
||||
|
||||
// Blocklist: sensitive headers that should NEVER be forwarded
|
||||
const BLOCKED_HEADERS = new Set([
|
||||
// Vercel infrastructure headers
|
||||
"x-vercel-id",
|
||||
"x-vercel-deployment-url",
|
||||
"x-vercel-oidc-token",
|
||||
"x-vercel-oidc-token-ts",
|
||||
"x-vercel-signature",
|
||||
"x-vercel-edgified",
|
||||
"x-vercel-ip-city",
|
||||
"x-vercel-ip-country",
|
||||
"x-vercel-ip-country-region",
|
||||
"x-vercel-ip-latency",
|
||||
"x-vercel-deployment-config",
|
||||
"x-vercel-rewritten-query",
|
||||
// Cloudflare specific
|
||||
"cf-ray",
|
||||
"cf-connecting-ip",
|
||||
"cf-ipcountry",
|
||||
"cf-ray-id",
|
||||
// Forwarding proxies (can leak internal network info)
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-proto",
|
||||
"forwarded",
|
||||
// Cookies (should be explicitly handled, not blindly forwarded)
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
// Internal infrastructure
|
||||
"x-real-ip",
|
||||
"x-cluster-client-ip",
|
||||
// Authentication tokens
|
||||
"x-api-key",
|
||||
// Caching
|
||||
"x-cache",
|
||||
]);
|
||||
|
||||
// Internal relay headers
|
||||
const INTERNAL_HEADERS = new Set([
|
||||
"x-relay-target",
|
||||
"x-relay-path",
|
||||
"host",
|
||||
]);
|
||||
|
||||
export interface RelayOptions {
|
||||
stripHeaders?: string[];
|
||||
}
|
||||
|
||||
export function isAllowedTarget(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
// Only allow HTTP/HTTPS (prevents file://, data:, etc.)
|
||||
return ["http:", "https:"].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeTargetUrl(
|
||||
target: string | null,
|
||||
relayPath: string,
|
||||
): string | null {
|
||||
if (!target) return null;
|
||||
return target.replace(/\/$/, "") + relayPath;
|
||||
}
|
||||
|
||||
export function filterHeaders(headers: Headers): Headers {
|
||||
const filtered = new Headers();
|
||||
|
||||
for (const [key, value] of headers.entries()) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
// Skip internal relay headers
|
||||
if (INTERNAL_HEADERS.has(lowerKey)) continue;
|
||||
|
||||
// Skip blocked headers (security critical)
|
||||
if (BLOCKED_HEADERS.has(lowerKey)) continue;
|
||||
|
||||
// Block headers with sensitive infrastructure prefixes
|
||||
if (lowerKey.startsWith("x-vercel-")) continue;
|
||||
if (lowerKey.startsWith("cf-")) continue;
|
||||
if (lowerKey.startsWith("x-forwarded-")) continue;
|
||||
|
||||
// For known safe headers, always allow
|
||||
if (ALLOWED_HEADERS.has(lowerKey)) {
|
||||
filtered.set(key, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allow custom headers (no sensitive prefix)
|
||||
// Custom headers typically use kebab-case (e.g., x-custom-header)
|
||||
filtered.set(key, value);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export function stripRelayHeaders(headers: Headers): Headers {
|
||||
// Use the secure filter instead of manual deletion
|
||||
return filterHeaders(headers);
|
||||
}
|
||||
|
||||
export function shouldSendBody(method: string): boolean {
|
||||
return method !== "GET" && method !== "HEAD";
|
||||
}
|
||||
|
||||
export function buildRelayRequest(
|
||||
req: Request,
|
||||
_headers: Headers,
|
||||
): RequestInit {
|
||||
return {
|
||||
method: req.method,
|
||||
headers: _headers,
|
||||
body: shouldSendBody(req.method) ? req.body : undefined,
|
||||
duplex: "half" as const,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRelayResponse(response: Response): Response {
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user