mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* 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:
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user