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/**",
|
||||
"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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
+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",
|
||||
"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",
|
||||
|
||||
@@ -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