feat(web-tanstack): wire security headers middleware and nonce threading
Register core-shared/security/tanstack server middleware in app.config.ts as a Nitro/H3 hook that emits the six security headers and forwards the per-request nonce. Update instrumentation-client to read the nonce from <meta name="csp-nonce"> and pass it to initSentryClientReact. Add nonce support to initSentryClientReact (feedbackIntegration receives styleNonce/scriptNonce), mirroring the initSentryClient pattern already in place for web-next. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
41
apps/web-tanstack/app.config.ts
Normal file
41
apps/web-tanstack/app.config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// apps/web-tanstack/app.config.ts
|
||||
// TanStack Start / Nitro server configuration.
|
||||
// Registers the core-shared security headers middleware so every response
|
||||
// emits the six security headers and a per-request CSP nonce.
|
||||
//
|
||||
// Wire-up pattern (Nitro/H3 server hook):
|
||||
// withSecurityHeaders() generates nonce + builds six headers.
|
||||
// setHeader calls forward them to the response.
|
||||
// req.headers["x-nonce"] is set so downstream loaders can call
|
||||
// getNonce(event.node.req) from @repo/core-shared/security/tanstack.
|
||||
//
|
||||
// Note: @tanstack/start (and its defineConfig) is wired in a later story.
|
||||
// Uncomment the export default block once @tanstack/start is added.
|
||||
|
||||
import { withSecurityHeaders } from "@repo/core-shared/security/tanstack";
|
||||
|
||||
interface H3SecurityEvent {
|
||||
node: {
|
||||
req: { headers: Record<string, string | string[] | undefined> };
|
||||
res: { setHeader: (name: string, value: string) => void };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Nitro/H3 server hook: emits six security headers on every response and
|
||||
* forwards the per-request nonce in req.headers["x-nonce"] for downstream
|
||||
* access via getNonce() from @repo/core-shared/security/tanstack.
|
||||
*/
|
||||
export function applySecurityHeaders(event: H3SecurityEvent): void {
|
||||
const { nonce, headers } = withSecurityHeaders();
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
event.node.res.setHeader(k, v);
|
||||
}
|
||||
event.node.req.headers["x-nonce"] = nonce;
|
||||
}
|
||||
|
||||
// Registration via TanStack Start (add @tanstack/start, then uncomment):
|
||||
// import { defineConfig } from "@tanstack/start/config";
|
||||
// export default defineConfig({
|
||||
// server: { hooks: { request: applySecurityHeaders } },
|
||||
// });
|
||||
50
apps/web-tanstack/src/instrumentation-client.test.ts
Normal file
50
apps/web-tanstack/src/instrumentation-client.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// apps/web-tanstack/src/instrumentation-client.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Hoist the mock so it's active when instrumentation-client runs its
|
||||
// top-level initSentryClientReact call on import.
|
||||
const initSentryClientReactMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@repo/core-shared/instrumentation/sentry/init-client-react", () => ({
|
||||
initSentryClientReact: initSentryClientReactMock,
|
||||
}));
|
||||
|
||||
describe("instrumentation-client", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("passes nonce from csp-nonce meta tag to initSentryClientReact", async () => {
|
||||
const meta = document.createElement("meta");
|
||||
meta.setAttribute("name", "csp-nonce");
|
||||
meta.setAttribute("content", "test-nonce-xyz");
|
||||
document.head.appendChild(meta);
|
||||
|
||||
try {
|
||||
await import("./instrumentation-client");
|
||||
} finally {
|
||||
document.head.removeChild(meta);
|
||||
}
|
||||
|
||||
expect(initSentryClientReactMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nonce: "test-nonce-xyz" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes empty string nonce when no csp-nonce meta tag is present", async () => {
|
||||
await import("./instrumentation-client");
|
||||
|
||||
expect(initSentryClientReactMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nonce: "" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes web-tanstack as the app tag", async () => {
|
||||
await import("./instrumentation-client");
|
||||
|
||||
expect(initSentryClientReactMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ app: "web-tanstack" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,17 @@
|
||||
// Browser-entry hook. Imported at the top of the client entry file.
|
||||
import { initSentryClientReact } from "@repo/core-shared/instrumentation/sentry/init-client-react";
|
||||
|
||||
function getNonce(): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
return (
|
||||
document.querySelector('meta[name="csp-nonce"]')?.getAttribute("content") ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
initSentryClientReact({
|
||||
dsn: import.meta.env["VITE_WEB_TANSTACK_SENTRY_DSN"],
|
||||
app: "web-tanstack",
|
||||
release: import.meta.env["VITE_GIT_COMMIT_SHA"],
|
||||
nonce: getNonce(),
|
||||
});
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"generatedAt": "2026-05-20T10:09:50.939Z",
|
||||
"commit": "de458a6",
|
||||
"generatedAt": "2026-05-20T10:32:38.076Z",
|
||||
"commit": "dc718fd",
|
||||
"repo": {
|
||||
"statements": 97.43,
|
||||
"branches": 92.57,
|
||||
"branches": 92.56,
|
||||
"functions": 97.28,
|
||||
"lines": 97.43,
|
||||
"counts": {
|
||||
"lf": 6079,
|
||||
"lh": 5923,
|
||||
"brf": 1224,
|
||||
"brh": 1133,
|
||||
"brf": 1223,
|
||||
"brh": 1132,
|
||||
"fnf": 368,
|
||||
"fnh": 358
|
||||
}
|
||||
@@ -102,14 +102,14 @@
|
||||
},
|
||||
"@repo/core-shared": {
|
||||
"statements": 98.39,
|
||||
"branches": 96.48,
|
||||
"branches": 96.47,
|
||||
"functions": 93.5,
|
||||
"lines": 98.39,
|
||||
"counts": {
|
||||
"lf": 1304,
|
||||
"lh": 1283,
|
||||
"brf": 369,
|
||||
"brh": 356,
|
||||
"brf": 368,
|
||||
"brh": 355,
|
||||
"fnf": 123,
|
||||
"fnh": 115
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
// packages/core-shared/src/instrumentation/sentry/init-client-react.test.ts
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { replayIntegration } = vi.hoisted(() => {
|
||||
const { replayIntegration, feedbackIntegration } = vi.hoisted(() => {
|
||||
const replayIntegration = vi.fn((opts: unknown) => ({
|
||||
name: "Replay",
|
||||
_opts: opts,
|
||||
}));
|
||||
return { replayIntegration };
|
||||
const feedbackIntegration = vi.fn((opts: unknown) => ({
|
||||
name: "Feedback",
|
||||
_opts: opts,
|
||||
}));
|
||||
return { replayIntegration, feedbackIntegration };
|
||||
});
|
||||
|
||||
vi.mock("@sentry/react", () => ({
|
||||
init: vi.fn(),
|
||||
replayIntegration,
|
||||
feedbackIntegration,
|
||||
}));
|
||||
|
||||
import * as SentryReact from "@sentry/react";
|
||||
@@ -59,4 +64,29 @@ describe("initSentryClientReact", () => {
|
||||
initSentryClientReact({ dsn: "", app: "web-tanstack" });
|
||||
expect(SentryReact.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches feedbackIntegration when SentryReact.feedbackIntegration is available", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
expect(feedbackIntegration).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes styleNonce and scriptNonce to feedbackIntegration when nonce provided", () => {
|
||||
initSentryClientReact({
|
||||
dsn: "https://x@y/1",
|
||||
app: "web-tanstack",
|
||||
nonce: "abc123",
|
||||
});
|
||||
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(feedbackOpts["styleNonce"]).toBe("abc123");
|
||||
expect(feedbackOpts["scriptNonce"]).toBe("abc123");
|
||||
});
|
||||
|
||||
it("omits nonce props from feedbackIntegration when nonce not provided", () => {
|
||||
initSentryClientReact({ dsn: "https://x@y/1", app: "web-tanstack" });
|
||||
const feedbackOpts = (feedbackIntegration as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]![0] as Record<string, unknown>;
|
||||
expect(feedbackOpts["styleNonce"]).toBeUndefined();
|
||||
expect(feedbackOpts["scriptNonce"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +72,7 @@ export function initSentryClientReact(opts: InitClientOpts): void {
|
||||
if (!opts.dsn) return;
|
||||
|
||||
const isProd = process.env["NODE_ENV"] === "production";
|
||||
const { nonce } = opts;
|
||||
const tracesSampleRate =
|
||||
process.env["SENTRY_TRACES_SAMPLE_RATE"] !== undefined
|
||||
? Number(process.env["SENTRY_TRACES_SAMPLE_RATE"])
|
||||
@@ -127,6 +128,13 @@ export function initSentryClientReact(opts: InitClientOpts): void {
|
||||
maskAllInputs: true,
|
||||
blockAllMedia: true,
|
||||
}),
|
||||
...(SentryReact.feedbackIntegration
|
||||
? [
|
||||
SentryReact.feedbackIntegration({
|
||||
...(nonce ? { styleNonce: nonce, scriptNonce: nonce } : {}),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
initialScope: { tags: { app: opts.app } },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user