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..33b88504 --- /dev/null +++ b/apps/api/src/__tests__/rest-cors.test.ts @@ -0,0 +1,182 @@ +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"; + +/* + * 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; +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(); +}); + +const customerOrigin = "https://customer.example"; + +function preflight(pathname: string, headers: string, from = docsOrigin) { + return fetch(`${origin}${pathname}`, { + method: "OPTIONS", + headers: { + Origin: from, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": headers, + }, + }); +} + +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", + ); + assert.equal(response.status, 204, pathname); + assert.equal( + response.headers.get("access-control-allow-origin"), + docsOrigin, + 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"), + 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("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"), "*"); + 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..f75065b2 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"; @@ -31,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; @@ -96,6 +100,21 @@ export function createApp(input: { ); app.use(express.urlencoded({ extended: false })); + // 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", /* 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;