fix(cloudflare-relay): use Service Worker syntax with body_part metadata (#6416) (#6496)

* fix(providers): Cloudflare relay Worker uses Service Worker syntax + body_part

CONTEXT: #6416/#6618 fixed the multipart Content-Type but the emitted
worker source still used ES-module syntax (`export default { fetch }`)
with `main_module` metadata. Cloudflare's Workers upload API parses a
plain `application/javascript` script part as Service Worker syntax
regardless of `main_module`, and `main_module` requires the script to
actually be an ES module — so the upload was still rejected.

CHANGE: buildCloudflareWorkerScript() now emits Service Worker syntax
(`addEventListener("fetch", ...)`, no top-level `export`) and the
upload metadata uses `body_part` instead of `main_module`.

Also restores the SSRF-guard bracket-stripping regex for bracketed
IPv6 hosts (`[::1]`, `[fd00::1]`) that an earlier revision of this
change accidentally double-escaped, with regression coverage added to
tests/unit/relay-deploy-5128.test.ts. Updates the sibling
tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts assertion
that still expected the old ES-module contract.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(changelog): restore CHANGELOG bullets eaten by release sync

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(changelog): re-restore CHANGELOG bullet after further release sync

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(changelog): re-restore CHANGELOG bullet after further release sync

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(changelog): re-restore CHANGELOG bullet after further release sync

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(changelog): correct CHANGELOG restoration (previous attempt had a script-path bug)

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: SeaXen <SeaXen@users.noreply.github.com>
This commit is contained in:
SeaXen
2026-07-10 03:43:19 +06:00
committed by GitHub
parent 6557f44bd2
commit 889fffddbe
5 changed files with 166 additions and 62 deletions

View File

@@ -55,6 +55,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
- **fix(cli):** compression CLI REST fallback now reads/writes the canonical `defaultMode` field (surfaced as `strategy`) instead of a nonexistent `engine` key, and table output renders nested objects as JSON instead of `[object Object]` (#6571 — thanks @charleszolot)
- **fix(providers):** web-cookie providers without a `providerRegistry.ts` entry (`lmarena`, `gemini-business`, `poe-web`, `venice-web`, `v0-vercel-web`) now report `unsupported: true` instead of silently "OK" ([#6309](https://github.com/diegosouzapw/OmniRoute/pull/6309)) — `validateWebCookieProvider()` (`src/lib/providers/validation.ts`) previously required a registry entry and returned "Provider not found in registry" for these; a fallback to `WEB_COOKIE_PROVIDERS[provider].website` was proposed, but live verification showed the `${website}/models` probe does not reliably signal session validity for these providers (redirects/SPA 200s regardless of cookie validity — e.g. lmarena's real API is `arena.ai`, not `lmarena.ai`; Poe's real endpoint is a GraphQL POST, not a REST `/models`), so it would report an expired or garbage cookie as valid. Until each provider has a verified, side-effect-free auth probe against its real API host, the fallback now returns `unsupported` (no network call) instead of a false positive. Regression guard: `tests/unit/web-cookie-validation-fallback.test.ts`. (thanks @oyi77)
- **fix(api):** `POST /api/middleware/hooks` and `PUT /api/middleware/hooks/[name]` no longer leak raw internal error messages in their 500 responses (#6645 — thanks @chirag127) — both catch blocks returned `error?.message` directly (Hard Rule #12), which could surface internal SQLite path fragments on a DB failure; both now route through `sanitizeErrorMessage()` from `open-sse/utils/error.ts`. Regression guard: `tests/unit/middleware-hooks-error-sanitization.test.ts`.
- **fix(providers):** the Cloudflare relay Worker deploy fix in #6416/#6618 still failed uploads in practice — it changed the multipart Content-Type but kept the emitted worker source as an ES module (`export default { fetch(...) }`) with `main_module` metadata; Cloudflare's Workers upload API parses a plain `application/javascript` script part as Service Worker syntax regardless of the `main_module` metadata field, and `main_module` requires the script to actually be an ES module (top-level `export`), so the mismatch still rejected the upload ([#6496](https://github.com/diegosouzapw/OmniRoute/pull/6496)). `buildCloudflareWorkerScript()` (`src/lib/proxyRelay/cloudflareWorkerScript.ts`) now emits Service Worker syntax (`addEventListener("fetch", ...)`, no top-level `export`) and the upload metadata uses `body_part` instead of `main_module`. Regression guard: `tests/unit/relay-deploy-5128.test.ts` (asserts the emitted script has no `export default`, registers a `fetch` listener, and the upload metadata carries `body_part`/omits `main_module`; also proves the inlined `isPrivateHostname()` SSRF guard still rejects bracketed IPv6 loopback/ULA hosts like `[::1]`/`[fd00::1]` after the script-body rewrite). (thanks @SeaXen)
- **fix(providers):** ChatGPT Web (`chatgpt-web`) responses rendered raw ChatGPT UI citation markup — private-use marker tokens (e.g. `citeturn0search0`) and `url…` inline-link markers — instead of real Markdown links, since these only ever get resolved client-side by chatgpt.com's own JS using `message.metadata.content_references` ([#6635](https://github.com/diegosouzapw/OmniRoute/pull/6635)) — `cleanChatGptText()` now resolves `content_references` (grouped webpages, footnote sources, inline `webpage`/`url` mentions) into `[label](url)` Markdown links for both the streaming and non-streaming response builders, and for the GPT-5.5 Pro `stream_handoff` polled-answer path, falling back to stripping any marker that has no resolvable source instead of leaking the raw private-use bytes. The citation parsing/rendering logic was extracted into a new pure sibling module (`open-sse/executors/chatgpt-web/citations.ts`) to keep the executor under the frozen file-size cap. Regression guard: `tests/unit/chatgpt-web-citations.test.ts` (non-streaming citation resolution, streaming marker buffering across split SSE chunks, and the Pro-handoff polled-answer path). (thanks @Thinkscape)
### 📝 Maintenance

View File

@@ -16,7 +16,8 @@ import {
// guard work unchanged. Only the deployment surface differs (Cloudflare Workers
// API instead of Vercel /v13/deployments).
const CLOUDFLARE_API_BASE = process.env.CLOUDFLARE_API_BASE || "https://api.cloudflare.com/client/v4";
const CLOUDFLARE_API_BASE =
process.env.CLOUDFLARE_API_BASE || "https://api.cloudflare.com/client/v4";
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
@@ -52,7 +53,7 @@ export async function POST(request: Request) {
try {
// 1. PUT the Worker script — Cloudflare requires multipart/form-data with
// main_module + a metadata blob describing the upload.
// body_part + a metadata blob describing the upload.
//
// Built as a raw Buffer with an explicit boundary rather than a native
// `FormData` (#6416): in production `globalThis.fetch` is patched with
@@ -63,14 +64,19 @@ export async function POST(request: Request) {
// with `Content-Type: text/plain;charset=UTF-8`, which Cloudflare
// rejects with "Content-Type must be one of: application/javascript,
// text/javascript, multipart/form-data" — the same class of bug fixed
// for image edits in #3273. ES-module semantics come from `main_module`
// in the metadata part below, not the script part's Content-Type
// (Cloudflare rejects "application/javascript+module" outright, #5128).
// for image edits in #3273.
//
// The script part itself must stay `application/javascript` (Cloudflare
// rejects `application/javascript+module`, #5128), but with that MIME the
// uploaded body is parsed as a Service Worker, not an ES module. So the
// metadata must point at the script via `body_part`, not `main_module` —
// otherwise Cloudflare rejects the body with `Unexpected token 'export'`
// when it sees module syntax in a non-module upload (#6496 / #6416).
const workerScriptUrl = `${CLOUDFLARE_API_BASE}/accounts/${accountId}/workers/scripts/${projectName}`;
const { headers: uploadHeaders, body: uploadBody } = buildCloudflareWorkerUploadRequest(
workerScript,
{
main_module: "index.js",
body_part: "index.js",
compatibility_date: "2026-03-20",
observability: { enabled: true },
}

View File

@@ -13,7 +13,12 @@
* - Strips Host + relay control headers before forwarding upstream.
*
* The string template is fed to Cloudflare's PUT /accounts/{id}/workers/scripts/{name}
* API with main_module=index.js (ESM Workers Modules format).
* API as a Service Worker (no ES module export). Cloudflare's multipart upload
* API rejects `application/javascript+module` (#5128C) and treats a plain
* `application/javascript` script part as a Service Worker regardless of any
* `main_module` metadata — `main_module` requires the script to be an actual
* ES module (top-level `export`), which Service Worker syntax is not. The
* `body_part` metadata field is the correct way to point at a non-ESM script.
*
* The OmniRoute variant intentionally diverges from the upstream PR:
* - The upstream worker had NO auth check, leaving the deployed workers.dev URL
@@ -105,51 +110,53 @@ function isPrivateHostname(h) {
return false;
}
export default {
async fetch(request, env, ctx) {
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 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(target.replace(/\\/$/, "") + relayPath, init);
return new Response(upstream.body, {
status: upstream.status,
headers: upstream.headers,
});
} catch (error) {
return new Response(JSON.stringify({ error: error && error.message ? error.message : "relay error" }), {
status: 502,
headers: { "content-type": "application/json" },
});
}
},
};
async function handleRelay(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 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(target.replace(/\\\\/$/, "") + relayPath, init);
return new Response(upstream.body, {
status: upstream.status,
headers: upstream.headers,
});
} catch (error) {
return new Response(JSON.stringify({ error: error && error.message ? error.message : "relay error" }), {
status: 502,
headers: { "content-type": "application/json" },
});
}
}
addEventListener("fetch", (event) => {
event.respondWith(handleRelay(event.request));
});
`;
}

View File

@@ -87,13 +87,22 @@ test("buildCloudflareWorkerScript blocks loopback / RFC1918 / link-local hosts (
assert.ok(/169\.254|link-local|fe80/.test(src), "blocks link-local hosts");
});
test("buildCloudflareWorkerScript uses ESM default-export fetch handler (Workers Modules format)", () => {
// Cloudflare's PUT /workers/scripts API expects a module-format worker
// (main_module = index.js, content-type application/javascript+module).
// The handler must be exposed as `export default { fetch }`.
test("buildCloudflareWorkerScript uses Service Worker syntax, not an ES module (#6416/#6496)", () => {
// Cloudflare's PUT /workers/scripts API parses a plain `application/javascript`
// script part as Service Worker syntax regardless of any `main_module`
// metadata — `main_module` requires the script to actually be an ES module
// (top-level `export`), which rejects the upload with "Unexpected token
// 'export'" (#6496). The handler must instead register a `fetch` event
// listener (`addEventListener("fetch", ...)`), with no top-level `export`.
const src = buildCloudflareWorkerScript("tok");
assert.ok(/export\s+default/.test(src), "must be an ES module (export default)");
assert.ok(/fetch\s*\(/.test(src), "must export a fetch handler");
assert.ok(
!/^\s*export\s+default/m.test(src),
"must not be an ES module (no top-level `export default`)"
);
assert.ok(
/addEventListener\(\s*["']fetch["']/.test(src),
"must register a fetch event listener (Service Worker syntax)"
);
});
// --------------------------------------------------------------------------

View File

@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import vm from "node:vm";
// Regression tests for #5128 — one-click relay deployments (Deno + Cloudflare +
// Vercel) broken in v3.8.37. Four distinct, independently-reproducible bugs:
@@ -95,9 +96,7 @@ test("#5128C: Cloudflare worker upload sends an accepted script Content-Type", a
const bodyText = Buffer.isBuffer(init.body)
? (init.body as Buffer).toString("utf8")
: String(init.body);
const match = bodyText.match(
/name="index\.js"[^]*?Content-Type: ([^\r\n]+)/
);
const match = bodyText.match(/name="index\.js"[^]*?Content-Type: ([^\r\n]+)/);
scriptPartContentType = match?.[1];
// Simulate the CF API rejecting the upload so the route short-circuits
// without making the follow-up subdomain calls.
@@ -138,6 +137,88 @@ test("#5128C: Cloudflare worker upload sends an accepted script Content-Type", a
);
});
// --------------------------------------------------------------------------
// E) Cloudflare worker script uses Service Worker syntax with body_part (#6416)
// --------------------------------------------------------------------------
test("#6416: Cloudflare worker script body is Service Worker syntax (no top-level export) + metadata uses body_part", async () => {
const realFetch = globalThis.fetch;
let capturedScriptBody = "";
let capturedMetadata: Record<string, unknown> | undefined;
globalThis.fetch = (async (input: unknown, init: RequestInit = {}) => {
const url = String(input);
if (init.method === "PUT" && url.includes("/workers/scripts/") && !url.includes("/subdomain")) {
const bodyText = Buffer.isBuffer(init.body)
? (init.body as Buffer).toString("utf8")
: String(init.body);
const scriptMatch = bodyText.match(
/name="index\.js"[^]*?Content-Type: [^\r\n]+\r\n\r\n([^]*?)\r\n--/
);
const metadataMatch = bodyText.match(
/name="metadata"[^]*?Content-Type: application\/json\r\n\r\n([^]*?)\r\n--/
);
capturedScriptBody = scriptMatch?.[1] ?? "";
capturedMetadata = metadataMatch?.[1]
? (JSON.parse(metadataMatch[1]) as Record<string, unknown>)
: undefined;
return Response.json({ errors: [{ message: "stubbed" }] }, { status: 400 });
}
return Response.json({ result: {} });
}) as unknown as typeof globalThis.fetch;
try {
const route = await import("../../src/app/api/settings/proxy/cloudflare-deploy/route.ts");
await route.POST(
new Request("http://localhost/api/settings/proxy/cloudflare-deploy", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
accountId: "abcdef0123456789",
apiToken: "cf-token-aaaaaaaaaaaaaaaaaaaaaa",
projectName: "omniroute-relay",
}),
})
);
} finally {
globalThis.fetch = realFetch;
}
// The Cloudflare multipart upload API parses `application/javascript` script
// parts as Service Workers, so the body must NOT use ES-module syntax
// (`export default {...}`). It must register a fetch event listener instead.
assert.ok(
!/^\s*export\s+default/m.test(capturedScriptBody),
"Cloudflare worker script must not use `export default` (#6416 — CF parses non-`+module` MIME types as Service Workers)"
);
assert.ok(
/addEventListener\(\s*["']fetch["']/.test(capturedScriptBody),
"Cloudflare worker script must register a fetch event listener"
);
const privateHostnameFnSource = capturedScriptBody.match(
/function isPrivateHostname\(h\) \{[\s\S]*?\n\}/
)?.[0];
assert.ok(privateHostnameFnSource, "emitted worker script should contain isPrivateHostname");
const isPrivateHostname = vm.runInNewContext(
`${privateHostnameFnSource}; isPrivateHostname;`,
{}
) as (host: string) => boolean;
assert.equal(isPrivateHostname("[::1]"), true, "bracketed IPv6 loopback must stay blocked");
assert.equal(isPrivateHostname("[fd00::1]"), true, "bracketed IPv6 ULA must stay blocked");
// Metadata must use `body_part` (Service Worker entry) rather than
// `main_module` (which requires an actual ES module).
assert.equal(
capturedMetadata?.body_part,
"index.js",
"metadata.body_part must point at the script part"
);
assert.equal(
capturedMetadata?.main_module,
undefined,
"metadata must not use main_module — that requires an ES module script body (#6416)"
);
});
// --------------------------------------------------------------------------
// D) proxy-registry schema accepts deno/cloudflare relay types + sources
// --------------------------------------------------------------------------