From 212ce324805a01280ca3b01a8462b7a9ca0538ad Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 7 May 2026 19:49:52 +0700 Subject: [PATCH] feat: implement Edge Proxy Relay with request handling and header filtering --- .gitignore | 1 - api/index.ts | 53 +++++++++++++++++ api/relay-utils.ts | 143 +++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 2 +- vercel.json | 6 +- 5 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 api/index.ts create mode 100644 api/relay-utils.ts diff --git a/.gitignore b/.gitignore index ecb6648..ecb0735 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ node_modules # output out dist -api *.tgz # code coverage diff --git a/api/index.ts b/api/index.ts new file mode 100644 index 0000000..f16a288 --- /dev/null +++ b/api/index.ts @@ -0,0 +1,53 @@ +import { + buildRelayRequest, + createRelayResponse, + normalizeTargetUrl, + stripRelayHeaders, + isAllowedTarget, +} from "~/relay-utils"; + +export const config = { runtime: "edge" }; + +// Only allow safe methods +const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]); + +export default async function handler(req: Request): Promise { + // 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); +} diff --git a/api/relay-utils.ts b/api/relay-utils.ts new file mode 100644 index 0000000..8855773 --- /dev/null +++ b/api/relay-utils.ts @@ -0,0 +1,143 @@ +// 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, + }); +} diff --git a/public/index.html b/public/index.html index 27e6edb..3c764a8 100644 --- a/public/index.html +++ b/public/index.html @@ -38,7 +38,7 @@ try { const start = Date.now(); - const res = await fetch('/', { + const res = await fetch('/api', { method: 'POST', headers: { 'x-relay-target': target, diff --git a/vercel.json b/vercel.json index 38fb2fe..9fbbe05 100644 --- a/vercel.json +++ b/vercel.json @@ -1,9 +1,5 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", "bunVersion": "1", - "buildCommand": "bun run build", - "installCommand": "bun install", - "routes": [ - { "src": "/(.*)", "dest": "/$1" } - ] + "installCommand": "bun install" } \ No newline at end of file