From 3cb79c2772fee5dbf95327009a728d1d8c0742fe Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 31 Oct 2026 22:52:39 +0530 Subject: [PATCH 1/3] fix(api): allow cross-origin calls to the REST API The "Try it" panel at medialit.cloud/docs/api calls api.medialit.cloud from the browser, but only /media/create and resumable uploads sent CORS headers, so every other endpoint failed its preflight. The REST API authenticates with an API key or bearer token, never a cookie, so allow any origin on /health, /media and /settings/media in one place instead of per route. The cookie-based dashboard API under /api stays same-origin. Co-Authored-By: Claude --- apps/api/src/__tests__/rest-cors.test.ts | 120 +++++++++++++++++++++++ apps/api/src/app.ts | 6 ++ apps/api/src/media/routes.ts | 4 - apps/api/src/tus/routes.ts | 3 +- 4 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/__tests__/rest-cors.test.ts diff --git a/apps/api/src/__tests__/rest-cors.test.ts b/apps/api/src/__tests__/rest-cors.test.ts new file mode 100644 index 00000000..2c495cc7 --- /dev/null +++ b/apps/api/src/__tests__/rest-cors.test.ts @@ -0,0 +1,120 @@ +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import type { AppDb } from "../db/client.js"; + +/* + * The REST API authenticates by header, so browsers on any origin may call + * it, e.g. the "Try it" panel at medialit.cloud/docs/api. The dashboard API + * uses session cookies and must stay same-origin. + */ +let server: Server; +let origin: string; +let client: PGlite; +const docsOrigin = "https://medialit.cloud"; + +before(async () => { + const schema = await import("../db/schema/index.js"); + const { setDb } = await import("../db/client.js"); + client = new PGlite(); + const db = drizzle(client, { schema }); + await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../drizzle", + ), + }); + setDb(db as unknown as AppDb); + + const { createMedialitAuth } = await import("../auth/better-auth.js"); + const { createApp } = await import("../app.js"); + const { apiReadiness } = await import("../lifecycle.js"); + + server = createServer(); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const auth = createMedialitAuth({ + publicApiUrl: origin, + secret: "cors-test-secret-that-is-at-least-32-chars", + webOrigin: "http://127.0.0.1:3000", + }); + server.on( + "request", + createApp({ + auth, + webOrigin: "http://127.0.0.1:3000", + readiness: () => + apiReadiness({ + started: false, + shuttingDown: false, + pingDatabase: async () => undefined, + }), + }), + ); +}); + +after(async () => { + await new Promise((resolve) => server.close(() => resolve())); + await client.close(); +}); + +function preflight(pathname: string, headers: string) { + return fetch(`${origin}${pathname}`, { + method: "OPTIONS", + headers: { + Origin: docsOrigin, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": headers, + }, + }); +} + +test("documented endpoints accept cross-origin calls with an API key", async () => { + for (const pathname of [ + "/media/get/count", + "/media/get/abc", + "/media/signature/create", + "/media/create", + "/settings/media/get", + ]) { + const response = await preflight( + pathname, + "content-type,x-medialit-apikey", + ); + assert.equal(response.status, 204, pathname); + assert.equal( + response.headers.get("access-control-allow-origin"), + "*", + pathname, + ); + assert.match( + response.headers.get("access-control-allow-headers") ?? "", + /x-medialit-apikey/, + pathname, + ); + } + + const response = await fetch(`${origin}/media/get/count`, { + method: "POST", + headers: { Origin: docsOrigin }, + }); + assert.equal(response.status, 401); + assert.equal(response.headers.get("access-control-allow-origin"), "*"); +}); + +test("the cookie-based dashboard API stays same-origin", async () => { + const response = await preflight("/api/apps", "content-type"); + assert.notEqual(response.headers.get("access-control-allow-origin"), "*"); + assert.notEqual( + response.headers.get("access-control-allow-origin"), + docsOrigin, + ); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 2c44b203..fd640a06 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,4 +1,5 @@ import express from "express"; +import cors from "cors"; import rateLimit from "express-rate-limit"; import mediaRoutes from "./media/routes"; import signatureRoutes from "./signature/routes"; @@ -96,6 +97,11 @@ export function createApp(input: { ); app.use(express.urlencoded({ extended: false })); + // The REST API authenticates with an API key or bearer token, never a + // cookie, so any origin may call it: browser uploads, and the "Try it" + // panel in the docs at medialit.cloud/docs/api. + app.use(["/health", "/media", "/settings/media"], cors()); + app.get( "/health", /* diff --git a/apps/api/src/media/routes.ts b/apps/api/src/media/routes.ts index 7890fde8..8958fd9a 100644 --- a/apps/api/src/media/routes.ts +++ b/apps/api/src/media/routes.ts @@ -1,5 +1,4 @@ import express from "express"; -import cors from "cors"; import apikey from "../apikey/middleware"; import { getMedia, @@ -73,7 +72,6 @@ router.post( } #swagger.responses[500] = { description: 'Internal Server Error' } */ - cors(), authenticatedApiLimiter, // Authenticate and check the declared size before reading the body, so // an unauthenticated or oversized upload never reaches the disk. @@ -84,8 +82,6 @@ router.post( uploadMedia, ); -router.options("/create", /* #swagger.ignore = true */ cors()); - router.post( "/get/count", /* diff --git a/apps/api/src/tus/routes.ts b/apps/api/src/tus/routes.ts index 59d3d632..4af27d28 100644 --- a/apps/api/src/tus/routes.ts +++ b/apps/api/src/tus/routes.ts @@ -1,9 +1,8 @@ import express from "express"; -import cors from "cors"; import { server } from "./tus-server"; const router = express.Router(); -router.all("/create/resumable*", cors(), server.handle.bind(server)); +router.all("/create/resumable*", server.handle.bind(server)); export default router; From f237603e9a50a4318aa23f58a1f70344dd0fd01b Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sat, 31 Oct 2026 23:52:27 +0530 Subject: [PATCH 2/3] fix(api): only let the docs call API-key endpoints cross-origin Uploads keep accepting any origin, since browsers upload from customers' own sites with a signature. Every other REST endpoint needs an API key, which belongs on a server, so only https://medialit.cloud (the docs' "Try it" panel) may call those from a browser. Co-Authored-By: Claude --- apps/api/src/__tests__/rest-cors.test.ts | 75 +++++++++++++++++++----- apps/api/src/app.ts | 21 +++++-- 2 files changed, 77 insertions(+), 19 deletions(-) diff --git a/apps/api/src/__tests__/rest-cors.test.ts b/apps/api/src/__tests__/rest-cors.test.ts index 2c495cc7..7ba624b2 100644 --- a/apps/api/src/__tests__/rest-cors.test.ts +++ b/apps/api/src/__tests__/rest-cors.test.ts @@ -10,9 +10,9 @@ import { migrate } from "drizzle-orm/pglite/migrator"; import type { AppDb } from "../db/client.js"; /* - * The REST API authenticates by header, so browsers on any origin may call - * it, e.g. the "Try it" panel at medialit.cloud/docs/api. The dashboard API - * uses session cookies and must stay same-origin. + * Browsers upload from any site with a signature. API-key endpoints only + * accept the "Try it" panel at medialit.cloud/docs/api. The cookie-based + * dashboard API stays same-origin. */ let server: Server; let origin: string; @@ -66,25 +66,31 @@ after(async () => { await client.close(); }); -function preflight(pathname: string, headers: string) { +const customerOrigin = "https://customer.example"; + +function preflight(pathname: string, headers: string, from = docsOrigin) { return fetch(`${origin}${pathname}`, { method: "OPTIONS", headers: { - Origin: docsOrigin, + Origin: from, "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": headers, }, }); } -test("documented endpoints accept cross-origin calls with an API key", async () => { - for (const pathname of [ - "/media/get/count", - "/media/get/abc", - "/media/signature/create", - "/media/create", - "/settings/media/get", - ]) { +const apiKeyEndpoints = [ + "/health", + "/media/get", + "/media/get/count", + "/media/get/abc", + "/media/seal/abc", + "/media/signature/create", + "/settings/media/get", +]; + +test("the docs can call API-key endpoints from the browser", async () => { + for (const pathname of apiKeyEndpoints) { const response = await preflight( pathname, "content-type,x-medialit-apikey", @@ -92,7 +98,7 @@ test("documented endpoints accept cross-origin calls with an API key", async () assert.equal(response.status, 204, pathname); assert.equal( response.headers.get("access-control-allow-origin"), - "*", + docsOrigin, pathname, ); assert.match( @@ -107,7 +113,46 @@ test("documented endpoints accept cross-origin calls with an API key", async () headers: { Origin: docsOrigin }, }); assert.equal(response.status, 401); - assert.equal(response.headers.get("access-control-allow-origin"), "*"); + assert.equal( + response.headers.get("access-control-allow-origin"), + docsOrigin, + ); +}); + +test("other sites can't call API-key endpoints from the browser", async () => { + for (const pathname of apiKeyEndpoints) { + const response = await preflight( + pathname, + "content-type,x-medialit-apikey", + customerOrigin, + ); + const allowed = response.headers.get("access-control-allow-origin"); + assert.ok( + allowed !== "*" && allowed !== customerOrigin, + `${pathname} allowed ${allowed}`, + ); + } +}); + +test("any site can upload with a signature", async () => { + for (const pathname of [ + "/media/create", + "/media/create?group=avatars", + "/media/create/resumable", + "/media/create/resumable/abc", + ]) { + const response = await preflight( + pathname, + "x-medialit-signature,tus-resumable,upload-length", + customerOrigin, + ); + assert.equal(response.status, 204, pathname); + assert.equal( + response.headers.get("access-control-allow-origin"), + "*", + pathname, + ); + } }); test("the cookie-based dashboard API stays same-origin", async () => { diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index fd640a06..f75065b2 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -32,6 +32,9 @@ type ReadinessReport = Awaited>; * Compose the API's HTTP stack. `index.ts` serves it in production and the * platform conformance test serves the same app on an ephemeral port. */ +/** The only site that calls API-key endpoints from a browser ("Try it"). */ +const DOCS_ORIGIN = "https://medialit.cloud"; + export function createApp(input: { auth: MedialitAuth; webOrigin: string; @@ -97,10 +100,20 @@ export function createApp(input: { ); app.use(express.urlencoded({ extended: false })); - // The REST API authenticates with an API key or bearer token, never a - // cookie, so any origin may call it: browser uploads, and the "Try it" - // panel in the docs at medialit.cloud/docs/api. - app.use(["/health", "/media", "/settings/media"], cors()); + // Browsers upload from customers' own sites with a short-lived signature, + // so uploads accept any origin. Every other REST call needs an API key, + // which belongs on a server: only the "Try it" panel in our docs calls + // those from a browser. + app.use( + ["/health", "/media", "/settings/media"], + cors((req, callback) => + callback(null, { + origin: /^\/media\/create(\/|\?|$)/.test(req.originalUrl) + ? "*" + : DOCS_ORIGIN, + }), + ), + ); app.get( "/health", From 93c93cebc836e65629042d887bf60491b787e67d Mon Sep 17 00:00:00 2001 From: Rajat Saxena Date: Sun, 1 Nov 2026 00:02:04 +0530 Subject: [PATCH 3/3] test(api): resumable uploads still expose the media header cross-origin tus clients such as CourseLit read the uploaded media from the media response header, which browsers only expose across origins when the response lists it. Co-Authored-By: Claude --- apps/api/src/__tests__/rest-cors.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/api/src/__tests__/rest-cors.test.ts b/apps/api/src/__tests__/rest-cors.test.ts index 7ba624b2..33b88504 100644 --- a/apps/api/src/__tests__/rest-cors.test.ts +++ b/apps/api/src/__tests__/rest-cors.test.ts @@ -155,6 +155,23 @@ test("any site can upload with a signature", async () => { } }); +test("resumable uploads expose the media header to other sites", async () => { + // tus clients such as CourseLit read the uploaded media from this header. + const response = await fetch(`${origin}/media/create/resumable`, { + method: "POST", + headers: { + Origin: customerOrigin, + "Tus-Resumable": "1.0.0", + "Upload-Length": "1", + }, + }); + assert.equal(response.headers.get("access-control-allow-origin"), "*"); + assert.match( + response.headers.get("access-control-expose-headers") ?? "", + /\bmedia\b/i, + ); +}); + test("the cookie-based dashboard API stays same-origin", async () => { const response = await preflight("/api/apps", "content-type"); assert.notEqual(response.headers.get("access-control-allow-origin"), "*");