Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
13.8 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 { 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://<slug>.<org-slug>.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");
}
}