From 6f88915c7ad167b8aa2efa47e47129583acc3043 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 21:14:32 +0900 Subject: [PATCH] feat: enforce browser security boundaries --- config/hosting/security-headers.json | 11 ++++ docs/security/browser-boundary.md | 13 +++++ eslint.config.js | 21 ++++++++ package.json | 3 +- scripts/check-browser-security.mjs | 42 +++++++++++++++ src/presentation/security/safe-text.jsx | 9 ++++ tests/component/browser-security.test.jsx | 53 +++++++++++++++++++ tests/fixtures/security/allowed/safe-text.jsx | 3 ++ .../security/forbidden/dynamic-script.js | 5 ++ tests/fixtures/security/forbidden/eval.js | 1 + .../fixtures/security/forbidden/raw-html.jsx | 3 ++ 11 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 config/hosting/security-headers.json create mode 100644 docs/security/browser-boundary.md create mode 100644 scripts/check-browser-security.mjs create mode 100644 src/presentation/security/safe-text.jsx create mode 100644 tests/component/browser-security.test.jsx create mode 100644 tests/fixtures/security/allowed/safe-text.jsx create mode 100644 tests/fixtures/security/forbidden/dynamic-script.js create mode 100644 tests/fixtures/security/forbidden/eval.js create mode 100644 tests/fixtures/security/forbidden/raw-html.jsx diff --git a/config/hosting/security-headers.json b/config/hosting/security-headers.json new file mode 100644 index 0000000..6b16ea2 --- /dev/null +++ b/config/hosting/security-headers.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "headers": { + "Content-Security-Policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' https:; font-src 'self'; upgrade-insecure-requests", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "X-Frame-Options": "DENY", + "Referrer-Policy": "strict-origin-when-cross-origin", + "X-Content-Type-Options": "nosniff", + "Permissions-Policy": "camera=(), microphone=(), geolocation=()" + } +} diff --git a/docs/security/browser-boundary.md b/docs/security/browser-boundary.md new file mode 100644 index 0000000..02f23dc --- /dev/null +++ b/docs/security/browser-boundary.md @@ -0,0 +1,13 @@ +# Browser security boundary + +The browser bundle is public. Secrets, token lifecycle, raw HTML injection, +dynamic code execution, untrusted script URLs, and public production source +maps are prohibited defaults. + +`config/hosting/security-headers.json` is the declared header set. Hosting +verification compares that declaration with live responses. CSP deliberately +omits `unsafe-inline` and `unsafe-eval`; production code and built assets must +remain compatible with that baseline. + +Route guards are UX hints and client validation does not replace backend +authorization or validation. diff --git a/eslint.config.js b/eslint.config.js index a6f17a3..715beee 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -35,6 +35,7 @@ export default [ "artifacts/**", "tests/fixtures/typecheck/**", "tests/fixtures/architecture/forbidden/**", + "tests/fixtures/security/forbidden/**", ], }, eslint.configs.recommended, @@ -98,4 +99,24 @@ export default [ ]), }, }, + { + files: ["**/*.{js,jsx}"], + rules: { + "no-eval": "error", + "no-new-func": "error", + "no-script-url": "error", + "no-restricted-syntax": [ + "error", + { + selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']", + message: "Raw HTML injection is prohibited by FE-OC-019.", + }, + { + selector: + "CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']", + message: "Runtime script construction is prohibited by FE-OC-019.", + }, + ], + }, + }, ]; diff --git a/package.json b/package.json index 2f56afb..f27c05c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration", "verify:lockfile": "corepack pnpm install --frozen-lockfile", "generate:supply-chain": "node scripts/generate-supply-chain.mjs", - "scan:security": "node scripts/security-scan.mjs" + "scan:security": "node scripts/security-scan.mjs", + "check:browser-security": "node scripts/check-browser-security.mjs" }, "dependencies": { "@tanstack/react-query": "5.101.4", diff --git a/scripts/check-browser-security.mjs b/scripts/check-browser-security.mjs new file mode 100644 index 0000000..034448b --- /dev/null +++ b/scripts/check-browser-security.mjs @@ -0,0 +1,42 @@ +import { readdir } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; + +const pnpmCli = /** @type {string} */ (process.env.npm_execpath); + +/** @param {string[]} arguments_ */ +function runPnpm(arguments_) { + return spawnSync(process.execPath, [pnpmCli, ...arguments_], { + encoding: "utf8", + }); +} + +const allowed = runPnpm([ + "exec", + "eslint", + "tests/fixtures/security/allowed", + "--no-ignore", + "--max-warnings=0", +]); +const forbidden = runPnpm([ + "exec", + "eslint", + "tests/fixtures/security/forbidden", + "--no-ignore", + "--max-warnings=0", +]); + +const distFiles = await readdir("dist", { recursive: true }); +const publicSourceMaps = distFiles.filter((file) => String(file).endsWith(".map")); + +if (allowed.status !== 0 || forbidden.status === 0 || publicSourceMaps.length > 0) { + process.stderr.write(allowed.stderr || allowed.stdout); + process.stderr.write(forbidden.stderr || forbidden.stdout); + if (publicSourceMaps.length > 0) { + process.stderr.write(`Public source maps found: ${publicSourceMaps.join(", ")}\n`); + } + process.exit(1); +} + +process.stdout.write( + "Browser security fixtures: injection rejected, public source maps absent\n", +); diff --git a/src/presentation/security/safe-text.jsx b/src/presentation/security/safe-text.jsx new file mode 100644 index 0000000..78efccd --- /dev/null +++ b/src/presentation/security/safe-text.jsx @@ -0,0 +1,9 @@ +/** + * Untrusted content is rendered as a React text node. HTML interpretation is + * intentionally not offered by this template. + * + * @param {{ value: unknown }} props + */ +export function SafeText({ value }) { + return {typeof value === "string" ? value : String(value ?? "")}; +} diff --git a/tests/component/browser-security.test.jsx b/tests/component/browser-security.test.jsx new file mode 100644 index 0000000..b27d45d --- /dev/null +++ b/tests/component/browser-security.test.jsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { SafeText } from "../../src/presentation/security/safe-text.jsx"; +import { assertSafeConfigNames } from "../../src/contracts/env.js"; +import { defineStorageKey } from "../../src/contracts/storage-keys.js"; +import { projectTelemetryEvent } from "../../src/contracts/telemetry.js"; + +describe("browser security boundary", () => { + it("renders untrusted text without script or inline handler injection", () => { + render( + '} />, + ); + expect(screen.getByText(/ { + expect(() => assertSafeConfigNames({ PRIVATE_KEY: "not-public" })).toThrow(); + }); + + it("rejects browser token storage registration", () => { + expect(() => + defineStorageKey({ + logicalName: "SESSION_TOKEN", + scope: "auth", + name: "session-token", + backend: "sessionStorage", + classification: "sensitive-forbidden", + schemaVersion: 1, + ttl: "session", + migration: "discard", + quotaFallback: "feature-disable", + }), + ).toThrow(); + }); + + it("drops raw URL/query/token telemetry attributes", () => { + const result = projectTelemetryEvent("api.request.failed", { + error_kind: "SERVER_FAILURE", + http_status_group: "5xx", + attempt_count_bucket: "1", + route_id: "APP_HOME", + raw_url: "https://api.test?token=private", + query_string: "token=private", + }); + expect(result.success).toBe(true); + expect(JSON.stringify(result)).not.toMatch(/raw_url|query_string|private/); + }); +}); diff --git a/tests/fixtures/security/allowed/safe-text.jsx b/tests/fixtures/security/allowed/safe-text.jsx new file mode 100644 index 0000000..2689f37 --- /dev/null +++ b/tests/fixtures/security/allowed/safe-text.jsx @@ -0,0 +1,3 @@ +export function Fixture({ value }) { + return {value}; +} diff --git a/tests/fixtures/security/forbidden/dynamic-script.js b/tests/fixtures/security/forbidden/dynamic-script.js new file mode 100644 index 0000000..4209075 --- /dev/null +++ b/tests/fixtures/security/forbidden/dynamic-script.js @@ -0,0 +1,5 @@ +export function attachScript(source) { + const script = document.createElement("script"); + script.src = source; + document.head.append(script); +} diff --git a/tests/fixtures/security/forbidden/eval.js b/tests/fixtures/security/forbidden/eval.js new file mode 100644 index 0000000..d9e2bdc --- /dev/null +++ b/tests/fixtures/security/forbidden/eval.js @@ -0,0 +1 @@ +export const execute = (source) => eval(source); diff --git a/tests/fixtures/security/forbidden/raw-html.jsx b/tests/fixtures/security/forbidden/raw-html.jsx new file mode 100644 index 0000000..701b1d5 --- /dev/null +++ b/tests/fixtures/security/forbidden/raw-html.jsx @@ -0,0 +1,3 @@ +export function RawHtml({ value }) { + return
; +}