feat(core-shared): add TanStack Start security header adapter
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-05-20T09:45:48.959Z",
|
"generatedAt": "2026-05-20T09:55:49.120Z",
|
||||||
"commit": "6575a48",
|
"commit": "6903c59",
|
||||||
"repo": {
|
"repo": {
|
||||||
"statements": 97.43,
|
"statements": 97.43,
|
||||||
"branches": 92.51,
|
"branches": 92.56,
|
||||||
"functions": 97.27,
|
"functions": 97.28,
|
||||||
"lines": 97.43,
|
"lines": 97.43,
|
||||||
"counts": {
|
"counts": {
|
||||||
"lf": 6063,
|
"lf": 6079,
|
||||||
"lh": 5907,
|
"lh": 5923,
|
||||||
"brf": 1215,
|
"brf": 1223,
|
||||||
"brh": 1124,
|
"brh": 1132,
|
||||||
"fnf": 366,
|
"fnf": 368,
|
||||||
"fnh": 356
|
"fnh": 358
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"byPackage": {
|
"byPackage": {
|
||||||
@@ -101,17 +101,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@repo/core-shared": {
|
"@repo/core-shared": {
|
||||||
"statements": 98.37,
|
"statements": 98.39,
|
||||||
"branches": 96.39,
|
"branches": 96.47,
|
||||||
"functions": 93.39,
|
"functions": 93.5,
|
||||||
"lines": 98.37,
|
"lines": 98.39,
|
||||||
"counts": {
|
"counts": {
|
||||||
"lf": 1288,
|
"lf": 1304,
|
||||||
"lh": 1267,
|
"lh": 1283,
|
||||||
"brf": 360,
|
"brf": 368,
|
||||||
"brh": 347,
|
"brh": 355,
|
||||||
"fnf": 121,
|
"fnf": 123,
|
||||||
"fnh": 113
|
"fnh": 115
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@repo/core-ui": {
|
"@repo/core-ui": {
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
"./instrumentation/sentry/init-client": "./src/instrumentation/sentry/init-client.ts",
|
"./instrumentation/sentry/init-client": "./src/instrumentation/sentry/init-client.ts",
|
||||||
"./instrumentation/sentry/init-client-react": "./src/instrumentation/sentry/init-client-react.ts",
|
"./instrumentation/sentry/init-client-react": "./src/instrumentation/sentry/init-client-react.ts",
|
||||||
"./security": "./src/security/index.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": {
|
"scripts": {
|
||||||
"build": "tsc --noEmit",
|
"build": "tsc --noEmit",
|
||||||
|
|||||||
28
packages/core-shared/src/security/tanstack/get-nonce.test.ts
Normal file
28
packages/core-shared/src/security/tanstack/get-nonce.test.ts
Normal file
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
14
packages/core-shared/src/security/tanstack/get-nonce.ts
Normal file
14
packages/core-shared/src/security/tanstack/get-nonce.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/** Minimal shape of H3's event.node.req used to read x-nonce. */
|
||||||
|
interface NodeRequest {
|
||||||
|
headers: Record<string, string | string[] | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ?? "";
|
||||||
|
}
|
||||||
3
packages/core-shared/src/security/tanstack/index.ts
Normal file
3
packages/core-shared/src/security/tanstack/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { withSecurityHeaders } from "./middleware";
|
||||||
|
export type { TanstackSecurityHeadersResult } from "./middleware";
|
||||||
|
export { getNonce } from "./get-nonce";
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
packages/core-shared/src/security/tanstack/middleware.ts
Normal file
24
packages/core-shared/src/security/tanstack/middleware.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { generateNonce } from "../nonce";
|
||||||
|
import { buildSecurityHeaders } from "../build-security-headers";
|
||||||
|
|
||||||
|
export type TanstackSecurityHeadersResult = {
|
||||||
|
nonce: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user