harden the reference backend: write bounds + CORS

The @pcbjam/backend-example server had a 1 GiB body limit, no per-owner/per-lib
quotas, bound to 0.0.0.0, and reflected any origin with credentials when
CORS_ORIGIN is *. Bound the write surface (5 MiB body cap, per-owner lib and
per-lib item quotas), bind 127.0.0.1 by default (opt in via HOST), and force
credentials off for a wildcard CORS origin. Refactor main() into an exported
buildApp() and add web/backend/test/security.test.ts (inject-based).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Istvan Matejcsok 2026-07-16 12:46:17 +02:00
commit 21e6f4c2d5
6 changed files with 192 additions and 25 deletions

View file

@ -9,7 +9,8 @@
"ensure-libs": "tsx src/extract/ensure-example-libs.ts",
"extract-libs": "tsx src/extract/extract-libs.ts",
"build": "tsc -p tsconfig.json --noEmit false --declaration false --outDir dist",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@fastify/cors": "^9.0.1",
@ -20,6 +21,7 @@
"devDependencies": {
"@types/node": "^22.10.5",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
"typescript": "^5.7.3",
"vitest": "^3.2.6"
}
}

View file

@ -11,6 +11,7 @@ import { createReadStream } from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import cors from "@fastify/cors";
import Fastify from "fastify";
import { initServer } from "@ts-rest/fastify";
@ -55,8 +56,18 @@ const PROJECT_DIR = path.resolve(
process.env.PROJECT_DIR ?? "./project",
);
const PORT = Number(process.env.PORT ?? 3060);
// Bind to loopback by default: this is an unauthenticated reference backend, so
// it shouldn't be reachable off-box unless an operator opts in via HOST. The
// owner namespace is a client-asserted hint, not an auth boundary — see
// user-libs.ts.
const HOST = process.env.HOST ?? "127.0.0.1";
const CORS_ORIGIN = process.env.CORS_ORIGIN ?? "http://localhost:3048";
// Write bounds: a real s-expr symbol/footprint body is KB. Cap the body parser
// well below the old 1 GiB so a single request can't write unbounded data.
// Overridable.
const MAX_ITEM_BYTES = Number(process.env.MAX_ITEM_BYTES ?? 5 * 1024 * 1024);
const TEXT_EXT = new Set([
".kicad_pcb",
".kicad_sch",
@ -147,21 +158,19 @@ async function project(scope: string): Promise<Project> {
};
}
async function main(): Promise<void> {
const app = Fastify({ logger: true, bodyLimit: 1024 * 1024 * 1024 });
export async function buildApp(): Promise<import("fastify").FastifyInstance> {
const app = Fastify({
logger: process.env.NODE_ENV !== "test",
bodyLimit: MAX_ITEM_BYTES,
});
// CORS: an explicit origin list is always reflected with credentials. A `*`
// opt-in is different — reflect-any-origin WITH credentials is unsafe, so a
// wildcard forces credentials OFF. The invariant is enforced in code, not just
// documented in the config.
const wildcard = CORS_ORIGIN === "*";
await app.register(cors, {
// `true` REFLECTS the request origin (never the literal `*`), so it stays
// valid for the editor's credentialed fetches; allow-credentials is what
// lets the browser accept those responses (cookie-less callers unaffected).
//
// SECURITY INVARIANT: reflected-origin + allow-credentials is safe ONLY
// while this example backend holds no ambient credentials (no cookies, no
// sessions, no auth — which is its whole design; default origin is the
// explicit :3048, `*` is an operator opt-in). If any credentialed auth is
// ever added here, the `*` reflection mode MUST go — allow only explicit
// origin lists.
origin: CORS_ORIGIN === "*" ? true : CORS_ORIGIN.split(","),
credentials: true,
origin: wildcard ? true : CORS_ORIGIN.split(","),
credentials: !wildcard,
});
app.get("/health", async () => ({ ok: true }));
@ -314,11 +323,19 @@ async function main(): Promise<void> {
},
);
await app.listen({ port: PORT, host: "0.0.0.0" });
app.log.info(`serving project "${SLUG}" from ${PROJECT_DIR}`);
return app;
}
main().catch((err) => {
async function main(): Promise<void> {
const app = await buildApp();
await app.listen({ port: PORT, host: HOST });
app.log.info(`serving project "${SLUG}" from ${PROJECT_DIR} on ${HOST}:${PORT}`);
}
// Only self-start when run directly (not when imported by a test).
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
});
}

View file

