reviews: Q195 S0 WordCraft security review and editor CSP measurement (#1798)

Conditional pass on WordCraft e05b7fe8: complete inventory, license
notices resolved, findings F1-F15 with mandatory mitigations M1-M14.
CSP: style-src 'self' records one rfd inline-style violation on the
unmodified artifact, the hash variant records none; the hash is held
for Mos under D8.
This commit is contained in:
jason.woltje committed 2026-10-22 18:58:36 -05:00
1 parent b7daf5198b
commit 85ae2be773
5 files changed
+734 -48

No files matched your search

+2
View File
@@ -5358,3 +5358,5 @@ Gander: after Duckworth-0100 design approval with C1-C5, prepared the two host-s
2026-10-22T23:13:27Z | Gyro Q195 O2 fenton-0007 F7/F8 | Named human-only editor.saveGroupPauseMs=2000 and editor.saveGroupMaxMs=15000 with integer/range/cross-field validation and W3a UI/CLI refusal tests. W3b owns production adoption refusal until W4b; isolated fixture conversion remains testable without request-flag bypass. W4b owns adoption module/API tests and reviewed lifting only with committed-reader/citation readiness and, if Q36 approves, the separate notice row PASS. Q36 unresolved stays closed. Static ten-brief, setting-key/bounds, refusal/lift ownership and two-question checks pass. Docs only; no build, browser/worker run, product/queue/live change. Fenton checks F7/F8 on next push. 2026-10-22T23:13:27Z | Gyro Q195 O2 fenton-0007 F7/F8 | Named human-only editor.saveGroupPauseMs=2000 and editor.saveGroupMaxMs=15000 with integer/range/cross-field validation and W3a UI/CLI refusal tests. W3b owns production adoption refusal until W4b; isolated fixture conversion remains testable without request-flag bypass. W4b owns adoption module/API tests and reviewed lifting only with committed-reader/citation readiness and, if Q36 approves, the separate notice row PASS. Q36 unresolved stays closed. Static ten-brief, setting-key/bounds, refusal/lift ownership and two-question checks pass. Docs only; no build, browser/worker run, product/queue/live change. Fenton checks F7/F8 on next push.
2026-10-22T23:13:48Z | Gyro mos-1359 / r3 push guard | D13 records save-group timing as Mos's accepted reading of Q226 F41, not Jason's ruling; both defaults remain human-only settings. Push guard identity gyro/purpose push: exit 0 documented degraded-pass, CANNOT_ASSERT credential-unresolvable; audit gyro-q195-ci-queue-audit.jsonl, no credential fallback or CI-green claim. 2026-10-22T23:13:48Z | Gyro mos-1359 / r3 push guard | D13 records save-group timing as Mos's accepted reading of Q226 F41, not Jason's ruling; both defaults remain human-only settings. Push guard identity gyro/purpose push: exit 0 documented degraded-pass, CANNOT_ASSERT credential-unresolvable; audit gyro-q195-ci-queue-audit.jsonl, no credential fallback or CI-green claim.
2026-10-22T23:58:36Z | Fenton Q195 O2 S0 (#1798, sage-3058 under mos-1360) | Before: partial inventory b7daf5198, no verdict. After: CONDITIONAL PASS on WordCraft e05b7fe8. Inventory 15 local / 199 normal / 10 build-only / 25 build scripts / 10 proc-macros, 3 git deps by commit. Licenses resolved by a notice bundle (L1: epaint fonts missing from WordCraft NOTICE; self_cell and ryu elect Apache-2.0). Findings F1-F15 (F1 wuff WOFF1 orig_length allocation trap reachable from DOCX open, source only), mandatory mitigations M1-M14 with rejection tests. Browser on the copied artifact: style-src self 1 violation (rfd inline style, removed by host-owned Open), style-hash 0 violations, all functions run; Print blob iframe and cross-origin frame navigation blocked. D8 hash fallback returned to Mos, not recommended. Corrections: style-hash attempt 1 had a harness-made connect-src violation (rerun clean); Print evidence copied from the transcript. No compile, no Console/policy/live change, loopback-only fixtures. Filbert reviews.
+1
View File
@@ -1552,3 +1552,4 @@ are never rewritten or removed; corrections are new entries.
2026-10-22T23:03:39Z | Gyro (Codex gpt-6.1-sol, mos-1356) | Q195/Q231/Q233 O2 design rewrite, gyro/editor-o2 from c4f77373f | Canonical queue next gyro: nothing. Ten docs-only briefs; full-feature WordCraft, independent 15-crate/199-package security review, editor-only CSP candidate, instant git DOCX saves and retained Markdown/wiki sources, committed-only DOCX citation gate, no agent story prose. Static structure/source-anchor/scope checks only; no code, build/run, queue or live change. Fenton review requested after push. 2026-10-22T23:03:39Z | Gyro (Codex gpt-6.1-sol, mos-1356) | Q195/Q231/Q233 O2 design rewrite, gyro/editor-o2 from c4f77373f | Canonical queue next gyro: nothing. Ten docs-only briefs; full-feature WordCraft, independent 15-crate/199-package security review, editor-only CSP candidate, instant git DOCX saves and retained Markdown/wiki sources, committed-only DOCX citation gate, no agent story prose. Static structure/source-anchor/scope checks only; no code, build/run, queue or live change. Fenton review requested after push.
2026-10-22T23:09:38Z | Gyro (Codex gpt-6.1-sol, fenton-0006) | Q195 O2 design corrections on gyro/editor-o2 | F1 requirement boundary sent to Mos, adoption gated; F2-F6 source-checked docs fixes prepared. Ten briefs and two open owner items checked statically. No implementation, build/browser/worker run, queue or live action; Fenton re-check after push. 2026-10-22T23:09:38Z | Gyro (Codex gpt-6.1-sol, fenton-0006) | Q195 O2 design corrections on gyro/editor-o2 | F1 requirement boundary sent to Mos, adoption gated; F2-F6 source-checked docs fixes prepared. Ten briefs and two open owner items checked statically. No implementation, build/browser/worker run, queue or live action; Fenton re-check after push.
2026-10-22T23:13:27Z | Gyro (Codex gpt-6.1-sol, fenton-0007) | Q195 O2 r3 F7/F8 | Save-group keys/defaults/validation and W3b refusal/W4b lift ownership explicit. Static docs checks only. Q36 remains open; no implementation or gate lift. Direct Fenton/Mos handoff after push. 2026-10-22T23:13:27Z | Gyro (Codex gpt-6.1-sol, fenton-0007) | Q195 O2 r3 F7/F8 | Save-group keys/defaults/validation and W3b refusal/W4b lift ownership explicit. Static docs checks only. Q36 remains open; no implementation or gate lift. Direct Fenton/Mos handoff after push.
2026-10-22T23:58:36Z | Fenton (Claude Opus 5.5, sage-3058) | Q195 O2 S0 WordCraft source security and CSP review, fenton/q195-s0 | CONDITIONAL PASS, M1-M14 mandatory, D8 style-src hash held for Mos. Reviews docs only; Filbert review requested.
+181
View File
@@ -0,0 +1,181 @@
# Q195 O2 S0: editor frame CSP measurement
Status: COMPLETE. Feeds the S0 verdict in `2026-10-22_q195-wordcraft-security.md`.
- Review id `s0-20261022`, Gitea #1798. Owner: Fenton. Reviewer: Filbert.
- Spec: `docs/plans/2026-10-21_q195-writing-editor.md` blob `322ec50f` at e36bce352, D7 and D8.
- Scratch: `/mnt/storage/scratch/q195-csp-review/s0-20261022/` (called R below). Receipts are in
`R/receipts/`.
- Nothing was compiled. No Console code, policy or live stack was touched. The harness is a
loopback-only stand-in server with fixture data, and the browser had no external network use:
every request in every run went to 127.0.0.1.
## What was measured
The unmodified WordCraft 0.9.0 web build, framed same-origin under a stand-in shell that sends
the Console shell CSP exactly. Three candidate frame policies differ only in `style-src`.
### Artifact
Copied from Louie's build (origin/louie/wordcraft-eval 880ef19e0); each hash matches Louie's receipt.
| File | SHA-256 |
|---|---|
| index.html (not served; reference only) | `361c9ec8eda95afa7483fef1f6e95270a199cfe59591285096bf22cb06bfef43` |
| wordcraft-fa59e09a3f921e03.ico | `53a23dcf66260f44d87d4d143d633395f3e967f391eda47e6e9cc3a52f820225` |
| wordcraft-web-88f7089f25a3b338.js | `866a1de6a33eff2ded8ae9588fd049aeacfc42557a3863ccd2107b3faf4d691e` |
| wordcraft-web-9bda74d1c7bc6623_bg.wasm | `90dec375924b47b40054eb4ecafb4235b6ed0bd63880b91ec138fdbb6f939e75` |
### Wrapper and harness (mine, scratch only)
| File | SHA-256 |
|---|---|
| www/frame.html | `6a8d141fa8b073cff73fa666fdf030a9f91030924e74a6aa0a441d76a5583f82` |
| www/frame-bootstrap.js | `32c8f71acfe14f3bdc4d31534ed3a84609fc4fb1fb8a1a4a96f56eb28f39d3d4` |
| www/frame.css | `629eb32e9cdf6b97f0ce79f841419ab4a80d42f322c10948ec5d23198799111e` |
| www/shell.html | `cf94f1ce359e8b90f86d39cdef98894d7874e97da9cf7eb0808b0f6e038b3bac` |
| www/shell.css | `372716168beb22dd9fbc7e3d45a76bc06c1b3b1d8f59807a187700689d9ff38c` |
| harness/serve.py | `ad34aed5cdf49a77128778f5f6523698d35f5a6eca771e1dbe53941f09fabe80` |
| harness/instrument.js | `028e914a994a518043dab0b834a9917fc67bc35730f9e3725b59ebd8fa8fb0fa` |
| fixtures/s0-full-feature.docx (9985 B) | `b40eb5875518701d10de1a5419931f02e10c378560fbd5ab6a2ed68d19be53f4` |
The frame wrapper loads the wasm-bindgen module from `frame-bootstrap.js`; it has no inline
script or inline style. The fixture is generated by `harness/make_fixture.py`. It carries
two sections (portrait, then landscape), a header and footer, headings, a table, a small PNG, an
external hyperlink to `https://example.invalid/` and wikilinks. It has no embedded font and no
real writing.
Browser: Chrome 150.0.0.0, Linux x86_64, driven through DevTools MCP isolated contexts. WebGPU
reported "No available adapters", so wgpu ran on its GL backend.
## Headers
Shell (stand-in for `serve.mjs:605`, unchanged, no SSO), sent on `/shell.html`, `/shell.css`,
assets, fixtures and every 404/405:
```
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'
```
Frame, sent only on `/editor/wordcraft/frame`:
```
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src <V>; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; frame-src 'none'; form-action 'none'
```
| Variant | `<V>` |
|---|---|
| style-self | `'self'` |
| style-hash | `'self' 'sha256-9tSqG2Th9gNn6sbn1ZYMSqakJLznxnsamRDeigez2Mo='` |
| style-inline | `'self' 'unsafe-inline'` (defined, not run: the first two answered the question) |
Every response also carries `x-content-type-options: nosniff`, `referrer-policy: no-referrer`
and `cache-control: no-store`. Routes are fixed (`/shell.html`, `/shell.css`,
`/editor/wordcraft/frame`, five allowlisted asset names, `/fixtures/<name>`). Anything else is a
404, and every non-GET method is a 405. Each request and its exact headers are logged to
`requests-<variant>.jsonl`.
Note on the shell header: the stand-in shell sends `frame-ancestors 'none'` as Console does.
The frame is framed by the shell, not the other way round, so this does not affect the
measurement. The real adapter's frame route sends the frame header above.
## Results
| Run | Port | CSP violations | Functions | Receipts |
|---|---|---|---|---|
| style-self full pass | 7391 | **1**: `style-src-elem`, blocked `inline`, js line 93 | all | `full-style-self-events.json`, `requests-style-self.jsonl` |
| style-hash clean pass | 7392 | **0** | all | `clean-style-hash-events.json`, `requests-style-hash.jsonl` |
| frame navigation test | 7392 | 1 in the shell, intended: `frame-src`, enforce | n/a | `browser-console-network.txt` |
| Print probe | 7392 | 1, intended: `frame-src`, blocked `blob` | Print blocked | `print-probe.json` |
Each full pass made exactly 8 requests: the 8 routes, all from 127.0.0.1, all 200. The fixture
fetch is one of them. No other origin was contacted.
The one style-self violation fired between the file-picker click and the fixture feed. Source:
rfd 0.17.2 `src/backend/wasm.rs:127-129` appends its `wasm/style.css` as a `<style>` element. Its
SHA-256 is the hash in the style-hash variant, and the console message names the same hash.
Nothing else in the app adds inline style. eframe and egui write styles through CSSOM, which
`style-src` does not govern. Blocking the `<style>` did not stop any function: the picker still
opened and the fixture loaded.
### Function coverage
Every function in both full passes ran on the same fixture. Event logs record each one.
| Function | Evidence (both passes) |
|---|---|
| Load and start | wgpu GL backend, event handlers installed |
| Open (Browse) | `input[type=file]` click, accept `.docx,.docm,.dotx,.dotm,.doc,.dot,.odt,.rtf,.txt,.md,.html,.htm,.tex,.json,.xml`; fixture fed, title changes to `s0-full-feature` |
| Edit | text inserted and the title gains the dirty mark (screenshots `clean-02`, `selffull-03`) |
| Resize | 1024x700 (style-self also 1280x800 then back) |
| Hyperlink ctrl+click | `window.open("https://example.invalid/", "_blank")` |
| Discord title button | `window.open("https://discord.gg/artcraft", "_blank")` |
| Save As (.docx) | `showSaveFilePicker`, then `createObjectURL` + anchor download, 10382 / 10386 B |
| Export PDF | same path, 29014 / 29085 B |
| Export HTML | same path, 1678 / 1689 B |
| Export PNG | same path, 126467 / 128310 B |
| About links | three `window.open(..., "_self")`: getartcraft.com, discord.gg, github.com |
| Sidebar community | `window.open("https://discord.gg/artcraft", "_blank")` |
Byte counts differ between runs because the typed edit text differs.
Print is not counted in the zero-violation result. D7 removes WordCraft's hidden blob PDF
iframe (`web.rs:548-604`) with no blob-frame exception. The probe confirms that path creates
`iframe#wordcraft-print-frame` with a `blob:` PDF and that `frame-src 'none'` blocks it. Print
under the adapter is host-owned and must be measured in W2.
Storage after the style-hash pass: `localStorage["wordcraft.ui"]` (1958 chars, including the
recent file list and author), sessionStorage empty, no cookies. An earlier run also showed the
IndexedDB `wordcraft` font store. See finding F9 in the security review.
### Frame navigation
After the style-hash pass the frame ran `location.href = "http://127.0.0.1:7391/shell.html"`,
another loopback origin. The shell CSP blocked it: `frame-src` falling back to `default-src
'self'`, disposition enforce. No request reached port 7391. The only new requests were three
`data:image/png` loads from Chrome's own blocked-frame error page. Same-origin navigation is not
blocked by CSP. That case needs the sandbox and host link gate (mitigation M7).
## Harness changes, disclosed
The harness changes behaviour in these places. Each one stands in for a host or OS
surface the scratch harness cannot provide.
1. `instrument.js` is injected into the frame by DevTools after load. It is never served. It
replaces `window.open` (records, returns null), `showSaveFilePicker` (records, rejects with
SecurityError so WordCraft's anchor fallback runs), file-input `click` (feeds the fixture from
`/fixtures/`), anchor `click` (records, drops the download) and wraps `URL.createObjectURL`.
A byte-identical copy is `receipts/instrument-as-injected.js`.
2. The `showSaveFilePicker` stub is an async function from the shell realm. WordCraft's
same-realm Promise check rejects it, which logs `web.rs:405: showSaveFilePicker failed: not a
promise` and an unhandled `SecurityError: S0 harness stub`. Both are harness artefacts. The
fallback path that ran is the one a picker failure or refusal takes.
3. DevTools keyboard typing does not reach egui. The edit was a synthetic
`InputEvent('input', {inputType: 'insertText'})` on eframe's hidden text-agent `<input>`. It
exercises the same egui text path. It is not real keyboard input.
4. Clicks are synthetic pointer and mouse events on the canvas (`__tap`).
5. style-hash attempt 1 recorded one `connect-src` violation from my own instrumentation: its
anchor stub fetched the `blob:` URL to measure it. The violation's source was DevTools
(`pptr`), not WordCraft. I removed the fetch and reran clean. Attempt 1's log is kept as
`requests-style-hash-attempt1.jsonl`.
6. style-self attempt 1 ran on an earlier serve.py that predates the current hash and covered
load and open only. Its log is kept as `requests-style-self-attempt1.jsonl` and
`server-style-self-attempt1.log`. The style-self result above is the full pass on
serve.py `ad34aed5`.
7. The Print probe's event excerpt was copied from the session transcript because it was not
saved to a file at the time (`print-probe.json` says so).
## Recommendation for D8
1. The final frame header uses `style-src 'self'`, with no hash, nonce or `'unsafe-inline'`.
The rest is as measured above.
2. D7 already requires host-owned Open/import, which replaces `Services.open_async`
(`web.rs:216-224`). That is the only web caller of rfd, so the measured violation goes away
with it. Mitigation M5 makes this a gate.
3. W2 re-measures the adapter build under `style-src 'self'` and must record 0 violations with
full function coverage, including host-owned Print.
**Held for Mos.** On the unmodified artifact, `style-src 'self'` is not violation-free. The
measured fallback is the rfd hash above. D8 says any extra source is a new allowance, so I am
not recommending it. It is returned to Mos as the delta. If the adapter cannot remove rfd from
the web path, the choice between the hash and stopping the change is Mos's to make.
@@ -23,10 +23,304 @@
"buildScripts": 25, "buildScripts": 25,
"procMacros": 10 "procMacros": 10
}, },
"verdict": "in-progress", "verdict": "conditional-pass: baseline source acceptable only with mandatory mitigations M1-M14 adopted as S1/W gates; D8 style-src question returned to Mos (style-src 'self' measured 1 violation on the unmodified artifact, attributed to rfd and removed by M5)",
"findings": [], "findings": [
"mandatoryMitigations": [], {
"packageNotes": {}, "id": "F1",
"severity": "medium",
"blocking": "mitigated-by M1",
"evidence": "source",
"title": "WOFF1 table decompression trusts orig_length (allocation trap)",
"detail": "wuff 0.2.9 decompress_woff1.rs:96 passes table.orig_length (u32 from file) to decompress_z, which calls Vec::with_capacity(size_hint) at :15. Only check is woff_length < orig_length. Reached on document open: engine/io.rs:164-182 open_bytes_in -> load_embedded_fonts -> docx fonts.rs:30 embedded_fonts (no fontKey: stored bytes as is) -> fonts user.rs:143-163 decode_font_file. MAX_FONT_FILE_BYTES 50 MiB caps input; MAX_DECODED_BYTES 128 MiB is checked after decode. user.rs:166 catch_unwind does not help on wasm32-unknown-unknown (panic=abort, no profile override) and allocation failure aborts regardless. Effect: wasm instance trap, unsaved edits lost. Also reachable from Add Font. Not reproduced in the browser."
},
{
"id": "F2",
"severity": "medium",
"blocking": "mitigated-by M2",
"evidence": "source",
"title": "DOCX hyperlink targets are not scheme-filtered on import",
"detail": "docx read/story.rs:485-496 keeps the relationship target as is (javascript:, data:, file:, custom schemes). is_safe_href exists but is used only by the latex/markdown/html exporters. Ctrl+click passes the target to window.open (browser: https://example.invalid/ _blank recorded). DOCX re-export writes it back."
},
{
"id": "F3",
"severity": "medium",
"blocking": "mitigated-by M3",
"evidence": "source+browser",
"title": ".json opens as a native serde Document, bypassing package caps",
"detail": "engine/io.rs:202 serde_json::from_slice::<Document> for the json extension. The web file input accept list includes .json (browser). No size or depth caps apply to this path beyond serde_json's recursion limit."
},
{
"id": "F4",
"severity": "medium",
"blocking": "mitigated-by M4",
"evidence": "agent-reported (spin cap source-verified)",
"title": "Main-thread resource exhaustion on import",
"detail": "Encrypted DOCX spin count up to 10,000,000 (crypt.rs:31, verified) runs on the UI thread. ODT has no package total. CFB caps at 1 GiB. image decoder default limits 512 MiB (objects.rs:1129, crop.rs:292). zip has no decompression ratio limit beyond per-part/total caps on DOCX (256 MiB part, 512 MiB total, 20k entries, depth 256, 8M elements). The same-origin frame shares the shell's renderer process, so a hang or OOM takes the Console tab."
},
{
"id": "F5",
"severity": "low",
"blocking": "mitigated-by M6",
"evidence": "source (package.rs) + agent-reported (embed paths)",
"title": "Active-content passthrough on round trip",
"detail": "docx package.rs keeps vbaProject (:40, :83, :87) and attachedTemplate (:27). OLE objects keep external relationships and are re-emitted (read/embed.rs:137-190, write/embed.rs:266). WordCraft never executes them, but a saved file carries them to a desktop Office user."
},
{
"id": "F6",
"severity": "medium",
"blocking": "mitigated-by M7",
"evidence": "browser+source",
"title": "Outbound navigation via window.open",
"detail": "Measured: hyperlink ctrl+click https://example.invalid/ _blank; Discord title button https://discord.gg/artcraft _blank (lib.rs:1683-1686, show_discord defaults true lib.rs:376); About links getartcraft.com, discord.gg, github.com _self; sidebar community _blank. Path: ui-egui lib.rs:2244-2245 ctx.open_url -> eframe web open_url. The frame navigating itself to a cross-origin shell URL was blocked by frame-src (shell CSP default-src fallback); nothing reached the target server."
},
{
"id": "F7",
"severity": "low",
"blocking": "mitigated-by M8",
"evidence": "source+browser",
"title": "Print uses a hidden blob PDF iframe",
"detail": "web.rs:541-604 creates iframe#wordcraft-print-frame with a blob: application/pdf. Blocked by frame-src 'none' (browser). D7 requires removing it, no blob-frame exception."
},
{
"id": "F8",
"severity": "medium",
"blocking": "mitigated-by M5",
"evidence": "source+browser",
"title": "Browser-owned Save/Open: File System Access picker and anchor-download fallback",
"detail": "web.rs:398-451 showSaveFilePicker, fallback createObjectURL + a.download (every save/export measured both). Open uses rfd via Services.open_async (web.rs:216-224) with an <input type=file>. These bypass the host-owned Open/Save that D7 requires."
},
{
"id": "F9",
"severity": "low",
"blocking": "mitigated-by M9",
"evidence": "source+browser",
"title": "Origin storage: localStorage prefs and IndexedDB fonts",
"detail": "prefs.rs writes localStorage wordcraft.ui (measured: recent file names, author, user dictionary, autocorrect and UI keys). font_store.rs keeps user fonts in IndexedDB wordcraft. Same origin as the Console shell."
},
{
"id": "F10",
"severity": "low",
"blocking": "mitigated-by M10",
"evidence": "source",
"title": "queryLocalFonts call",
"detail": "web.rs:274-301 calls window.queryLocalFonts (Local Font Access; permission prompt, fingerprinting surface)."
},
{
"id": "F11",
"severity": "low",
"blocking": "mitigated-by M11",
"evidence": "source",
"title": "Unconditional document paste listener",
"detail": "web.rs ~203 adds a paste listener and forgets the closure; clipboard HTML/image content enters the importer with the same caps as files."
},
{
"id": "F12",
"severity": "low",
"blocking": "no",
"evidence": "source",
"title": "innerHTML on start failure",
"detail": "web.rs:89 set_inner_html with format!(\"{e:?}\") of the eframe start error. Not document-derived today; use textContent in the adapter."
},
{
"id": "F13",
"severity": "info",
"blocking": "no",
"evidence": "browser+source",
"title": "rfd injects one inline <style>",
"detail": "rfd 0.17.2 wasm.rs:127-129 appends wasm/style.css as <style>. Sole style-src violation under style-src 'self' (style-src-elem, js line 93). Hash sha256-9tSqG2Th9gNn6sbn1ZYMSqakJLznxnsamRDeigez2Mo= clears it. Functions continue when blocked."
},
{
"id": "F14",
"severity": "low",
"blocking": "mitigated-by M12",
"evidence": "source",
"title": "?sample and ?webgl query flags",
"detail": "apps/wordcraft-web/src/main.rs:10 documents query flags (?webgl forces WebGL2, ?sample opens the sample document) that load a sample document or change the renderer. The frame URL must be fixed by the host."
},
{
"id": "F15",
"severity": "low",
"blocking": "no",
"evidence": "agent-reported",
"title": "Non-hermetic build inputs",
"detail": "wasm-bindgen-shared build.rs runs git rev-parse HEAD; document-features reads Cargo.toml; rustversion reads the compiler and date. No build script or proc-macro uses the network."
},
{
"id": "L1",
"severity": "license",
"blocking": "mitigated-by M13",
"evidence": "source+artifact",
"title": "epaint default fonts are in the wasm but not in WordCraft's NOTICE",
"detail": "epaint_default_fonts embeds emoji-icon-font.ttf (MIT), Hack-Regular.ttf (MIT + Bitstream Vera), NotoEmoji-Regular.ttf (OFL-1.1), Ubuntu-Light.ttf (UFL-1.0). WordCraft NOTICE/ATTRIBUTION omit them. The Mosaic notice bundle must ship OFL.txt, UFL.txt, Hack-Regular.txt and emoji-icon-font-mit-license.txt from the crate."
},
{
"id": "L2",
"severity": "license",
"blocking": "mitigated-by M13",
"evidence": "source",
"title": "Ship WordCraft NOTICE and OFL texts for its bundled fonts and hyphenation data",
"detail": "Inter, JetBrains Mono, Source Sans 3, Source Serif 4 (OFL), hyphenation en-us/it/pl/pt patterns and spelling en-extra/ru-extra are covered by WordCraft NOTICE, which must ship."
},
{
"id": "L3",
"severity": "license",
"blocking": "mitigated-by M13",
"evidence": "source",
"title": "Third-party notice files",
"detail": "subsetter NOTICE (ttf-parser MIT); craft-libs LICENSE-APACHE, LICENSE-MIT, NOTICE at both revs and licenses/pdf-writer at c3487320; encoding_rs WHATWG license; Unicode-3.0 text for unicode-ident, yoke, yoke-derive, zerofrom, zerofrom-derive. Apache-2.0-only: codespan-reporting, unicode-general-category, unicode-linebreak, zopfli. 13 packages carry no license file in the crate (accesskit, craft-pdf, craft-ui, ecolor, eframe, egui, egui-wgpu, emath, epaint, epaint_default_fonts, krilla, profiling, wuff): the bundle supplies the standard text for the declared license."
},
{
"id": "L4",
"severity": "license",
"blocking": "no",
"evidence": "source",
"title": "License elections",
"detail": "self_cell Apache-2.0 OR GPL-2.0-only: elect Apache-2.0. ryu Apache-2.0 OR BSL-1.0: elect Apache-2.0. Record both in the notice bundle."
},
{
"id": "L5",
"severity": "license",
"blocking": "no",
"evidence": "artifact",
"title": "craft-fonts Japanese fonts are absent from this artifact",
"detail": "0 matches for BIZ UDPGothic and Shippori in the wasm. If a later build enables them, their licenses join the bundle."
}
],
"mandatoryMitigations": [
{
"id": "M1",
"for": [
"F1"
],
"requirement": "Reject WOFF/WOFF2 whose declared decoded size (header totalSfntSize and each table origLength) sum exceeds MAX_DECODED_BYTES before any decode, in the adapter or a reviewed WordCraft patch; embedded fonts are subject to the same gate.",
"rejectionTest": "DOCX with an embedded WOFF1 whose table origLength is 0xFFFFFFFF opens with the font refused and the editor still responsive; same file through Add Font is refused."
},
{
"id": "M2",
"for": [
"F2",
"F6"
],
"requirement": "Hyperlink targets pass a host allowlist (https, http, mailto) at import and at activation; anything else is kept as inert text.",
"rejectionTest": "DOCX with javascript:, data:, file: and vbscript: hyperlinks: ctrl+click opens nothing and no window.open call is made."
},
{
"id": "M3",
"for": [
"F3"
],
"requirement": "The host import allowlist excludes .json (and any native format) unless a later reviewed decision adds it with caps.",
"rejectionTest": "Uploading a .json file through host Open is refused before it reaches WordCraft."
},
{
"id": "M4",
"for": [
"F4"
],
"requirement": "Host-side input caps before handoff (file size, zip entry count and total uncompressed size, encrypted documents refused or spin count capped low), and an import watchdog that tears down the frame on stall.",
"rejectionTest": "Zip bomb DOCX, ODT with oversize total, encrypted DOCX with spinCount 10,000,000 and an oversize PNG are each refused or aborted with the shell responsive."
},
{
"id": "M5",
"for": [
"F8",
"F13"
],
"requirement": "Host-owned Open/Save/Export replace Services.open_async and showSaveFilePicker/anchor download on web; rfd is never called. W2 re-measures style-src 'self' with 0 violations.",
"rejectionTest": "Full function pass under style-src 'self' records 0 CSP violations, no input[type=file] click, no showSaveFilePicker and no a.download in the frame."
},
{
"id": "M6",
"for": [
"F5"
],
"requirement": "Strip vbaProject, attachedTemplate and OLE external relationships on save, or refuse .docm/.dotm import.",
"rejectionTest": "A .docm with vbaProject and an attachedTemplate round-trips to a file with neither part."
},
{
"id": "M7",
"for": [
"F6"
],
"requirement": "Frame sandbox without allow-popups and allow-top-navigation; show_discord forced false; About/community links removed or routed through the host link gate.",
"rejectionTest": "Each measured window.open trigger (hyperlink, Discord, About x3, sidebar community) produces no new browsing context and no request off-origin."
},
{
"id": "M8",
"for": [
"F7"
],
"requirement": "Remove the blob print iframe; Print goes through the host. frame-src 'none' stays.",
"rejectionTest": "Print creates no iframe and records no frame-src violation."
},
{
"id": "M9",
"for": [
"F9"
],
"requirement": "No document-derived data in origin storage: recent list and author disabled or host-provided; dictionary/autocorrect and font store either host-owned or in a partition the host clears.",
"rejectionTest": "After open, edit and close, localStorage and IndexedDB hold no file name, author or user word from the fixture."
},
{
"id": "M10",
"for": [
"F10"
],
"requirement": "Local font access disabled (Permissions-Policy local-fonts=() on the frame response, or the call removed).",
"rejectionTest": "queryLocalFonts is undefined or rejects; no permission prompt."
},
{
"id": "M11",
"for": [
"F11"
],
"requirement": "Paste content goes through the same import caps as M4.",
"rejectionTest": "Pasting an oversize image or deep HTML is refused with the editor responsive."
},
{
"id": "M12",
"for": [
"F14"
],
"requirement": "The host serves a fixed frame URL; query strings are ignored or refused.",
"rejectionTest": "Requesting the frame with ?sample or ?webgl returns the same document and behaviour as without."
},
{
"id": "M13",
"for": [
"L1",
"L2",
"L3"
],
"requirement": "Mosaic notice bundle: WordCraft NOTICE and ATTRIBUTION, epaint font licenses, craft-libs notices, subsetter NOTICE, WHATWG and Unicode-3.0 texts, standard texts for every declared license, elections from L4.",
"rejectionTest": "A notice-bundle check lists every package in the inventory and every font file embedded in the wasm with a shipped license text."
},
{
"id": "M14",
"for": [
"residuals"
],
"requirement": "W6 malformed-input corpus covers deep nesting (stack), panics (each is a trap) and the PDF writer.",
"rejectionTest": "Corpus run: every input either opens or is refused; no trap, no hang over the watchdog limit."
}
],
"packageNotes": {
"wuff": "F1: WOFF1 orig_length trusted for allocation (decompress_woff1.rs:15,96)",
"rfd": "F8/F13: web picker via input[type=file]; injects inline <style> (wasm.rs:127-129); removed by M5",
"epaint_default_fonts": "L1: four fonts embedded in the wasm, absent from WordCraft NOTICE",
"krilla": "git dep craft-libs c3487320 vendor/krilla; fs markers are doc/test only; PDF writer reviewed by marker scan only (residual)",
"craft-pdf": "git dep craft-libs c3487320; no license file in crate, repo has LICENSE-APACHE/MIT/NOTICE",
"craft-ui": "git dep craft-libs d27646a5; no license file in crate, repo has LICENSE-APACHE/MIT/NOTICE",
"self_cell": "L4: elect Apache-2.0",
"ryu": "L4: elect Apache-2.0",
"encoding_rs": "L3: ship WHATWG license text",
"subsetter": "L3: ship NOTICE (ttf-parser MIT)",
"wasm-bindgen-shared": "F15: build.rs runs git rev-parse HEAD",
"image": "F4: default 512 MiB decode limits",
"zip": "F4: no decompression ratio limit of its own",
"zune-jpeg": "decoder; covered by the W6 corpus (M14)",
"gif": "decoder; covered by the W6 corpus (M14)"
},
"packages": [ "packages": [
{ {
"name": "accesskit", "name": "accesskit",
@@ -1104,7 +1398,8 @@
"unsafe": 0, "unsafe": 0,
"wasm_bindgen_inline_js": 0 "wasm_bindgen_inline_js": 0
}, },
"manifest_sha256": "02ea7d973efb42d412086db0bdd5cd905cf975667860ca4e7e32c5b4e77fc60b" "manifest_sha256": "02ea7d973efb42d412086db0bdd5cd905cf975667860ca4e7e32c5b4e77fc60b",
"note": "git dep craft-libs c3487320; no license file in crate, repo has LICENSE-APACHE/MIT/NOTICE"
}, },
{ {
"name": "craft-ui", "name": "craft-ui",
@@ -1134,7 +1429,8 @@
"unsafe": 0, "unsafe": 0,
"wasm_bindgen_inline_js": 0 "wasm_bindgen_inline_js": 0
}, },
"manifest_sha256": "88159b4eaacfa21ca796d419fe1acad81c2780db8ac033303de6ff46083be90d" "manifest_sha256": "88159b4eaacfa21ca796d419fe1acad81c2780db8ac033303de6ff46083be90d",
"note": "git dep craft-libs d27646a5; no license file in crate, repo has LICENSE-APACHE/MIT/NOTICE"
}, },
{ {
"name": "crc32fast", "name": "crc32fast",
@@ -1517,7 +1813,8 @@
"unsafe": 193, "unsafe": 193,
"wasm_bindgen_inline_js": 0 "wasm_bindgen_inline_js": 0
}, },
"manifest_sha256": "ad8b14108d47cbcbb2ae80ba3acdb8c5099b8aaea65a759e8b0c8d17ab0f122a" "manifest_sha256": "ad8b14108d47cbcbb2ae80ba3acdb8c5099b8aaea65a759e8b0c8d17ab0f122a",
"note": "L3: ship WHATWG license text"
}, },
{ {
"name": "epaint", "name": "epaint",
@@ -1577,7 +1874,8 @@
"unsafe": 0, "unsafe": 0,
"wasm_bindgen_inline_js": 0 "wasm_bindgen_inline_js": 0
}, },
"manifest_sha256": "1f8ca15b386d6b71272af2e324b40339ed0ada3ff38dd25d6dc571b76c7f5e67" "manifest_sha256": "1f8ca15b386d6b71272af2e324b40339ed0ada3ff38dd25d6dc571b76c7f5e67",
"note": "L1: four fonts embedded in the wasm, absent from WordCraft NOTICE"
}, },
{ {
"name": "equivalent", "name": "equivalent",
@@ -2103,7 +2401,8 @@
"unsafe": 0, "unsafe": 0,
"wasm_bindgen_inline_js": 0 "wasm_bindgen_inline_js": 0
}, },
"manifest_sha256": "46fc2e9680048ac63668bb39b1ce086b9195a142a31d5f82869dbe0ccc417ca9" "manifest_sha256": "46fc2e9680048ac63668bb39b1ce086b9195a142a31d5f82869dbe0ccc417ca9",
"note": "decoder; covered by the W6 corpus (M14)"
}, },
{ {
"name": "glifo", "name": "glifo",
@@ -2432,7 +2731,8 @@
"unsafe": 11, "unsafe": 11,
"wasm_bindgen_inline_js": 0 "wasm_bindgen_inline_js": 0
}, },
"manifest_sha256": "b45592b42e4f065d5258379bac78e2a00ab6e50bbeec265fcceeea9cf5829c26" "manifest_sha256": "b45592b42e4f065d5258379bac78e2a00ab6e50bbeec265fcceeea9cf5829c26",
"note": "F4: default 512 MiB decode limits"
}, },
{ {
"name": "image-webp", "name": "image-webp",
@@ -2692,7 +2992,8 @@
"unsafe": 0, "unsafe": 0,
"wasm_bindgen_inline_js": 0 "wasm_bindgen_inline_js": 0
}, },
"manifest_sha256": "edc07e480c43e421bf098371c01aba2ab66a2c97e0ec0bf03d513eabc074fa87" "manifest_sha256": "edc07e480c43e421bf098371c01aba2ab66a2c97e0ec0bf03d513eabc074fa87",
"note": "git dep craft-libs c3487320 vendor/krilla; fs markers are doc/test only; PDF writer reviewed by marker scan only (residual)"
}, },
{ {
"name": "kurbo", "name": "kurbo",
@@ -3980,7 +4281,8 @@
"unsafe": 245, "unsafe": 245,
"wasm_bindgen_inline_js": 0 "wasm_bindgen_inline_js": 0
}, },
"manifest_sha256": "b98351241be56428b9e422eab911906b68ec80b77d0e41f0ed1a633b9b12312d" "manifest_sha256": "b98351241be56428b9e422eab911906b68ec80b77d0e41f0ed1a633b9b12312d",
"note": "F8/F13: web picker via input[type=file]; injects inline <style> (wasm.rs:127-129); removed by M5"
}, },
{ {
"name": "rustc-hash", "name": "rustc-hash",
@@ -4144,7 +4446,8 @@
"unsafe": 30, "unsafe": 30,
"wasm_bindgen_inline_js": 0 "wasm_bindgen_inline_js": 0
}, },
"manifest_sha256": "410de3eae58a75b9d2558d0a4e3ed43c2c8317781ffbd30a7cbe4aebaa833e1a" "manifest_sha256": "410de3eae58a75b9d2558d0a4e3ed43c2c8317781ffbd30a7cbe4aebaa833e1a",
"note": "L4: elect Apache-2.0"
}, },
{ {
"name": "scopeguard", "name": "scopeguard",
@@ -4210,7 +4513,8 @@
"unsafe": 48, "unsafe": 48,
"wasm_bindgen_inline_js": 0 "wasm_bindgen_inline_js": 0
}, },
"manifest_sha256": "749b8099307e198cce66b130b9905175e51ab6b7c0acf2a7125ef256ab72f316" "manifest_sha256": "749b8099307e198cce66b130b9905175e51ab6b7c0acf2a7125ef256ab72f316",
"note": "L4: elect Apache-2.0"
}, },
{ {
"name": "serde", "name": "serde",
@@ -4835,7 +5139,8 @@
"unsafe": 0, "unsafe": 0,
"wasm_bindgen_inline_js": 0 "wasm_bindgen_inline_js": 0
}, },
"manifest_sha256": "493624d760959a1570db621f3bd052f787c56587c86045fd240356e6c3713754" "manifest_sha256": "493624d760959a1570db621f3bd052f787c56587c86045fd240356e6c3713754",
"note": "L3: ship NOTICE (ttf-parser MIT)"
}, },
{ {
"name": "syn", "name": "syn",
@@ -6025,7 +6330,8 @@
"unsafe": 0, "unsafe": 0,
"wasm_bindgen_inline_js": 1 "wasm_bindgen_inline_js": 1
}, },
"manifest_sha256": "0c881c48b419371c15f715f2909ed2e5291601e33e9fd87a356a3d9eb998e611" "manifest_sha256": "0c881c48b419371c15f715f2909ed2e5291601e33e9fd87a356a3d9eb998e611",
"note": "F15: build.rs runs git rev-parse HEAD"
}, },
{ {
"name": "web-sys", "name": "web-sys",
@@ -6867,7 +7173,8 @@
"unsafe": 0, "unsafe": 0,
"wasm_bindgen_inline_js": 0 "wasm_bindgen_inline_js": 0
}, },
"manifest_sha256": "b54ea2017c862d0d8bd7483dca2abb3f5750c90de7c505630292d263610f39bd" "manifest_sha256": "b54ea2017c862d0d8bd7483dca2abb3f5750c90de7c505630292d263610f39bd",
"note": "F1: WOFF1 orig_length trusted for allocation (decompress_woff1.rs:15,96)"
}, },
{ {
"name": "xmp-writer", "name": "xmp-writer",
@@ -7161,7 +7468,8 @@
"unsafe": 22, "unsafe": 22,
"wasm_bindgen_inline_js": 0 "wasm_bindgen_inline_js": 0
}, },
"manifest_sha256": "b0043fb5b38c8285058b39428b232d1ddef4490d3e0a90b66dd3aec4f025f934" "manifest_sha256": "b0043fb5b38c8285058b39428b232d1ddef4490d3e0a90b66dd3aec4f025f934",
"note": "F4: no decompression ratio limit of its own"
}, },
{ {
"name": "zlib-rs", "name": "zlib-rs",
@@ -7325,7 +7633,8 @@
"unsafe": 85, "unsafe": 85,
"wasm_bindgen_inline_js": 0 "wasm_bindgen_inline_js": 0
}, },
"manifest_sha256": "0ff579eaf0dbe4cb0529bc3e5256cd25eff1ae146289f40a4f77442171b5995b" "manifest_sha256": "0ff579eaf0dbe4cb0529bc3e5256cd25eff1ae146289f40a4f77442171b5995b",
"note": "decoder; covered by the W6 corpus (M14)"
} }
] ]
} }
@@ -1,28 +1,50 @@
# Q195 O2 S0: WordCraft source security review # Q195 O2 S0: WordCraft source security review
Status: IN PROGRESS (partial push). No verdict yet. Do not cite this file as PASS or FAIL. Status: COMPLETE. Verdict: **CONDITIONAL PASS**. The baseline source is acceptable for the
O2 adapter only with mandatory mitigations M1-M14 adopted as gates. One CSP question goes back
to Mos under D8 (see "Verdict").
- Review id: `s0-20261022`. Record: Gitea #1798. Owner: Fenton. Reviewer: Filbert. - Review id: `s0-20261022`. Record: Gitea #1798. Owner: Fenton. Reviewer: Filbert.
- Spec: `docs/plans/2026-10-21_q195-writing-editor.md` blob `322ec50f`, at e36bce352, - Spec: `docs/plans/2026-10-21_q195-writing-editor.md` blob `322ec50f` at e36bce352: D6-D9 and
D6-D9 and the "S0 independent WordCraft source security review" section. the "S0 independent WordCraft source security review" section.
- Source: WordCraft `e05b7fe86f3b85454000dffc321de638d7009817` (0.9.0), exported with - Source: WordCraft `e05b7fe86f3b85454000dffc321de638d7009817` (0.9.0), exported with
`git archive` into the review dir. `Cargo.lock` SHA-256 `git archive` into the review dir. `Cargo.lock` SHA-256
`06b745e43c13df3df7f048fa6a1981625ce7a5fe4cd484bc6254d0913d865b0b`, byte-identical to the pin. `06b745e43c13df3df7f048fa6a1981625ce7a5fe4cd484bc6254d0913d865b0b`, byte-identical to the pin.
- Artifact: Louie's web build from origin/louie/wordcraft-eval 880ef19e0, copied into the review
dir, with hashes checked against Louie's receipt. The hashes are listed in the CSP report.
- Companion files: `2026-10-22_q195-wordcraft-security.json` (inventory, findings,
mitigations, per-package notes) and `2026-10-22_q195-editor-csp.md` (browser measurement).
- Independence: I did not author any WordCraft import, patch or browser adapter. I reviewed the - Independence: I did not author any WordCraft import, patch or browser adapter. I reviewed the
O2 design itself (fenton-0004 to fenton-0008). That review shaped D6-D9's wording, not the code. O2 design (fenton-0004 to fenton-0008). That review shaped the wording of D6-D9, not the code.
## Method ## Method
Evidence labels used below: **[source]** means read from pinned source, **[browser]** means Evidence labels:
measured in the scratch harness. Nothing here was compiled.
1. `cargo fetch --locked --target wasm32-unknown-unknown` into an isolated `CARGO_HOME` in the - **[source]**: I read it in the pinned source.
review dir. Cargo checks each crates.io archive against the `Cargo.lock` checksum. - **[browser]**: measured in the scratch harness (Chrome 150, Linux).
2. `cargo tree -p wordcraft-web --target wasm32-unknown-unknown --locked`, with - **[agent-reported]**: found by a read-only subagent; I did not re-read the cited lines unless
`--edges normal` and `--edges normal,build`, then `cargo metadata --filter-platform`. the text says so.
3. `harness/inventory.py` joins the three into one record per package: lock checksum, declared
license, license/notice files, build script, proc-macro and capability marker counts. Nothing was compiled, and no code from the tree was run. Browser runs used the copied
Marker counts are leads for reading, not findings. prebuilt artifact.
1. Ran `cargo fetch --locked --target wasm32-unknown-unknown` into an isolated `CARGO_HOME`.
Cargo checks every crates.io archive against its `Cargo.lock` checksum.
2. Ran `cargo tree -p wordcraft-web --target wasm32-unknown-unknown --locked` with
`--edges normal` and with `--edges normal,build`, then `cargo metadata --filter-platform`.
3. `harness/inventory.py` joined these into one record per package: lock checksum, declared
license, license and notice files, build script, proc-macro flag, and counts of capability
markers. A marker count is a lead for reading, not a finding.
4. Read the D7 surfaces in all 15 local crates myself, plus the parts of `wuff`, `rfd`,
`eframe` and `epaint_default_fonts` named below.
5. Four read-only subagents traced the same ground in parallel, split as ui-egui and web,
document parsers, fonts/zotero/rest, and third-party hotspots. I re-read every finding that
sets a mitigation's scope (F1, F2, F3, the spin cap in F4, F5's package parts, F6-F14). The
rest stay labelled [agent-reported].
6. Did the license pass from crate files, the craft-libs git checkouts, and a string scan of the
built wasm for embedded fonts and data.
7. Browser measurement: see the CSP report.
## Inventory (complete) ## Inventory (complete)
@@ -34,23 +56,194 @@ measured in the scratch harness. Nothing here was compiled.
| Third-party packages with a build script | 25 | | Third-party packages with a build script | 25 |
| Third-party proc-macro crates | 10 | | Third-party proc-macro crates | 10 |
The 15 local crates match D6's list. `control-key` and `mcp` exist in the source tree but are The local crates are wordcraft-doc, -docbin, -docx, -engine, -fonts, -formats, -geom, -layout,
not in the web closure. -metafile, -pdf, -proof, -render, -ui-egui, -web and -zotero. This matches D6. `control-key` and
`mcp` exist in the source tree but are not in the web closure.
Three packages come from git, not crates.io, and have no `Cargo.lock` checksum: `craft-pdf` Three packages come from git rather than crates.io and have no `Cargo.lock` checksum:
and `krilla` at craft-libs `c3487320`, and `craft-ui` at craft-libs `d27646a5`. Their pin is the `craft-pdf` and `krilla` at craft-libs `c3487320` (krilla lives at `vendor/krilla`), and
git commit id. `craft-ui` at craft-libs `d27646a5`. Their pin is the git commit id.
Declared licenses are all permissive or have a permissive option. `Apache-2.0 OR GPL-2.0-only` ## Licenses and notices
appears once and takes Apache-2.0. `epaint_default_fonts` carries OFL-1.1 and Ubuntu-font-1.0
font licenses, which need shipped notices. The full per-package list is in the companion
`.json`.
## Remaining work Declared licenses for the 209 third-party packages (199 normal plus 10 build-only):
- License findings: per-package notice obligations, font notices, git-dependency license files. - 116 `MIT OR Apache-2.0` and 25 `Apache-2.0 OR MIT`.
- Build scripts and proc-macros: what each runs at build time. - 23 `MIT`.
- D7 trace through all 15 local crates, including native-only cfg paths, and third-party - The remainder are other permissive forms: Zlib, BSD-2/3, 0BSD and Unlicense options.
marker hotspots (eframe, rfd, web-sys, egui, image decoders, zip, flate2). - Unicode-3.0, alone or combined: unicode-ident, yoke, yoke-derive, zerofrom, zerofrom-derive.
- Browser measurement and the CSP report (`2026-10-22_q195-editor-csp.md`). - Apache-2.0 only: codespan-reporting, unicode-general-category, unicode-linebreak, zopfli.
- Findings, mandatory mitigations, residual risks and verdict. - Two dual licenses need an election (L4):
- `ryu`: Apache-2.0 OR BSL-1.0.
- `self_cell`: Apache-2.0 OR GPL-2.0-only.
- `encoding_rs`: (Apache-2.0 OR MIT) AND BSD-3-Clause, with a WHATWG license file.
- `epaint_default_fonts`: (MIT OR Apache-2.0) AND OFL-1.1 AND Ubuntu-font-1.0.
No copyleft license is forced. The only GPL option is avoided by electing Apache-2.0.
Notice obligations:
- **L1** [source + artifact]: `epaint_default_fonts` is enabled, and the wasm contains its
emoji-icon-font.ttf (MIT), Hack-Regular.ttf (MIT plus the Bitstream Vera license),
NotoEmoji-Regular.ttf (OFL-1.1) and Ubuntu-Light.ttf (UFL-1.0). WordCraft's NOTICE and
ATTRIBUTION do not mention these fonts. The Mosaic notice bundle must ship `OFL.txt`,
`UFL.txt`, `Hack-Regular.txt` and `emoji-icon-font-mit-license.txt` from the crate's `fonts/`.
- **L2** [source + artifact]: WordCraft's own bundled fonts are Inter, JetBrains Mono, Source
Sans 3 and Source Serif 4 (all OFL). It also bundles en-us, it, pl and pt hyphenation data and
the en-extra and ru-extra spelling lists. WordCraft's NOTICE covers all of these, so it must
ship. Its line "Rust dependencies… (MIT/Apache-2.0/BSD/ISC/Zlib)" leaves out Unicode-3.0, BSL
and the font licenses, so it is not a complete notice for the build on its own.
- **L3** [source]: also ship:
- subsetter's NOTICE (ttf-parser, MIT).
- The craft-libs `LICENSE-APACHE`, `LICENSE-MIT` and `NOTICE` at both revs, plus
`licenses/pdf-writer` at `c3487320`.
- encoding_rs's WHATWG text.
- The Unicode-3.0 text.
Thirteen packages carry no license file in the crate. The bundle supplies the standard text
of the declared license for each: accesskit, craft-pdf, craft-ui, ecolor, eframe, egui,
egui-wgpu, emath, epaint, epaint_default_fonts (font licenses are in `fonts/`), krilla,
profiling and wuff. `cfg_aliases` has a NOTICES.md but is build-only, so nothing ships from it.
- **L4**: Record the elections in the bundle: `self_cell` takes Apache-2.0, and `ryu` takes
Apache-2.0.
- **L5** [artifact]: The craft-fonts Japanese fonts are not in this artifact. A scan for "BIZ
UDPGothic" and "Shippori" found 0 matches. If a later build enables them, their licenses join
the bundle.
The license findings are resolved by M13, the notice bundle. None blocks.
## Build scripts and proc-macros
[agent-reported, file list source-verified from metadata]
- Build scripts (25): ahash, crc32fast, encoding_rs, getrandom, libm, naga, num-traits,
parking_lot_core, proc-macro2, quote, rfd, rustversion, serde, serde_core, serde_json, slotmap,
thiserror, unicode-general-category, wasm-bindgen, wasm-bindgen-shared, wgpu, wgpu-core,
wgpu-hal, zerocopy, zmij.
- Agent report: they probe the compiler or target and emit cfgs or generated tables. None
uses the network.
- `wasm-bindgen-shared` runs `git rev-parse HEAD`, which is non-hermetic (F15).
- Proc-macros (10): bytemuck_derive, document-features, multiversion_no_op, rustversion,
serde_derive, thiserror-impl, wasm-bindgen-macro, yoke-derive, zerocopy-derive,
zerofrom-derive.
- `document-features` reads its crate's `Cargo.toml`.
- `rustversion` reads the compiler version and date.
- None writes files or uses the network.
- Build-only packages (10): autocfg, cfg_aliases, rustversion, serde_spanned, toml,
toml_datetime, toml_parser, toml_writer, version_check, winnow.
## D7 trace
| D7 surface | What WordCraft does on web | Evidence | Disposition |
|---|---|---|---|
| Open/import | `Services.open_async` (web.rs:216-224) opens rfd's `<input type=file>` for documents, pictures, fonts and recipients. Accept list includes `.json`, which loads a native `Document` through serde (io.rs:202). | [source][browser] | F3, F8; M3, M5 |
| Save/export | `showSaveFilePicker` (web.rs:398-451), then a `createObjectURL` plus `a.download` fallback. Every save and export took this path. | [source][browser] | F8; M5 |
| Fonts | Embedded DOCX fonts and Add Font decode through `wuff`. User fonts persist in IndexedDB (font_store.rs). `queryLocalFonts` is called (web.rs:274-301). | [source][browser] | F1, F9, F10; M1, M9, M10 |
| Storage | localStorage `wordcraft.ui` (prefs.rs) holds the recent list, author, dictionary, autocorrect and UI keys. No cookies, and sessionStorage is empty. | [source][browser] | F9; M9 |
| Clipboard | Document-level paste listener, added once and never removed (web.rs ~203). | [source] | F11; M11 |
| Objects | vbaProject, attachedTemplate and OLE external relationships survive a round trip. Nothing executes them. | [source][agent-reported] | F5; M6 |
| Hyperlinks | Targets are imported unfiltered (docx read/story.rs:485-496). Ctrl+click, the About links and the Discord buttons all call `window.open`. `show_discord` defaults to true (ui-egui lib.rs:376). | [source][browser] | F2, F6; M2, M7 |
| Print/PDF | Hidden `iframe#wordcraft-print-frame` with a `blob:` PDF (web.rs:541-604). `frame-src 'none'` blocks it. | [source][browser] | F7; M8 |
| MCP | The `mcp` crate is not in the web closure. | [source] | none |
| Zotero | A protocol driver that takes a caller-supplied answer callback and has no transport of its own. Its UI actions are disabled on wasm32 (widgets.rs:183). | [source] | none |
| Script and eval | No `eval`, `Function`, `Worker` or dynamic script anywhere in the closure. eframe and egui set styles through CSSOM. The only inline `<style>` comes from rfd. | [agent-reported][browser] | F13 |
| Start error | `set_inner_html` with a formatted eframe error (web.rs:89). It is not derived from the document. | [source] | F12 |
| URL flags | `?webgl` and `?sample` (apps/wordcraft-web/src/main.rs:10). | [source] | F14; M12 |
## Findings
Severity is for the O2 deployment: same-origin frame, single trusted user, documents they
choose. The full text of each finding is in the companion `.json`.
| Id | Severity | Evidence | Finding |
|---|---|---|---|
| F1 | medium | source | `wuff` 0.2.9 trusts the WOFF1 `orig_length`: `decompress_woff1.rs:96` passes it to `decompress_z`, which runs `Vec::with_capacity` at `:15`. The only check is `woff_length < orig_length`. A DOCX with an embedded WOFF reaches this on open: io.rs:164-182 → docx fonts.rs:30 → fonts user.rs:143-163 → wuff. The input cap is 50 MiB; the 128 MiB decoded cap is checked only after decoding. `catch_unwind` (user.rs:166) does nothing on wasm32-unknown-unknown (panic=abort), and allocation failure aborts anyway. Result: the wasm instance traps and unsaved edits are lost. Not reproduced in the browser. |
| F2 | medium | source | DOCX hyperlink targets are kept unfiltered, including `javascript:`, `data:`, `file:` and custom schemes. `is_safe_href` exists but only the latex, markdown and html exporters use it. |
| F3 | medium | source + browser | `.json` opens as a native serde `Document` and skips the package caps. The web picker accepts it. |
| F4 | medium | agent-reported, spin cap source-verified | Import exhausts resources on the main thread: encrypted DOCX spin count up to 10,000,000 (crypt.rs:31); no ODT package total; CFB up to 1 GiB; image decode limits 512 MiB; zip has no ratio limit of its own. The frame shares the shell's renderer process. |
| F5 | low | source + agent-reported | vbaProject (package.rs:40/83/87), attachedTemplate (:27) and OLE external relationships are passed through on save. |
| F6 | medium | browser + source | Outbound `window.open` from the hyperlink, Discord, the three About links and the sidebar community link. A cross-origin self-navigation of the frame was blocked by the shell CSP. |
| F7 | low | source + browser | The Print blob iframe is blocked by `frame-src 'none'` and must be removed. |
| F8 | medium | source + browser | Open and Save are browser-owned: rfd's input and the FS Access picker with an anchor-download fallback. |
| F9 | low | source + browser | localStorage holds document-derived data (the recent list, the author); fonts persist in IndexedDB. |
| F10 | low | source | `queryLocalFonts` is called. |
| F11 | low | source | An unconditional paste listener feeds clipboard content to the importer. |
| F12 | low | source | `innerHTML` is used for the start error; not derived from the document. |
| F13 | info | source + browser | rfd injects one inline `<style>`, the only `style-src 'self'` violation. |
| F14 | low | source | The `?sample` and `?webgl` URL flags. |
| F15 | low | agent-reported | Non-hermetic build inputs; no build script or proc-macro uses the network. |
| L1-L5 | license | source + artifact | See "Licenses and notices". |
No finding blocks on its own. Each medium is closed by a mandatory mitigation with a rejection
test. Two things would turn F1 into a blocker: the adapter failing to gate font decode size, or
the corpus in M14 finding a trap that M1 does not cover.
## Mandatory mitigations
Each mitigation is a gate on the named W brief. Its rejection test must exist and fail on the
unmodified baseline before the mitigation lands.
| Id | Closes | Requirement | Rejection test |
|---|---|---|---|
| M1 | F1 | Refuse a WOFF or WOFF2 whose declared decoded sizes exceed `MAX_DECODED_BYTES` before decoding, for embedded fonts and Add Font alike. | A DOCX embedding a WOFF1 with `origLength` 0xFFFFFFFF opens with the font refused and the editor responsive. Add Font refuses the same font. |
| M2 | F2, F6 | Hyperlink targets pass a host allowlist (`https`, `http`, `mailto`) on import and on activation. Anything else stays as inert text. | `javascript:`, `data:`, `file:` and `vbscript:` links: ctrl+click makes no `window.open` call. |
| M3 | F3 | The host import allowlist excludes `.json` and any other native format. | A `.json` file sent through host Open is refused before WordCraft sees it. |
| M4 | F4 | Host caps before handoff: file size, zip entry count and uncompressed total. Encrypted documents are refused, or their spin count is capped low. An import watchdog tears down the frame on a stall. | A zip bomb, an oversize ODT, an encrypted DOCX with spinCount 10,000,000 and an oversize PNG are each refused or aborted, and the shell stays responsive. |
| M5 | F8, F13 | Host-owned Open, Save and Export replace `Services.open_async`, `showSaveFilePicker` and the anchor download. rfd is never called on web. | A full pass under `style-src 'self'` records 0 violations, no file-input click, no `showSaveFilePicker` call and no `a.download`. |
| M6 | F5 | Strip vbaProject, attachedTemplate and OLE external relationships on save, or refuse `.docm` and `.dotm`. | A `.docm` round-trips with neither part present. |
| M7 | F6 | Frame sandbox without `allow-popups` or `allow-top-navigation`. `show_discord` forced false. The About and community links are removed or go through the host link gate. | None of the six measured `window.open` triggers creates a browsing context or sends an off-origin request. |
| M8 | F7 | Remove the blob print iframe. Print goes through the host, and `frame-src 'none'` stays. | Print creates no iframe and records no `frame-src` violation. |
| M9 | F9 | No document-derived data goes into origin storage. The dictionary, autocorrect list and fonts are host-owned, or kept in a partition the host clears. | After open, edit and close, no fixture file name, author or user word remains in localStorage or IndexedDB. |
| M10 | F10 | Disable Local Font Access: `Permissions-Policy: local-fonts=()` on the frame response, or remove the call. | `queryLocalFonts` is missing or rejects, and no prompt appears. |
| M11 | F11 | Pasted content goes through M4's caps. | Pasting an oversize image or deeply nested HTML is refused, and the editor stays responsive. |
| M12 | F14 | The host serves a fixed frame URL and ignores or refuses query strings. | `?sample` and `?webgl` change nothing. |
| M13 | L1-L3 | Mosaic notice bundle as listed under "Licenses and notices", with the L4 elections. | A bundle check maps every inventory package and every font embedded in the wasm to a shipped license text. |
| M14 | residuals | The W6 malformed-input corpus covers deep nesting, panic sites and the PDF writer. | Every corpus input opens or is refused, with no trap and no hang past the watchdog. |
## Residual risks (not cleared by S0)
1. **Recursion depth against the wasm stack.** The DOCX element-depth cap is 256
[agent-reported]. I did not show that every recursive walk (layout, render, the serde
`Document` path) fits in the default wasm stack at that depth. M14 covers this.
2. **Panics.** On wasm32-unknown-unknown every panic traps the instance. S0 did not audit
every `unwrap`, index and arithmetic overflow in the importers. M14 is the empirical
backstop, and M4's watchdog limits the damage to the frame.
3. **krilla and craft-pdf** were reviewed by marker scan only: their filesystem markers are in
docs and tests (krilla lib.rs:42 and :135, util.rs:278). The PDF writer works on WordCraft's
own model, not on raw input, which lowers the exposure.
4. **Decoders** (`image`, `zune-jpeg`, `gif`, `png`) were trusted to their upstream limits
beyond the 512 MiB cap noted in F4.
5. **[agent-reported] items** that were not re-read: the DOCX cap values, ODT and CFB totals,
the OLE paths, the image limits and the build-script behaviour. Filbert may spot-check any
of them. Each cites file and line in the `.json`.
## Verdict
**CONDITIONAL PASS**, against the S0 conditions:
- Complete inventory: yes. 15 local crates, 199 normal, 10 build-only, 25 build scripts,
10 proc-macros, and 3 git dependencies pinned by commit.
- License findings resolved: yes, by M13 and the L4 elections. Nothing copyleft is forced.
- No unresolved blocking security finding: yes, provided M1-M12 are accepted. With any of M1,
M2, M3, M5 or M7 refused, S0 fails.
- An accepted list of mandatory adapter mitigations and rejection tests: M1-M14 are proposed
here. They need acceptance by Filbert's review and Mos.
- Baseline meets the restrictions: yes. The measured frame header holds with every function
working, and the only `style-src 'self'` violation sits on a path that D7 already removes.
**Held for Mos (D8).** On the unmodified artifact, `style-src 'self'` records one violation:
rfd's inline `<style>`. My recommendation is the final header with `style-src 'self'` and no
extra source, with M5 removing rfd from the web path and W2 re-measuring to 0. The measured
fallback, rfd's hash `'sha256-9tSqG2Th9gNn6sbn1ZYMSqakJLznxnsamRDeigez2Mo='`, is a new allowance
under D8. I am returning it to Mos as the delta and not recommending it.
## Process disclosures
- Four general-purpose subagents ran read-only traces of the scratch source copy. They were
told not to compile, run code from the tree, use the network or write files. The
document-parser agent reported one slip: it sent a loop's output to `/tmp/none`. The file was
empty and the agent deleted it at once. Nothing else was written.
- The harness changes made for the browser runs are disclosed in the CSP report, "Harness
changes, disclosed".
- I did not read any other seat's scratch apart from copying Louie's artifact, which Sage
authorized (sage-3058). No credential was used or read.