From e961d0ece9fd0d9a7fc5c9f8911326aadde2bc08 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Tue, 1 Sep 2026 14:59:14 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20/v1/assets/*=20=E2=80=94=20streaming=20?= =?UTF-8?q?CORS=20proxy=20for=20release=20assets=20(TODO.client-work=2013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsers cannot fetch GH Releases directly (the github.com redirect hop carries no ACAO — found by the E3 bench against the live demo). Releases remain the origin of truth; this is the edge front door. --- src/rest.ts | 25 +++++++++++++++++++++++++ test/rest.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/rest.ts b/src/rest.ts index 945a6b1..90993fe 100644 --- a/src/rest.ts +++ b/src/rest.ts @@ -137,6 +137,31 @@ rest.post("/v1/detect", async (c) => { return c.json({ count: results.length, results }) }) +const RELEASE_BASE = "https://github.com/interscript/interscript-ml/releases/download" + +/** + * CORS-enabled streaming proxy for release assets (TODO.client-work 13): + * browsers cannot fetch GH Releases directly — the github.com redirect + * hop carries no Access-Control-Allow-Origin. Releases stay the origin + * of truth; this is the edge front door on the API's own domain. + */ +rest.get("/v1/assets/*", async (c) => { + const path = c.req.path.replace(/^\/v1\/assets\//, "") + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._.-]*$/.test(path)) { + return errorResponse(400, "bad_request", "path must be {tag}/{file}") + } + const upstream = await fetch(`${RELEASE_BASE}/${path}`).catch(() => null) + if (!upstream || !upstream.ok) { + return errorResponse(404, "asset_not_found", `Couldn't locate ${path}`) + } + const headers = new Headers() + headers.set("access-control-allow-origin", "*") + headers.set("content-type", upstream.headers.get("content-type") ?? "application/octet-stream") + const length = upstream.headers.get("content-length") + if (length) headers.set("content-length", length) + return new Response(upstream.body, { status: upstream.status, headers }) +}) + rest.get("/v1/models", (c) => c.json({ count: listModels().length, models: listModels() })) rest.get("/v1/models/:id", (c) => { diff --git a/test/rest.test.ts b/test/rest.test.ts index 50a8aa0..5afef2d 100644 --- a/test/rest.test.ts +++ b/test/rest.test.ts @@ -222,3 +222,31 @@ describe("POST /v1/infer/batch", () => { expect(many.status).toBe(400) }) }) + +describe("GET /v1/assets/* (CORS release proxy)", () => { + it("streams release assets with permissive CORS and passthrough status", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async (url: string | URL | Request) => { + if (String(url).includes("releases/download")) { + return new Response("asset-bytes", { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }) + } + return originalFetch(url as RequestInfo) + }) as typeof fetch + try { + const res = await get("/v1/assets/index-v2/models-index.yaml") + expect(res.status).toBe(200) + expect(res.headers.get("access-control-allow-origin")).toBe("*") + expect(await res.text()).toBe("asset-bytes") + } finally { + globalThis.fetch = originalFetch + } + }) + + it("rejects encoded traversal within the assets path", async () => { + const res = await get("/v1/assets/index-v2/..%2fsecret.yaml") + expect([400, 404]).toContain(res.status) + }) +})