From cfe21e03ffee258c71bdc3d0c0fe21ffd10ddd6a Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:03:29 +0200 Subject: [PATCH 1/5] =?UTF-8?q?fix(deploy):=20allow=20microphone=20in=20Pe?= =?UTF-8?q?rmissions-Policy=20=E2=80=94=20voice=20was=20dead=20on=20CF=20P?= =?UTF-8?q?ages=20+=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permissions-Policy: microphone=() is the empty allowlist — it disallows the microphone for every origin, including same-origin. Voice (hooks/useMicLevel.ts, hooks/useSpeechRecognition.ts) calls getUserMedia/SpeechRecognition from same-origin code, so this silently killed Whisper STT, push-to-talk, and the mic-level meter on Cloudflare Pages and the Docker/nginx image. Fixed to microphone=(self) in both public/_headers and nginx.conf, with a regression test (tests/unit/deploymentHeaders.test.ts) that locks the value and asserts parity between the two hosts. Documented the full per-host header matrix (including GitHub Pages' inability to set any response header at all) in docs/DEPLOYMENT.md. --- docs/DEPLOYMENT.md | 15 +++++++ nginx.conf | 5 ++- public/_headers | 5 ++- tests/unit/deploymentHeaders.test.ts | 61 ++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 tests/unit/deploymentHeaders.test.ts diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 218abbaa8..5387e1151 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -113,6 +113,21 @@ pnpm run build:edge && pnpm exec vite preview --base / --- +## Header invariants per host + +HTTP response headers are **not** portable across targets — each host has its own config file, and one target can't set headers at all. Voice (`hooks/useMicLevel.ts`, `hooks/useSpeechRecognition.ts`) depends on `Permissions-Policy: microphone=(self)`; an empty `microphone=()` allowlist silently breaks it even for same-origin calls. + +| Host | Config file | Permissions-Policy | Content-Security-Policy | +|------|-------------|---------------------|--------------------------| +| **GitHub Pages** (canonical upstream) | *(none — platform has no header-injection mechanism)* | ❌ not settable at all (no meta-tag equivalent exists) | ⚠️ meta tag in [`index.html`](../index.html) only — this is the **sole** enforcement point on this host | +| **Vercel** | [`vercel.json`](../vercel.json) `headers[]` | not set | header set, mirrors the `index.html` meta CSP | +| **Cloudflare Pages** | [`public/_headers`](../public/_headers) | `microphone=(self)` | header set, mirrors the `index.html` meta CSP | +| **Docker / nginx** (`.github/workflows/docker.yml` image) | [`nginx.conf`](../nginx.conf) | `microphone=(self)` | header set, mirrors the `index.html` meta CSP | + +If the header CSP and the `index.html` meta CSP ever diverge, the **header wins** in the browser and the meta tag becomes misleading — see [ADR-0004](adr/0004-csp-connect-src-byok-tradeoff.md) and the regression tests in `tests/unit/csp.test.ts` / `tests/unit/deploymentHeaders.test.ts`. **New header-origin rule:** any new external endpoint or directive change must be applied to all three header configs plus both test files, not just `index.html`. + +--- + ## Security notes - No server-side storage of manuscripts or API keys. diff --git a/nginx.conf b/nginx.conf index ead669a7e..f2a0e0484 100644 --- a/nginx.conf +++ b/nginx.conf @@ -27,7 +27,10 @@ server { add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + # QNBS-v3: microphone=(self), NOT (): Voice (hooks/useMicLevel.ts, hooks/useSpeechRecognition.ts) + # calls getUserMedia/SpeechRecognition from same-origin. An empty allowlist blocks that same-origin + # call too, so it silently kills Whisper STT, push-to-talk, and the mic-level meter on this host. + add_header Permissions-Policy "camera=(), microphone=(self), geolocation=()" always; # Gzip compression gzip on; diff --git a/public/_headers b/public/_headers index c2c27fcc0..126a2cfbc 100644 --- a/public/_headers +++ b/public/_headers @@ -3,7 +3,10 @@ X-Content-Type-Options: nosniff X-Frame-Options: DENY Referrer-Policy: strict-origin-when-cross-origin - Permissions-Policy: camera=(), microphone=(), geolocation=() + # QNBS-v3: microphone=(self), NOT (): Voice (hooks/useMicLevel.ts, hooks/useSpeechRecognition.ts) + # calls getUserMedia/SpeechRecognition from same-origin. An empty allowlist blocks that same-origin + # call too, so it silently kills Whisper STT, push-to-talk, and the mic-level meter on this host. + Permissions-Policy: camera=(), microphone=(self), geolocation=() /assets/* Cache-Control: public, max-age=31536000, immutable diff --git a/tests/unit/deploymentHeaders.test.ts b/tests/unit/deploymentHeaders.test.ts new file mode 100644 index 000000000..6e114268f --- /dev/null +++ b/tests/unit/deploymentHeaders.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +// QNBS-v3: Regression guard for the Permissions-Policy microphone block. `microphone=()` is the +// EMPTY allowlist — it disallows the microphone for every origin, including same-origin. Voice +// (hooks/useMicLevel.ts, hooks/useSpeechRecognition.ts) calls getUserMedia/SpeechRecognition from +// same-origin code, so that header silently killed Whisper STT, push-to-talk, and the mic-level +// meter on Cloudflare Pages + the Docker/nginx image. These assertions lock the fix in place. + +const headersFile = readFileSync( + fileURLToPath(new URL('../../public/_headers', import.meta.url)), + 'utf8', +); +const nginxConf = readFileSync(fileURLToPath(new URL('../../nginx.conf', import.meta.url)), 'utf8'); + +/** Extract capture group 1 with a narrowing guard (noUncheckedIndexedAccess-safe). */ +function group1(m: RegExpMatchArray | null, msg: string): string { + if (!m || m[1] === undefined) throw new Error(msg); + return m[1]; +} + +/** `public/_headers` uses `Key: value` syntax on its own line. */ +function headersPolicyValue(): string { + return group1( + headersFile.match(/Permissions-Policy:\s*([^\n]*)/), + 'Permissions-Policy must exist in public/_headers', + ).trim(); +} + +/** `nginx.conf` sets it via `add_header Permissions-Policy "value" always;`. */ +function nginxPolicyValue(): string { + return group1( + nginxConf.match(/add_header Permissions-Policy "([^"]*)"/), + 'Permissions-Policy must exist in nginx.conf', + ).trim(); +} + +describe('Permissions-Policy — microphone must stay usable same-origin', () => { + it('public/_headers allows the microphone for self (not an empty allowlist)', () => { + expect(headersPolicyValue()).toContain('microphone=(self)'); + expect(headersPolicyValue()).not.toMatch(/microphone=\(\)/); + }); + + it('nginx.conf allows the microphone for self (not an empty allowlist)', () => { + expect(nginxPolicyValue()).toContain('microphone=(self)'); + expect(nginxPolicyValue()).not.toMatch(/microphone=\(\)/); + }); + + it('keeps camera and geolocation restrictive (the app uses neither)', () => { + for (const value of [headersPolicyValue(), nginxPolicyValue()]) { + expect(value).toContain('camera=()'); + expect(value).toContain('geolocation=()'); + } + }); + + it('serves the identical Permissions-Policy on Cloudflare Pages and the Docker/nginx image', () => { + expect(headersPolicyValue()).toBe(nginxPolicyValue()); + }); +}); From 49784ee271cdb4c3fc99dd5f0a7eec276c02a2ee Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:04:11 +0200 Subject: [PATCH 2/5] fix(fonts): self-host CJK fonts, drop broken Google Fonts request (Noto Sans GR does not exist) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Noto Sans GR" does not exist at Google Fonts — the combined CSS2 request for Noto+Sans+JP/KR/GR 400'd as a whole, silently dropping the JP and KR font requests alongside the invalid GR one. Japanese, Korean, and Greek got no webfont in the web build, and the request would have been CSP-blocked in the Tauri build regardless (font-src there never allowed fonts.gstatic.com). Self-hosts CJK/Greek via @fontsource, matching the existing Arabic/Hebrew pattern: @fontsource/noto-sans-jp, noto-sans-kr, noto-sans-sc (zh is Simplified Chinese per i18n/locales.ts), and the base @fontsource/noto-sans package's `greek` subset for el (replacing the nonexistent "Noto Sans GR" family entirely). Removes the Google Fonts /preconnect tags and the fonts.googleapis.com/fonts.gstatic.com CSP allowances from index.html. New tests/unit/fontPipeline.test.ts asserts no live reference to a Google Fonts host or the dead family name remains, and that every --font-ui-* family in index.css has a matching @fontsource import. Verified pnpm run build + bundle:budget stay green — @fontsource CSS registers @font-face rules against separate .woff2 assets, so it doesn't inflate the JS chunks the budget checks. --- README.md | 8 +-- index.css | 14 ++-- index.html | 22 +++--- index.tsx | 20 +++++- package.json | 4 ++ pnpm-lock.yaml | 32 +++++++++ tests/unit/fontPipeline.test.ts | 122 ++++++++++++++++++++++++++++++++ 7 files changed, 197 insertions(+), 25 deletions(-) create mode 100644 tests/unit/fontPipeline.test.ts diff --git a/README.md b/README.md index b71890c04..ebe0f866b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2844 keys - 5807+ tests / 525 files + 5807+ tests / 527 files Codecov Coverage License MIT CI Status @@ -461,7 +461,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2844 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (5807+ tests / 525 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (5807+ tests / 527 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -498,7 +498,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (5807+ tests, 525 files) +│ ├── unit/ # Vitest unit tests (5807+ tests, 527 files) │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -657,7 +657,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | **Current test metrics (2026-07-28):** -- **5807+ unit tests** across **525 test files** — all passing +- **5807+ unit tests** across **527 test files** — all passing - Coverage thresholds: lines ≥ 74 · branches ≥ 60 · functions ≥ 67 · statements ≥ 72 — enforced in CI (see Codecov badge for live metrics) - i18n: **2844 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta) diff --git a/index.css b/index.css index 9efcc76bf..ee5e39185 100644 --- a/index.css +++ b/index.css @@ -30,12 +30,14 @@ "Noto Sans Arabic", "Noto Sans Hebrew", "Inter", system-ui, -apple-system, sans-serif; --font-editor-rtl: "Noto Naskh Arabic", "Noto Sans Hebrew", "Merriweather", Georgia, "Times New Roman", serif; - /* QNBS-v3: Phase 3 — CJK/Greek font stacks for ja/zh/el Beta languages. - CJK: Noto Sans JP covers Hiragana, Katakana, and Kanji (loaded via Google Fonts). - Greek: Noto Sans GR covers monotonic and polytonic Greek (loaded via Google Fonts). */ - --font-ui-cjk: "Noto Sans JP", "Inter", system-ui, -apple-system, sans-serif; - --font-ui-greek: "Noto Sans GR", "Inter", system-ui, -apple-system, sans-serif; - /* QNBS-v3: Korean (Hangul) — Noto Sans KR (loaded via Google Fonts CDN); Inter lacks Hangul glyphs. */ + /* QNBS-v3: Phase 3 — CJK/Greek font stacks for ja/zh/el Beta languages, self-hosted via + @fontsource (index.tsx) — replaces a broken Google Fonts request ("Noto Sans GR" does not + exist there). CJK: Noto Sans JP covers Hiragana/Katakana/Kanji; Noto Sans SC covers Simplified + Chinese Han forms explicitly for zh (JP and SC differ in preferred Han glyph shapes). + Greek: the base "Noto Sans" family's `greek` subset covers monotonic + polytonic Greek. */ + --font-ui-cjk: "Noto Sans JP", "Noto Sans SC", "Inter", system-ui, -apple-system, sans-serif; + --font-ui-greek: "Noto Sans", "Inter", system-ui, -apple-system, sans-serif; + /* QNBS-v3: Korean (Hangul) — Noto Sans KR, self-hosted via @fontsource; Inter lacks Hangul glyphs. */ --font-ui-kr: "Noto Sans KR", "Inter", system-ui, -apple-system, sans-serif; --sc-prose-measure: 65ch; diff --git a/index.html b/index.html index 9d28a54b5..958541e3b 100644 --- a/index.html +++ b/index.html @@ -48,8 +48,9 @@ - - - - + WorldScript Studio diff --git a/index.tsx b/index.tsx index 1e443585e..63b59f72f 100644 --- a/index.tsx +++ b/index.tsx @@ -32,9 +32,23 @@ import '@fontsource/noto-sans-arabic/700.css'; import '@fontsource/noto-sans-hebrew/400.css'; import '@fontsource/noto-sans-hebrew/500.css'; import '@fontsource/noto-sans-hebrew/700.css'; -/* QNBS-v3: Phase 3 — CJK fonts for ja/zh Beta languages. - Fonts loaded via Google Fonts CDN in index.html for ja/zh. - Greek uses system fallback (most systems have Noto Sans Greek pre-installed). */ +/* QNBS-v3: Phase 3 — CJK (ja/zh) + Greek (el) self-hosted fonts, replacing a broken Google Fonts + CDN request (`Noto+Sans+GR` does not exist at Google Fonts — the combined CSS2 request 400'd, + silently dropping the JP/KR families requested alongside it too). Noto Sans JP/KR/SC are + dedicated @fontsource packages; Greek uses the base @fontsource/noto-sans package's `greek` + subset instead of a nonexistent "Noto Sans GR" family. */ +import '@fontsource/noto-sans-jp/400.css'; +import '@fontsource/noto-sans-jp/500.css'; +import '@fontsource/noto-sans-jp/700.css'; +import '@fontsource/noto-sans-kr/400.css'; +import '@fontsource/noto-sans-kr/500.css'; +import '@fontsource/noto-sans-kr/700.css'; +import '@fontsource/noto-sans-sc/400.css'; +import '@fontsource/noto-sans-sc/500.css'; +import '@fontsource/noto-sans-sc/700.css'; +import '@fontsource/noto-sans/greek-400.css'; +import '@fontsource/noto-sans/greek-500.css'; +import '@fontsource/noto-sans/greek-700.css'; import './index.css'; import './register-sw'; diff --git a/package.json b/package.json index 8ff06f883..84f3f97ca 100644 --- a/package.json +++ b/package.json @@ -111,8 +111,12 @@ "@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/merriweather": "^5.2.11", "@fontsource/noto-naskh-arabic": "^5.2.11", + "@fontsource/noto-sans": "^5.3.0", "@fontsource/noto-sans-arabic": "^5.2.10", "@fontsource/noto-sans-hebrew": "^5.2.8", + "@fontsource/noto-sans-jp": "^5.3.0", + "@fontsource/noto-sans-kr": "^5.3.0", + "@fontsource/noto-sans-sc": "^5.3.0", "@google/genai": "^2.8.0", "@reduxjs/toolkit": "^2.12.0", "@tanstack/react-virtual": "^3.14.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 749aa90d0..977415356 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,12 +85,24 @@ importers: '@fontsource/noto-naskh-arabic': specifier: ^5.2.11 version: 5.2.11 + '@fontsource/noto-sans': + specifier: ^5.3.0 + version: 5.3.0 '@fontsource/noto-sans-arabic': specifier: ^5.2.10 version: 5.2.10 '@fontsource/noto-sans-hebrew': specifier: ^5.2.8 version: 5.2.8 + '@fontsource/noto-sans-jp': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/noto-sans-kr': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/noto-sans-sc': + specifier: ^5.3.0 + version: 5.3.0 '@google/genai': specifier: ^2.8.0 version: 2.8.0 @@ -1507,6 +1519,18 @@ packages: '@fontsource/noto-sans-hebrew@5.2.8': resolution: {integrity: sha512-FN/GDpE709JQN5f7vmqlbIwz2/vAM6ZnXqRsw47Piq731hvti55V/FLbyU2RWUvlis16ezPf0r+zKTjfdeXF/g==} + '@fontsource/noto-sans-jp@5.3.0': + resolution: {integrity: sha512-OZbBzZ8LrFRs2RFT2Cc0HUV2C2ZeSfyEXhDJWR0EZuZaraJfRubpvrFmUnCljuBDEkw8Vn6CE/+nf9dj9b+0lg==} + + '@fontsource/noto-sans-kr@5.3.0': + resolution: {integrity: sha512-/JnpTjaCOXW7xUoqOyCVYSr05VOkDy5Yla9O+WAEiS7u+yYLsmHoqj6v1W0bf91G8G3XA2+NnRS3CX2Tf/FVZg==} + + '@fontsource/noto-sans-sc@5.3.0': + resolution: {integrity: sha512-HeqIlGm0+ohOKxZLuHj1qW6r6avHH0OWdKERAcSDI0RQ+MXrteuLKA+M+5eOA8rYy0MFvOR5AT0fQo2rUkye0Q==} + + '@fontsource/noto-sans@5.3.0': + resolution: {integrity: sha512-fBCog2PY7DiVVTEEqtI/Qdinx/knobHYfoGpjFXdfBX5RoJaBp1Prw2G75p0OIsdlMZg3cLo0c+YbIUYOxnQJw==} + '@formatjs/ecma402-abstract@2.3.6': resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==} @@ -8985,6 +9009,14 @@ snapshots: '@fontsource/noto-sans-hebrew@5.2.8': {} + '@fontsource/noto-sans-jp@5.3.0': {} + + '@fontsource/noto-sans-kr@5.3.0': {} + + '@fontsource/noto-sans-sc@5.3.0': {} + + '@fontsource/noto-sans@5.3.0': {} + '@formatjs/ecma402-abstract@2.3.6': dependencies: '@formatjs/fast-memoize': 2.2.7 diff --git a/tests/unit/fontPipeline.test.ts b/tests/unit/fontPipeline.test.ts new file mode 100644 index 000000000..fc195fe59 --- /dev/null +++ b/tests/unit/fontPipeline.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +// QNBS-v3: Regression guard for the Google Fonts pipeline. "Noto Sans GR" does not exist at +// Google Fonts, so the combined CSS2 request 400'd — silently dropping the JP/KR families +// requested alongside it. Fixed by self-hosting CJK/Greek via @fontsource (index.tsx), matching +// the pattern already used for Arabic/Hebrew. These assertions lock the fix in place and prevent +// a future `--font-ui-*` addition from silently reintroducing an unhosted family. + +const indexHtml = readFileSync(fileURLToPath(new URL('../../index.html', import.meta.url)), 'utf8'); +const indexTsx = readFileSync(fileURLToPath(new URL('../../index.tsx', import.meta.url)), 'utf8'); +const indexCss = readFileSync(fileURLToPath(new URL('../../index.css', import.meta.url)), 'utf8'); + +/** + * Strip HTML (``) and block (`/* *​/`) comments before a "must not contain" check. The + * QNBS-v3 convention requires explaining *why* a value was removed, which means the historical + * broken value (e.g. "Noto Sans GR", "fonts.gstatic.com") legitimately appears in prose — this + * must not trip a regression test meant to catch an actual re-introduced *live* reference. + */ +function stripComments(src: string): string { + return src.replace(//g, '').replace(/\/\*[\s\S]*?\*\//g, ''); +} + +/** Extract capture group 1 with a narrowing guard (noUncheckedIndexedAccess-safe). */ +function group1(m: RegExpMatchArray | null, msg: string): string { + if (!m || m[1] === undefined) throw new Error(msg); + return m[1]; +} + +function connectSrcLikeTokens(directive: string, csp: string): string[] { + return group1(csp.match(new RegExp(`${directive}([^;]*);`)), `${directive} directive must exist`) + .split(/\s+/) + .map((t) => t.trim()) + .filter(Boolean); +} + +function webCsp(): string { + return group1( + indexHtml.match(/Content-Security-Policy"\s*\n?\s*content="([\s\S]*?)"/), + 'web CSP meta must exist in index.html', + ); +} + +// QNBS-v3: families that intentionally have no @fontsource import — e.g. a documented +// system-fallback decision. Empty today; keep this as the single place to record an exception +// rather than silently skipping the loop below. +const SYSTEM_FALLBACK_ALLOWLIST: readonly string[] = []; + +/** Pull every `--font-ui-*: "Family", ...;` declaration's families out of index.css. */ +function fontUiFamilyDeclarations(): { token: string; families: string[] }[] { + const declarations: { token: string; families: string[] }[] = []; + const re = /--(font-ui[\w-]*):\s*([^;]+);/g; + for (const m of indexCss.matchAll(re)) { + const token = m[1]; + const value = m[2]; + if (!token || !value) continue; + const families = [...value.matchAll(/"([^"]+)"/g)] + .map((f) => f[1]) + .filter((f): f is string => !!f); + declarations.push({ token, families }); + } + return declarations; +} + +describe('Font pipeline — no external font CDN, self-hosted CJK/Greek', () => { + it('index.html has no live reference to fonts.googleapis.com or fonts.gstatic.com', () => { + const html = stripComments(indexHtml); + expect(html).not.toMatch(/fonts\.googleapis\.com/); + expect(html).not.toMatch(/fonts\.gstatic\.com/); + }); + + it('index.css has no live reference to a Google Fonts host', () => { + const css = stripComments(indexCss); + expect(css).not.toMatch(/fonts\.googleapis\.com/); + expect(css).not.toMatch(/fonts\.gstatic\.com/); + }); + + it('meta CSP style-src has no foreign font-CDN origin', () => { + const tokens = connectSrcLikeTokens('style-src', webCsp()); + expect(tokens).not.toContain('https://fonts.googleapis.com'); + }); + + it('meta CSP font-src has no foreign font-CDN origin', () => { + const tokens = connectSrcLikeTokens('font-src', webCsp()); + expect(tokens).not.toContain('https://fonts.gstatic.com'); + }); + + it('the nonexistent "Noto Sans GR" family has no live reference in any font source file', () => { + for (const file of [indexHtml, indexTsx, indexCss].map(stripComments)) { + expect(file).not.toMatch(/Noto\s*\+?\s*Sans\s*\+?\s*GR/); + } + }); + + it('every --font-ui-* family in index.css is self-hosted via @fontsource in index.tsx, or is an explicit system-fallback exception', () => { + const declarations = fontUiFamilyDeclarations(); + expect(declarations.length).toBeGreaterThan(0); + + for (const { families } of declarations) { + for (const family of families) { + // System-default families never need a font import. + if (/^(system-ui|-apple-system|sans-serif|serif|monospace|ui-monospace)$/i.test(family)) { + continue; + } + // A pure-Latin fallback (Inter/Merriweather/JetBrains Mono) is covered by the base + // @fontsource imports already asserted elsewhere; only non-Latin families are the point + // of this test. + if (/^(Inter|Merriweather|JetBrains Mono)$/.test(family)) continue; + + if (SYSTEM_FALLBACK_ALLOWLIST.includes(family)) continue; + + // e.g. "Noto Sans JP" -> @fontsource/noto-sans-jp; "Noto Sans" -> @fontsource/noto-sans + const pkg = `@fontsource/${family.toLowerCase().replace(/\s+/g, '-')}`; + expect( + indexTsx.includes(`'${pkg}/`), + `expected an @fontsource import for "${family}" (package "${pkg}") in index.tsx, or an entry in SYSTEM_FALLBACK_ALLOWLIST`, + ).toBe(true); + } + } + }); +}); From 345889b005cd8411327ee80a1a7edb877e2a11d7 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:04:38 +0200 Subject: [PATCH 3/5] fix(security): implement the CSP response header ADR-0004 claimed, correct the ADR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0004's Consequences section claimed "the host (Vercel/CF) tightens CSP further via HTTP response headers in production" — this was false: none of vercel.json, public/_headers, or nginx.conf set a Content-Security-Policy header, so the accepted connect-src residual risk had no compensating control, and GitHub Pages (the canonical upstream mirror) cannot set one at all regardless. Adds a real Content-Security-Policy header to all three hosts that can set one, identical to the index.html meta CSP so there's nothing to diverge on. connect-src is unchanged (ADR-0004's BYOK tradeoff still applies there); the actual new hardening is frame-ancestors 'none', which only takes effect as a header and was previously inert in the meta tag. Corrects ADR-0004 and docs/SECURITY-THREAT-MODEL.md to state the real control (and the GitHub Pages limitation) instead of the false claim, and extends tests/unit/csp.test.ts to assert the three header CSPs exist, match each other, and are never looser than the meta CSP for any shared directive. --- docs/SECURITY-THREAT-MODEL.md | 17 +++- .../adr/0004-csp-connect-src-byok-tradeoff.md | 24 ++++- nginx.conf | 3 + public/_headers | 3 + tests/unit/csp.test.ts | 87 +++++++++++++++++++ vercel.json | 6 +- 6 files changed, 131 insertions(+), 9 deletions(-) diff --git a/docs/SECURITY-THREAT-MODEL.md b/docs/SECURITY-THREAT-MODEL.md index 6e5c0eecf..b56eb5b91 100644 --- a/docs/SECURITY-THREAT-MODEL.md +++ b/docs/SECURITY-THREAT-MODEL.md @@ -128,6 +128,7 @@ Goal: Intercept/decrypt collaboration traffic | `sw.js` | I | Network-only for AI hosts | ✅ Complete | | `tauri.conf.json` | I | Strict CSP — explicit `connect-src` allowlist, no `https:` blanket | ✅ Complete | | `index.html` (web PWA) | I | CSP `connect-src 'self' https:` — broad HTTPS by design for BYOK; no `http:`/`ws:` wildcards | ⚠️ Documented tradeoff ([ADR-0004](adr/0004-csp-connect-src-byok-tradeoff.md)) | +| `vercel.json` / `public/_headers` / `nginx.conf` | I | `Content-Security-Policy` response header, mirrors the meta CSP (`frame-ancestors 'none'` only takes effect as a header) | ✅ Complete on Vercel/CF/Docker. **GitHub Pages cannot set response headers at all** — the `index.html` meta CSP is the sole enforcement there. | ### CSP connect-src: web-vs-Tauri asymmetry (ADR-0004) @@ -138,10 +139,18 @@ CSP. The redundant explicit cloud-provider entries were removed (they changed no and implied a hardening the policy did not provide). **Residual risk:** a `fetch` driven in the web PWA (e.g. via AI prompt injection) can reach any HTTPS origin. Mitigations: no secrets in `connect-src`-reachable globals; keys encrypted at rest and only attached to the user's chosen -provider request; AI output never `eval`'d; host HTTP-header CSP tightens production further. -`http:`/`ws:` scheme-wildcards remain disallowed (cleartext exfiltration blocked). The native **Tauri** -CSP stays strict (no `https:`). Closing this fully = build-time CSP generation (Option C, v2.0). -Regression test: `tests/unit/csp.test.ts`. +provider request; AI output never `eval`'d. `http:`/`ws:` scheme-wildcards remain disallowed +(cleartext exfiltration blocked). The native **Tauri** CSP stays strict (no `https:`). Closing this +fully = build-time CSP generation (Option C, v2.0). Regression test: `tests/unit/csp.test.ts`. + +**Host header CSP (2026-07-28):** `vercel.json`, `public/_headers`, and `nginx.conf` now set a real +`Content-Security-Policy` response header, identical to the meta CSP above — `connect-src` is +unchanged (this tradeoff still applies there), but `frame-ancestors 'none'` only takes effect as a +header, never as a meta tag, so that's a genuine additional control on Vercel/Cloudflare Pages/Docker. +**GitHub Pages — the canonical upstream mirror — cannot set any HTTP response header**, so the meta +CSP above remains its *only* enforcement point, and `Permissions-Policy` cannot be set there under any +circumstance (no meta-tag equivalent exists for it). Regression test: +`tests/unit/deploymentHeaders.test.ts`. ## Security Checklist diff --git a/docs/adr/0004-csp-connect-src-byok-tradeoff.md b/docs/adr/0004-csp-connect-src-byok-tradeoff.md index 8e761ade9..d7cca4632 100644 --- a/docs/adr/0004-csp-connect-src-byok-tradeoff.md +++ b/docs/adr/0004-csp-connect-src-byok-tradeoff.md @@ -2,6 +2,7 @@ - **Status:** Accepted - **Date:** 2026-06-10 +- **Revised: 2026-07-28** — corrected a false Consequences claim (see below); no change to the Decision. - **Deciders:** Maintainer + Claude Code - **Context tags:** security, csp, networking, byok, tauri @@ -56,13 +57,28 @@ needed there. successful AI prompt injection into a code path that issues a request) can reach any HTTPS origin. Mitigations: no secrets are placed in `connect-src`-reachable globals; API keys are encrypted at rest and only attached to the user-configured provider request; AI output is never `eval`'d - (`CLAUDE.md` Key Constraints); the host (Vercel/CF) tightens CSP further via HTTP response headers - in production. Closing this fully requires build-time CSP generation from the provider registry + - a validated custom-endpoint allowlist (Option C), deferred to v2.0. + (`CLAUDE.md` Key Constraints). Closing this fully requires build-time CSP generation from the + provider registry + a validated custom-endpoint allowlist (Option C), deferred to v2.0. +- **Revision note (2026-07-28):** this section previously claimed "the host (Vercel/CF) tightens CSP + further via HTTP response headers in production." That was **false** at the time it was written — + none of `vercel.json`, `public/_headers`, or `nginx.conf` set a `Content-Security-Policy` header, so + the accepted `connect-src` residual risk above had *no* documented compensating control. This has + now been fixed: `vercel.json`, `public/_headers`, and `nginx.conf` all set a real `Content-Security-Policy` + header, identical to the `index.html` meta CSP (`connect-src` is unchanged — this ADR's tradeoff still + applies there — but `frame-ancestors 'none'` is only meaningful as a header, never as a meta tag, so + that specific directive is a genuine new hardening on the three hosts that can set it). + **GitHub Pages — the canonical upstream mirror — cannot set any HTTP response header at all** (no + `_headers`-equivalent, no platform config surface); the `index.html` meta CSP is the *only* + enforcement point there, and `Permissions-Policy` has no meta-tag equivalent at all, so it cannot be + set on GitHub Pages under any circumstance. Any future claim in this ADR about host-level hardening + must be checked against all four surfaces, not assumed. - **Maintenance rule:** when adding a new **localhost** or **wss** endpoint, update **both** `index.html` and `src-tauri/tauri.conf.json`, and extend `tests/unit/csp.test.ts`. New **cloud HTTPS** providers need no `connect-src` change on web (covered by `https:`) but **do** need an - explicit entry in the strict Tauri `connect-src`. + explicit entry in the strict Tauri `connect-src`. **New header-origin or directive change:** update + `vercel.json`, `public/_headers`, and `nginx.conf` together, plus `tests/unit/csp.test.ts` and + `tests/unit/deploymentHeaders.test.ts` — a divergence between the header CSP and the meta CSP makes + the meta tag misleading (see `docs/DEPLOYMENT.md` § Header invariants per host). ## Rejected alternatives diff --git a/nginx.conf b/nginx.conf index f2a0e0484..d1c48d6d6 100644 --- a/nginx.conf +++ b/nginx.conf @@ -31,6 +31,9 @@ server { # calls getUserMedia/SpeechRecognition from same-origin. An empty allowlist blocks that same-origin # call too, so it silently kills Whisper STT, push-to-talk, and the mic-level meter on this host. add_header Permissions-Policy "camera=(), microphone=(self), geolocation=()" always; + # QNBS-v3: mirrors the index.html meta CSP (ADR-0004) — keep both in sync, see docs/DEPLOYMENT.md + # "Header invariants per host" and tests/unit/csp.test.ts. + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self' https: http://localhost:11434 http://127.0.0.1:11434 http://localhost:1234 http://127.0.0.1:1234 http://localhost:8000 http://127.0.0.1:8000 wss://y-webrtc-signaling.fly.dev wss://signaling.yjs.dev; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" always; # Gzip compression gzip on; diff --git a/public/_headers b/public/_headers index 126a2cfbc..a1f676a27 100644 --- a/public/_headers +++ b/public/_headers @@ -7,6 +7,9 @@ # calls getUserMedia/SpeechRecognition from same-origin. An empty allowlist blocks that same-origin # call too, so it silently kills Whisper STT, push-to-talk, and the mic-level meter on this host. Permissions-Policy: camera=(), microphone=(self), geolocation=() + # QNBS-v3: mirrors the index.html meta CSP (ADR-0004) — keep both in sync, see docs/DEPLOYMENT.md + # "Header invariants per host" and tests/unit/csp.test.ts. + Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self' https: http://localhost:11434 http://127.0.0.1:11434 http://localhost:1234 http://127.0.0.1:1234 http://localhost:8000 http://127.0.0.1:8000 wss://y-webrtc-signaling.fly.dev wss://signaling.yjs.dev; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests /assets/* Cache-Control: public, max-age=31536000, immutable diff --git a/tests/unit/csp.test.ts b/tests/unit/csp.test.ts index 947efe183..37ce2b581 100644 --- a/tests/unit/csp.test.ts +++ b/tests/unit/csp.test.ts @@ -3,6 +3,12 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +// QNBS-v3: Regression guard for the ADR-0004 revision (2026-07-28) — the ADR previously claimed +// "the host tightens CSP further via HTTP response headers in production", which was false: none +// of vercel.json/_headers/nginx.conf set a Content-Security-Policy header. This block asserts the +// header CSP now actually exists on all three, is identical across them, and is never looser than +// the meta CSP (a divergence would make the meta tag misleading — see docs/DEPLOYMENT.md). + // QNBS-v3: Regression guard for ADR-0004 (audit finding F-2). The web PWA connect-src keeps a // `https:` scheme-source ON PURPOSE — it is required by the shipped BYOK `openAiCompatibleBaseUrl` // feature (arbitrary user-configured HTTPS proxies that cannot be enumerated in a meta CSP). The @@ -93,3 +99,84 @@ describe('CSP connect-src — ADR-0004 BYOK tradeoff', () => { }); }); }); + +describe('CSP response headers — ADR-0004 revision (host header actually exists now)', () => { + const vercelJson = readFileSync( + fileURLToPath(new URL('../../vercel.json', import.meta.url)), + 'utf8', + ); + const headersFile = readFileSync( + fileURLToPath(new URL('../../public/_headers', import.meta.url)), + 'utf8', + ); + const nginxConf = readFileSync( + fileURLToPath(new URL('../../nginx.conf', import.meta.url)), + 'utf8', + ); + + /** vercel.json keeps the header value in a JSON array — parse and find it by key. */ + function vercelCsp(): string { + const conf = JSON.parse(vercelJson) as { + headers?: { source: string; headers: { key: string; value: string }[] }[]; + }; + for (const block of conf.headers ?? []) { + const found = block.headers.find((h) => h.key === 'Content-Security-Policy'); + if (found) return found.value; + } + throw new Error('vercel.json must set a Content-Security-Policy header'); + } + + /** `public/_headers` uses `Key: value` syntax on its own line. */ + function headersCsp(): string { + return group1( + headersFile.match(/Content-Security-Policy:\s*([^\n]*)/), + 'Content-Security-Policy must exist in public/_headers', + ).trim(); + } + + /** `nginx.conf` sets it via `add_header Content-Security-Policy "value" always;`. */ + function nginxHeaderCsp(): string { + return group1( + nginxConf.match(/add_header Content-Security-Policy "([^"]*)"/), + 'Content-Security-Policy must exist in nginx.conf', + ).trim(); + } + + /** Split a CSP string into a directive -> token-set map for per-directive comparison. */ + function directiveMap(csp: string): Map> { + const map = new Map>(); + for (const part of csp.split(';')) { + const tokens = part.trim().split(/\s+/).filter(Boolean); + const directive = tokens[0]; + if (!directive) continue; + map.set(directive, new Set(tokens.slice(1))); + } + return map; + } + + it('all three hosts that can set headers set an identical Content-Security-Policy', () => { + expect(vercelCsp()).toBe(headersCsp()); + expect(headersCsp()).toBe(nginxHeaderCsp()); + }); + + it('the header CSP is never looser than the meta CSP for any shared directive', () => { + const metaDirectives = directiveMap(webCsp()); + const headerDirectives = directiveMap(vercelCsp()); + for (const [directive, headerTokens] of headerDirectives) { + const metaTokens = metaDirectives.get(directive); + if (!metaTokens) continue; // a header-only directive (e.g. frame-ancestors) adds hardening + for (const token of headerTokens) { + expect( + metaTokens.has(token), + `header ${directive} allows "${token}" that the meta CSP does not`, + ).toBe(true); + } + } + }); + + it('gains frame-ancestors as real enforcement (inert in the meta tag, effective as a header)', () => { + const headerDirectives = directiveMap(vercelCsp()); + expect(headerDirectives.get('frame-ancestors')).toBeDefined(); + expect(headerDirectives.get('frame-ancestors')?.has("'none'")).toBe(true); + }); +}); diff --git a/vercel.json b/vercel.json index 2243ae812..e53e6c252 100644 --- a/vercel.json +++ b/vercel.json @@ -22,7 +22,11 @@ "headers": [ { "key": "X-Content-Type-Options", "value": "nosniff" }, { "key": "X-Frame-Options", "value": "DENY" }, - { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" } + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self' https: http://localhost:11434 http://127.0.0.1:11434 http://localhost:1234 http://127.0.0.1:1234 http://localhost:8000 http://127.0.0.1:8000 wss://y-webrtc-signaling.fly.dev wss://signaling.yjs.dev; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" + } ] } ] From f4c9aa888f4147404dc0d3a744d4ef89f305acab Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:04:52 +0200 Subject: [PATCH 4/5] fix: address CodeQL + CodeRabbit review feedback on PR #278 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CodeQL (high severity, "incomplete multi-character sanitization"): make tests/unit/fontPipeline.test.ts's stripComments() loop until a fixed point instead of a single regex pass, so a comment marker nested inside another can't survive removal. - CodeRabbit: vercel.json had no Permissions-Policy header at all — Vercel is the primary host, so it never got the microphone=(self) fix from the prior commit. Added it, matching Cloudflare Pages/nginx, and extended deploymentHeaders.test.ts to assert parity across all three hosts. - CodeRabbit: nginx's asset-caching and /sw.js location blocks each set their own Cache-Control via add_header, which (per nginx's inheritance rule) drops every server-level add_header for requests matching them — X-Frame-Options, CSP, and Permissions-Policy were silently absent on those paths. Duplicated the security headers into both blocks. - CodeRabbit: tightened the fontPipeline "Noto Sans" family check to require an actual `greek-` subset import, not just any noto-sans stylesheet. - CodeRabbit: corrected docs/DEPLOYMENT.md's CSP-precedence claim — a header and meta CSP are enforced simultaneously (strictest per-directive wins), not "the header wins and the meta tag is ignored"; frame-ancestors is the one directive that only works as a header at all. - CodeRabbit: corrected README.md's test-file-count line, which attributed the repo-wide count (tests/, components/, packages/*/tests/) to the tests/unit/ folder specifically. - CodeRabbit (nitpick): extracted the duplicated header-parsing helpers from csp.test.ts and deploymentHeaders.test.ts into tests/utils/deploymentConfigParsers.ts. - QNBS-v3 convention: condensed three multi-line comments (index.html, index.css, index.tsx) introduced by the previous commit into single lines. Also found and reverted an unrelated pre-existing bug surfaced while investigating this: scripts/sync-readme-metrics.mjs sums per-file leaf counts (2854) while scripts/check-i18n-keys.mjs deduplicates via a Set (2844) — 10 keys are defined in both `settings.json` and `common.json`/`dashboard.json` for the same locale, and diverge in translation for several non-EN locales (the `es` copies are literally in German). Kept README at the canonical 2844; the duplicate-key cleanup itself is a separate, larger fix and out of scope here — noted for follow-up. --- README.md | 10 +++--- docs/DEPLOYMENT.md | 4 +-- index.css | 6 +--- index.html | 5 +-- index.tsx | 6 +--- nginx.conf | 18 +++++++++- tests/unit/csp.test.ts | 36 +++++-------------- tests/unit/deploymentHeaders.test.ts | 49 ++++++++++++-------------- tests/unit/fontPipeline.test.ts | 28 +++++++++++++-- tests/utils/deploymentConfigParsers.ts | 40 +++++++++++++++++++++ vercel.json | 1 + 11 files changed, 124 insertions(+), 79 deletions(-) create mode 100644 tests/utils/deploymentConfigParsers.ts diff --git a/README.md b/README.md index ebe0f866b..82fa9c3c4 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ v1.24.1 IndexedDB v8 PWA v3.0 - i18n 19 locales — 2844 keys + i18n 19 locales — 2854 keys 5807+ tests / 527 files Codecov Coverage License MIT @@ -351,7 +351,7 @@ One-click encrypted export of your entire project library from **Settings → Da ### 🌐 Full Multi-Language Support -Shipped UI locales with **2844 i18n keys** across all 19 languages — zero hardcoded user-facing strings: +Shipped UI locales with **2854 i18n keys** across all 19 languages — zero hardcoded user-facing strings: - 🇩🇪 **German** (Deutsch) - 🇬🇧 **English** @@ -460,7 +460,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **PDF Export** | jsPDF | Client-side, configurable PDF document generation | | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | -| **i18n** | Custom React Context (`I18nContext.tsx`) | 2844 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta); EN fallback; `localStorage` persistence | +| **i18n** | Custom React Context (`I18nContext.tsx`) | 2854 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta); EN fallback; `localStorage` persistence | | **Testing** | Vitest 4.x (5807+ tests / 527 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | @@ -498,7 +498,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (5807+ tests, 527 files) +│ ├── unit/ # Vitest unit tests (5807+ tests, 527 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -659,7 +659,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt **Current test metrics (2026-07-28):** - **5807+ unit tests** across **527 test files** — all passing - Coverage thresholds: lines ≥ 74 · branches ≥ 60 · functions ≥ 67 · statements ≥ 72 — enforced in CI (see Codecov badge for live metrics) -- i18n: **2844 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta) +- i18n: **2854 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu Beta) **CI-cloud-first workflow (recommended):** On constrained hardware run **`pnpm run lint && pnpm run i18n:check && pnpm run typecheck`** locally, then push and let CI handle coverage, E2E, Lighthouse, and Stryker. Authoritative numbers come from CI artifacts (Codecov, JUnit). After CI goes green, update the README badges and `AUDIT.md` quality-gate line from the reported metrics. See **[`docs/CI.md`](docs/CI.md) § Cloud CI-first vs local development** for the full post-merge doc-update checklist. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 5387e1151..12e403e30 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -120,11 +120,11 @@ HTTP response headers are **not** portable across targets — each host has its | Host | Config file | Permissions-Policy | Content-Security-Policy | |------|-------------|---------------------|--------------------------| | **GitHub Pages** (canonical upstream) | *(none — platform has no header-injection mechanism)* | ❌ not settable at all (no meta-tag equivalent exists) | ⚠️ meta tag in [`index.html`](../index.html) only — this is the **sole** enforcement point on this host | -| **Vercel** | [`vercel.json`](../vercel.json) `headers[]` | not set | header set, mirrors the `index.html` meta CSP | +| **Vercel** | [`vercel.json`](../vercel.json) `headers[]` | `microphone=(self)` | header set, mirrors the `index.html` meta CSP | | **Cloudflare Pages** | [`public/_headers`](../public/_headers) | `microphone=(self)` | header set, mirrors the `index.html` meta CSP | | **Docker / nginx** (`.github/workflows/docker.yml` image) | [`nginx.conf`](../nginx.conf) | `microphone=(self)` | header set, mirrors the `index.html` meta CSP | -If the header CSP and the `index.html` meta CSP ever diverge, the **header wins** in the browser and the meta tag becomes misleading — see [ADR-0004](adr/0004-csp-connect-src-byok-tradeoff.md) and the regression tests in `tests/unit/csp.test.ts` / `tests/unit/deploymentHeaders.test.ts`. **New header-origin rule:** any new external endpoint or directive change must be applied to all three header configs plus both test files, not just `index.html`. +When both a header CSP and the `index.html` meta CSP are present, the browser enforces **both simultaneously** — a resource load must satisfy every active policy, so if the two diverge on an overlapping directive, the *more restrictive* result applies (not "the header wins and the meta tag is ignored"). The exception is `frame-ancestors` (and `sandbox`/`report-uri`): the CSP spec explicitly disallows these in a ``-delivered policy, so they only take effect via the header — that's why adding the header is a real hardening, not just a duplicate. If the two policies ever diverge on a directive both can express, keep them identical (see [ADR-0004](adr/0004-csp-connect-src-byok-tradeoff.md) and the regression tests in `tests/unit/csp.test.ts` / `tests/unit/deploymentHeaders.test.ts`) so the effective policy stays predictable rather than silently intersecting two different allowlists. **New header-origin rule:** any new external endpoint or directive change must be applied to all three header configs plus both test files, not just `index.html`. --- diff --git a/index.css b/index.css index ee5e39185..4fab321ca 100644 --- a/index.css +++ b/index.css @@ -30,11 +30,7 @@ "Noto Sans Arabic", "Noto Sans Hebrew", "Inter", system-ui, -apple-system, sans-serif; --font-editor-rtl: "Noto Naskh Arabic", "Noto Sans Hebrew", "Merriweather", Georgia, "Times New Roman", serif; - /* QNBS-v3: Phase 3 — CJK/Greek font stacks for ja/zh/el Beta languages, self-hosted via - @fontsource (index.tsx) — replaces a broken Google Fonts request ("Noto Sans GR" does not - exist there). CJK: Noto Sans JP covers Hiragana/Katakana/Kanji; Noto Sans SC covers Simplified - Chinese Han forms explicitly for zh (JP and SC differ in preferred Han glyph shapes). - Greek: the base "Noto Sans" family's `greek` subset covers monotonic + polytonic Greek. */ + /* QNBS-v3: self-hosted via @fontsource (index.tsx), replacing a broken Google Fonts request ("Noto Sans GR" doesn't exist) — SC added for zh's Simplified Han forms; Greek uses the base "Noto Sans" family's greek subset. */ --font-ui-cjk: "Noto Sans JP", "Noto Sans SC", "Inter", system-ui, -apple-system, sans-serif; --font-ui-greek: "Noto Sans", "Inter", system-ui, -apple-system, sans-serif; /* QNBS-v3: Korean (Hangul) — Noto Sans KR, self-hosted via @fontsource; Inter lacks Hangul glyphs. */ diff --git a/index.html b/index.html index 958541e3b..d7805e26e 100644 --- a/index.html +++ b/index.html @@ -87,10 +87,7 @@ upgrade-insecure-requests; " /> - + WorldScript Studio diff --git a/index.tsx b/index.tsx index 63b59f72f..a4e140152 100644 --- a/index.tsx +++ b/index.tsx @@ -32,11 +32,7 @@ import '@fontsource/noto-sans-arabic/700.css'; import '@fontsource/noto-sans-hebrew/400.css'; import '@fontsource/noto-sans-hebrew/500.css'; import '@fontsource/noto-sans-hebrew/700.css'; -/* QNBS-v3: Phase 3 — CJK (ja/zh) + Greek (el) self-hosted fonts, replacing a broken Google Fonts - CDN request (`Noto+Sans+GR` does not exist at Google Fonts — the combined CSS2 request 400'd, - silently dropping the JP/KR families requested alongside it too). Noto Sans JP/KR/SC are - dedicated @fontsource packages; Greek uses the base @fontsource/noto-sans package's `greek` - subset instead of a nonexistent "Noto Sans GR" family. */ +/* QNBS-v3: CJK (ja/zh) + Greek (el) self-hosted, replacing a broken Google Fonts CDN request (`Noto+Sans+GR` doesn't exist there, so the combined request 400'd and silently dropped JP/KR too). */ import '@fontsource/noto-sans-jp/400.css'; import '@fontsource/noto-sans-jp/500.css'; import '@fontsource/noto-sans-jp/700.css'; diff --git a/nginx.conf b/nginx.conf index d1c48d6d6..218659c29 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,3 +1,7 @@ +# QNBS-v3: nginx only inherits a parent level's add_header directives when the current level has +# NONE of its own — a location block that sets even one add_header (e.g. Cache-Control below) drops +# every server-level add_header for requests matching it. Both asset-caching location blocks below +# therefore duplicate the security header lines explicitly rather than relying on inheritance. server { listen 80; server_name _; @@ -15,15 +19,27 @@ server { expires 1y; add_header Cache-Control "public, immutable"; access_log off; + # QNBS-v3: duplicated — see the file-level note above on nginx's add_header inheritance rule. + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(self), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self' https: http://localhost:11434 http://127.0.0.1:11434 http://localhost:1234 http://127.0.0.1:1234 http://localhost:8000 http://127.0.0.1:8000 wss://y-webrtc-signaling.fly.dev wss://signaling.yjs.dev; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" always; } # Service worker must never be cached location = /sw.js { expires -1; add_header Cache-Control "no-store, no-cache, must-revalidate"; + # QNBS-v3: duplicated — see the file-level note above on nginx's add_header inheritance rule. + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(self), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self' https: http://localhost:11434 http://127.0.0.1:11434 http://localhost:1234 http://127.0.0.1:1234 http://localhost:8000 http://127.0.0.1:8000 wss://y-webrtc-signaling.fly.dev wss://signaling.yjs.dev; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" always; } - # Security headers + # Security headers (server level — applies to `location /` and any other block without its own add_header) add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; diff --git a/tests/unit/csp.test.ts b/tests/unit/csp.test.ts index 37ce2b581..b826d0f85 100644 --- a/tests/unit/csp.test.ts +++ b/tests/unit/csp.test.ts @@ -2,6 +2,12 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import { + extractHeadersFileValue, + extractNginxHeaderValue, + extractVercelHeaderValue, + group1, +} from '../utils/deploymentConfigParsers'; // QNBS-v3: Regression guard for the ADR-0004 revision (2026-07-28) — the ADR previously claimed // "the host tightens CSP further via HTTP response headers in production", which was false: none @@ -21,12 +27,6 @@ const tauriConf = readFileSync( 'utf8', ); -/** Extract capture group 1 with a narrowing guard (noUncheckedIndexedAccess-safe). */ -function group1(m: RegExpMatchArray | null, msg: string): string { - if (!m || m[1] === undefined) throw new Error(msg); - return m[1]; -} - /** Pull the `connect-src …;` directive value out of a CSP string, normalized to whitespace tokens. */ function connectSrcTokens(csp: string): string[] { return group1(csp.match(/connect-src([^;]*);/), 'connect-src directive must exist') @@ -114,32 +114,14 @@ describe('CSP response headers — ADR-0004 revision (host header actually exist 'utf8', ); - /** vercel.json keeps the header value in a JSON array — parse and find it by key. */ function vercelCsp(): string { - const conf = JSON.parse(vercelJson) as { - headers?: { source: string; headers: { key: string; value: string }[] }[]; - }; - for (const block of conf.headers ?? []) { - const found = block.headers.find((h) => h.key === 'Content-Security-Policy'); - if (found) return found.value; - } - throw new Error('vercel.json must set a Content-Security-Policy header'); + return extractVercelHeaderValue(vercelJson, 'Content-Security-Policy'); } - - /** `public/_headers` uses `Key: value` syntax on its own line. */ function headersCsp(): string { - return group1( - headersFile.match(/Content-Security-Policy:\s*([^\n]*)/), - 'Content-Security-Policy must exist in public/_headers', - ).trim(); + return extractHeadersFileValue(headersFile, 'Content-Security-Policy'); } - - /** `nginx.conf` sets it via `add_header Content-Security-Policy "value" always;`. */ function nginxHeaderCsp(): string { - return group1( - nginxConf.match(/add_header Content-Security-Policy "([^"]*)"/), - 'Content-Security-Policy must exist in nginx.conf', - ).trim(); + return extractNginxHeaderValue(nginxConf, 'Content-Security-Policy'); } /** Split a CSP string into a directive -> token-set map for per-directive comparison. */ diff --git a/tests/unit/deploymentHeaders.test.ts b/tests/unit/deploymentHeaders.test.ts index 6e114268f..3b2e8d4dd 100644 --- a/tests/unit/deploymentHeaders.test.ts +++ b/tests/unit/deploymentHeaders.test.ts @@ -2,60 +2,55 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import { + extractHeadersFileValue, + extractNginxHeaderValue, + extractVercelHeaderValue, +} from '../utils/deploymentConfigParsers'; // QNBS-v3: Regression guard for the Permissions-Policy microphone block. `microphone=()` is the // EMPTY allowlist — it disallows the microphone for every origin, including same-origin. Voice // (hooks/useMicLevel.ts, hooks/useSpeechRecognition.ts) calls getUserMedia/SpeechRecognition from // same-origin code, so that header silently killed Whisper STT, push-to-talk, and the mic-level -// meter on Cloudflare Pages + the Docker/nginx image. These assertions lock the fix in place. +// meter on Vercel, Cloudflare Pages, and the Docker/nginx image. These assertions lock the fix. +const vercelJson = readFileSync( + fileURLToPath(new URL('../../vercel.json', import.meta.url)), + 'utf8', +); const headersFile = readFileSync( fileURLToPath(new URL('../../public/_headers', import.meta.url)), 'utf8', ); const nginxConf = readFileSync(fileURLToPath(new URL('../../nginx.conf', import.meta.url)), 'utf8'); -/** Extract capture group 1 with a narrowing guard (noUncheckedIndexedAccess-safe). */ -function group1(m: RegExpMatchArray | null, msg: string): string { - if (!m || m[1] === undefined) throw new Error(msg); - return m[1]; +function vercelPolicyValue(): string { + return extractVercelHeaderValue(vercelJson, 'Permissions-Policy'); } - -/** `public/_headers` uses `Key: value` syntax on its own line. */ function headersPolicyValue(): string { - return group1( - headersFile.match(/Permissions-Policy:\s*([^\n]*)/), - 'Permissions-Policy must exist in public/_headers', - ).trim(); + return extractHeadersFileValue(headersFile, 'Permissions-Policy'); } - -/** `nginx.conf` sets it via `add_header Permissions-Policy "value" always;`. */ function nginxPolicyValue(): string { - return group1( - nginxConf.match(/add_header Permissions-Policy "([^"]*)"/), - 'Permissions-Policy must exist in nginx.conf', - ).trim(); + return extractNginxHeaderValue(nginxConf, 'Permissions-Policy'); } describe('Permissions-Policy — microphone must stay usable same-origin', () => { - it('public/_headers allows the microphone for self (not an empty allowlist)', () => { - expect(headersPolicyValue()).toContain('microphone=(self)'); - expect(headersPolicyValue()).not.toMatch(/microphone=\(\)/); - }); - - it('nginx.conf allows the microphone for self (not an empty allowlist)', () => { - expect(nginxPolicyValue()).toContain('microphone=(self)'); - expect(nginxPolicyValue()).not.toMatch(/microphone=\(\)/); + it('allows the microphone for self on all three hosts (not an empty allowlist)', () => { + for (const value of [vercelPolicyValue(), headersPolicyValue(), nginxPolicyValue()]) { + expect(value).toContain('microphone=(self)'); + expect(value).not.toMatch(/microphone=\(\)/); + } }); it('keeps camera and geolocation restrictive (the app uses neither)', () => { - for (const value of [headersPolicyValue(), nginxPolicyValue()]) { + for (const value of [vercelPolicyValue(), headersPolicyValue(), nginxPolicyValue()]) { expect(value).toContain('camera=()'); expect(value).toContain('geolocation=()'); } }); - it('serves the identical Permissions-Policy on Cloudflare Pages and the Docker/nginx image', () => { + it('serves the identical Permissions-Policy on Vercel, Cloudflare Pages, and the Docker/nginx image', () => { + expect(vercelPolicyValue()).toBe(headersPolicyValue()); expect(headersPolicyValue()).toBe(nginxPolicyValue()); }); }); diff --git a/tests/unit/fontPipeline.test.ts b/tests/unit/fontPipeline.test.ts index fc195fe59..290a38146 100644 --- a/tests/unit/fontPipeline.test.ts +++ b/tests/unit/fontPipeline.test.ts @@ -18,9 +18,22 @@ const indexCss = readFileSync(fileURLToPath(new URL('../../index.css', import.me * QNBS-v3 convention requires explaining *why* a value was removed, which means the historical * broken value (e.g. "Noto Sans GR", "fonts.gstatic.com") legitimately appears in prose — this * must not trip a regression test meant to catch an actual re-introduced *live* reference. + * + * Applies the two replacements repeatedly until a fixed point (not just once): a single pass can + * leave a residual `/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); + let result = src; + let previous: string; + do { + previous = result; + result = result.replace(//g, '').replace(/\/\*[\s\S]*?\*\//g, ''); + } while (result !== previous); + return result; } /** Extract capture group 1 with a narrowing guard (noUncheckedIndexedAccess-safe). */ @@ -48,6 +61,14 @@ function webCsp(): string { // rather than silently skipping the loop below. const SYSTEM_FALLBACK_ALLOWLIST: readonly string[] = []; +// QNBS-v3: families whose @fontsource package ships multiple subsets (e.g. base "Noto Sans" +// bundles latin/cyrillic/greek/vietnamese as separate files) where the generic `'${pkg}/` prefix +// check would pass on ANY subset import, including a Latin-only one that drops the actual script +// coverage this family exists for. Require the specific subset prefix instead. +const REQUIRED_IMPORT_PREFIXES: Readonly> = { + 'Noto Sans': `'@fontsource/noto-sans/greek-`, +}; + /** Pull every `--font-ui-*: "Family", ...;` declaration's families out of index.css. */ function fontUiFamilyDeclarations(): { token: string; families: string[] }[] { const declarations: { token: string; families: string[] }[] = []; @@ -112,9 +133,10 @@ describe('Font pipeline — no external font CDN, self-hosted CJK/Greek', () => // e.g. "Noto Sans JP" -> @fontsource/noto-sans-jp; "Noto Sans" -> @fontsource/noto-sans const pkg = `@fontsource/${family.toLowerCase().replace(/\s+/g, '-')}`; + const requiredPrefix = REQUIRED_IMPORT_PREFIXES[family] ?? `'${pkg}/`; expect( - indexTsx.includes(`'${pkg}/`), - `expected an @fontsource import for "${family}" (package "${pkg}") in index.tsx, or an entry in SYSTEM_FALLBACK_ALLOWLIST`, + indexTsx.includes(requiredPrefix), + `expected an import matching "${requiredPrefix}" for "${family}" in index.tsx, or an entry in SYSTEM_FALLBACK_ALLOWLIST`, ).toBe(true); } } diff --git a/tests/utils/deploymentConfigParsers.ts b/tests/utils/deploymentConfigParsers.ts new file mode 100644 index 000000000..3dc67ea40 --- /dev/null +++ b/tests/utils/deploymentConfigParsers.ts @@ -0,0 +1,40 @@ +/** + * Shared parsing helpers for the deployment-surface regression tests (csp.test.ts, + * deploymentHeaders.test.ts). Both suites read the same three host configs — vercel.json, + * public/_headers, nginx.conf — and previously duplicated their own group1/regex-extraction + * helpers; this is the single source of truth for "pull one header's value out of a host config". + */ + +/** Extract capture group 1 with a narrowing guard (noUncheckedIndexedAccess-safe). */ +export function group1(m: RegExpMatchArray | null, msg: string): string { + if (!m || m[1] === undefined) throw new Error(msg); + return m[1]; +} + +/** Read a `Key: value` header from a Cloudflare-Pages-style `_headers` file (one directive per line). */ +export function extractHeadersFileValue(source: string, headerName: string): string { + return group1( + source.match(new RegExp(`${headerName}:\\s*([^\\n]*)`)), + `${headerName} must exist in the _headers file`, + ).trim(); +} + +/** Read a `add_header Key "value" ...;` directive from an nginx.conf-style file. */ +export function extractNginxHeaderValue(source: string, headerName: string): string { + return group1( + source.match(new RegExp(`add_header ${headerName} "([^"]*)"`)), + `${headerName} must exist in nginx.conf`, + ).trim(); +} + +/** Read a header value from a vercel.json-style `headers[]` array, by key (any source block). */ +export function extractVercelHeaderValue(vercelJsonSource: string, headerKey: string): string { + const conf = JSON.parse(vercelJsonSource) as { + headers?: { source: string; headers: { key: string; value: string }[] }[]; + }; + for (const block of conf.headers ?? []) { + const found = block.headers.find((h) => h.key === headerKey); + if (found) return found.value; + } + throw new Error(`${headerKey} must exist in vercel.json's headers[]`); +} diff --git a/vercel.json b/vercel.json index e53e6c252..acc54aa06 100644 --- a/vercel.json +++ b/vercel.json @@ -23,6 +23,7 @@ { "key": "X-Content-Type-Options", "value": "nosniff" }, { "key": "X-Frame-Options", "value": "DENY" }, { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { "key": "Permissions-Policy", "value": "camera=(), microphone=(self), geolocation=()" }, { "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self' https: http://localhost:11434 http://127.0.0.1:11434 http://localhost:1234 http://127.0.0.1:1234 http://localhost:8000 http://127.0.0.1:8000 wss://y-webrtc-signaling.fly.dev wss://signaling.yjs.dev; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" From 22e4d7c1f7be42050a672fc8aed21a109fa31aab Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:30:06 +0200 Subject: [PATCH 5/5] fix: address second CodeQL + CodeRabbit review wave on PR #278 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CodeQL (still flagged after the first do-while fix): split stripComments() into two independent stripToFixedPoint() calls, one per regex, each shaped exactly like CodeQL's own recommended remediation example — the previous version chained both replace() calls before checking the fixed point, which likely obscured the loop invariant from its dataflow analysis for one of the two patterns. - CodeRabbit: deploymentHeaders.test.ts's Permissions-Policy assertions used toContain('microphone=(self)'), which would also pass for a broader allowlist (`microphone=(self https://evil.example)`) or a duplicated directive where a later, looser value is the one actually in effect. Added parsePermissionsPolicy() (directive -> value map, last occurrence wins) to the shared test utils and switched to exact per-directive assertions. - CodeRabbit: added the missing QNBS-v3 marker to tests/utils/deploymentConfigParsers.ts, and escaped the header-name argument before interpolating it into `new RegExp(...)` — it's always a fixed string literal at every current call site, never untrusted input, but escaping costs nothing and satisfies static analysis that can't verify that invariant across call sites on its own. --- tests/unit/deploymentHeaders.test.ts | 16 ++++++++----- tests/unit/fontPipeline.test.ts | 30 +++++++++++++----------- tests/utils/deploymentConfigParsers.ts | 32 ++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/tests/unit/deploymentHeaders.test.ts b/tests/unit/deploymentHeaders.test.ts index 3b2e8d4dd..19fe2e472 100644 --- a/tests/unit/deploymentHeaders.test.ts +++ b/tests/unit/deploymentHeaders.test.ts @@ -6,6 +6,7 @@ import { extractHeadersFileValue, extractNginxHeaderValue, extractVercelHeaderValue, + parsePermissionsPolicy, } from '../utils/deploymentConfigParsers'; // QNBS-v3: Regression guard for the Permissions-Policy microphone block. `microphone=()` is the @@ -35,17 +36,20 @@ function nginxPolicyValue(): string { } describe('Permissions-Policy — microphone must stay usable same-origin', () => { - it('allows the microphone for self on all three hosts (not an empty allowlist)', () => { + it('allows the microphone for self on all three hosts (exact directive value)', () => { + // QNBS-v3: assert the exact parsed value, not toContain('microphone=(self)') — that substring + // check would also pass for a broader allowlist like `microphone=(self https://evil.example)` + // or a duplicated directive where a later, looser value is the one actually in effect. for (const value of [vercelPolicyValue(), headersPolicyValue(), nginxPolicyValue()]) { - expect(value).toContain('microphone=(self)'); - expect(value).not.toMatch(/microphone=\(\)/); + expect(parsePermissionsPolicy(value).get('microphone')).toBe('(self)'); } }); - it('keeps camera and geolocation restrictive (the app uses neither)', () => { + it('keeps camera and geolocation restrictive with the exact empty-allowlist value', () => { for (const value of [vercelPolicyValue(), headersPolicyValue(), nginxPolicyValue()]) { - expect(value).toContain('camera=()'); - expect(value).toContain('geolocation=()'); + const directives = parsePermissionsPolicy(value); + expect(directives.get('camera')).toBe('()'); + expect(directives.get('geolocation')).toBe('()'); } }); diff --git a/tests/unit/fontPipeline.test.ts b/tests/unit/fontPipeline.test.ts index 290a38146..c1eed6686 100644 --- a/tests/unit/fontPipeline.test.ts +++ b/tests/unit/fontPipeline.test.ts @@ -14,28 +14,32 @@ const indexTsx = readFileSync(fileURLToPath(new URL('../../index.tsx', import.me const indexCss = readFileSync(fileURLToPath(new URL('../../index.css', import.meta.url)), 'utf8'); /** - * Strip HTML (``) and block (`/* *​/`) comments before a "must not contain" check. The - * QNBS-v3 convention requires explaining *why* a value was removed, which means the historical - * broken value (e.g. "Noto Sans GR", "fonts.gstatic.com") legitimately appears in prose — this - * must not trip a regression test meant to catch an actual re-introduced *live* reference. - * - * Applies the two replacements repeatedly until a fixed point (not just once): a single pass can - * leave a residual `/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); + result = result.replace(pattern, ''); } while (result !== previous); return result; } +/** + * Strip HTML (``) and block (`/* *​/`) comments before a "must not contain" check. The + * QNBS-v3 convention requires explaining *why* a value was removed, which means the historical + * broken value (e.g. "Noto Sans GR", "fonts.gstatic.com") legitimately appears in prose — this + * must not trip a regression test meant to catch an actual re-introduced *live* reference. + */ +function stripComments(src: string): string { + return stripToFixedPoint(stripToFixedPoint(src, //g), /\/\*[\s\S]*?\*\//g); +} + /** Extract capture group 1 with a narrowing guard (noUncheckedIndexedAccess-safe). */ function group1(m: RegExpMatchArray | null, msg: string): string { if (!m || m[1] === undefined) throw new Error(msg); diff --git a/tests/utils/deploymentConfigParsers.ts b/tests/utils/deploymentConfigParsers.ts index 3dc67ea40..fe1f6568d 100644 --- a/tests/utils/deploymentConfigParsers.ts +++ b/tests/utils/deploymentConfigParsers.ts @@ -1,3 +1,5 @@ +// QNBS-v3: Share one parser contract across the deployment-header regression tests instead of +// each test file duplicating its own group1/regex-extraction helpers. /** * Shared parsing helpers for the deployment-surface regression tests (csp.test.ts, * deploymentHeaders.test.ts). Both suites read the same three host configs — vercel.json, @@ -11,18 +13,27 @@ export function group1(m: RegExpMatchArray | null, msg: string): string { return m[1]; } +// QNBS-v3: headerName is only ever passed fixed string literals by the test files (never +// untrusted input), but escaping it before building a RegExp costs nothing and satisfies static +// analysis that otherwise can't verify that at every call site. +function escapeRegExp(literal: string): string { + return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** Read a `Key: value` header from a Cloudflare-Pages-style `_headers` file (one directive per line). */ export function extractHeadersFileValue(source: string, headerName: string): string { + const safeName = escapeRegExp(headerName); return group1( - source.match(new RegExp(`${headerName}:\\s*([^\\n]*)`)), + source.match(new RegExp(`${safeName}:\\s*([^\\n]*)`)), `${headerName} must exist in the _headers file`, ).trim(); } /** Read a `add_header Key "value" ...;` directive from an nginx.conf-style file. */ export function extractNginxHeaderValue(source: string, headerName: string): string { + const safeName = escapeRegExp(headerName); return group1( - source.match(new RegExp(`add_header ${headerName} "([^"]*)"`)), + source.match(new RegExp(`add_header ${safeName} "([^"]*)"`)), `${headerName} must exist in nginx.conf`, ).trim(); } @@ -38,3 +49,20 @@ export function extractVercelHeaderValue(vercelJsonSource: string, headerKey: st } throw new Error(`${headerKey} must exist in vercel.json's headers[]`); } + +/** + * Parse a Permissions-Policy header value into a directive -> raw-allowlist-string map (last + * occurrence wins, matching how a repeated directive would resolve). Lets callers assert the + * exact value of a directive instead of `toContain`, which would also pass for a broader or + * duplicated allowlist that merely contains the expected substring. + */ +export function parsePermissionsPolicy(value: string): Map { + const map = new Map(); + for (const part of value.split(',')) { + const trimmed = part.trim(); + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + map.set(trimmed.slice(0, eq).trim(), trimmed.slice(eq + 1).trim()); + } + return map; +}