From a48af7e91ca9061ac1a003253579b1bc6cd170c4 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 20 May 2026 09:56:03 +0000 Subject: [PATCH] feat(core-shared): add TanStack Start security header adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exports withSecurityHeaders() and getNonce() from the ./security/tanstack subpath. withSecurityHeaders() returns all six security headers plus x-nonce for use inside a TanStack/Nitro H3 server middleware; getNonce() reads x-nonce from the node request headers forwarded by that middleware. Mirrors the ./security/next adapter pattern while staying free of any @tanstack/start dependency — the adapter works with plain H3 IncomingMessage types that TanStack Start exposes at wiring time (Story 09). Co-Authored-By: Claude Sonnet 4.6 --- coverage/summary.json | 40 +++++++------- packages/core-shared/package.json | 3 +- .../src/security/tanstack/get-nonce.test.ts | 28 ++++++++++ .../src/security/tanstack/get-nonce.ts | 14 +++++ .../src/security/tanstack/index.ts | 3 ++ .../src/security/tanstack/middleware.test.ts | 52 +++++++++++++++++++ .../src/security/tanstack/middleware.ts | 24 +++++++++ 7 files changed, 143 insertions(+), 21 deletions(-) create mode 100644 packages/core-shared/src/security/tanstack/get-nonce.test.ts create mode 100644 packages/core-shared/src/security/tanstack/get-nonce.ts create mode 100644 packages/core-shared/src/security/tanstack/index.ts create mode 100644 packages/core-shared/src/security/tanstack/middleware.test.ts create mode 100644 packages/core-shared/src/security/tanstack/middleware.ts diff --git a/coverage/summary.json b/coverage/summary.json index 31ebe37..8a0cba9 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,18 +1,18 @@ { - "generatedAt": "2026-05-20T09:45:48.959Z", - "commit": "6575a48", + "generatedAt": "2026-05-20T09:55:49.120Z", + "commit": "6903c59", "repo": { "statements": 97.43, - "branches": 92.51, - "functions": 97.27, + "branches": 92.56, + "functions": 97.28, "lines": 97.43, "counts": { - "lf": 6063, - "lh": 5907, - "brf": 1215, - "brh": 1124, - "fnf": 366, - "fnh": 356 + "lf": 6079, + "lh": 5923, + "brf": 1223, + "brh": 1132, + "fnf": 368, + "fnh": 358 } }, "byPackage": { @@ -101,17 +101,17 @@ } }, "@repo/core-shared": { - "statements": 98.37, - "branches": 96.39, - "functions": 93.39, - "lines": 98.37, + "statements": 98.39, + "branches": 96.47, + "functions": 93.5, + "lines": 98.39, "counts": { - "lf": 1288, - "lh": 1267, - "brf": 360, - "brh": 347, - "fnf": 121, - "fnh": 113 + "lf": 1304, + "lh": 1283, + "brf": 368, + "brh": 355, + "fnf": 123, + "fnh": 115 } }, "@repo/core-ui": { diff --git a/packages/core-shared/package.json b/packages/core-shared/package.json index 0927487..140eca3 100644 --- a/packages/core-shared/package.json +++ b/packages/core-shared/package.json @@ -23,7 +23,8 @@ "./instrumentation/sentry/init-client": "./src/instrumentation/sentry/init-client.ts", "./instrumentation/sentry/init-client-react": "./src/instrumentation/sentry/init-client-react.ts", "./security": "./src/security/index.ts", - "./security/next": "./src/security/next/index.ts" + "./security/next": "./src/security/next/index.ts", + "./security/tanstack": "./src/security/tanstack/index.ts" }, "scripts": { "build": "tsc --noEmit", diff --git a/packages/core-shared/src/security/tanstack/get-nonce.test.ts b/packages/core-shared/src/security/tanstack/get-nonce.test.ts new file mode 100644 index 0000000..074e853 --- /dev/null +++ b/packages/core-shared/src/security/tanstack/get-nonce.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { getNonce } from "@/security/tanstack/get-nonce"; + +describe("getNonce", () => { + it("reads x-nonce from node request headers", () => { + expect(getNonce({ headers: { "x-nonce": "test-nonce-value" } })).toBe( + "test-nonce-value", + ); + }); + + it("returns the first value when x-nonce is an array", () => { + expect( + getNonce({ headers: { "x-nonce": ["first-nonce", "second"] } }), + ).toBe("first-nonce"); + }); + + it("returns empty string when x-nonce is absent", () => { + expect(getNonce({ headers: {} })).toBe(""); + }); + + it("returns empty string when x-nonce is undefined", () => { + expect(getNonce({ headers: { "x-nonce": undefined } })).toBe(""); + }); + + it("returns empty string when x-nonce is an empty array", () => { + expect(getNonce({ headers: { "x-nonce": [] } })).toBe(""); + }); +}); diff --git a/packages/core-shared/src/security/tanstack/get-nonce.ts b/packages/core-shared/src/security/tanstack/get-nonce.ts new file mode 100644 index 0000000..0021ac8 --- /dev/null +++ b/packages/core-shared/src/security/tanstack/get-nonce.ts @@ -0,0 +1,14 @@ +/** Minimal shape of H3's event.node.req used to read x-nonce. */ +interface NodeRequest { + headers: Record; +} + +/** + * Reads the nonce from a TanStack Start / H3 server request. + * The nonce is set on req.headers["x-nonce"] by withSecurityHeaders(). + */ +export function getNonce(req: NodeRequest): string { + const value = req.headers["x-nonce"]; + if (Array.isArray(value)) return value[0] ?? ""; + return value ?? ""; +} diff --git a/packages/core-shared/src/security/tanstack/index.ts b/packages/core-shared/src/security/tanstack/index.ts new file mode 100644 index 0000000..75c7182 --- /dev/null +++ b/packages/core-shared/src/security/tanstack/index.ts @@ -0,0 +1,3 @@ +export { withSecurityHeaders } from "./middleware"; +export type { TanstackSecurityHeadersResult } from "./middleware"; +export { getNonce } from "./get-nonce"; diff --git a/packages/core-shared/src/security/tanstack/middleware.test.ts b/packages/core-shared/src/security/tanstack/middleware.test.ts new file mode 100644 index 0000000..fdf1218 --- /dev/null +++ b/packages/core-shared/src/security/tanstack/middleware.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from "vitest"; +import { withSecurityHeaders } from "@/security/tanstack/middleware"; + +const ALL_SIX_HEADERS = [ + "Strict-Transport-Security", + "X-Frame-Options", + "X-Content-Type-Options", + "Referrer-Policy", + "Permissions-Policy", + "Content-Security-Policy", +] as const; + +describe("withSecurityHeaders", () => { + it("returns all six security headers", () => { + const { headers } = withSecurityHeaders(); + + for (const header of ALL_SIX_HEADERS) { + expect(headers).toHaveProperty(header); + } + }); + + it("returns x-nonce in headers equal to the returned nonce", () => { + const { headers, nonce } = withSecurityHeaders(); + + expect(headers["x-nonce"]).toBe(nonce); + expect(typeof nonce).toBe("string"); + expect(nonce.length).toBeGreaterThan(0); + }); + + it("nonce in x-nonce matches nonce threaded into CSP in production mode", () => { + vi.stubEnv("NODE_ENV", "production"); + + const { headers, nonce } = withSecurityHeaders(); + + expect(headers["Content-Security-Policy"]).toContain(`'nonce-${nonce}'`); + }); + + it("uses dev-mode CSP when NODE_ENV is not production", () => { + vi.stubEnv("NODE_ENV", "test"); + + const { headers } = withSecurityHeaders(); + + expect(headers["Content-Security-Policy"]).toContain("'unsafe-inline'"); + }); + + it("each call produces a unique nonce", () => { + const a = withSecurityHeaders(); + const b = withSecurityHeaders(); + + expect(a.nonce).not.toBe(b.nonce); + }); +}); diff --git a/packages/core-shared/src/security/tanstack/middleware.ts b/packages/core-shared/src/security/tanstack/middleware.ts new file mode 100644 index 0000000..b23878c --- /dev/null +++ b/packages/core-shared/src/security/tanstack/middleware.ts @@ -0,0 +1,24 @@ +import { generateNonce } from "../nonce"; +import { buildSecurityHeaders } from "../build-security-headers"; + +export type TanstackSecurityHeadersResult = { + nonce: string; + headers: Record; +}; + +/** + * Generates a nonce and builds all six security headers plus x-nonce. + * Intended for use inside a TanStack Start / Nitro server middleware: + * const { nonce, headers } = withSecurityHeaders(); + * for (const [k, v] of Object.entries(headers)) setResponseHeader(event, k, v); + * event.node.req.headers["x-nonce"] = nonce; // forward for getNonce() + */ +export function withSecurityHeaders(): TanstackSecurityHeadersResult { + const nonce = generateNonce(); + const mode = process.env.NODE_ENV === "production" ? "prod" : "dev"; + const secHeaders = buildSecurityHeaders({ mode, nonce }); + return { + nonce, + headers: { ...secHeaders, "x-nonce": nonce }, + }; +}