@ -21,6 +21,12 @@ import { extForKind } from "./libs.js";
export const DEFAULT_OWNER = "default";
// Write quotas: bound per-owner lib count and per-lib item count so an
// unauthenticated caller can't create unlimited libs/items; per-body size is
// capped by the server's bodyLimit (413). Overridable.
const MAX_LIBS_PER_OWNER = Number(process.env.MAX_LIBS_PER_OWNER ?? 100);
const MAX_ITEMS_PER_LIB = Number(process.env.MAX_ITEMS_PER_LIB ?? 1000);
export interface UserLibsConfig {
/** Root for owner-namespaced writable user libs; null ⇒ writes disabled. */
dir: string | null;
@ -82,13 +88,18 @@ async function writeIndex(dir: string, idx: IndexFile): Promise<void> {
export class UserLibError extends Error {
constructor(
public status: 400 | 404 | 409,
public status: 400 | 404 | 409 | 429,
message: string,
) {
super(message);
}
}
/** Count an owner's existing user libs (dirs with a readable index.json). */
async function ownerLibCount(cfg: UserLibsConfig, owner: string): Promise<number> {
return (await listUserLibs(cfg, owner)).length;
}
/** Create a user lib for an owner. Idempotent-ish: 409 if the id already exists. */
export async function createUserLib(
cfg: UserLibsConfig,
@ -101,6 +112,12 @@ export async function createUserLib(
if (await readIndex(dir)) {
throw new UserLibError(409, `library "${id}" already exists`);
}
if ((await ownerLibCount(cfg, owner)) >= MAX_LIBS_PER_OWNER) {
throw new UserLibError(
400,
`library limit reached (max ${MAX_LIBS_PER_OWNER} per owner)`,
);
}
await fs.mkdir(dir, { recursive: true });
const idx: IndexFile = { type: "user", name, description: null, items: [] };
await writeIndex(dir, idx);
@ -185,10 +202,19 @@ export async function writeUserItem(
const idx = await readIndex(dir);
if (!idx) throw new UserLibError(404, `user library "${lib}" not found`);
await fs.writeFile(path.join(dir, `${name}${ext}`), body, "utf8");
const items = idx.items ?? [];
const existing = items.find((i) => i.kind === kind && i.name === name);
// Only NEW items grow the lib — overwrites of an existing item are always
// allowed (the editor re-saves), so being at the cap never blocks edits.
if (!existing && items.length >= MAX_ITEMS_PER_LIB) {
throw new UserLibError(
429,
`item limit reached (max ${MAX_ITEMS_PER_LIB} per library)`,
);
}
await fs.writeFile(path.join(dir, `${name}${ext}`), body, "utf8");
if (!existing) items.push({ kind, name });
idx.items = items;
await writeIndex(dir, idx);

View file

@ -0,0 +1,109 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { FastifyInstance } from "fastify";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
/**
* Reference-backend hardening: write bounds (body size + per-owner/per-lib
* quotas) and CORS. The app is exercised via Fastify's app.inject() no network
* listen. Env is set BEFORE the dynamic import so the module-level config
* (bodyLimit, quotas, CORS) captures the test values.
*/
const USER = "x-pcbjam-user";
async function tmpdir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
describe("reference backend write bounds + CORS", () => {
let app: FastifyInstance;
let projectDir: string;
let userLibsDir: string;
beforeAll(async () => {
projectDir = await tmpdir("pcbjam-proj-");
userLibsDir = await tmpdir("pcbjam-userlibs-");
await fs.writeFile(path.join(projectDir, "board.kicad_pcb"), "(kicad_pcb)");
process.env.PROJECT_DIR = projectDir;
process.env.USER_LIBS_DIR = userLibsDir;
process.env.MAX_ITEM_BYTES = "2048";
process.env.MAX_LIBS_PER_OWNER = "2";
process.env.CORS_ORIGIN = "*";
process.env.NODE_ENV = "test";
vi.resetModules();
const { buildApp } = await import("../src/server.js");
app = await buildApp();
await app.ready();
});
afterAll(async () => {
await app?.close();
await fs.rm(projectDir, { recursive: true, force: true });
await fs.rm(userLibsDir, { recursive: true, force: true });
});
it("rejects an item body over the size cap with 413", async () => {
const oversized = "x".repeat(4096); // > MAX_ITEM_BYTES (2048)
const res = await app.inject({
method: "PUT",
url: "/api/scopes/s/libs/anylib/items/symbol/Foo",
headers: { "content-type": "text/plain", [USER]: "sizer" },
payload: oversized,
});
expect(res.statusCode).toBe(413);
});
it("caps the number of libs an owner can create", async () => {
const mk = (name: string) =>
app.inject({
method: "POST",
url: "/api/scopes/s/libs",
headers: { "content-type": "application/json", [USER]: "quota" },
payload: { name },
});
expect((await mk("lib-a")).statusCode).toBe(201);
expect((await mk("lib-b")).statusCode).toBe(201);
const third = await mk("lib-c"); // exceeds MAX_LIBS_PER_OWNER (2)
expect(third.statusCode).toBe(400);
expect(third.json().message).toMatch(/limit/i);
});
it("a wildcard CORS origin does NOT also allow credentials", async () => {
const res = await app.inject({
method: "GET",
url: "/health",
headers: { origin: "https://other.example" },
});
// reflect-any-origin with allow-credentials is unsafe; the wildcard opt-in
// must force credentials OFF.
expect(res.headers["access-control-allow-credentials"]).not.toBe("true");
});
});
describe("reference backend CORS — explicit origin still allows credentials", () => {
it("a configured origin keeps credentials on", async () => {
const projectDir = await tmpdir("pcbjam-proj2-");
await fs.writeFile(path.join(projectDir, "board.kicad_pcb"), "(kicad_pcb)");
process.env.PROJECT_DIR = projectDir;
process.env.USER_LIBS_DIR = await tmpdir("pcbjam-userlibs2-");
process.env.CORS_ORIGIN = "http://localhost:3048";
process.env.NODE_ENV = "test";
vi.resetModules();
const { buildApp } = await import("../src/server.js");
const app = await buildApp();
await app.ready();
try {
const res = await app.inject({
method: "GET",
url: "/health",
headers: { origin: "http://localhost:3048" },
});
expect(res.headers["access-control-allow-credentials"]).toBe("true");
expect(res.headers["access-control-allow-origin"]).toBe("http://localhost:3048");
} finally {
await app.close();
await fs.rm(projectDir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
// The reference backend is a Fastify app; tests drive it via app.inject() in a
// plain node env (no network listen).
export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"],
},
});

3
web/pnpm-lock.yaml generated
View file

@ -39,6 +39,9 @@ importers:
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^3.2.6
version: 3.2.6(@types/node@22.19.19)(jiti@1.21.7)(tsx@4.22.4)
pcbjam-shared:
dependencies: