import { randomBytes } from "crypto"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { vercelDeploySchema } from "@/shared/validation/freeProxySchemas"; import { createProxy } from "@/lib/localDb"; import { encrypt } from "@/lib/db/encryption"; // Shared SSRF-safe relay-path resolver — the same pure guard embedded in the // Deno Deploy worker. Both edge relays must enforce identical path validation, // so they import one source of truth rather than diverging copies. import { resolveRelayTarget } from "../deno-deploy/route"; const VERCEL_API_BASE = process.env.VERCEL_API_BASE || "https://api.vercel.com"; const POLL_INTERVAL_MS = 3000; const POLL_MAX_ATTEMPTS = 40; // ~2 min function buildRelayFunction(relayAuth: string): string { // relayAuth is a random hex string generated server-side — no user input. // The runtime SSRF guard is inlined into the edge function (cannot import // Node-side helpers from the Edge runtime); it blocks RFC1918, loopback, // link-local, IPv6 ULA, and embedded credentials on the x-relay-target host. // `resolveRelayTarget` (shared with the Deno worker) closes the x-relay-path // host-confusion hole and is embedded verbatim via Function#toString. return `export const config = { runtime: "edge" }; ${resolveRelayTarget.toString()} function isPrivateHostname(h) { if (!h) return true; const host = h.trim().toLowerCase().replace(/^\\[|\\]$/g, ""); if ( host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host === "::1" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal") || host.startsWith("::ffff:") ) return true; const v4 = host.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/); if (v4) { const a = +v4[1], b = +v4[2]; if (a === 0 || a === 10 || a === 127) return true; if (a === 169 && b === 254) return true; if (a === 192 && b === 168) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 100 && b >= 64 && b <= 127) return true; return false; } if (host.includes(":")) { return host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80:"); } return false; } export default async function handler(req) { const auth = req.headers.get("x-relay-auth"); if (auth !== "${relayAuth}") return new Response("Unauthorized", { status: 401 }); const target = req.headers.get("x-relay-target"); if (!target) return new Response("missing x-relay-target", { status: 400 }); let targetUrl; try { targetUrl = new URL(target); } catch { return new Response("invalid x-relay-target", { status: 400 }); } if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") { return new Response("forbidden x-relay-target protocol", { status: 403 }); } if (targetUrl.username || targetUrl.password) { return new Response("forbidden x-relay-target (embedded credentials)", { status: 403 }); } if (isPrivateHostname(targetUrl.hostname)) { return new Response("forbidden x-relay-target (private/loopback host)", { status: 403 }); } const relayPath = req.headers.get("x-relay-path") || "/"; const resolved = resolveRelayTarget(target, relayPath); if (!resolved.ok) { return new Response(resolved.reason, { status: resolved.status }); } const headers = new Headers(req.headers); ["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach(h => headers.delete(h)); const upstream = await fetch(resolved.url, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, duplex: "half", }); return new Response(upstream.body, { status: upstream.status, headers: upstream.headers }); }`; } /** * Test-only hook exposing the generated Vercel worker source so the SSRF * regression test can assert it no longer string-concatenates the relay path * and embeds the shared `resolveRelayTarget` guard. Not part of the route * contract. */ export const __buildRelayFunctionForTest = buildRelayFunction; async function pollDeployment(deploymentApiUrl: string, token: string): Promise<"READY" | "ERROR"> { for (let i = 0; i < POLL_MAX_ATTEMPTS; i++) { await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); try { const res = await fetch(deploymentApiUrl, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) continue; const data = (await res.json()) as { readyState?: string }; if (data.readyState === "READY") return "READY"; if (data.readyState === "ERROR") return "ERROR"; } catch {} } return "ERROR"; } export async function POST(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; let rawBody: unknown = {}; try { rawBody = await request.json(); } catch { return createErrorResponse({ status: 400, message: "Invalid JSON body", type: "invalid_request", }); } const validation = validateBody(vercelDeploySchema, rawBody); if (isValidationFailure(validation)) { return createErrorResponse({ status: 400, message: validation.error.message, type: "invalid_request", }); } const { token, projectName } = validation.data; // Generate random auth secret for the relay — stored in proxy notes, never returned to client const relayAuth = randomBytes(24).toString("hex"); const relayCode = buildRelayFunction(relayAuth); try { const deployRes = await fetch(`${VERCEL_API_BASE}/v13/deployments`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ name: projectName, files: [ { file: "api/relay.js", data: relayCode }, { file: "package.json", data: JSON.stringify({ name: projectName, version: "1.0.0" }), }, { file: "vercel.json", data: JSON.stringify({ rewrites: [{ source: "/(.*)", destination: "/api/relay" }], }), }, ], projectSettings: { framework: null }, target: "production", }), }); if (!deployRes.ok) { // Avoid forwarding 200 bytes of raw Vercel error text — it may contain // project IDs, team slugs, deployment hashes or internal Vercel error // strings. Parse the canonical { error: { message } } shape and surface // only the human-readable message (or a generic fallback). let upstreamMessage = "Vercel API rejected the deployment"; try { const parsed = (await deployRes.json().catch(() => null)) as { error?: { message?: string }; } | null; const candidate = parsed?.error?.message; if (typeof candidate === "string" && candidate.trim()) { upstreamMessage = candidate.trim().slice(0, 200); } } catch { /* fall through to generic message */ } return createErrorResponse({ status: deployRes.status, message: `Vercel deployment failed: ${upstreamMessage}`, type: "upstream_error", }); } const deployment = (await deployRes.json()) as { id?: string; url?: string; projectId?: string; }; if (!deployment.url) { return createErrorResponse({ status: 502, message: "Vercel returned no deployment URL", type: "upstream_error", }); } // Disable Vercel SSO protection so the relay is publicly accessible if (deployment.projectId) { await fetch(`${VERCEL_API_BASE}/v9/projects/${deployment.projectId}`, { method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ ssoProtection: null }), }).catch(() => {}); } // Poll until READY const deploymentApiUrl = `${VERCEL_API_BASE}/v13/deployments/${deployment.id}`; const readyState = await pollDeployment(deploymentApiUrl, token); if (readyState !== "READY") { return createErrorResponse({ status: 504, message: "Deployment did not reach READY state within 2 minutes. Check your Vercel dashboard.", type: "timeout", }); } // Store as proxy pool entry — token is NOT stored. relayAuth is encrypted // at rest when STORAGE_ENCRYPTION_KEY is configured (encrypt() is a no-op // in passthrough mode); the redactor strips both shapes from API responses. const encryptedRelayAuth = encrypt(relayAuth); const notesPayload = encryptedRelayAuth && encryptedRelayAuth !== relayAuth ? { relayAuthEnc: encryptedRelayAuth } : { relayAuth }; const poolProxy = await createProxy({ name: `Vercel Relay (${projectName})`, type: "vercel", host: deployment.url, port: 443, notes: JSON.stringify(notesPayload), source: "vercel-relay", }); return Response.json({ success: true, relayUrl: `https://${deployment.url}`, poolProxyId: poolProxy?.id, }); } catch (error) { return createErrorResponseFromUnknown(error, "Vercel deploy failed"); } }