diff --git a/README.md b/README.md index b71890c0..82fa9c3c 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ v1.24.1 IndexedDB v8 PWA v3.0 - i18n 19 locales β€” 2844 keys - 5807+ tests / 525 files + i18n 19 locales β€” 2854 keys + 5807+ tests / 527 files Codecov Coverage License MIT CI Status @@ -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,8 +460,8 @@ 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 | -| **Testing** | Vitest 4.x (5807+ tests / 525 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **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 | | **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) β€” count spans tests/, components/, packages/*/tests/, not just this folder β”‚ β”‚ β”œβ”€β”€ ai/ # aiSmallModules, aiCoreFallbackPaths β”‚ β”‚ └── settings/ # WebLlmPanel, AiSections β”‚ └── e2e/ # Playwright specs + helpers.ts @@ -657,9 +657,9 @@ 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) +- 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 218abbaa..12e403e3 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[]` | `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 | + +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`. + +--- + ## Security notes - No server-side storage of manuscripts or API keys. diff --git a/docs/SECURITY-THREAT-MODEL.md b/docs/SECURITY-THREAT-MODEL.md index 6e5c0eec..b56eb5b9 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 8e761ade..d7cca463 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/index.css b/index.css index 9efcc76b..4fab321c 100644 --- a/index.css +++ b/index.css @@ -30,12 +30,10 @@ "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: 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. */ --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 9d28a54b..d7805e26 100644 --- a/index.html +++ b/index.html @@ -48,8 +48,9 @@ - - - - + WorldScript Studio diff --git a/index.tsx b/index.tsx index 1e443585..a4e14015 100644 --- a/index.tsx +++ b/index.tsx @@ -32,9 +32,19 @@ 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: 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'; +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/nginx.conf b/nginx.conf index ead669a7..218659c2 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,19 +19,37 @@ 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; - 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; + # 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/package.json b/package.json index 8ff06f88..84f3f97c 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 749aa90d..97741535 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/public/_headers b/public/_headers index c2c27fcc..a1f676a2 100644 --- a/public/_headers +++ b/public/_headers @@ -3,7 +3,13 @@ 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=() + # 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 947efe18..b826d0f8 100644 --- a/tests/unit/csp.test.ts +++ b/tests/unit/csp.test.ts @@ -2,6 +2,18 @@ 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 +// 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` @@ -15,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') @@ -93,3 +99,66 @@ 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', + ); + + function vercelCsp(): string { + return extractVercelHeaderValue(vercelJson, 'Content-Security-Policy'); + } + function headersCsp(): string { + return extractHeadersFileValue(headersFile, 'Content-Security-Policy'); + } + function nginxHeaderCsp(): string { + return extractNginxHeaderValue(nginxConf, 'Content-Security-Policy'); + } + + /** 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/tests/unit/deploymentHeaders.test.ts b/tests/unit/deploymentHeaders.test.ts new file mode 100644 index 00000000..19fe2e47 --- /dev/null +++ b/tests/unit/deploymentHeaders.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + extractHeadersFileValue, + extractNginxHeaderValue, + extractVercelHeaderValue, + parsePermissionsPolicy, +} 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 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'); + +function vercelPolicyValue(): string { + return extractVercelHeaderValue(vercelJson, 'Permissions-Policy'); +} +function headersPolicyValue(): string { + return extractHeadersFileValue(headersFile, 'Permissions-Policy'); +} +function nginxPolicyValue(): string { + return extractNginxHeaderValue(nginxConf, 'Permissions-Policy'); +} + +describe('Permissions-Policy β€” microphone must stay usable same-origin', () => { + 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(parsePermissionsPolicy(value).get('microphone')).toBe('(self)'); + } + }); + + it('keeps camera and geolocation restrictive with the exact empty-allowlist value', () => { + for (const value of [vercelPolicyValue(), headersPolicyValue(), nginxPolicyValue()]) { + const directives = parsePermissionsPolicy(value); + expect(directives.get('camera')).toBe('()'); + expect(directives.get('geolocation')).toBe('()'); + } + }); + + 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 new file mode 100644 index 00000000..c1eed668 --- /dev/null +++ b/tests/unit/fontPipeline.test.ts @@ -0,0 +1,148 @@ +// @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'); + +/** + * Repeatedly apply a single-pattern replace until a fixed point, so a marker nested inside + * another (e.g. a `/*` inside an HTML comment, exposed once the HTML comment around it is + * stripped) can't survive as a residual match β€” the canonical fix for CodeQL's + * js/incomplete-multi-character-sanitization, applied to exactly one pattern per call so its + * dataflow analysis can verify each loop independently. + */ +function stripToFixedPoint(src: string, pattern: RegExp): string { + let result = src; + let previous: string; + do { + previous = result; + 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); + 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[] = []; + +// 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[] }[] = []; + 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, '-')}`; + const requiredPrefix = REQUIRED_IMPORT_PREFIXES[family] ?? `'${pkg}/`; + expect( + 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 00000000..fe1f6568 --- /dev/null +++ b/tests/utils/deploymentConfigParsers.ts @@ -0,0 +1,68 @@ +// 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, + * 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]; +} + +// 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(`${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 ${safeName} "([^"]*)"`)), + `${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[]`); +} + +/** + * 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; +} diff --git a/vercel.json b/vercel.json index 2243ae81..acc54aa0 100644 --- a/vercel.json +++ b/vercel.json @@ -22,7 +22,12 @@ "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": "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" + } ] } ]