feat: implement Edge Proxy Relay with request handling and header filtering
This commit is contained in:
@@ -4,7 +4,6 @@ node_modules
|
|||||||
# output
|
# output
|
||||||
out
|
out
|
||||||
dist
|
dist
|
||||||
api
|
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|
||||||
# code coverage
|
# code coverage
|
||||||
|
|||||||
@@ -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<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,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,
|
||||||
|
});
|
||||||
|
}
|
||||||
+1
-1
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const res = await fetch('/', {
|
const res = await fetch('/api', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'x-relay-target': target,
|
'x-relay-target': target,
|
||||||
|
|||||||
+1
-5
@@ -1,9 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||||
"bunVersion": "1",
|
"bunVersion": "1",
|
||||||
"buildCommand": "bun run build",
|
"installCommand": "bun install"
|
||||||
"installCommand": "bun install",
|
|
||||||
"routes": [
|
|
||||||
{ "src": "/(.*)", "dest": "/$1" }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user