File size: 13,751 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
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");
  }
}