Version: 1.0.0
Date: 2026-06-05 (baseline); desktop-crypto mitigation row updated 2026-08-13 (F-05/F-06 reopened β not resolved by the 2026-07-29 change, see Mitigation Mapping below)
Status: v1.24.2 baseline
This document provides a formal STRIDE threat analysis for WorldScript Studio, mapping threats to mitigations and code locations.
| Threat | Mitigation | Code Location |
|---|---|---|
| User impersonation in collaboration | Password-derived room key; awareness state encrypted | services/collaborationService.ts:deriveEncryptionKey() |
| AI provider spoofing via malicious config | Provider allowlist; URL validation | services/ai/aiPolicy.ts:LOCAL_INFERENCE_PROVIDERS |
| Plugin identity spoofing | Zod schema validation on descriptor | services/pluginRegistry.ts:PluginDescriptorSchema |
| Threat | Mitigation | Code Location |
|---|---|---|
| Browser IndexedDB manuscript-data modification | AES-256-GCM authentication tag verification when optional at-rest encryption is configured | services/storage/storageEncryptionService.ts:decrypt() |
| Desktop filesystem manuscript-data modification by a local file-write attacker | Not mitigated on current main: Tauri filesystem records are plaintext/compressed and require filesystem write access to alter; schema validation detects only some malformed settings, not authenticated tampering |
services/fs/*Store.ts, services/fs/fsCore.ts |
| Collaboration payload tampering | RTCDataChannel E2E encryption | packages/collab-transport/src/crypto.js |
| Settings corruption | Schema validation on load | features/settings/settingsSlice.ts:normalizePersistedSettings() |
| Plugin code injection | Content guard script | scripts/content-guard.mjs |
| AI-proposed manuscript corruption | Control-character / lone-surrogate rejection with per-item skip | services/proForge/applyReviewEdits.ts:validateProposedText() |
| Threat | Mitigation | Code Location |
|---|---|---|
| User actions not traceable | StructuredLogger with GDPR sanitization | services/logger.ts:createLogger() |
| AI calls not logged | Telemetry service (opt-in) | services/ai/telemetryService.ts |
| Collaboration actions anonymous | Awareness state includes user identity | services/collaborationService.ts |
| Threat | Mitigation | Code Location |
|---|---|---|
| API key leakage via logs | StructuredLogger sanitization; never log keys | services/logger.ts:sanitizeLogContext() |
| Desktop API key exposure via local filesystem read | Resolved 2026-08-14 (was "not resolved" through 2026-08-13 β see history). Filesystem API-key persistence is disabled: storageService's key methods route directly to the IndexedDB key store (random non-extractable AES-GCM key) on every platform, desktop included β the filesystem adapter's own saveApiKey is a defense-in-depth backstop that throws if ever called directly. The prior PBKDF2-from-reconstructible-material derivation (deriveFileSystemCryptoKey()) is no longer used for API keys; it remains in fsCore.ts as shared crypto plumbing for other filesystem-encrypted data (see PR #356, closed 2026-08-18 as superseded). Legacy filesystem key files are removed on a best-effort basis (each failure is logged, not retried indefinitely) and re-entry is required if cleanup or decryption fails. Gemini split-persistence bug also resolved: components/ApiKeySection.tsx and services/geminiService.ts now both route through storageService, closing #358 (previously ApiKeySection read/wrote the Gemini key via dbService directly while geminiService read it via storageService, so a key saved on desktop was invisible to the code that used it). |
services/storage/idbKeyStore.ts, services/storageService.ts, services/fs/settingsFsStore.ts |
| Desktop project/settings/snapshot/Codex/RAG/image/binder-asset data disclosure via local file-read access | Not resolved on current main. Tauri filesystem records, including binder .bin payloads, are plaintext (some text records are compressed only), so an attacker who can read the app-data directory can disclose them. The separate tampering threat requires local file-write access and is modeled under Tampering above. Enabling the current browser/IndexedDB setting does not protect these files. R-15 (docs/native/CORE-MIGRATION-LEDGER.md row 10) is the design-complete remediation β implementation readiness is tracked live by row 10's own readiness marker, additionally gated behind row 9 (the project state-shape compatibility adapter) converging first; check the ledger directly rather than trusting a copied status here, and do not broaden this claim until implementation actually starts. |
services/fs/*Store.ts |
| Manuscript data in IndexedDB | AES-256-GCM at-rest encryption | services/storage/storageEncryptionService.ts |
| Voice audio to cloud | Web Speech API consent gate | components/voice/VoicePrivacyConsentModal.tsx |
| DuckDB analytics unencrypted (SEC-6) | Bounded by design, with one prose column now encrypted: most persisted fields are local metadata only (titles, loglines, character names, word counts, embeddings) and nothing leaves the device. The one column that genuinely holds literal manuscript prose, codex_mentions.excerpt, is now cell-level encrypted (AES-256-GCM via services/duckdb/duckdbEncryption.ts, reusing the IDB at-rest encryption key) whenever enableIdbAtRestEncryption is active: duckdbCodexWrite() writes ciphertext into excerpt_enc BLOB and nulls the plaintext excerpt column; services/duckdb/codexExcerptEncryptionMigration.ts backfills any pre-existing plaintext rows once encryption is unlocked. Gated by enableDuckDbAnalytics and the Settings β Privacy "Analytics" opt-out (isAnalyticsPersistenceAllowed in app/listenerMiddleware.ts); turning the toggle off stops all DuckDB writes + inference telemetry. Full OPFS file-level encryption remains infeasible β DuckDB-WASM owns the OPFS file handle directly, so there is no app-level interception point; the other metadata columns stay intentionally plaintext (bounded-exposure design). |
app/listenerMiddleware.ts:isAnalyticsPersistenceAllowed, services/duckdb/duckdbAnalytics.ts:duckdbCodexWrite(), services/duckdb/duckdbEncryption.ts, services/duckdb/codexExcerptEncryptionMigration.ts |
| Prompt injection exposing context | Prompt sanitization | services/ai/ragPromptAssembly.ts:sanitizePromptBlock() |
| Prompt injection via AI-proposed edits | Control-character / lone-surrogate validation; per-item skip | services/proForge/applyReviewEdits.ts:validateProposedText() |
| Claude BYOK key transits WorldScript's own infrastructure (web/PWA only β see Β§Claude serverless proxy below) | Stateless relay, no logging of key/prompt/response on any path; same-origin check rejects third-party callers | api/_shared/claudeProxyCore.ts:handleClaudeProxyRequest() |
| Threat | Mitigation | Code Location |
|---|---|---|
| Large model download OOM | Bundle exclusion from SW precache | vite.config.ts:globIgnores |
| Worker pool exhaustion | PriorityTaskQueue with MAX_QUEUE_SIZE=32 | packages/worker-bus/src/taskQueue.ts |
| Infinite AI retry loops | Exponential backoff with cap (30s) | services/ai/aiRetry.ts |
| Malicious plugin CPU burn | Worker isolation with timeout | workers/plugin.worker.ts (P0-2) |
Public claude-proxy endpoint used as an open relay / resource-exhaustion surface (CWE-400) |
Zod schema validation, 256 KiB body-size cap (checked via header and actual body length), same-origin check, per-client in-memory rate limit (20 req/60s), 20s outbound timeout to Anthropic | api/_shared/claudeProxyCore.ts |
| Threat | Mitigation | Code Location |
|---|---|---|
| Plugin accessing unauthorized APIs | Permission gate in sandboxed API | services/pluginRegistry.ts:PERMISSION_API_MAP |
| Plugin cross-storage access | Namespace prefix + length/character/traversal validation | services/pluginRegistry.ts:validatePluginStorageKey() |
| Plugin storage DoS | Serialized value size cap (2 MiB) | services/pluginRegistry.ts:validatePluginStorageValue() |
| Collaboration without password | CollabEncryptionRequiredError | services/collaborationService.ts:connect() |
| Feature flag bypass | Runtime gate checks | features/featureFlags/featureFlagsSlice.ts |
Goal: Inject malicious prompt to extract/manipulate manuscript data
ββ OR: Direct user input in AI prompt
β ββ Mitigation: sanitizePromptBlock() strips control chars, fences
ββ OR: RAG context poisoning
β ββ Vector embedding manipulation
β β ββ Mitigation: RAG source validation, embedding integrity
β ββ Lexical index poisoning
β ββ Mitigation: Index sanitization on write
ββ OR: Plugin-generated prompts
β ββ Mitigation: Plugin sandboxed API, no direct prompt access
ββ OR: AI-proposed edits carrying malicious control characters
ββ Mitigation: validateProposedText() rejects C0 controls, null bytes, lone surrogates
Goal: Access app state outside plugin permissions
ββ OR: Dynamic import in main thread
β ββ Mitigation: Worker isolation (P0-2)
ββ OR: Prototype pollution
β ββ Mitigation: Zod validation, frozen globals
ββ OR: Resource exhaustion
β ββ Mitigation: Worker timeout, circuit breaker
ββ OR: Cross-plugin storage access
β ββ Mitigation: `plugin:${id}:` prefix + length/char/traversal validation
ββ OR: Plugin storage DoS
β ββ Mitigation: 2 MiB serialized value size cap
ββ OR: Crypto key extraction
ββ Mitigation: Non-extractable CryptoKey, no key export
Goal: Intercept/decrypt collaboration traffic
ββ OR: Signaling server compromise
β ββ Password strength weakness
β β ββ Mitigation: PBKDF2 600k iterations
β ββ Room name enumeration
β ββ Mitigation: Deterministic salt from projectId
ββ OR: RTCDataChannel interception
β ββ Mitigation: AES-256-GCM E2E encryption
ββ OR: Awareness state tampering
ββ Mitigation: Encrypted awareness payload
Goal: Recover a user's cloud-provider API key from the Tauri desktop install
ββ OR: Read the Tauri AppData filesystem directly (local process / malware with user-level FS access)
β ββ Mitigation: RESOLVED 2026-08-14. API keys are not stored there β `storageService` routes every
β key operation to the WebView's IndexedDB key store regardless of platform; the filesystem
β adapter's own key-write path is a defense-in-depth backstop that throws rather than
β persisting. Legacy derived-key files are removed best-effort (logged, not guaranteed) during
β startup cleanup. (Previously not resolved: the pre-2026-08-14 PBKDF2 derivation input was
β public/reconstructible β see the Information Disclosure table above for that history.)
ββ OR: Read the IDB-at-rest passphrase sentinel (enableIdbAtRestEncryption)
β ββ Mitigation: same PBKDF2 + non-extractable-key pattern; session-scoped in-memory key, never
β persisted to disk (`services/storage/storageEncryptionService.ts`)
ββ OR: Tamper with the CSP to re-enable a weaker script-src and inject a key-exfiltration script
ββ Mitigation: strict Tauri connect-src allowlist (no `https:` blanket); CSP is bundled into the
signed app binary, not user-editable at runtime without re-signing (ADR-0004, ADR-0013)
| Component | Threat | Mitigation | Status |
|---|---|---|---|
storageEncryptionService.ts |
I | AES-256-GCM, PBKDF2 600k, extractable:false | β Complete |
collaborationService.ts |
S,T,I | Password-derived key, E2E encryption | β Complete |
pluginRegistry.ts |
E,D | Permission gate, sandboxed API | β Complete (P0-2: worker isolation via plugin.worker.ts) |
aiPolicy.ts |
S | Provider allowlist, localStorageOnly gate | β Complete |
logger.ts |
I,R | GDPR sanitization, no key logging | β Complete |
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 uses the explicit shared origin list; no https:/http:/ws: wildcards; runtime preflight rejects unlisted configured endpoints |
β Complete (ADR-0004) |
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. |
script-src (all 5 CSP surfaces) |
D (denial of advertised functionality) | 'wasm-unsafe-eval' (not 'unsafe-eval') β WebAssembly compile/instantiate for WebLLM/ONNX/Transformers.js/DuckDB-WASM/Whisper/Kokoro; plugin-sandbox WASM denial (workers/plugin.worker.ts) is a separate JS-level guard, unaffected |
β Complete (ADR-0013) β was absent 2026-05-27 to 2026-07-29, blocking the entire local-inference stack in production (F-01/F-02) |
api/_shared/claudeProxyCore.ts (Vercel Edge Function + Cloudflare Pages Function) |
I, D | Schema validation, body-size cap, same-origin check, per-client rate limit, outbound timeout, no logging of key/prompt/response on any path | β Complete (ADR-0016 Track B) |
The web PWA and Tauri surfaces now use the same explicit origin list from
config/csp-connect-src.json, generated into every deployment surface by scripts/sync-csp.mjs.
The shipped openAiCompatibleBaseUrl feature therefore supports only explicitly admitted origins;
an arbitrary custom proxy is rejected before fetch with a clear policy error rather than appearing as
an opaque network failure. http:/ws: scheme-wildcards remain disallowed (cleartext exfiltration
blocked). Tauri's native HTTP plugin is a separate transport and may be used for admitted local
desktop endpoints; browser-local requests are checked by the same runtime preflight. Regression
coverage includes the shared policy, all seven emitted CSP strings, and endpoint rejection in
tests/unit/csp.test.ts, tests/unit/cspCorrectness.test.ts, and tests/unit/cspOriginPolicy.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.
From 2026-05-27 (faad8f0) to 2026-07-29, script-src was 'self' with no 'wasm-unsafe-eval'
on any of the 5 deployment surfaces, so WebAssembly.instantiate was blocked in every Chromium
browser in production β the entire advertised local-inference stack (WebLLM, ONNX Runtime Web,
Transformers.js, DuckDB-WASM, Whisper-STT, Kokoro-TTS) never functioned. No test caught this: the
existing CSP tests (Layer A) only assert cross-surface consistency, and scripts/smoke-prod-build.mjs
listened only for pageerror, which CSP violations never fire (they surface as console warnings and
securitypolicyviolation DOM events instead). This is now closed with 'wasm-unsafe-eval' (never
the broader 'unsafe-eval') plus two new test layers: Layer B (tests/unit/cspCorrectness.test.ts
β functional-directive assertions across all 5 surfaces, including a check that would have caught the
inline-script defect on day one) and Layer C (the hardened smoke-prod-build.mjs, which now
captures both violation channels and runs a real WebAssembly.instantiate probe in headless
Chromium). Does this weaken the plugin sandbox? No β workers/plugin.worker.ts sets
self.WebAssembly = undefined before executing untrusted plugin code and restores it on both the
success and error paths, independent of CSP; the adversarial tests in
tests/unit/workers/plugin.worker.test.ts remain green, unaffected. Full decision record:
docs/adr/0013-csp-wasm-and-blob-frames.md.
Every cloud AI provider in WorldScript except Claude-on-web is a direct browserβprovider call β
the user's API key leaves their machine and goes straight to Gemini/OpenAI/Grok/OpenRouter, never
touching WorldScript's own infrastructure. This is the one exception. Anthropic blocks direct
browser requests entirely (no CORS allowlist WorldScript can request), so the web/PWA build (Vercel,
Cloudflare Pages β not GitHub Pages, which is static-only and can host neither function) relays
Claude calls through api/claude-proxy.ts / functions/api/claude-proxy.ts, both thin
platform-specific wrappers around the shared api/_shared/claudeProxyCore.ts relay. Concretely: the
user's browser β WorldScript's own Vercel/Cloudflare deployment β api.anthropic.com. The desktop
app (Tauri, ADR-0012's native-HTTP escape hatch β see ADR-0016 Track A)
does not go through this proxy; it calls Anthropic directly, matching every other provider's
trust model.
Statelessness guarantee: the proxy is a pure relay. It never writes the API key, prompt, or
response to any log, database, or cache β tests/unit/api/claudeProxyCore.test.ts asserts
console.log/.warn/.error are never called on any code path (success, validation failure, rate
limit, upstream error). Abuse controls (the endpoint is public and unauthenticated by
necessity β it exists so any user's own browser can reach it): Zod schema validation, a 256 KiB
body-size cap enforced against both the declared Content-Length header and the actual received
body length (defeats a spoofed header), a same-origin check (Origin header must match the
deployment's own host β rejects third-party pages driving traffic through the proxy with a stolen
or attacker-supplied key), a best-effort per-client-IP rate limit (20 requests/60s, in-memory β
genuinely per-instance, not distributed; see the code comment for why a platform KV/rate-limit
product was judged out of scope), and a 20s timeout on the outbound call to Anthropic so a hung
upstream can't tie up function instances indefinitely.
What this does not change: the proxy never sees the user's manuscript content in a way it
didn't already see as the request body β it is a transit point, not a new data store. It also
doesn't affect the CSP tradeoff above (ADR-0004): the clientβproxy leg is same-origin ('self',
already allowed), and the proxyβAnthropic leg is server-side, never subject to browser CSP at all.
Monitoring / anomaly detection: the proxy intentionally does not perform in-app logging of
requests, rate-limit hits, or errors β tests/unit/api/claudeProxyCore.test.ts enforces a hard
zero-console-call guarantee on every path, and adding request-level logging here would violate that
stateless contract. Instead, rely on the hosting platform's own request-level observability:
Vercel Function Logs or Vercel Observability (primary deployment) for request volume, status
codes, and latency; on Cloudflare Pages, Cloudflare Workers Metrics/Analytics and Workers
Logs provide the equivalent view β note that Workers observability (Logs) is opt-in and must be
explicitly enabled in the Worker's Wrangler configuration (observability.enabled = true) before
it captures anything. Spikes in 429 (rate-limited) or 4xx responses on the claude-proxy
route are the actionable signal for abuse; alert thresholds should be configured directly in the
platform dashboard, not in application code.
- PBKDF2 iterations β₯ 600,000 (OWASP 2024 minimum)
- CryptoKey extractable = false everywhere
- IV uniqueness per operation (random 12-byte)
- No API keys in localStorage/sessionStorage
- No console.log of sensitive data
- CSP connect-src: shared explicit origins on all surfaces; runtime preflight rejects unlisted endpoints (ADR-0004)
- Collaboration requires password in production
- Plugin system permission-gated
- Plugin system Worker-isolated (P0-2) β
workers/plugin.worker.ts - DuckDB analytics privacy-gated (SEC-6) β writes require
enableDuckDbAnalyticsand the Settings β Privacy "Analytics" opt-out (isAnalyticsPersistenceAllowed,app/listenerMiddleware.ts); only local metadata is stored, nothing leaves the device. - DuckDB cell-level excerpt encryption (SEC-6, v1.25.0) β
codex_mentions.excerpt(the one column holding literal manuscript prose) is AES-256-GCM encrypted intoexcerpt_enc BLOBand nulled from the plaintext column whenenableIdbAtRestEncryptionis active, with a backfill migration for pre-existing rows (services/duckdb/codexExcerptEncryptionMigration.ts). - DuckDB OPFS file-level encryption (SEC-6) β infeasible / accepted risk, not deferred-pending-work. DuckDB-WASM owns the OPFS file handle directly (
workers/v2/duckdb.worker.ts), leaving no app-level interception point for transparent file encryption. Remaining plaintext metadata columns (title/logline/name/character_names/label) are bounded-exposure by design, not manuscript prose. - Voice WASM download UX (P0-5) β
components/voice/VoiceModelDownloadModal.tsx - Claude web proxy (ADR-0016 Track B): stateless (no key/prompt/response logging), schema-validated, body-size-capped, same-origin-checked, rate-limited, timeout-bounded β
api/_shared/claudeProxyCore.ts
- OWASP 2024 Password Storage Guidelines
- NIST SP 800-63B Digital Identity Guidelines
- CWE-200: Exposure of Sensitive Information
- CWE-79: Cross-site Scripting (XSS)
- CWE-89: SQL Injection (N/A - no SQL backend)