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 { denoDeploySchema } from "@/shared/validation/freeProxySchemas"; import { createProxy } from "@/lib/localDb"; import { encrypt } from "@/lib/db/encryption"; const DENO_API_BASE = process.env.DENO_DEPLOY_API_BASE || "https://api.deno.com/v2"; const POLL_INTERVAL_MS = 2000; const POLL_MAX_ATTEMPTS = 30; // ~60 s /** * SSRF-safe resolution of the relay path against an already-validated target. * * The relay worker receives an attacker-controlled `x-relay-path` and must * append it to the target ORIGIN without letting the caller swap the host that * `x-relay-target` was validated for. The pre-#4643-fix worker did * `target.replace(/\/$/, "") + relayPath`, which is bypassable via userinfo * (`/x@evil.com`), a backslash (`\evil.com`), or a protocol-relative path * (`//evil.com/x`). We instead resolve with `new URL(relayPath, targetUrl)` and * re-assert that the resolved host/credentials still match the target. * * Pure (only `URL`, no Node/Deno globals) so the SAME source can be embedded * verbatim into the Deno edge worker AND unit-tested directly in Node. Returns a * tagged result instead of throwing so the worker can map it to an HTTP status. */ export function resolveRelayTarget( target: string, relayPath: string ): { ok: true; url: string } | { ok: false; status: 400 | 403; reason: string } { let targetUrl; try { targetUrl = new URL(target); } catch { return { ok: false, status: 400, reason: "invalid x-relay-target" }; } // Reject the host-confusion vectors up front. A backslash or '@' has no place // in a legitimate path/query, and `new URL` (special-scheme parsing) would // otherwise treat them as authority/userinfo delimiters. if ( typeof relayPath !== "string" || relayPath.indexOf("@") !== -1 || relayPath.indexOf("\\") !== -1 || relayPath.charAt(0) !== "/" ) { return { ok: false, status: 403, reason: "forbidden x-relay-path" }; } let finalUrl; try { finalUrl = new URL(relayPath, targetUrl); } catch { return { ok: false, status: 403, reason: "forbidden x-relay-path" }; } // A protocol-relative path ("//evil.com/x") resolves to a different host; // userinfo would surface as username/password. Either means the path tried to // re-point the request away from the validated target — reject. if ( finalUrl.hostname !== targetUrl.hostname || finalUrl.protocol !== targetUrl.protocol || finalUrl.port !== targetUrl.port || finalUrl.username || finalUrl.password ) { return { ok: false, status: 403, reason: "forbidden x-relay-path (host mismatch)" }; } return { ok: true, url: finalUrl.toString() }; } // Inlined Deno Deploy relay worker. The relayAuth secret is generated // server-side (no user input); the runtime SSRF guard is inlined into the // edge function because Deno Deploy isolates each app and cannot import // Node-side helpers. The path-resolution guard (`resolveRelayTarget`) is the // SAME source used by the server and by the unit tests — embedded here via // Function#toString so the worker enforces byte-for-byte the audited policy. // Mirrors the Vercel-relay guard so a future audit can diff the two. function buildRelayWorker(relayAuth: string): string { return `${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; } Deno.serve(async (request) => { const auth = request.headers.get("x-relay-auth"); if (auth !== "${relayAuth}") return new Response("Unauthorized", { status: 401 }); const target = request.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 = request.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(request.headers); ["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach(h => headers.delete(h)); const init = { method: request.method, headers }; if (request.method !== "GET" && request.method !== "HEAD") { init.body = request.body; init.duplex = "half"; } try { const upstream = await fetch(resolved.url, init); return new Response(upstream.body, { status: upstream.status, headers: upstream.headers }); } catch (error) { return new Response(JSON.stringify({ error: String(error && error.message || error) }), { status: 502, headers: { "content-type": "application/json" }, }); } });`; } /** * Test-only hook exposing the generated worker source so the SSRF regression * test can assert the worker no longer string-concatenates the relay path and * embeds the shared `resolveRelayTarget` guard. Not part of the route contract. */ export const __buildRelayWorkerForTest = buildRelayWorker; async function pollRevision( revisionId: string, token: string ): Promise<"succeeded" | "failed" | "timeout"> { for (let i = 0; i < POLL_MAX_ATTEMPTS; i++) { await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); try { const res = await fetch(`${DENO_API_BASE}/revisions/${revisionId}`, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) continue; const data = (await res.json()) as { status?: string }; const status = data.status; if (status === "succeeded") return "succeeded"; if (status === "failed" || status === "errored") return "failed"; // "queued" / "building" — keep polling. } catch { /* network blip; keep polling */ } } return "timeout"; } 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(denoDeploySchema, rawBody); if (isValidationFailure(validation)) { return createErrorResponse({ status: 400, message: validation.error.message, type: "invalid_request", }); } const { denoToken, orgDomain, projectName } = validation.data; const relayAuth = randomBytes(24).toString("hex"); const relayCode = buildRelayWorker(relayAuth); const headers = { Authorization: `Bearer ${denoToken}`, "Content-Type": "application/json", }; try { // 1. Create app slot. const createRes = await fetch(`${DENO_API_BASE}/apps`, { method: "POST", headers, body: JSON.stringify({ slug: projectName, labels: { "custom.kind": "omniroute-relay" }, config: { install: "deno install", runtime: { type: "dynamic", entrypoint: "main.ts" }, }, }), }); if (!createRes.ok) { let upstreamMessage = "Deno Deploy API rejected the create-app call"; try { const parsed = (await createRes.json().catch(() => null)) as { error?: { message?: string } | string; message?: string; } | null; const candidate = (typeof parsed?.error === "object" && parsed?.error?.message) || (typeof parsed?.error === "string" && parsed?.error) || parsed?.message; if (typeof candidate === "string" && candidate.trim()) { upstreamMessage = candidate.trim().slice(0, 200); } } catch { /* fall through */ } if (createRes.status === 409) { return createErrorResponse({ status: 409, message: `Deno Deploy app "${projectName}" already exists. Choose a different name.`, type: "conflict", }); } return createErrorResponse({ status: createRes.status, message: `Deno Deploy create-app failed: ${upstreamMessage}`, type: "upstream_error", }); } const app = (await createRes.json()) as { id?: string }; if (!app.id) { return createErrorResponse({ status: 502, message: "Deno Deploy returned no app id", type: "upstream_error", }); } // 2. Push the relay code as a single-file deployment. const deployRes = await fetch(`${DENO_API_BASE}/apps/${app.id}/deploy`, { method: "POST", headers, body: JSON.stringify({ assets: { "main.ts": { kind: "file", content: relayCode, encoding: "utf-8", }, }, }), }); if (!deployRes.ok) { // Best-effort cleanup so a failed deploy does not leave an empty app // behind. Mirrors the upstream PR-1437 behaviour. await fetch(`${DENO_API_BASE}/apps/${app.id}`, { method: "DELETE", headers: { Authorization: `Bearer ${denoToken}` }, }).catch(() => {}); let upstreamMessage = "Deno Deploy rejected the deployment"; try { const parsed = (await deployRes.json().catch(() => null)) as { error?: { message?: string } | string; } | null; const candidate = (typeof parsed?.error === "object" && parsed?.error?.message) || (typeof parsed?.error === "string" && parsed?.error); if (typeof candidate === "string" && candidate.trim()) { upstreamMessage = candidate.trim().slice(0, 200); } } catch { /* fall through */ } return createErrorResponse({ status: deployRes.status, message: `Deno Deploy failed: ${upstreamMessage}`, type: "upstream_error", }); } const revision = (await deployRes.json()) as { id?: string; status?: string }; const revisionId = revision.id; let finalStatus: "succeeded" | "failed" | "timeout" = revision.status === "succeeded" ? "succeeded" : revision.status === "failed" || revision.status === "errored" ? "failed" : "timeout"; if (revisionId && finalStatus !== "succeeded" && finalStatus !== "failed") { finalStatus = await pollRevision(revisionId, denoToken); } if (finalStatus !== "succeeded") { await fetch(`${DENO_API_BASE}/apps/${app.id}`, { method: "DELETE", headers: { Authorization: `Bearer ${denoToken}` }, }).catch(() => {}); return createErrorResponse({ status: finalStatus === "timeout" ? 504 : 502, message: finalStatus === "timeout" ? "Deno Deploy did not reach 'succeeded' state within 60 seconds. Check your Deno Deploy dashboard." : "Deno Deploy revision failed.", type: finalStatus === "timeout" ? "timeout" : "upstream_error", }); } // Deno apps are reachable at `https://..deno.net`. // We accept the user's full org domain ("acme.deno.net") and derive the // org slug from it (split on first dot). Tolerates trailing dots / paths // by trimming first. const cleanOrg = orgDomain.replace(/^https?:\/\//i, "").replace(/\/+$/, ""); const orgSlug = cleanOrg.split(".")[0]; const deployHost = `${projectName}.${orgSlug}.deno.net`; // Store as proxy registry entry — token is NOT stored. relayAuth is // encrypted at rest when STORAGE_ENCRYPTION_KEY is configured. const encryptedRelayAuth = encrypt(relayAuth); const notesPayload = encryptedRelayAuth && encryptedRelayAuth !== relayAuth ? { relayAuthEnc: encryptedRelayAuth } : { relayAuth }; const poolProxy = await createProxy({ name: `Deno Relay (${projectName})`, type: "deno", host: deployHost, port: 443, notes: JSON.stringify(notesPayload), source: "deno-relay", }); return Response.json({ success: true, relayUrl: `https://${deployHost}`, poolProxyId: poolProxy?.id, }); } catch (error) { return createErrorResponseFromUnknown(error, "Deno Deploy failed"); } }