Merge branch 'feature-frontend-browser-security-boundary-contract' into develop
This commit is contained in:
@@ -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=()"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -35,6 +35,7 @@ export default [
|
|||||||
"artifacts/**",
|
"artifacts/**",
|
||||||
"tests/fixtures/typecheck/**",
|
"tests/fixtures/typecheck/**",
|
||||||
"tests/fixtures/architecture/forbidden/**",
|
"tests/fixtures/architecture/forbidden/**",
|
||||||
|
"tests/fixtures/security/forbidden/**",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
eslint.configs.recommended,
|
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.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+2
-1
@@ -28,7 +28,8 @@
|
|||||||
"test:all": "corepack pnpm test:runtime-schema && corepack pnpm test:unit && corepack pnpm test:component && corepack pnpm test:integration",
|
"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",
|
"verify:lockfile": "corepack pnpm install --frozen-lockfile",
|
||||||
"generate:supply-chain": "node scripts/generate-supply-chain.mjs",
|
"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": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "5.101.4",
|
"@tanstack/react-query": "5.101.4",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
@@ -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 <span>{typeof value === "string" ? value : String(value ?? "")}</span>;
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<SafeText value={'<img src=x onerror="window.compromised=true"><script>x</script>'} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/<img/)).toBeVisible();
|
||||||
|
expect(document.querySelector("script")).toBeNull();
|
||||||
|
expect(document.querySelector("[onerror]")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects secret-like client configuration names", () => {
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function Fixture({ value }) {
|
||||||
|
return <span>{value}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function attachScript(source) {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = source;
|
||||||
|
document.head.append(script);
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export const execute = (source) => eval(source);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function RawHtml({ value }) {
|
||||||
|
return <div dangerouslySetInnerHTML={{ __html: value }} />;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user