From b4f5bad19d991fdf2a385f828552bbf0258539bc Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 7 May 2026 19:22:28 +0700 Subject: [PATCH] feat: enhance request handling with method validation and target URL checks --- src/index.ts | 23 ++++++++++ src/relay-utils.ts | 111 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 129 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2a345e1..f16a288 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,11 +3,23 @@ import { 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") || "/"; @@ -22,6 +34,17 @@ export default async function handler(req: Request): Promise { ); } + // 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); diff --git a/src/relay-utils.ts b/src/relay-utils.ts index 585c4a6..8855773 100644 --- a/src/relay-utils.ts +++ b/src/relay-utils.ts @@ -1,7 +1,80 @@ +// 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, @@ -10,12 +83,40 @@ export function normalizeTargetUrl( 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 { - const stripped = new Headers(headers); - stripped.delete("x-relay-target"); - stripped.delete("x-relay-path"); - stripped.delete("host"); - return stripped; + // Use the secure filter instead of manual deletion + return filterHeaders(headers); } export function shouldSendBody(method: string): boolean {