diff --git a/apps/api/package.json b/apps/api/package.json index f30d34c2..150f1154 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,6 +9,8 @@ "typecheck": "cd ../.. && tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@fastify/cors": "~8.5.0", + "@fastify/helmet": "~11.1.1", "@vooster/contracts": "workspace:*" } } diff --git a/apps/api/src/http/server.ts b/apps/api/src/http/server.ts index c04e47f9..8f000cd4 100644 --- a/apps/api/src/http/server.ts +++ b/apps/api/src/http/server.ts @@ -1,4 +1,6 @@ import Fastify, { type FastifyInstance } from "fastify"; +import fastifyCors from "@fastify/cors"; +import fastifyHelmet from "@fastify/helmet"; import { healthResponseSchema } from "@vooster/contracts"; import { createMemoryApiKeyStore } from "../infrastructure/memory-api-key-store.js"; import { createMemoryActorStore } from "../infrastructure/memory-actor-store.js"; @@ -63,6 +65,10 @@ import type { UserStore } from "../ports/user-store.js"; export async function createServer(options: ServerOptions): Promise { const serverOptions = withGithubOAuthFromEnv(options); const app = Fastify({ logger: false }); + await app.register(fastifyHelmet); + await app.register(fastifyCors, { + origin: allowedCorsOriginsFromEnv() + }); const state = initialState(); const apiKeyStore = serverOptions.signupStore ?? createMemoryApiKeyStore(); const actorStore = serverOptions.signupStore ?? createMemoryActorStore(); @@ -425,6 +431,14 @@ export async function createServer(options: ServerOptions): Promise origin.trim()) + .filter((origin) => origin.length > 0); + + return origins && origins.length > 0 ? origins : false; +} + function initialState(): SignupState { const state: SignupState = { pendingOAuth: new Map(), diff --git a/apps/api/tests/unit/http/server.test.ts b/apps/api/tests/unit/http/server.test.ts index 2645aaa0..4371739a 100644 --- a/apps/api/tests/unit/http/server.test.ts +++ b/apps/api/tests/unit/http/server.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { createServer } from "../../../src/http/server.js"; import type { StoredUser } from "../../../src/domain/entities/index.js"; import type { SignupStore } from "../../../src/ports/signup-store.js"; describe("server startup", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + test("does not reseed the stub user when it already exists", async () => { const saveUser = vi.fn(); const app = await createServer({ @@ -45,6 +49,72 @@ describe("server startup", () => { }) ).rejects.toThrow("lookup failed"); }); + + test("adds default security headers to health responses", async () => { + const app = await createServer({ authStub: true }); + + try { + const response = await app.inject({ method: "GET", url: "/healthz" }); + + expect(response.headers["x-content-type-options"]).toBe("nosniff"); + expect(response.headers["x-frame-options"]).toBe("SAMEORIGIN"); + } finally { + await app.close(); + } + }); + + test("does not allow cross-origin requests without an allow-list", async () => { + const app = await createServer({ authStub: true }); + + try { + const response = await app.inject({ + method: "OPTIONS", + url: "/healthz", + headers: { + "access-control-request-method": "GET", + origin: "https://evil.example" + } + }); + + expect(response.headers["access-control-allow-origin"]).toBeUndefined(); + } finally { + await app.close(); + } + }); + + test("allows configured CORS origins and trims list entries", async () => { + vi.stubEnv( + "VSPEC_ALLOWED_ORIGINS", + "https://app.example.com, https://admin.example.com" + ); + const app = await createServer({ authStub: true }); + + try { + const allowed = await app.inject({ + method: "OPTIONS", + url: "/healthz", + headers: { + "access-control-request-method": "GET", + origin: "https://admin.example.com" + } + }); + const blocked = await app.inject({ + method: "OPTIONS", + url: "/healthz", + headers: { + "access-control-request-method": "GET", + origin: "https://evil.example" + } + }); + + expect(allowed.headers["access-control-allow-origin"]).toBe( + "https://admin.example.com" + ); + expect(blocked.headers["access-control-allow-origin"]).toBeUndefined(); + } finally { + await app.close(); + } + }); }); function signupStore(overrides: Partial): SignupStore { diff --git a/docs/decisions/ADR-001-fastify-security-headers-cors.md b/docs/decisions/ADR-001-fastify-security-headers-cors.md new file mode 100644 index 00000000..e64a1b78 --- /dev/null +++ b/docs/decisions/ADR-001-fastify-security-headers-cors.md @@ -0,0 +1,29 @@ +# ADR-001 - Fastify Security Headers and CORS Plugins + +Date: 2026-06-15 +Status: ACCEPTED + +## Context + +The API server had no centralized HTTP security header middleware and no explicit +CORS policy. The MVP web app and API are separate deployment surfaces, so the API +needs a boring, framework-supported way to add common response hardening headers +and to avoid reflecting arbitrary browser origins. + +## Decision + +Add `@fastify/helmet` and `@fastify/cors` to `@vooster/api`. + +Register Helmet globally with its defaults. Register CORS globally with origins +read from `VSPEC_ALLOWED_ORIGINS`, parsed as a comma-separated list. When the +environment variable is absent or empty, configure CORS with `origin: false` so +the API does not emit permissive cross-origin headers by default. + +## Consequences + +API responses include common security headers such as `X-Content-Type-Options` +and `X-Frame-Options`. + +Browser cross-origin access must be configured explicitly for deployed frontends. +Local or production environments that need browser calls to the API must set +`VSPEC_ALLOWED_ORIGINS` to the exact allowed origins. diff --git a/docs/decisions/_index.md b/docs/decisions/_index.md index d0b2f6ee..05c0a607 100644 --- a/docs/decisions/_index.md +++ b/docs/decisions/_index.md @@ -37,4 +37,4 @@ What follows from this — both costs and benefits. ## Index -(none yet) +- [ADR-001 - Fastify Security Headers and CORS Plugins](ADR-001-fastify-security-headers-cors.md) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d2064ce..060e47d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,12 @@ importers: apps/api: dependencies: + '@fastify/cors': + specifier: ~8.5.0 + version: 8.5.0 + '@fastify/helmet': + specifier: ~11.1.1 + version: 11.1.1 '@vooster/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -957,12 +963,18 @@ packages: '@fastify/ajv-compiler@3.6.0': resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==} + '@fastify/cors@8.5.0': + resolution: {integrity: sha512-/oZ1QSb02XjP0IK1U0IXktEsw/dUBTxJOW7IpIeO8c/tNalw/KjoNSJv1Sf6eqoBPO+TDGkifq6ynFK3v68HFQ==} + '@fastify/error@3.4.1': resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==} '@fastify/fast-json-stringify-compiler@4.3.0': resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} + '@fastify/helmet@11.1.1': + resolution: {integrity: sha512-pjJxjk6SLEimITWadtYIXt6wBMfFC1I6OQyH/jYVCqSAn36sgAIFjeNiibHtifjCd+e25442pObis3Rjtame6A==} + '@fastify/merge-json-schemas@0.1.1': resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==} @@ -3525,6 +3537,9 @@ packages: fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastify-plugin@4.5.1: + resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} + fastify@4.29.1: resolution: {integrity: sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==} @@ -3740,6 +3755,10 @@ packages: headers-polyfill@5.0.1: resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} + helmet@7.2.0: + resolution: {integrity: sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==} + engines: {node: '>=16.0.0'} + hono@4.12.21: resolution: {integrity: sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==} engines: {node: '>=16.9.0'} @@ -4329,6 +4348,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mnemonist@0.39.6: + resolution: {integrity: sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -4462,6 +4484,9 @@ packages: resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} engines: {node: '>= 10'} + obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -6474,12 +6499,22 @@ snapshots: ajv-formats: 2.1.1(ajv@8.20.0) fast-uri: 2.4.0 + '@fastify/cors@8.5.0': + dependencies: + fastify-plugin: 4.5.1 + mnemonist: 0.39.6 + '@fastify/error@3.4.1': {} '@fastify/fast-json-stringify-compiler@4.3.0': dependencies: fast-json-stringify: 5.16.1 + '@fastify/helmet@11.1.1': + dependencies: + fastify-plugin: 4.5.1 + helmet: 7.2.0 + '@fastify/merge-json-schemas@0.1.1': dependencies: fast-deep-equal: 3.1.3 @@ -9171,6 +9206,8 @@ snapshots: dependencies: fast-string-width: 3.0.2 + fastify-plugin@4.5.1: {} + fastify@4.29.1: dependencies: '@fastify/ajv-compiler': 3.6.0 @@ -9463,6 +9500,8 @@ snapshots: '@types/set-cookie-parser': 2.4.10 set-cookie-parser: 3.1.0 + helmet@7.2.0: {} + hono@4.12.21: {} html-escaper@2.0.2: {} @@ -10120,6 +10159,10 @@ snapshots: minimist@1.2.8: {} + mnemonist@0.39.6: + dependencies: + obliterator: 2.0.5 + mrmime@2.0.1: {} ms@2.1.3: {} @@ -10242,6 +10285,8 @@ snapshots: object-treeify@1.1.33: {} + obliterator@2.0.5: {} + obug@2.1.1: {} ofetch@1.5.1: