diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fe249bca4..c7ef085701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/app/api/settings/proxy/cloudflare-deploy/route.ts b/src/app/api/settings/proxy/cloudflare-deploy/route.ts index 85f3da7b47..7f8f7a1ef6 100644 --- a/src/app/api/settings/proxy/cloudflare-deploy/route.ts +++ b/src/app/api/settings/proxy/cloudflare-deploy/route.ts @@ -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 }, } diff --git a/src/lib/proxyRelay/cloudflareWorkerScript.ts b/src/lib/proxyRelay/cloudflareWorkerScript.ts index 64f062ad17..680254b264 100644 --- a/src/lib/proxyRelay/cloudflareWorkerScript.ts +++ b/src/lib/proxyRelay/cloudflareWorkerScript.ts @@ -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)); +}); `; } diff --git a/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts b/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts index 6109a52452..2a3c717bfe 100644 --- a/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts +++ b/tests/unit/proxy-pool-cloudflare-workers-deployer.test.ts @@ -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)" + ); }); // -------------------------------------------------------------------------- diff --git a/tests/unit/relay-deploy-5128.test.ts b/tests/unit/relay-deploy-5128.test.ts index f745280ac0..31ac95240a 100644 --- a/tests/unit/relay-deploy-5128.test.ts +++ b/tests/unit/relay-deploy-5128.test.ts @@ -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 | 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) + : 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 // --------------------------------------------------------------------------