From 6f28d18b955b3910603456a27f32e841125961d1 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 7 May 2026 19:55:39 +0700 Subject: [PATCH] feat: refactor header handling and update module path in package.json --- api/index.ts | 122 ++++++++++++++++++++++++++++++-------- api/relay-utils.ts | 143 --------------------------------------------- package.json | 2 +- 3 files changed, 99 insertions(+), 168 deletions(-) delete mode 100644 api/relay-utils.ts diff --git a/api/index.ts b/api/index.ts index 993aaac..39b83b4 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,18 +1,91 @@ -import { - buildRelayRequest, - createRelayResponse, - normalizeTargetUrl, - stripRelayHeaders, - isAllowedTarget, -} from "./relay-utils"; - export const config = { runtime: "edge" }; -// Only allow safe methods +// Allowlist: only these headers are forwarded to prevent leaking +// Vercel internal metadata, credentials, and infrastructure info +const ALLOWED_HEADERS = new Set([ + "content-type", + "accept", + "accept-encoding", + "accept-language", + "user-agent", + "referer", + "origin", + "authorization", + "proxy-authorization", + "cache-control", +]); + +// Blocklist: sensitive headers that should NEVER be forwarded +const BLOCKED_HEADERS = new Set([ + "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", + "cf-ray", + "cf-connecting-ip", + "cf-ipcountry", + "cf-ray-id", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "forwarded", + "cookie", + "set-cookie", + "x-real-ip", + "x-cluster-client-ip", + "x-api-key", + "x-cache", +]); + +const INTERNAL_HEADERS = new Set(["x-relay-target", "x-relay-path", "host"]); + +export function isAllowedTarget(url: string): boolean { + try { + const parsed = new URL(url); + 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(); + if (INTERNAL_HEADERS.has(lowerKey)) continue; + if (BLOCKED_HEADERS.has(lowerKey)) continue; + if (lowerKey.startsWith("x-vercel-")) continue; + if (lowerKey.startsWith("cf-")) continue; + if (lowerKey.startsWith("x-forwarded-")) continue; + if (ALLOWED_HEADERS.has(lowerKey)) { + filtered.set(key, value); + continue; + } + filtered.set(key, value); + } + return filtered; +} + +export function shouldSendBody(method: string): boolean { + return method !== "GET" && method !== "HEAD"; +} + 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, @@ -22,32 +95,33 @@ export default async function handler(req: Request): Promise { 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" }, - }, + { 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" }, - }, + { status: 403, headers: { "content-type": "application/json" } }, ); } - const headers = stripRelayHeaders(new Headers(req.headers)); - const fetchOptions = buildRelayRequest(req, headers); + const headers = filterHeaders(new Headers(req.headers)); + const fetchOptions: RequestInit = { + method: req.method, + headers, + body: shouldSendBody(req.method) ? req.body : undefined, + duplex: "half", + }; const response = await fetch(targetUrl, fetchOptions); - return createRelayResponse(response); -} + return new Response(response.body, { + status: response.status, + headers: response.headers, + }); +} \ No newline at end of file diff --git a/api/relay-utils.ts b/api/relay-utils.ts deleted file mode 100644 index 8855773..0000000 --- a/api/relay-utils.ts +++ /dev/null @@ -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, - }); -} diff --git a/package.json b/package.json index 133ffa7..2cd2d1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxy-bun", - "module": "src/index.ts", + "module": "api/index.ts", "type": "module", "private": true, "scripts": {