Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
9.53 kB
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");
}
}