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:
parent
40b53ad530
commit
21e6f4c2d5
6 changed files with 192 additions and 25 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
109
web/backend/test/security.test.ts
Normal file
109
web/backend/test/security.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
10
web/backend/vitest.config.ts
Normal file
10
web/backend/vitest.config.ts
Normal 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
3
web/pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue