Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions apps/api/src/__tests__/rest-cors.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((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,
);
});
19 changes: 19 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -31,6 +32,9 @@ type ReadinessReport = Awaited<ReturnType<typeof apiReadiness>>;
* 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;
Expand Down Expand Up @@ -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<express.Request>((req, callback) =>
callback(null, {
origin: /^\/media\/create(\/|\?|$)/.test(req.originalUrl)
? "*"
: DOCS_ORIGIN,
}),
),
);

app.get(
"/health",
/*
Expand Down
4 changes: 0 additions & 4 deletions apps/api/src/media/routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import express from "express";
import cors from "cors";
import apikey from "../apikey/middleware";
import {
getMedia,
Expand Down Expand Up @@ -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.
Expand All @@ -84,8 +82,6 @@ router.post(
uploadMedia,
);

router.options("/create", /* #swagger.ignore = true */ cors());

router.post(
"/get/count",
/*
Expand Down
3 changes: 1 addition & 2 deletions apps/api/src/tus/routes.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading