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 ;
+}