From 17e5b6292e45a0431dd94b012dfa4771e13903fb Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 7 Oct 2026 18:52:53 -0400 Subject: [PATCH 1/2] docs(security): the v1.0.0 security review, closed on rc.4 Slice 54's combined report. Three reviewers attacked v1.0.0-rc.1 (27 findings, 3 medium); the lead reran every proof; fresh reviewers confirmed each round of fixes on rc.2 (D), rc.3 (E) and rc.4 (F). Every finding is fixed by a pull request with a regression test or recorded as an accepted risk with an issue. The six reports are appended unchanged. The gate line names rc.4; v1.0.0 differs from it only in documentation. check-old-name exempts the report: the appended reports name the old data folder (finding B-03) and can't be edited. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- docs/security/review-v1.0.0.md | 2035 ++++++++++++++++++++++++++++++++ scripts/check-old-name.mjs | 3 + 2 files changed, 2038 insertions(+) create mode 100644 docs/security/review-v1.0.0.md diff --git a/docs/security/review-v1.0.0.md b/docs/security/review-v1.0.0.md new file mode 100644 index 0000000..53cebf7 --- /dev/null +++ b/docs/security/review-v1.0.0.md @@ -0,0 +1,2035 @@ + + +# Security review of v1.0.0 + +The pre-release security review (slice 54) of Kotiko's first store release. Three reviewers +attacked the first release candidate independently, and the lead reran every proof. The +fixes went through three more candidates, each checked by a fresh reviewer, until a +reviewer confirmed the last round of fixes and found nothing new at medium or above. Every +confirmed finding is fixed by a pull request with a regression test, or recorded below as +an accepted risk with a reason and an issue. + +Gate: closed 2026-10-07 by Claude (lead reviewer), fixes confirmed on v1.0.0-rc.4 + +## Scope and candidates + +- **In scope:** `extension/`, `server/`, `spec/`, `scripts/`, `.github/`, the zips built from + each tag, and the store listings and privacy policy, read against the code. +- **Candidates:** every tag is signed with the release key + `SHA256:HqIaFq/thUm844NjYC3a+4Br40cqD2yJHEq1L2iiw4E`, and `scripts/verify-tag.sh` passes on + each. + + | Tag | Commit | Reviewed by | Result | + |---|---|---|---| + | `v1.0.0-rc.1` | `cf7378e` | A, B and C (the full review) | 27 findings, 3 of them medium | + | `v1.0.0-rc.2` | `9b5a6df` | D (fixes, with bypass attempts) | 5 new findings (D-01 to D-05) | + | `v1.0.0-rc.3` | `088bbea` | E (fixes, page-side checks, the signing scheme) | 7 new findings (E-01 to E-07) | + | `v1.0.0-rc.4` | `01312d2` | F (E's fixes only) | E's fixes hold; 2 informational findings (F-01, F-02), accepted | + +- **Dates:** 2026-10-06 to 2026-10-07. + +## Reviewers and independence + +- **A:** a hostile web page or model answer. +- **B:** a network neighbour, another local account, a Telegram stranger. +- **C:** the supply chain and the release. +- **D, E and F:** each confirmed the previous round's fixes on the next candidate. +- **Lead:** Claude (Opus 5.5), the coding agent. It is none of the reviewers. The + maintainer, Ayla Croft, is the final reader. + +All six reviewers are AI agents (Claude). Each started in a fresh context, with no access to +the others' notes, and worked in its own clean checkout of its tag (`kotiko-sec-a` to +`kotiko-sec-f`), with its own report folder and its own port range. Every finding and every +"no issue" answer needed proof. The rules forbade real keys, the maintainer's data, the +maintainer's running server, any outward action (no pushes, issues, store, OpenRouter or +Telegram traffic), and the maintainer's identity in any request. + +## Findings on rc.1 (reviewers A, B and C) + +The lead reran every proof on `cf7378e` and saw the claimed result. None was rejected, and +none was left needing proof. Two pairs are duplicates: A-03 = C-04 and B-09 = C-09. No +finding is critical or high. + +| ID | Finding | Severity | Outcome | Fix | +|---|---|---|---|---| +| A-01 | A page reads the learner's word list by planting hidden text and reading Kotiko's swaps (60,652 words recovered in about 4.2 s) | Medium | Fixed: only visible text is swapped; at most 500 words a page view, each in at most 3 languages | #32, #37 | +| B-01 | Another local account receives the server token by listening on `[::1]:4747` | Medium | Fixed: the server holds both loopbacks, and the extension never sends the token; it signs each request (after D-01 and E-01) | #29, #31, #35, #38 | +| C-01 | Release authority is documented but not enforced by repository settings | Medium | Fixed in settings and CI; second-maintainer review is an accepted risk (below) | settings, #28 | +| A-02 | A page's `` text appears verbatim in the popup | Low | Fixed | #32 | +| A-03 / C-04 | A page can switch Kotiko off, and detect it where it's paused, off or on a sensitive site | Low | Fixed | #32 | +| A-04 | Page script opens the word card with a synthetic click and probes it with `window.find` | Low | Fixed: untrusted events are ignored, and the card opens only on a word the learner sees (after E-05) | #32, #37 | +| A-05 | Any page can use up a pending "Connect OpenRouter" sign-in | Low | Fixed | #31 | +| A-06 | After "Delete everything", a planted 0.2-style server and token are adopted | Low | Fixed | #31, #32 | +| A-07 | Lookups to paid providers carry no output-token cap | Low | Fixed, in the extension and the server (after D-03) | #31, #34 | +| B-02 | Someone who can write in the data folder chooses the token, or links `kotiko.db` elsewhere | Low | Fixed: the server refuses to start | #29 | +| B-03 | The copy of the words from before the rename stays readable after the move, and `--delete-data` keeps it | Low | Fixed | #29 | +| B-04 | The refused-Host log limiter resets at 1,000 names | Low | Fixed | #29 | +| B-05 | A user name and password in `LLM_URL` are logged at start | Low | Fixed: such addresses are refused | #29 | +| B-06 | No warning when a provider key goes over plain HTTP to another machine | Low | Fixed (and for an upper-case scheme, D-04) | #29, #31, #34 | +| C-02 | A signed rc tag re-pointed as the final tag verifies | Low | Fixed | #28 | +| C-03 | Release tag names can be squatted, and the docs say a bad tag can be deleted | Low | Fixed in settings: only the release manager creates `v*` tags and can delete a wrong one | settings, #28 | +| A-08 | Content scripts can force syncs past the throttle | Info | Fixed | #31 | +| A-09 | Release zips ship the `__kotiko` test hook | Info | Fixed: stripped at build, and the build fails if it remains | #31 | +| B-07 | At debug level, the model's words are logged without `LOG_LOOKUPS` | Info | Fixed | #29 | +| B-08 | `SECURITY.md` and the requirements describe a project with no releases | Info | Fixed | #28 | +| B-09 / C-09 | Weak `API_TOKEN` values are accepted, and wrong tokens aren't throttled | Info | Fixed: a startup warning, and a lockout after 10 wrong tokens a minute from another computer | #29, #35 | +| C-05 | The store check passed on any line starting with the closing phrase | Info | Fixed (and against disguises, D-05 and E-06) | #28, #34, #38 | +| C-06 | AMO receives a zip repacked by web-ext, not the attested one | Info | Fixed: the release zip is uploaded unchanged | #28 | +| C-07 | Detected browser languages reach `storage.sync` before the learner confirms them | Info | Fixed | #31 | +| C-08 | Unseeded and date-dependent tests can fail a release at random (rc.1's did) | Info | Fixed | #28, #30, #31, #32 | +| C-10 | Cloudflare injects scripts into the published site | Info | Fixed in Cloudflare: email obfuscation, NEL, Web Analytics and RUM off; HSTS, CSP and security headers on | settings | +| C-11 | The Firefox and Chrome data declarations disagree about the API key | Info | Fixed: Firefox declares `authenticationInfo` | #28 | + +## Findings on rc.2 (reviewer D) + +D confirmed 20 of the 27 rc.1 findings fixed outright, rebuilt both zips byte for byte from +the tag, and found: + +| ID | Finding | Severity | Outcome | Fix | +|---|---|---|---|---| +| D-01 | A cached server proof let a port squatter receive the token after the server stopped (the lead reproduced 3 tokens received); this reopened B-01 | Low | Fixed: the extension never sends the token; it signs each request and checks a signed answer | #35 | +| D-02 | Shared loopback rate limits let another local program, or a page in Firefox, lock the extension out | Low | Fixed: loopback requests without a forwarding header are exempt; the proof route refuses non-JSON before counting | #35 | +| D-03 | The server's model client sent no output-token cap for `LLM_MODEL` models | Low | Fixed | #34 | +| D-04 | An upper-case `HTTP://` scheme skipped the plain-HTTP warning | Info | Fixed | #34 | +| D-05 | A status line disguised with lookalike letters fooled the store check | Info | Fixed | #34 | + +## Findings on rc.3 (reviewer E) + +E confirmed D's findings and every rc.1 proof it reran fixed (including the token squatter +in Chromium and Firefox: 0 tokens received), measured the A-01 limits against the privacy +policy, and found: + +| ID | Finding | Severity | Outcome | Fix | +|---|---|---|---|---| +| E-01 | A signed request caught while the server was down could be replayed after a restart if the client's clock ran ahead | Low | Fixed: each request is bound to the server's boot | #38 | +| E-02 | Behind a reverse proxy, a stranger could keep the owner's other devices locked out; some forwarding headers weren't recognised | Low | Fixed: more headers recognised; opt-in `TRUSTED_PROXY_HEADER` counts proxied clients apart | #38 | +| E-03 | An API key in `LLM_URL`'s query string was logged | Low | Fixed | #38 | +| E-04 | Text hidden by `filter`, `clip-path` or `mask` was still swapped | Low | Fixed: such text, and text under 10 % opacity, isn't swapped | #37 | +| E-05 | A page could get the word card opened by a hover over a stretched, nearly transparent swap, then read it (the lead reproduced it) | Low | Fixed: the card opens only on a word the learner sees, where it is drawn | #37 | +| E-06 | Fourteen more disguised status lines fooled the store check | Info | Fixed | #38 | +| E-07 | The open routes answered other spellings of their paths | Info | Fixed | #38 | + +## Final confirmation (reviewer F, rc.4) + +F reran E's proofs, adapted to rc.4, and tried a bypass of each: + +| ID | Verdict on rc.4 | What F saw | +|---|---|---| +| E-01 | Fixed | A request caught while the server was down, replayed 0.6 to 1 s after a restart at 0, 30, 90 and 119 s of clock skew, gets `401 stale_boot`; so does the very request D's squatter caught. Boot ids differ on every start, and the open proof route's boot id can't be used without the token | +| E-02 | Fixed, within the documented limits | With `TRUSTED_PROXY_HEADER`, a stranger is locked out and the owner gets through, also when the stranger prepends a fake address. Without it, the shared bucket is documented. Ten rarer forwarding headers are still not recognised, which `configuration.md` says | +| E-03 | Fixed | No form of a query-string key reaches the log, at debug level too; requests still carry the query | +| E-04 | Fixed for the three tricks found; others remain (F-01) | `filter: opacity(0)`, `clip-path` and a transparent mask reveal 0 of 300 words. A transparent text colour, text turned away, and `contrast(0)` still reveal 300, under the 500 cap | +| E-05 | Fixed for the stretched swap; the documented case remains | The hover and clickjack tricks keep the card closed. A disguised word kept under the pointer still opens it on a rest or a click | +| E-06 | Fixed for every known disguise; 8 new ones pass (F-02) | All 30 of D's and E's cases read open; 8 new, contrived ones read closed | +| E-07 | Fixed | Of 287 unsigned requests in 41 spellings, only the exact `GET/HEAD /health` and `POST /api/v1/proof` answer | + +The token squatter gets 0 copies of the token (D's proof), every suite passes (`npm test` +2,081, `mix test` 1,205 and 23 properties, Playwright security and full-stack), and the +rc.4 zips rebuild byte for byte from the tag. + +**F-01 and F-02 are informational**, so under the stop rule they are accepted risks (below) +with issues: SCR-836 (treat a transparent text colour and text turned away as hidden) and +SCR-837 (read the report the way a CommonMark renderer shows it). The privacy policy is +now version 6, which names every way a page can hide text that Kotiko still swaps. +v1.0.0 differs from rc.4 only in documentation (this report, the privacy policy's wording +and other docs) and the release notes. + +One slip: a misquoted test setting sent two of F's test lookups ("shukran", "gato") to +OpenRouter's public endpoint, with no key, token or identity. OpenRouter refused them +(`401`), and F fixed the setting at once. + +## Accepted risks + +- **No second-maintainer review (C-01, in part).** + - What is enforced: + - pull requests only, with the required checks and CodeQL results; + - the scans read their settings from the base commit; + - store uploads wait for the maintainer's approval in the `release` environment; + - only the release manager creates release tags. + - What is not enforced: + - a second maintainer's review: one maintainer is active, and the approval rule would + block every merge; + - a pull request can also edit the workflow files that run on it. + - Revisit when the second maintainer reviews regularly (SCR-763). +- **What a page can still learn (A-01, A-04 and E-04, what remains).** + - A site sees the swaps it shows: at most 500 words a page view, each in at most 3 + languages. + - A site that reloads itself, or that the learner visits often, learns more over time + (SCR-763). + - Tiny, background-coloured, blurred or covered text on screen still counts as visible. + - While the learner has a word card open, `window.find` can test guesses against its text. + - A word disguised in the background colour, under the learner's resting pointer, can + still have its card opened. + - The privacy policy (version 5) says each of these. + - The rule for `clip-path` and `mask` is deliberately cautious and may hold back some + visible text on real sites (SCR-816). +- **Text a page hides in ways Kotiko doesn't detect (F-01)**: a transparent text colour, + text turned away, `contrast(0)` or `brightness(0)`. It is still swapped, and its card + can still be opened by a page that keeps it under the pointer. This stays under the + 500-word cap, and the privacy policy says so (SCR-836). +- **The store's report check can still be fooled (F-02)** by eight contrived ways of + writing a report. Each needs write access to the repository, and the `release` + environment's approval by the maintainer is the second lock (SCR-837). +- **A squatter that wins a race after the server crashes** can read the body of one write + sent within 30 s of the last proof. It never gets the token, and it can't replay a request + to the server. +- **The proof route allows offline guessing of a weak chosen token** (B-01). The token the + server generates makes this harmless; a weak chosen token gets a warning at every start. + Programs on the same computer, which aren't rate-limited, can guess the same way. +- **Behind a reverse proxy without `TRUSTED_PROXY_HEADER`**, one stranger's lockout refuses + every proxied client for that minute. A proxy that adds no forwarding header makes its + clients look local. Both are documented in `configuration.md`. +- **Clocks:** signed requests need the extension's and the server's clocks within 120 s of + each other. Kotiko says so when they aren't. + +## Coverage + +On rc.1 every reviewer answered all nine areas. Reviewer B marked page isolation as partly +checked and supply chain as lightly checked, outside its focus, where A and C went deep. + +| Area | A | B | C | Lead and later reviewers | +|---|---|---|---|---| +| 1. Secrets | checked | checked | checked | B-01 and B-05 reproduced; D-01, E-01 and E-03 found and fixed | +| 2. Page isolation | checked | partly (existing suites only) | checked (C-04) | A-01, A-03 and A-04 reproduced; E measured every A-01 variant against the documented limits; E-04 and E-05 found and fixed | +| 3. Injection | checked, including prompts | checked; prompts not checked | checked | A-02 reproduced; prompt injection rests on A alone (no finding) | +| 4. Messaging | checked | checked (sender kinds, 15 cases) | checked | A-08 reproduced | +| 5. Network | checked | checked | checked | B-01 reproduced over `[::1]`; D-02 and E-02 found and fixed | +| 6. Server | checked (no issues) | checked | checked | B-02 to B-07 reproduced; E-07 found and fixed | +| 7. Resource exhaustion | checked | checked (B-04) | checked | B-04 reproduced | +| 8. Supply chain and build | checked (A-09) | lightly (audits, pinning) | checked | A-09, C-02, C-05, C-06 and C-08 reproduced; D and E rebuilt the zips byte for byte from rc.2 and rc.3 | +| 9. Privacy | checked | checked (server side) | checked | C-07, C-10 and C-11 reproduced; the live site checked in Chromium and Firefox | + +**Checked later, after rc.1 couldn't:** +- Firefox at runtime (E: the token squatter, A-01, A-03 and A-04); +- the published release assets and their provenance (D and E); +- the page-side bypass variants D couldn't run (E), and new ones on rc.4 (F). + +**Not checked by any reviewer:** +- the attestation's Sigstore chain and Rekor inclusion (gh 2.45 has no `attestation` + command; the provenance subjects and the DSSE signature were checked); +- `install-service.sh` install mode, and the systemd `UMask`; +- B-02 with files owned by a second account (simulated with the same uid); +- a real reverse proxy (simulated with forwarding headers); +- macOS, Windows and WSL; +- live Telegram, OpenRouter, Wiktionary and store traffic, which the rules forbade (stubs + and fakes only). + +The reviewers' reports follow unchanged. Their proof files stayed in their own checkouts, +uncommitted; the regression tests in the fix pull requests replace them. + +## Appendix + +### Reviewer A, on v1.0.0-rc.1 (a hostile web page or model answer) + +# Kotiko v1.0.0-rc.1: security review, reviewer A + + + +- **Commit:** `cf7378e` (tag `v1.0.0-rc.1`), clean detached checkout at `/home/aylac/Projects/personal-projects/kotiko-sec-a`. The only change is the untracked `test/security/poc/` folder. +- **Starting attacker:** a hostile web page, and a hostile or confused model answer. All nine threat areas are covered. +- **Independence:** I worked alone. I read no other reviewer's checkout or folder, didn't touch the maintainer's live checkout or server, and did nothing outward-facing. +- **Environment:** + - Node 22: `npm ci` reported 0 vulnerabilities. + - Playwright's bundled Chromium with the unpacked extension. + - Elixir 1.19.2 / OTP 28. + - The fixture server ran on 127.0.0.1:41234. + - My local Kotiko server ran on 127.0.0.1:41587 and was stopped by PID afterwards. + +## Summary + +| Severity | Count | IDs | +|---|---|---| +| Critical | 0 | | +| High | 0 | | +| Medium | 1 | A-01 | +| Low | 6 | A-02, A-03, A-04, A-05, A-06, A-07 | +| Informational | 2 | A-08, A-09 | + +**The main problem: any web page can silently read the learner's word list (A-01).** It plants hidden dictionary text and reads back the `` swaps. This contradicts the privacy policy's "They can't see … your word list". + +Also proven: +- A page can put its own text into Kotiko's popup. +- A page can detect Kotiko on sites where it's paused. +- A page can probe the closed shadow root of the word card. +- A page can spoil a pending OpenRouter sign-in. +- After "Delete everything", code running in the content-script world can point Kotiko at its own server. + +These held up, each with proof: +- Key and token isolation. +- The message router. +- Hostile model answers are rendered as text only. +- CSV and Anki exports. +- Server auth, the Host check and body limits. +- The bot's sender allowlist. +- Reproducible zips. + +**How to run the proofs** (from the checkout root): +``` +npx playwright test -c test/security/poc/playwright.config.mjs # 11 tests, all pass +node --test test/security/poc/a-llm-limits.test.mjs test/security/poc/a-exports.test.mjs +node test/security/poc/a-routes.mjs +(cd server && MIX_ENV=test mix test ../test/security/poc/a_bot_stranger_test.exs) +npx playwright test -c test/security/poc/playwright.e2e.config.mjs privacy connect popover # project specs, 21/21 pass +``` + +--- + +## Findings + +### A-01: A web page can read the learner's whole word list from text nobody sees (Medium) + +**Severity:** Medium. What it means for the learner: +- Any site, or any third-party script it loads, gets the learner's meanings, target words and languages. +- It takes about 4 seconds, even with a 60,000-word dictionary. +- The learner does nothing and sees nothing. + +What it contradicts: +- The privacy policy: `docs/privacy/en.md:105-108`, shipped as `extension/privacy/en.md:105-108`: "They can't see the original words, their meanings or your word list". +- The threat model asset: "the word list … reveals which languages they study" (`docs/security/assurance-case.md:49-50`). + +**Location** +- `extension/content/engine.js:92-127` (`swap`): each match becomes `` with the target word as its `textContent` (`:98`, `:119`). + - The walk (`:160`, `:180-182`) skips only tags and editables. It never skips hidden subtrees. + - Nothing caps how many concepts one page view can reveal. +- `extension/content.js:233-266` (`plan`): matches any text node, visible or not. +- `docs/security/assurance-case.md:178` ("Pages can see swapped words") understates the risk. +- `test/e2e/popover.spec.mjs:89` is named "page scripts see no vocabulary", but it only checks attributes. + +**Proof:** `test/security/poc/a-hostile-page.spec.mjs`, tests A-01 and A-01b. +- The page has one visible sentence and a `display:none` div. + - A-01: 30 guesses, each in 3 sentences. + - A-01b: all 60,652 lowercase words from `/usr/share/dict/american-english`. +- The page's own script reads `#probe kotiko-w` (`textContent` and `lang`) and maps each swap back to the guess it planted. + +**Observed:** +``` +A-01 page-world loot: {"please":["пожалуйста [ru]"],"thanks":["спасибо [ru]","谢谢 [zh]"],"dog":["犬 [ja]"],"book":["كتاب [ar]"],"good":["хорошо [ru]"],"water":["agua [es]"],"castle":["замок [ru]"]} +A-01 probe visible to the learner: false +A-01b dictionary size: 60652; recovered in 4178 ms: {"book":["كتاب [ar]"],"castle":["замок [ru]"],"dog":["犬 [ja]"],"dogs":["犬 [ja]"],"good":["хорошо [ru]"],"please":["пожалуйста [ru]"],"thanks":["спасибо [ru]"],"water":["agua [es]"]} +``` +- Every meaning the learner has was recovered, and nothing else. +- Repeating a guess in different sentences ("mix within the page") also reveals each language the learner has for that concept. + +**Fix** +- Swap only rendered text: use `checkVisibility({contentVisibilityAuto, opacityProperty, visibilityProperty})`, swapping as text comes into view. +- Cap the number of distinct concepts swapped per page view (a spec rule). +- Correct `docs/privacy/en.md` §"What websites can see" and assurance-case §7. + +**Regression test:** A-01 and A-01b, asserting that hidden or off-screen probes get no swaps and that at most N distinct concepts are revealed. + +--- + +### A-02: The page's `` text appears verbatim in Kotiko's popup (Low) + +**Severity:** Low. A page can put any text it likes into Kotiko's trusted popup, for example a fake "key leaked, paste a new one at …" notice. +- Text only: no markup and no script. +- The learner has to open the popup on that page. + +**Location** (the path the text takes): +1. `extension/content.js:122-128` (`declaredLanguage`) reads the raw `lang` attribute, unbounded. +2. `extension/lib/page-lang.js:36-39` returns it as `lang: declared` with reason `declared_other`. +3. `extension/content.js:380-387` (`pageStatus`) sends it to the popup. +4. `extension/popup.js:521-531`, line `:527`, renders it with `name(l) = I18n.languageName(l) ?? l`. +5. `extension/lib/i18n.js:150-161`: `languageName` returns null for a non-tag, so the raw string is shown, twice. + +**Proof:** `a-hostile-page.spec.mjs`, test A-02. +- The page is `` with the body "12345 67890". +- The test opens the real `popup.html`. The only change is that `tabs.query({active})` returns that tab, which is what a toolbar click on that tab would give. +- A screenshot is saved as `a-02-popup.png` under `test-results/sec-a/`. + +**Observed:** +``` +A-02 page-status reply: {"base":null,"reason":"declared_other","lang":"Kotiko security notice: your OpenRouter key leaked. Paste a new key at evil.example/kotiko to keep using Kotiko","words":0} +A-02 popup #pageLangText: "This page is in Kotiko security notice: your OpenRouter key leaked. Paste a new key at evil.example/kotiko to keep using Kotiko, which isn't one of your languages. Kotiko leaves it alone. I read Kotiko security notice: … too" +``` + +**Fix** +- Canonicalise the declared tag in the content script: `Intl.getCanonicalLocales`, at most 35 characters. Treat an invalid tag as undeclared. +- The popup should never fall back to the raw string. + +**Regression test:** a jsdom popup test with a non-tag `lang`, plus a `page-lang` unit test asserting that `lang` is canonical or null. + +--- + +### A-03: A page can switch Kotiko off with one event, and detect it where it's paused or on sensitive sites (Low) + +**Severity:** Low. +- **Teardown:** any page can stop Kotiko by dispatching `kotiko:handoff`. Pages can already opt out with `data-kotiko-skip`, so this alone matters little. +- **Detection:** a page can tell Kotiko is installed even where the learner paused it, or where Kotiko steps back (banks and other sensitive sites). It sees either the handoff event, or the injected CSS on a `` element it creates itself. + - This contradicts the policy's "To stop this on a site, pause Kotiko there" (`docs/privacy/en.md:105-109`). + - It helps fingerprinting. + +**Location** +- `extension/content.js:469-472`: `onHandoff` tears down on any foreign `detail`. +- `extension/content.js:531-532`: dispatches and listens on `document`, before any check for paused or sensitive sites. +- `extension/content.css:7-20`: the `kotiko-w` style (`cursor: help`, dotted underline) is injected into every page. + +**Proof:** `a-hostile-page.spec.mjs`, test A-03. +- The page fires the handoff event, then adds new text. +- Then `pausedHosts: ["localhost"]` is set, and a second page listens for the event from `` and styles its own ``. + +**Observed:** +``` +A-03 swaps before the event: 7; after: 0; swaps in text added afterwards: 0 +A-03 paused host: computed style of a page-made : help dotted +A-03 paused host: swaps 0 handoff events seen by the page: ["muxeu41q-gvpqmtyjhyo"] +``` + +**Fix** +- Hand off through a channel pages can't forge, for example `storage.session` or the background calling `tabs.sendMessage`. +- Expose nothing page-visible (no event, no CSS) on paused or sensitive hosts. Insert the CSS only when swapping starts. +- Mention detectability in the privacy policy. + +**Regression test:** A-03. + +--- + +### A-04: Page script can open the word card with a synthetic click and probe its closed shadow root with `window.find` (Low) + +**Severity:** Low. The closed shadow root is not a confidentiality boundary: +- The card opens without the learner, because its handlers never check `isTrusted`. +- `window.find()` then answers yes or no about any text in the card: romanization, pronunciation, note, meanings in other base languages, and the "Also" words in other languages. + +This contradicts "can't see … their meanings" (`docs/privacy/en.md:107`). It needs guesses, but with a dictionary it is cheap. + +**Location** +- `extension/content/popover.js:666-684` (`onClick`, which opens the card at `:683`) and `:687-701` (`onKeyDown`: a synthetic "s" also speaks). Neither checks `isTrusted`. +- `extension/content/popover.js:211`: the shadow root is closed, but it lives in the page's document, where `window.find` searches it. + +**Proof:** `a-hostile-page.spec.mjs`, test A-04. On a swapped "Please", the page calls `window.find("pozhaluysta")` before and after `dispatchEvent(new MouseEvent("click",{bubbles:true}))`. + +**Observed:** +``` +A-04 page-world result: {"open":true,"shadowRoot":null,…,"romanization":true,"notThere":false,"romanizationBeforeOpen":false} +A-04 card open (read through CDP): true +``` + +**Fix:** add `if (!e.isTrusted) return;` to `onClick`, `onPointerOver`, `onPointerDown` and `onKeyDown`. + +**Regression test:** A-04, expecting the card to stay closed. + +--- + +### A-05: Any page can use up a pending "Connect OpenRouter" sign-in (Low) + +**Severity:** Low. This is a denial of service on sign-in; no key is stolen and PKCE holds. While a sign-in is pending: +- Any page can navigate to `https://kotiko.org/connect/?code=`. +- The background deletes the stored verifier before it tries the exchange. +- So the learner's real code is then refused with a misleading "This sign-in has expired". + +**Location** +- `extension/background.js:1736-1758` (`oauth.code`): `store.secrets.remove("pkce:pending")` runs at `:1743`, before the exchange. +- `extension/content/connect.js:16-31`: any `?code=` on that path is sent. +- `extension/lib/pkce.js:74-81`: there is no per-sign-in nonce. + +**Proof:** `a-hostile-page.spec.mjs`, test A-05. +- kotiko.org is stood in by `context.route`, and the key exchange is stubbed in the worker. Only `real-code-0123456789` is accepted. +- An extension page sends `oauth.start`, a localhost page navigates to the junk callback, and then the real callback arrives. + +**Observed:** +``` +A-05 junk callback says: "Kotiko couldn't finish connecting to OpenRouter. …" +A-05 real callback says: "This sign-in has expired. Open Kotiko's settings and select Connect OpenRouter again." +A-05 codes sent to OpenRouter: ["junk-from-a-page"] secrets: {} +``` + +**Fix:** +- Add a random `state` to `callback_url` (`…/connect/?s=`), and use up the verifier only when it matches. +- Or delete the pending entry only after a successful exchange or on expiry. + +**Regression test:** A-05, expecting the real code to succeed. + +--- + +### A-06: After "Delete everything", content-world code can plant a server and token that Kotiko adopts as trusted (Low, defence in depth; the lead may raise it) + +**Severity:** Low. The precondition is code running in Kotiko's content-script world; no page reached that world in any of my tests. But: +- It breaks the stated guarantee "Even if a page subverted Kotiko's own script … puts back anything it changes" (`docs/security/requirements.md:62-65`; `docs/security/assurance-case.md:179-186`). +- The impact is that everything the learner types next, and their lookups, go to the attacker's server. + +**Location** +- `extension/background.js:1120-1137` (`wipeEverything`: sets `readyP = null` and clears `storage.local`). +- `extension/background.js:1941-1943`: `if (wiped) return;`, so nothing is put back while wiped. +- `extension/background.js:77-103` (`ready`): a fresh store runs `Local.migrate` with `legacy: ext.storage.local`. +- `extension/lib/local-mode.js:57-95` (`migrate`): + - It reads `token` and `serverUrl` from `storage.local` (`:59`). + - If there is a token, it stores it as the server secret and sets the server URL, `wordsHome: "server"` and `lookup.kind: "server"` in the trusted copy (`:63-67`, `:86`). + - The "fresh" mark only gates `adoptOnce`, not this. + +**Proof:** `test/security/poc/a-content-world.spec.mjs`, test A-06. +1. An extension page sends `data.deleteAll`. +2. In the content-script world (reached over CDP, as `privacy.spec.mjs` does), the test writes `{token: , serverUrl: "http://localhost:41234/kotiko"}`. +3. The learner opens the popup and adds "my secret diary word". The worker's `fetch` calls are recorded. + +**Observed:** +``` +A-06 before: {"wordsHome":"local","server":{"url":"http://localhost:4747"},"keys":{"server":false,"providers":{}}} +A-06 deleteAll: {"ok":true,"server":null} +A-06 what the worker sent to the attacker's address: [{"url":"http://localhost:41234/kotiko/api/v1/words","method":"POST","body":"{\"text\":\"my secret diary word\",\"preview\":true,\"base_langs\":[\"en\"]}"},{"url":"http://localhost:41234/kotiko/api/v1/llm/status","method":"GET","body":null}] +A-06 settings after the add: {"wordsHome":"server","server":{"url":"http://localhost:41234/kotiko"},"lookup":"server","keys":{"server":true,"providers":{}}} secrets: {"secrets":{"server":"test-t…stuv"}} +``` + +**Fix:** +- After a wipe, recreate the store at once with an empty legacy area, so a store born from a wipe never reads `storage.local`. +- Read 0.2 `token`/`serverUrl` only on a real update from a version before 0.3 (`onInstalled` `reason: "update"`). + +**Regression test:** A-06, asserting the defaults stay and nothing is sent to the planted host. + +**Not tested:** whether a service-worker restart while wiped (which resets the in-memory `wiped` flag) lets a later storage change trigger the same adoption with no learner action. The code suggests it does. + +--- + +### A-07: Lookups to paid providers have no output-token cap (Low) + +**Severity:** Low. +- A hostile or confused model, for example one stuck repeating itself, can generate up to the model's maximum output on every lookup, billed to the learner's key. +- This applies to OpenAI, Anthropic, Gemini, Groq and custom endpoints. +- Only OpenRouter catalogue models that list `max_tokens` get the spec's cap of 1200. +- The 25 s deadline cuts the client off, but providers still bill tokens generated after a disconnect. + +**Location** +- `extension/lib/llm/client.js:275`: `max_tokens` is sent only when `caps.max_tokens` is set. +- `extension/lib/llm/catalog.js:75`: `defaultCaps` sets `max_tokens: false`; see also `:30`. + +**Proof:** `node --test test/security/poc/a-llm-limits.test.mjs`, one lookup per preset against a stub model that answers prose. + +**Observed:** `max_tokens` was `1200` for openrouter and `null` for openai, anthropic, gemini, groq, ollama, lmstudio and custom. Each preset made one chat request per add. + +**Fix:** always send an output cap (`max_tokens`, or `max_completion_tokens` for OpenAI reasoning models) from the spec's policy. + +**Regression test:** extend the per-preset test in `test/unit/llm-client.test.mjs`. + +--- + +### A-08: Content-world code can force unthrottled syncs (Informational) + +The `sync` route accepts `force: true` from content scripts, which skips the 5 s page throttle. Content-world code can therefore make the learner's server send its full word list in a tight loop. The server has no rate limit for token holders. As with A-06, this needs content-world code. + +**Location:** `extension/background.js:1470-1476`; `extension/lib/sync-controller.js:142-148`. + +**Proof:** `a-content-world.spec.mjs`, test A-08. **Observed:** `plain 0, force:true 20 (in 61 ms)` for 20 messages of each kind. + +**Fix:** honour `force` only from senders of kind `page`. + +**Regression test:** A-08. + +--- + +### A-09: The release zips ship the `globalThis.__kotiko` test hook (Informational) + +The hook exposes `seed` (which writes the trusted settings copy), `toServer`, `toLocal`, `getStore` and more. No page or content script can reach the worker's global (see A-ISO and the route table). Only someone with DevTools on the extension could use it, but it is test code in production. + +**Location:** `extension/background.js:1992`. + +**Proof:** +- Ran `node scripts/build-extension.mjs --version 1.0.0 --out …` twice. +- Inspected the zips with Python `zipfile`. + +**Observed:** `__kotiko hook shipped: True` in both the Chrome and the Firefox zip. + +**Fix:** define the hook only when a test flag is set, or strip it at build time. + +**Regression test:** in `test/unit/build-extension.test.mjs`, assert there is no `__kotiko` in the zip's `background.js`. + +--- + +## Coverage matrix + +### 1. Secrets: checked; no exposure to pages or content scripts (defence-in-depth gap A-06) + +- **Content-script world:** `test/e2e/privacy.spec.mjs` passes. It shows the content-script world reads neither the key nor the token; `storage.session` is refused; and Kotiko's IndexedDB is not visible. +- **Page world (A-ISO):** + - `chrome.runtime` is undefined. + - There are no `kotiko*` globals. + - `fetch` of `chrome-extension://…/manifest.json`, `background.js` and `lib/settings.js` is refused. +- **Exports:** `test/unit/backup.test.mjs` "no export holds a key or a token" passes. Together with the settings, local-mode, privacy and sync suites: 163/163. +- **Server logs:** sent a key-shaped bearer and the word "sobaka" to the live server on :41587. A grep of its log for the token, `LLM_API_KEY`, the key and "sobaka" found nothing. +- **Repository history:** `gitleaks detect --config .gitleaks.toml` reported "144 commits scanned … no leaks found". +- **OAuth:** the exchange goes only to the fixed `KEYS_URL`, with `credentials: "omit"` (A-05). +- **Not checked:** Firefox, dynamically. + +### 2. Page isolation: checked; A-01, A-03 and A-04 found + +- **A-ISO:** the page can't load, frame or message the extension. A frame of `dashboard.html` becomes `chrome-error://chromewebdata/`, and a script tag pointing at the extension fires `error`. +- **Project spec `popover.spec.mjs`:** 9/9 pass (top layer above hostile CSS, closed shadow root, no `data-*`). +- **Project spec `privacy.spec.mjs`:** "page scripts see the swaps but no original word" passes, ×2. +- **Not checked:** timing side channels; Firefox. + +### 3. Injection: checked; no markup or script injection, CSV formula or SQL issue found; A-02 found (text spoofing) + +- **Hostile model answer (A-INJ):** a stubbed model returned `` in the note, romanization and language fields, and as the native word. + - Popup line: "Added agua (AH-gwah) = water · Spanish", with 0 markup elements. + - Word card: the payload shows as text, there are 0 img, svg or script elements, and the page's `__pwned` stays null. + - Dashboard: 0 markup elements; the payload shows as text. + - The payload in the native field was dropped by the word spec. +- **Lint:** `npx eslint extension` exits 0, including `no-unsanitized` and the `innerHTML` ban. +- **HTML sinks:** a grep for `innerHTML`, `outerHTML`, `insertAdjacentHTML`, `document.write`, `srcdoc`, `createContextualFragment` and `setHTMLUnsafe` in `extension/` finds nothing. The only `DOMParser` (`bulk/sheet.js:111`) parses into an inert document and keeps `textContent`. +- **CSV and Anki (`a-exports.test.mjs`):** every cell starting with `= + - @ TAB CR` is defused with a `'` prefix. The Anki export uses `#html:false` with tabs and newlines flattened. +- **Prompts:** page text never reaches a prompt, because no content-reachable route calls the model. `recent` holds language tags only (`lib/wordspec.js:776`). +- **SQL:** every `Repo.query!` in `server/lib` uses `?`-bound values. `language_tags.ex:183` interpolates column names that come from code, not from input. Server suite: 1106 tests, 0 failures. +- **Telegram:** `sendMessage` sets no `parse_mode` (`telegram.ex:32`), so replies are plain text. +- **Not checked:** + - The respell prompt as a stored prompt-injection path (read only). + - Cells with a leading space or a full-width `=` in a real spreadsheet. + - The welcome tab with a hostile answer, dynamically (lint only). + +### 4. Messaging: checked; A-08 found + +- **Routes (`a-routes.mjs`):** 1 `onMessage` listener and 44 routes. + - From extension pages: 40 routes. + - From pages and content scripts: `sync`. + - From content scripts: `sensitiveSites` and `neverSwap`. + - From the docs site: `oauth.code`. +- **Other entry points:** none. A grep for `onMessageExternal`, `onConnect`, `connectNative`, `externally_connectable`, `web_accessible` and window `message` listeners finds nothing. +- **Privileged messages:** `privacy.spec.mjs` shows every privileged type is answered with "forbidden" when sent from content. +- **Docs route:** `connect.spec.mjs` 4/4 pass, including "another site's `/connect/?code=` is ignored". +- The content script's own `onMessage` (`content.js:391-405`) accepts messages only from senders without a tab. +- `neverSwap` is capped at 5,000 entries × 200 characters (`settings.js:178`). + +### 5. Network: checked; no SSRF reachable from a page or a model answer + +- No content-reachable route takes a URL. +- `oauth.code` uses a fixed URL. +- Wiktionary uses a fixed host with `encodeURIComponent` (`background.js:660`). +- The LLM client uses `credentials: "omit"`. +- **Privacy spec:** the "planted address" and "rewritten settings" tests pass. +- **A-PRIV:** visiting a page with marker text, path and query made 0 worker requests, and the fixture log was empty. +- **Server TLS:** no `verify_none` or `transport_opts` anywhere in `server/lib`. +- **Not checked:** whether `Authorization` is dropped when the learner's own server redirects cross-origin. + +### 6. Server: checked; no issues + +Curl results against the live server on :41587: + +| Request | Result | +|---|---| +| No token | 401 | +| Cross-site `text/plain` POST | 401 | +| CORS preflight | 401, no `Access-Control-*` headers | +| `Host: evil.example` with a valid token | 421 | +| Valid request | 200 | +| `/%68ealth/../api/v1/words` and `/health/../…` | 401 | +| `POST /health` | 401 | +| 70 KB body with token | 413 | +| 70 KB body without token | 401 | +| Two `Authorization` headers | 401 | +| Token in the query string | 401 | + +- **File permissions:** the data folder was made 0700 at start; the db, `-wal` and `-shm` files are 0600. +- **Tests:** `mix test` 1106 tests, 0 failures. `a_bot_stranger_test.exs` 3/3: + - A stranger's Add/Skip button presses make no Telegram call, and the word stays pending. + - A stranger in a group gets no reply and spends no model request. + - An update with no sender is ignored. +- **Not checked:** binding beyond loopback, and `ALLOWED_HOSTS=*`. + +### 7. Resource exhaustion: checked; A-07 and A-08 found + +- A-01b: a 60,652-word hidden page was processed in about 4.2 s without the engine standing down. A page can only load its own tab. +- Syncs triggered by a page are throttled ("plain 0" in A-08). +- Content scripts can't add words, so a page can't trigger lookups. +- A confused model answering prose costs 1 request per add. +- Server bodies over 64 KB get 413. +- **Not checked:** huge imports and bulk adds dynamically; the perf suite. + +### 8. Supply chain and build: checked; A-09 found + +- `npm audit`: 0 vulnerabilities. `mix hex.audit` and `mix deps.audit`: clean. gitleaks: clean. +- **Workflows:** every action is pinned by SHA; no `pull_request_target` or `workflow_run`; CI has `contents: read`. +- **Zips:** + - Built twice with identical SHA-256: Chrome `0582ec92…`, Firefox `4bbc3516…`. + - 123 files each; no tests, maps or tools. + - No `web_accessible_resources` or `externally_connectable`. + - CSP `script-src 'self'`. + - `web-ext lint`: 0 warnings. +- **Not checked:** osv-scanner (not installed); the GitHub environment and approval settings. + +### 9. Privacy claims: checked; A-01, A-03 and A-04 contradict `docs/privacy/en.md` §"What websites can see"; A-06 contradicts `requirements.md:62-65` + +- **Holds:** "page text never leaves your browser" (A-PRIV). +- **Holds:** online voices are off by default (`speak.js:72`). +- **Holds:** keys and exports, as in area 1. +- **Not checked:** the store listing text, line by line. + +### Other observations (not findings) + +- On the first full `npm test` run, 1 of 1973 tests failed: `test/dom/welcome.test.mjs:221` got `['es','en']` and expected `['es']`. Three reruns of that file alone passed 25/25, so it looks flaky under load. +- The test name "page scripts see no vocabulary" (`popover.spec.mjs:89`) overstates what it checks. + +## Proof files (in the checkout, uncommitted) + +All in `/home/aylac/Projects/personal-projects/kotiko-sec-a/test/security/poc/`: +- `playwright.config.mjs` and `global-setup.mjs`: run the proofs with the fixture server on :41234. +- `playwright.e2e.config.mjs`: runs the project's own e2e specs on :41234. +- `a-hostile-page.spec.mjs`: A-01, A-01b, A-02, A-03, A-04, A-05, A-INJ, A-PRIV, A-ISO. +- `a-content-world.spec.mjs`: A-06, A-08. +- `a-llm-limits.test.mjs`: A-07. +- `a-exports.test.mjs`: the CSV and Anki checks. +- `a-routes.mjs`: the message route table. +- `a_bot_stranger_test.exs`: the bot stranger checks; run from `server/`. + +### Reviewer B, on v1.0.0-rc.1 (network, local process, Telegram stranger) + +# Slice 54 security review: report B + + + +- **Reviewer:** B (an AI agent working alone, in its own session) +- **Starting attacker:** someone on the same network, a malicious process or another account on the same computer, and a Telegram stranger +- **Candidate:** `v1.0.0-rc.1`, commit `cf7378e` (detached checkout `/home/aylac/Projects/personal-projects/kotiko-sec-b`) +- **Date:** 2026-10-06 +- **Environment:** Linux 6.14, Erlang 28.1.1, Elixir 1.19.2, Node 22, Playwright Chromium 1243 and Firefox 1543 +- **How I ran things:** the proofs ran against my own server instances started from the checkout, on ports 42001 to 42007. Each instance had its own temporary data folder and its own env file under `scratchpad/sec-b/runN/`. Model providers were faked with `test/helpers/fixture-server.mjs` (port 42100) and `test/security/poc/b-fake-llm.py` (port 42110). Telegram was faked with `Kotiko.TelegramStub` / `Req.Test`. I did not connect to the maintainer's server on `127.0.0.1:4747`, I read no `.env`, and nothing left the machine apart from `npm ci`, `mix deps.get` and `mix deps.audit` fetching packages and the advisory database. At the end I stopped every server I had started, killing each one by its PID. Some Chrome processes from other sessions (their profiles are `/tmp/puppeteer_dev_chrome_profile-*`) listen on ports in the 42xxx range; they are not mine and I left them alone. + +**Findings by severity:** +- **Critical:** 0 +- **High:** 0 +- **Medium:** 1 + - B-01: another local account can take the extension's server token by listening on `[::1]:4747`, even while Kotiko is running. +- **Low:** 5 + - B-02: anyone who can write in the data folder chooses the API token, and a planted `kotiko.db` symlink sends every word to a world-readable file without warning. + - B-03: after the Slovo-to-Kotiko copy, the old world-readable database stays, and `--uninstall --delete-data` doesn't remove it. + - B-04: the limiter on refused-Host log lines stops working after 1,000 names, so any client can write unlimited log lines. + - B-05: a username and password in `LLM_URL` or `TRANSCRIBE_URL` are written to the log at info level. + - B-06: no warning when a model or transcription key is sent over plain HTTP to another machine. +- **Informational:** 3 + - B-07: the learner's words reach the log at debug level without `LOG_LOOKUPS` (docs and code disagree). + - B-08: `SECURITY.md` and the requirements still describe a pre-1.0 project with no releases. + - B-09: weak `API_TOKEN` values are accepted, and nothing limits wrong-token attempts. + +**Not checked:** +- B-02 with files owned by a different account. There is no second account and no `newuidmap` here; that part comes from reading the code only. +- B-01 with the real extension in Firefox. Firefox page loads and `fetch` reached the squatter, but I loaded the extension only in Chromium. +- Prompt injection, and deep page-isolation attacks (DOM attributes, timing, shadow root). I relied only on the existing e2e and unit suites passing. +- Anki's own handling of a first data row that starts with `#`. +- An oversized server response crashing the extension (`background.js:596`). I read the code but did not run it. +- `install-service.sh` install mode. +- Real Wiktionary, Telegram, OpenRouter or store traffic, which the rules forbid. + +## Summary + +| Severity | Count | IDs | +|---|---|---| +| Critical | 0 | | +| High | 0 | | +| Medium | 1 | B-01 | +| Low | 5 | B-02, B-03, B-04, B-05, B-06 | +| Informational | 3 | B-07, B-08, B-09 | + +The one medium finding: a browser connects to `http://localhost:4747` (the extension's default address, and the address the docs give) over IPv6 `::1` first, but the server listens only on IPv4 `127.0.0.1`. Any other process or account on the computer can therefore listen on `[::1]:4747` and receive the extension's bearer token, even while the real server is running. I proved this with the real extension in Chromium. + +Everything else in my attacker's area held up when tested. +- **Authentication:** every method and every path spelling gets 401 without the token, and only `GET`/`HEAD /health` are open. +- **Host check:** it works over HTTP/1.0, HTTP/1.1, h2c and absolute-form requests. +- **Body caps:** they apply before anything is read. +- **Telegram:** a stranger gets no reply and causes no model call and no database change. +- **Log redaction:** keys and tokens were redacted in every shape I tried. +- **Network traffic:** the server makes no outbound connection except to the model URL it was given (traced with strace). +- **Builds:** the zips are reproducible. +- **Dependency audits:** clean. + +## Findings + +### B-01: Another local account can take the extension's server token by listening on `[::1]:4747`, even while Kotiko is running + +**Severity: Medium.** The attacker needs only an unprivileged process or account on the same computer, the learner using the default `http://localhost:4747` address, and IPv6 loopback (on by default in Linux and macOS). The attacker gains the bearer token within one sync (at most a minute). With it they can read, change and delete every word through the real server, and they also receive the text of every add. This breaks the requirement "Other accounts on the computer can't read your words" and the claim "A strong token, kept private" (`docs/security/requirements.md:25`, `:20`). By CVSS (local attack vector, low privileges, high confidentiality and integrity impact) it would score higher. I rate it Medium because it needs a second local account or a compromised local service. + +**Location** +- `extension/lib/local-mode.js:25`: `DEFAULT_SERVER = "http://localhost:4747"`. Also `extension/welcome.js:502` (`url || "http://localhost:4747"`) and the placeholders at `extension/popup.html:142`, `extension/welcome.html:123` and `extension/dashboard.html:203`. +- `site/src/content/docs/server/index.md:64` tells users to enter `http://localhost:4747`. +- `server/lib/kotiko/config.ex:19` (`@default_bind "127.0.0.1"`), `:353` and `:369-384`. `BIND=localhost` also resolves to IPv4 only. `server/.env.example:45` has `BIND=127.0.0.1`. +- `extension/background.js:446-475`: `connection()` and `request()` send `Authorization: Bearer ` on the first request, before any check that the peer is the user's Kotiko server. The token is bound to the address, and the attacker answers at that same address. + +**Proof** +1. `test/security/poc/b-v6-squatter.py 42001` listens on `[::1]:42001` while my Kotiko server is on `127.0.0.1:42001`. +2. `node test/security/poc/b-localhost-which.mjs 42001` loads `http://localhost:42001/health` in real Chromium and Firefox, then makes a same-origin `fetch` with a token. +3. `node test/security/poc/b-extension-v6-squat.mjs 42001 ` loads the **unpacked extension** in Chromium, runs `server.connect` with `http://localhost:42001` and the real server's token (as the dashboard does), forces a sync, and then reads what the squatter logged. + +**Observed** +``` +LISTEN 0 1024 127.0.0.1:42001 0.0.0.0:* <- Kotiko +LISTEN 0 5 [::1]:42001 [::]:* <- squatter +chromium: /health -> {"name":"not-kotiko","squatter":true} | API with token -> 200 {"name":"not-kotiko","squatter":true} +firefox: /health -> {"name":"not-kotiko","squatter":true} | API with token -> 200 {"name":"not-kotiko","squatter":true} +curl: {"name":"not-kotiko","squatter":true} +SQUATTER got GET /api/v1/words Host=localhost:42001 Authorization='Bearer extension-token-0123456789abcdef' +# real extension: +server.connect -> {"ok":true,"wordsHome":"server","sync":{"code":"not_kotiko_server"}} +squatter received the real token in 3 request(s): + SQUATTER got GET /api/words Host=localhost:42001 Authorization='Bearer LuJrNY…(real token)' + SQUATTER got GET /api/words Host=localhost:42001 Authorization='Bearer LuJrNY…(real token)' + SQUATTER got GET /api/v1/words?status=active,paused Host=localhost:42001 Authorization='Bearer LuJrNY…(real token)' +``` +On this machine `getent ahosts localhost` returns only `127.0.0.1`. The browsers resolve `localhost` themselves and tried `::1` first. The extension notices that the peer isn't Kotiko (`not_kotiko_server`), but only after it has already sent the token. + +Related cases I did not run separately: +- When the server isn't running, a squatter can also take `127.0.0.1:4747`. +- In local mode, the Ollama and LM Studio presets (`localhost:11434` and `localhost:1234`) reach a `[::1]` squatter the same way. Those presets carry no key, but the squatter receives the typed text and can return model answers. The answers are still validated. + +**Fix** +- **Server:** by default, listen on both loopback families (`127.0.0.1` and `::1`, or a `BIND=localhost` that binds every address it resolves to). +- **Extension:** make the default and the docs `http://127.0.0.1:4747` (the pairing string already uses that, `exposure.ex:70`). Optionally, have `normalizeServerUrl` map `localhost` to `127.0.0.1`. +- **Defence in depth:** before sending the token to a new address, have the server prove it knows the token without revealing it. For example, the extension sends a nonce, the server answers with HMAC(token, nonce), and only then does the extension send the token. This also covers squatting while the server is stopped. +- **Regression tests:** + - An ExUnit socket test: with the default config, a second listener on `::1:` fails with `eaddrinuse`. + - A unit test: `DEFAULT_SERVER`'s host is a literal loopback IP. + - A Playwright test built from `b-extension-v6-squat.mjs`: the squatter must log no token. + +### B-02: Anyone who can write in the data folder chooses the API token, and a planted `kotiko.db` symlink sends every word to a world-readable file without warning + +**Severity: Low.** The attacker needs a data folder that another account can write to (a shared or group folder, `/tmp/...`, or a folder that was ever `0777`) and access before a server start. They gain full API access with a token they chose, and every word written to a file they can read. The requirements describe the shared-folder risk as "Others can then see the files' names, not what's in them" (`docs/security/requirements.md:89`), which understates it. + +**Location** +- `server/lib/kotiko/token.ex:27-43`: `resolve/2` adopts whatever `api-token` holds, with no check of the file's owner. +- `server/lib/kotiko/private.ex:48-60`: `create/1` treats `eexist` as `:ok`, including when the path is a symlink. +- `server/lib/kotiko/private.ex:88-102`: `restrict/1` leaves symlinks alone. +- `server/lib/kotiko/data_dir.ex:243-277` and `:282-298`: only warnings, and the start continues. + +**Proof:** `test/security/poc/b-data-dir-plant.sh 42002 scratchpad/sec-b/bin/start-server.sh`. Before the start, the script plants `api-token` (contents chosen by the attacker) and `kotiko.db -> ../elsewhere/loot.db` (mode 0666) in a `0777` folder. It then starts the server with umask 077, uses the planted token and adds one word. + +**Observed** +``` +[warning] Other users of this computer could open Kotiko's files in .../run2/data (api-token). Made them private ... +[warning] Other users of this computer can open the data folder .../run2/data. ... +== API with the planted token: +200 +add: 200 +== where the words went: +-rw-rw-rw- loot.db -rw-rw-rw- loot.db-shm -rw-rw-rw- loot.db-wal +== readable by anyone: +ensecret[{"enabled":true,"case":"any","text":"secret",...}]activemanual2026-10-07T00:59:38... +``` +No warning mentions the database symlink. The version where the planted files belong to another account (chmod then fails with EPERM, and the server only warns) is **not checked** by running: there is no second account and no `newuidmap`. It comes from reading `private.ex:111-116`. + +**Fix** +- Refuse to start, with a message saying how to fix it, when: + - the data folder or any of Kotiko's files is owned by another uid; + - `kotiko.db`, `api-token` or a backup is a symlink whose target is outside the folder or readable by others; + - the folder is writable by group or others. +- Alternatively, open `api-token` with `O_NOFOLLOW` and check its owner and mode with `fstat`. +- Fix requirements.md:89. +- **Regression tests:** ExUnit tests that start against a folder holding a symlinked `kotiko.db`, and against a group-writable folder, and expect a refusal. + +### B-03: After the Slovo-to-Kotiko copy, the old world-readable database stays, and `--uninstall --delete-data` doesn't remove it + +**Severity: Low (privacy).** This affects upgrades from a 0.2 Slovo install whose `slovo.db` was created under umask 022. Another account that can traverse `~/.local/share` can read every word the learner had, and `--delete-data` gives the impression that the data is gone. The old `api-token` is copied as the live token (it was 0600 here). + +**Location** +- `server/lib/kotiko/data_dir.ex:414-442`: the legacy folder is never touched. +- `server/lib/kotiko/data_dir.ex:217`: `make_private` runs only on the target. +- `server/lib/kotiko/data_dir.ex:518-532`: the token is copied, so the old and live tokens are the same. +- `server/install-service.sh:114-140`: `delete_data_folder` covers only the current folder. + +**Proof:** `test/security/poc/b-legacy-folder.sh 42003 `. The script builds a fake HOME with `.local/share/slovo/slovo.db` at mode 0644, boots the server with that HOME, then runs `install-service.sh --uninstall --delete-data` with that HOME. No unit was installed, so no `systemctl` call was made. + +**Observed** +``` +[info] Copied your API token from .../slovo/api-token ... +[info] Moved your words from .../slovo/slovo.db to .../kotiko/kotiko.db (5 words) +== after the copy, the old folder: -rw------- api-token -rw------- MOVED-TO-KOTIKO.txt -rw-r--r-- slovo.db +== live token == old token? yes +== --uninstall --delete-data: Deleted .../kotiko/kotiko.db, .../kotiko/api-token, the folder .../kotiko +== left behind: -rw------- api-token -rw------- MOVED-TO-KOTIKO.txt -rw-r--r-- slovo.db +words still readable by others: 5 +``` + +**Fix** +- Run `Private.restrict/1` on the legacy `slovo.db*` and `api-token` after the copy, or remove the old token once the copy is verified. +- Have `--delete-data` also delete those files when `MOVED-TO-KOTIKO.txt` is there. +- **Regression tests:** a migration test asserting the legacy files are 0600; an extension to `scripts_test.exs` for `--delete-data` with a legacy folder. + +### B-04: The limiter on refused-Host log lines stops working after 1,000 names, so any client can write unlimited log lines + +**Severity: Low.** The attacker needs one of these: +- a TCP connection to the server: any local process, or the network when `BIND` isn't loopback; +- a web page that points a wildcard DNS name at 127.0.0.1. I did not try this path in a browser. + +The attacker gains log or journal flooding, and the log handler then drops other lines. This defeats the code's stated aim: "at most once an hour per name so a scanner can't flood the log". + +**Location:** `server/lib/kotiko/plug/host_check.ex:253-254` and `:345-375`. At `:364` the table is cleared once it reaches 1,000 names, so names already seen log again. + +**Proof:** `python3 test/security/poc/b-host-log-flood.py 42001 3 1001` sends three rounds of the same 1,001 names, paced under the logger's burst limit. + +**Observed** +``` +requests=3003 status_of_last=HTTP/1.1 421 Misdirected Request log_lines_added=2998 (a working limiter would add at most 1001) in 18.9s +``` +An unpaced run added only 1,001 lines, because the log handler's overload protection dropped the rest. + +**Fix** +- When the table is full, stop logging new names for the rest of the hour and log one summary line with the count, rather than clearing the table. +- **Regression test:** 3 × 1,001 hosts give at most 1,001 lines plus one summary line. + +### B-05: A username and password in `LLM_URL` or `TRANSCRIBE_URL` are written to the log at info level + +**Severity: Low.** The attacker needs read access to the server log (journald, or a log the user pastes into an issue). They gain the credentials for the user's model proxy. This breaks "Keys and tokens stay out of the logs, at every level" (`requirements.md:43`). + +**Location** +- `server/lib/kotiko/config.ex:436-456`: userinfo in the URL is accepted. +- `server/lib/kotiko/application.ex:132-141`: `model_line/0` logs the full URL. +- `server/lib/kotiko/log/redact.ex:137-142` and `:164-170`: not covered. + +**Proof:** the run4 env had `LLM_URL=http://proxyuser:Pr0xy-Pa55-SECRET@127.0.0.1:42100/llm/v1`, then `grep -n SECRET server.log`. Also `b_config_probe.exs`. + +**Observed** +``` +40: Model: http://proxyuser:Pr0xy-Pa55-SECRET@127.0.0.1:42100/llm/v1, 1 model from LLM_MODEL +LLM_URL with credentials in it: starts. warnings=[] llm_url=https://user:pa55word@llm.example/v1 +``` + +**Fix:** refuse userinfo in `LLM_URL`, `TRANSCRIBE_URL` and `PUBLIC_URL`, or strip it before logging and add it to `Redact.put_secrets`. **Regression test:** config and startup-summary tests asserting the password never appears. + +### B-06: No warning when a model or transcription key is sent over plain HTTP to another machine + +**Severity: Low.** The attacker needs a position on the network path and a learner who chose an `http://` provider on another host. They gain the provider key and the typed text. The server token gets a warning (`sendsInClear`, and the `BIND` warning), but provider keys get none. + +**Location** +- **Server:** `server/lib/kotiko/config.ex:436-456` and `:492-493`; `transcriber.ex:76-80`; `llm/client.ex` `headers/0`. +- **Extension:** `extension/background.js:1398`; `lib/llm/client.js:104-108`. `sendsInClear` is used only at `popup.js:1013`, `welcome.js:1147` and `dashboard.js:2817`. + +**Proof:** `cd server && MIX_ENV=prod mix run --no-start ../test/security/poc/b_config_probe.exs` + +**Observed** +``` +LLM key over plain HTTP to a remote host: starts. warnings=[] llm_url=http://203.0.113.7/v1 +transcription key over plain HTTP to a remote host: starts. warnings=[] ... transcribe_url="http://203.0.113.8/v1/audio/transcriptions" +``` + +**Fix:** +- **Server:** log a startup warning for a key sent to non-loopback, non-Tailscale `http://`. +- **Extension:** show the same warning under the custom provider address, using `ServerUrl.sendsInClear`. +- **Regression tests:** a config test and a jsdom test. + +### B-07: The learner's words reach the log at debug level without `LOG_LOOKUPS` (docs and code disagree) + +**Severity: Informational.** `LOG_LEVEL=debug` is needed. `docs/reference/configuration.md:197` and `ARCHITECTURE.md:160` claim words are never logged without `LOG_LOOKUPS`. + +**Location:** `server/lib/kotiko/router.ex:110` and `router_v1.ex:264`. + +**Proof:** run5 had `LOG_LEVEL=debug` and `LOG_LOOKUPS=false`, with a fake model answering a Latin-script `ru` word. I then sent `POST /api/words {"text":"mytypedprivatetext"}`. + +**Observed** +``` +[debug] Couldn't save [%{native: "PRIVATEWORDXYZ", reason: "script_mismatch", base_lang: "en", gloss: "MYSECRETGLOSS"}] +``` +The typed text itself was not logged. The model's answer was. + +**Fix:** guard the line with `Kotiko.LLM.log_lookups?/0`, or narrow the docs. **Regression test:** `capture_log` at debug level contains neither field. + +### B-08: The security policy and requirements still describe a pre-1.0 project with no releases + +**Severity: Informational.** + +**Location:** `SECURITY.md:5` ("Kotiko is before 1.0") and `docs/security/requirements.md:108` ("There are no releases yet"). Meanwhile `extension/manifest.json:5` has `"version": "1.0.0"` and `server/mix.exs:11` has `version: "1.0.0"`. + +**Proof and observed:** the `grep -n` output above. + +**Fix:** update both files; add a check to `check-versions.mjs`. + +### B-09: Weak `API_TOKEN` values are accepted, and nothing limits wrong-token attempts + +**Severity: Informational.** This applies only when the owner chooses the token and binds to a network. The no-rate-limit part is documented as a residual risk. + +**Location:** `server/lib/kotiko/config.ex:389-402`, `token.ex:11`, `router.ex:209-219`. + +**Proof:** `b_config_probe.exs` and `python3 test/security/poc/b-auth-rate.py 42001 16 10`. + +**Observed** +``` +API_TOKEN of 24 a's: starts. warnings=[] +API_TOKEN 'password' x3: starts. warnings=[] +{401: '19725/s'} +``` + +**Fix:** warn on a token with low variety, and optionally add a failure backoff when `BIND` isn't loopback. **Regression test:** a config test for the warning. + +## Coverage matrix + +| # | Area | Status | Evidence (what, how, result) | +|---|---|---|---| +| 1 | Secrets | **Checked with proof**. B-01, B-05, B-09 |
  • **Token file:** a fresh start under umask 022 gave `api-token`, `kotiko.db`, `-wal` and `-shm` at 600 and the folder at 700 (`stat`).
  • **`run.sh`** (temp copy with a fake `mix`): `.env` 664 became 600, `umask=0077`, `ERL_CRASH_DUMP_SECONDS=0`.
  • **`NAME_FILE`** (`b_config_probe.exs`): two-line, fifo, `/dev/zero` symlink, 70 KB, missing and short files are all refused; a 0644 file gets a warning; no secret appears in any output (`grep -c SECRET` gives 0).
  • **Redaction** (`b_redact_test.exs`): a Telegram URL, `inspect(%Req.Request{})`, an Erlang `~p` long term, a report, a charlist and a URL-encoded value were all redacted, with no fragments.
  • **Redirects:** a cross-host 307 from the model URL arrived without `Authorization` (Req 0.7.4).
| +| 2 | Page isolation | **Partly checked** |
  • Playwright `privacy.spec` + `popover.spec`: 17 passed.
  • `npm test`: 1,973 passed, 0 failed.
  • Deeper DOM, timing and shadow-root attacks: **not checked**.
| +| 3 | Injection | **Checked with proof**; prompts **not checked** |
  • **SQL:** the only interpolated SQL uses fixed identifiers (`word_model_v2.ex:89`, `language_tags.ex:52`/`:183`). About 35 malformed authenticated inputs gave no 500.
  • **CSV/Anki** (`b-export-injection.mjs`): cells starting with `= + - @ \t \r` get `'` prepended and are quoted; Anki writes `#html:false`.
  • **Logs:** Host log lines are escaped (`"evil\e[31mred..."`, `"a\"; [error] fake line"`).
  • **HTML sinks:** none found by grep (`bulk/sheet.js:111` uses `DOMParser` and reads `textContent`, which is inert). `npx eslint extension` exits 0.
  • **Anki first data row starting with `#`:** not checked (no Anki).
| +| 4 | Messaging | **Checked with proof** |
  • `b-sender-kinds.test.mjs`: 15/15. Another extension, its own URL claimed by another id, no id, prefix tricks, `file:`, `about:blank`, `data:` and a tab-less http sender all get no kind. `kotiko.org` look-alikes, http, userinfo and another port get no `docs` kind.
  • `test/bg/privacy.test.mjs` "every other type refuses them": pass.
  • Content-script routes are only `sync`, `sensitiveSites` and `neverSwap`. `oauth.code` also checks `isCallback`.
  • No `externally_connectable` and no `web_accessible_resources`.
| +| 5 | Network | **Checked with proof**. B-01, B-06 |
  • **Default bind:** 127.0.0.1 only. With `BIND=0.0.0.0` the server logs the plain-HTTP warning; through the LAN IP `/health` gives 200 and the API gives 401.
  • **TLS:** Req refuses a self-signed `openssl s_server` (`selfsigned_peer`).
  • **Outbound connections:** `strace -f -e connect` over boot plus a lookup showed only `127.0.0.1:42110` (the configured `LLM_URL`).
  • **Wiktionary:** the title is percent-encoded (`pronounce.ex:620`).
  • **Not checked:** an uncapped `res.text()` at `background.js:596`.
| +| 6 | Server | **Checked with proof**. B-02, B-04 |
  • **Auth:** 14 path spellings (`/%61pi`, `//api`, `/%2e/`, `/health/..`) and 8 methods on `/health` give 401. `bearer`, a double space, `Basic`, a wrong or truncated token, or two `Authorization` headers give 401.
  • **Host check:** `evil.com`, `foo.localhost`, `0x7f000001`, `127.1`, `localhost.evil.com` and `127.0.0.1.nip.io` give 421; h2c with `:authority` `evil.com` gives 421; no Host or two Hosts gives 400; absolute-form requests use the URI host.
  • **Body caps:** an unauthenticated 50 MB body (plain or chunked) gets 401 after 0 bytes uploaded; 65 KB authenticated gets 413 (v1 and legacy); the batch route gets 413; `text/plain` gets 415.
  • **Repeated parameters:** the last value wins; `lang[]`, `status[]` and `include[]` give 400; legacy `/api/words?lang[]` ignores the filter (harmless).
  • **Legacy delete:** `/api/words/<20 digits>` gives 404.
  • **Bot** (`b_bot_stranger_test.exs`): every stranger update (text, `/list`, `/remove`, `/bases`, `/add` in a group, voice, Add/Del buttons, no `from`) gave `telegram calls: []`, 0 model calls and no database change. With no allowlist the bot only replies with the sender's ID.
  • **`install-service.sh` install mode:** not run.
| +| 7 | Resource exhaustion | **Checked with proof** |
  • 17,000 half-sent request lines: the owner still got 200, and all were closed with 408 after 60 s.
  • A flood of about 14k unauthenticated `/health` requests per second moved the owner's median latency from 0.6 ms to 10.3 ms.
  • Nested JSON (64 KB, and 1 MB on the batch route) was rejected fast.
  • A batch of 500 maximum-size words gave 200 in 0.22 s.
  • Extension imports (`b-import-caps.mjs`): over 5 MB gives `too_big`, over 20 MB gives `too_big`, 20,001 words gives `too_many`, and 2 MB of nested arrays is rejected in 152 ms.
| +| 8 | Supply chain and build | **Checked with proof** (lightly) |
  • `npm audit`: 0. `mix hex.audit`: clean. `mix deps.audit`: "No vulnerabilities found".
  • Every action is pinned by SHA; no `pull_request_target`; CI has `contents: read`; `eval.yml` runs only on `workflow_dispatch` with inputs passed through `env`.
  • Zips built twice gave identical SHA-256 (Chrome `0582ec92…`, Firefox `4bbc3516…`), 123 files each, nothing stray.
  • `mix test`: 1,108 tests and 23 properties, 0 failures.
| +| 9 | Privacy | **Checked with proof** (server side) |
  • "No telemetry" confirmed by strace.
  • The model receives the typed text, base languages, prompt, key, the user agent and `x-title: Kotiko` (also sent to non-OpenRouter providers; harmless).
  • The log claim holds for typed text (see B-07 for the model's words).
  • The requirement "Other accounts can't read your words" is broken by B-01, B-02 and B-03.
  • Not checked at runtime: extension local-mode traffic (I relied on `privacy.test.mjs`), and Wiktionary, Telegram and OpenRouter traffic.
| + +## Proof-of-concept files + +All are in `/home/aylac/Projects/personal-projects/kotiko-sec-b/test/security/poc/`, local and uncommitted: +- `b-v6-squatter.py`, `b-localhost-which.mjs`, `b-extension-v6-squat.mjs`: B-01 +- `b-data-dir-plant.sh`: B-02 +- `b-legacy-folder.sh`: B-03 +- `b-host-log-flood.py`: B-04 +- `b_config_probe.exs`: B-05, B-06, B-09 and the `NAME_FILE` checks +- `b-fake-llm.py`: B-07 and the privacy capture +- `b-auth-rate.py`: B-09 +- `b_bot_stranger_test.exs`: bot (symlinked into `server/test/security_poc/`) +- `b_redact_test.exs`: redaction (symlinked into `server/test/security_poc/`) +- `b-sender-kinds.test.mjs`: messaging +- `b-export-injection.mjs`: CSV and Anki +- `b-import-caps.mjs`: import caps +- `b-slow-headers.py`: slow connections +- `b-health-flood.py`: `/health` flood + +The server launch helpers are in `scratchpad/sec-b/bin/` (`start-server.sh` and `start-traced.sh`). + +## Not checked + +- B-02 with files owned by a different account (no second account or `newuidmap`). +- B-01 with the real extension in Firefox (only Firefox page loads and `fetch` were run); macOS; Windows with WSL. +- Prompt injection, and deep page-isolation attacks (I relied on the existing suites). +- Anki's handling of a first data row starting with `#`. +- An oversized server response crashing the extension (`background.js:596`). +- `install-service.sh` install mode, and the systemd `UMask` at runtime. +- Real Wiktionary, Telegram, OpenRouter and store traffic (forbidden by the rules). + +### Reviewer C, on v1.0.0-rc.1 (supply chain and release) + +# Slice 54 security review: Reviewer C report + + + +- **Candidate:** `v1.0.0-rc.1`, commit `cf7378e1b97e25043f322182e8f0f39e6dc90b44` (annotated tag, SSH-signed: `Good "git" signature for dev@scriptkittyos.com with ED25519 key SHA256:HqIaFq/thUm844NjYC3a+4Br40cqD2yJHEq1L2iiw4E`). +- **Reviewer:** C (starting attacker: supply chain and release). I worked alone, in `/home/aylac/Projects/personal-projects/kotiko-sec-c` (detached at the tag) and the `sec-c` scratch folder only. I read no other reviewer's checkout or notes and did nothing outward-facing. GitHub access was read-only `gh`/`gh api` calls, plus read-only HTTPS GETs of `kotiko.org`. +- **Environment:** Node v22.23.2, Elixir 1.19.2 / OTP 28.1, Playwright 1.63.0 (Chromium), gitleaks 8.30.1 and OSV-Scanner 2.6.0. I downloaded both tools myself into `sec-c/tools/` and checked them against the SHA-256s CI pins (`OK` / `OK`). +- **Release status:** the GitHub pre-release for `v1.0.0-rc.1` does not exist. Release run 37553786338 failed in `build` at `npm run coverage` (`not ok 5 - a word on the never-swap list stays, on every page, until it is taken off`, `# fail 1`), so it published no zips, SHA256SUMS, SBOM or attestations. I compared local builds instead (area 8); see C-08. + +**PoCs** (local, uncommitted, in `test/security/poc/`): + +| File | What it shows | +|---|---| +| `server-probe.sh` | Black-box checks of auth, Host, body limits, CORS and failed-auth throttling against a local test server | +| `fake-provider.mjs` | A fake OpenAI-compatible provider that logs every request and returns a chosen (hostile) answer | +| `routes-audit.mjs` | Loads the real `background.js`, lists every message type with its allowed senders, and sends each one from five senders that must be refused | +| `page-handoff.spec.mjs` + `playwright.config.mjs` | C-04, and a markup `native` rendered as text | +| `gate-false-positive.sh` | C-05 | +| `sync-browser-langs.mjs` | C-07 | + +Run the Playwright PoC with `npx playwright test -c test/security/poc/playwright.config.mjs`. Scratch outputs are in the `sec-c` folder: `server-probe.out`, `server.log`, `provider.jsonl`, `npm-test.log`, `build1/`, `build2/`, `build3/`, `build-fresh/`, `amo/`, `tagpoc/`, `prsim/`, `allrefs/`, `site-fetch/`. + +## Summary + +| Severity | Count | IDs | +|---|---|---| +| Critical | 0 | | +| High | 0 | | +| Medium | 1 | C-01 | +| Low | 3 | C-02, C-03, C-04 | +| Informational | 7 | C-05 to C-11 | + +In short: the dependencies are clean (OSV, npm audit, hex.audit and deps.audit all report zero), and no secret appears in any ref, PR ref or zip. The zips rebuild byte for byte. The CI workflows pin every action by SHA, use no `pull_request_target`, and have no script-injection sinks. Messaging, the server's auth and Host check, and TLS verification all held up under testing. The weak spots are release governance (one maintainer account can release alone, a signed tag can be replayed under another name, tag names can be squatted) and a few privacy or verification claims that are stricter than the code. + +--- + +## Findings + +### C-01 · Release authority is documented but not enforced by repository settings · Medium + +**Why medium:** the protections the docs rely on are not switched on: reviewed changes to signing keys, review on every PR, and a maintainer approving store uploads. One compromised maintainer account or write-scoped token can add its own signing key and get a signed, attested GitHub release. Once store secrets exist, it can also upload to the stores with nobody approving. + +**Requires:** write access, meaning one maintainer account or token. Collaborators today are `HackTuah` (admin) and `minitru` (maintain). + +**Locations** +- `.github/allowed_signers:8`: "Adding or removing a key is a reviewed pull request." +- `docs/security/assurance-case.md:68` ("Review by a maintainer") and `:142` ("review on every pull request"). +- `docs/security/assurance-case.md:103` and `docs/stores.md:98-104`: the `release` environment with required reviewers. +- `.github/workflows/release.yml:256` and `:322` (`environment: release`), `store-status.yml:54`, `eval.yml:31`. +- `ci.yml:42`: gitleaks reads `.gitleaks.toml` from the PR's tree. +- `dependency-scan.yml:266`: `osv-scanner.toml` comes from the PR's tree. + +**Proof** + +Read-only API calls: +```sh +gh api repos/ScriptKittyOS/kotiko/rulesets/24531669 --jq '.rules[] | select(.type=="pull_request") .parameters' +gh api repos/ScriptKittyOS/kotiko/environments --jq '[.environments[].name]' +gh api repos/ScriptKittyOS/kotiko/actions/permissions +gh pr list --repo ScriptKittyOS/kotiko --state merged --limit 30 --json number,author,mergedBy,reviews \ + --jq '.[] | "\(.number) author=\(.author.login) mergedBy=\(.mergedBy.login) reviews=\(.reviews|length)"' +``` + +A PR switches off its own required secret scan. This ran in a local clone (`sec-c/prsim`); nothing was pushed: +```sh +printf 'const t = "ghp_<36 random alphanumerics>";\n' > leak.js && git add leak.js && git commit -s -m "add helper" +gitleaks git --log-opts=HEAD --redact --config .gitleaks.toml --exit-code 1 . # what ci.yml:42 runs +sed -i 's/^stopwords = \["0123456789"\]/&\npaths = ['"'"'''leak\\.js'"'"''']/' .gitleaks.toml && git commit -asm "tidy gitleaks config" +gitleaks git --log-opts=HEAD --redact --config .gitleaks.toml --exit-code 1 . +``` + +**Observed** +``` +{"allowed_merge_methods":["merge","squash","rebase"],...,"require_code_owner_review":false,...,"required_approving_review_count":0,...} +["github-pages"] <- no release / store-status / eval environment exists +{"enabled":true,"allowed_actions":"all","sha_pinning_required":false} +27 author=HackTuah mergedBy=HackTuah reviews=0 <- PR #27 added the release signing key to allowed_signers +... 22 of 23 merged PRs have 0 reviews (#20 has 1) +--- 1. PR adds a token; .gitleaks.toml as on main: exit=1 leaks found: 1 +--- 2. same PR also edits .gitleaks.toml: exit=0 no leaks found +``` + +What these settings allow: +1. A maintain-role account can merge its own PR alone, including changes to `allowed_signers`, `release.yml` and `verify-tag.sh` (what verify-tag trusts "from main"). After that, `git tag -s` with its own key is accepted: build, attest, non-prerelease GitHub release. +2. GitHub creates a missing environment on first use with no protection rules. `publish-chrome` and `publish-firefox` would then run without approval. Today they skip only because no CWS or AMO secrets exist; that changes as soon as the secrets are added, especially if they go in as repository secrets. +3. The required `secrets` and `osv-scanner` checks read their allowlists (`.gitleaks.toml`, `.gitleaksignore`, `osv-scanner.toml`) and `scripts/check-*.mjs` from the PR itself. + +**Fix** +- Main ruleset: `required_approving_review_count: 1`, `require_code_owner_review: true`, and CODEOWNERS on `.github/**`, `scripts/verify-tag.sh`, `scripts/check-security-gate.mjs`, `.gitleaks*`, `osv-scanner.toml` and `extension/manifest.json`. If only one person is active, record that as an accepted risk in GOVERNANCE instead of claiming review. +- Keep "Actions can approve PRs" off. +- Create the `release`, `store-status` and `eval` environments now: required reviewers; `v*` tags only for `release`, `main` only for the other two. Store secrets go only into those environments. +- Turn on `sha_pinning_required`. + +**Regression test:** a read-only settings check (`scripts/check-repo-settings.mjs`, run by the release checklist or a weekly job). It fails unless the review count is at least 1, code-owner review is on, `release` exists with a reviewer and a `v*`-only policy, and SHA pinning is required. Today it fails on all four. + +### C-02 · `verify-tag.sh` doesn't check the signed tag's own name, so a signed rc can be replayed as a final release · Low + +**Why low:** anyone with write access can create `refs/tags/vX.Y.Z` pointing at the release manager's signed `vX.Y.Z-rc.N` tag object. verify-tag then reports `prerelease=false`, and the full release path runs (non-prerelease GitHub release, release PR relabelled, store-gate, store jobs) without the release manager ever signing a release. The code shipped is still code the release manager signed as a candidate, which limits the gain. Because tags are permanent (C-03), the replay also burns the version name. + +**Requires:** write access. + +**Locations:** +- `scripts/verify-tag.sh:48-54`: the name is parsed from the ref only. +- `:57-59`: the tag object is read, but its `tag` header is never compared. +- `:84` (the commit), `:104-107` (only the core version is compared). +- `.github/workflows/release.yml:76`. + +**Proof** (local clone, real signed rc.1 tag object, real `.github/allowed_signers`): +```sh +git clone -q --no-checkout tagpoc && cd tagpoc +git fetch -q 'refs/tags/v1.0.0-rc.1:refs/tags/v1.0.0-rc.1' +git checkout -q -f db62607 +git -c commit.gpgsign=false merge -q --no-ff -m "sim: merge release PR" cf7378e # ruleset allows "merge" +git update-ref refs/remotes/origin/main HEAD +git update-ref refs/tags/v1.0.0 "$(git rev-parse refs/tags/v1.0.0-rc.1)" # creation isn't blocked +git cat-file -p refs/tags/v1.0.0 | sed -n 1,4p +bash /scripts/verify-tag.sh v1.0.0 --signers /.github/allowed_signers --keys /.github/release-keys.asc +``` + +**Observed** +``` +object cf7378e1b97e25043f322182e8f0f39e6dc90b44 +type commit +tag v1.0.0-rc.1 +tagger Ayla Croft 1791334034 -0400 +Good "git" signature for dev@scriptkittyos.com with ED25519 key SHA256:HqIaFq/thUm844NjYC3a+4Br40cqD2yJHEq1L2iiw4E +version=1.0.0 +prerelease=false +EXIT=0 +``` + +A defence-in-depth gap: `verify-tag.sh:115` outputs `commit=`, but the build job never compares it with what it checks out. The "tags are permanent" ruleset blocks tag updates today, so I found no TOCTOU path. + +**Fix:** +- After `verify-tag.sh:59`: `[ "$(git cat-file tag "refs/tags/$tag" | sed -n 's/^tag //p')" = "$tag" ] || die …`. +- Expose `commit` as a job output and fail the build when `git rev-parse HEAD` differs from it. +- Add the same check to `tag-release.sh` and to verify.md's manual steps. + +**Regression test:** a throwaway repo with a test key, signed `v9.9.9-rc.1`, and `refs/tags/v9.9.9` pointing at it. `verify-tag.sh v9.9.9` must exit 1. + +### C-03 · Anyone with write access can permanently squat release tag names; the docs say a bad tag can be deleted, but it can't · Low + +**Why low:** the "tags are permanent" ruleset blocks update and deletion of all tags with no bypass actors, but nothing restricts creation. Any account with write access can push `v1.0.0` first (unsigned, or the C-02 replay). The real signed `v1.0.0` can then never be pushed, and the version is burned until an admin edits the ruleset. docs/stores.md tells maintainers to recover by deleting the tag, which the ruleset refuses. + +**Requires:** write access. + +**Locations:** ruleset 24531685; `docs/stores.md:107-111` and `:243-244`. + +**Proof:** +```sh +gh api repos/ScriptKittyOS/kotiko/rulesets/24531685 --jq '{conditions,bypass_actors,rules}' +sed -n 241,245p docs/stores.md +``` + +**Observed** +``` +{"bypass_actors":[],"conditions":{"ref_name":{"exclude":[],"include":["~ALL"]}},"rules":[{"type":"update"},{"type":"deletion"},{"type":"non_fast_forward"}]} + Delete the tag (`git push origin :refs/tags/vX.Y.Z` and `git tag -d vX.Y.Z`; the ruleset + lets maintainers do this), fix the key, run `scripts/tag-release.sh` again. +``` +There is no `creation` rule, and the empty bypass list means deletion is refused for everyone, maintainers included. + +**Fix:** add a `v*` tag ruleset with the `creation` rule and a bypass for release managers only. Either let release managers bypass deletion too, or change stores.md:243 to say a wrong tag can't be deleted and the version must be bumped. + +**Regression test:** the C-01 settings check also asserts a `creation` rule on `v*` with the release-manager bypass. + +### C-04 · Pages can detect Kotiko even where it's paused or off, and can switch Kotiko off on themselves · Low + +**Why low:** `content.js` dispatches a `kotiko:handoff` CustomEvent on the page's own `document` and tears down on any such event with a different detail. Page script can therefore: +- learn Kotiko is installed on every http(s) page, including paused sites, sensitive sites, and with Kotiko switched off; +- remove all swaps on its own page. + +The privacy policy says pausing a site or turning Kotiko off stops sites from telling that you use Kotiko. The impact is a one-bit fingerprint, with no access to words or secrets. + +**Requires:** nothing beyond being a web page. + +**Locations:** +- `extension/content.js:531-532`: dispatches, then listens on `document`. +- `:470-472`: `onHandoff` tears down on any other detail; `:474-480`. +- The claim: `docs/privacy/en.md:104-109` and `docs/privacy/inventory.md` §2. + +**Proof:** `npx playwright test -c test/security/poc/playwright.config.mjs` runs `page-handoff.spec.mjs` with the repo's e2e fixtures and the unpacked extension in Chromium. A page listener is registered before load; then the page dispatches its own event. + +**Observed** +``` +BEFORE {"swaps":2,"seen":[{"detail":"muxegkdw-0kdg1j2tlwca","trusted":false}],"imgs":0,"pwned":null,"texts":["perro","дом"]} +AFTER page dispatched kotiko:handoff {"swaps":0} +SWITCHED OFF {"swaps":0,"seen":["string"]} +SITE PAUSED {"swaps":0,"seen":["string"]} + 2 passed (5.2s) +``` +The same run shows the markup `native` rendered as text: no `` element, and `__pwned` stays null. + +**Fix:** drop the page-visible DOM event for the handoff. Options: +- signal through `chrome.storage.session` or a runtime message keyed by tab; +- have the old instance tear down when `ext.runtime.id` becomes undefined, which `contextValid()` already checks. + +If a DOM signal has to stay, dispatch nothing while the page is paused, off or sensitive. + +**Regression test:** in `privacy.spec.mjs`, a page sees no `kotiko:*` event while Kotiko is off or the site is paused, and a page-dispatched `kotiko:handoff` leaves the swap count unchanged. + +### C-05 · The store gate accepts an open review when any line starts with the gate phrase · Informational + +**Why:** `CLOSED_RE` matches a line that starts with the word Gate, a colon and "closed", anywhere in a `review-v*.md`. That includes appended reviewer reports (spec §5 appends them unchanged) and "not yet" notes. The `release` environment approval would be the second lock, once it exists (C-01). + +**Location:** `scripts/check-security-gate.mjs:25,36`; `release.yml:250`. + +**Proof:** `test/security/poc/gate-false-positive.sh ` writes three reports that all say the gate is open. + +**Observed:** +- the plain "open" text: exit 1; +- text with an appended `- Gate: closed is what the lead writes at the end; not yet.`: `Security review closed: review-v1.0.0-rc.1.md`, exit 0; +- `**Gate:** closed? No: two medium findings are still open.`: exit 0. + +**Fix:** require one exact form, above a fixed `## Appendix` heading: +``` +^Gate: closed (\d{4}-\d{2}-\d{2}) by .+, fixes confirmed on (v\d+\.\d+\.\d+-rc\.\d+)$ +``` +Also require that no "open" line is present, and optionally that the rc's core version matches the tag's. + +**Regression test:** the three texts above, as unit tests of `closedReviews()`, each expecting `[]`. + +### C-06 · The Firefox package AMO receives is a fresh web-ext zip, not the released, attested zip · Informational + +**Why:** `docs/verify.md:3` says the GitHub zips are "the exact packages sent to the Chrome Web Store and Firefox Add-ons". `docs/reproducible-builds.md:3-4` says a rebuild gives "the same bytes as … the stores". But `release.yml:363,372` unzips the release zip and runs `web-ext sign --source-dir`, which repacks it with current timestamps (`web-ext/lib/cmd/sign.js:2,45` calls `build`). The files inside are the same, but AMO's copy can't be matched to SHA256SUMS or the attestation. + +**Proof:** +```sh +unzip -q build1/kotiko-firefox-1.0.0-rc.1.zip -d amo/src +npx --no-install web-ext build --no-config-discovery --source-dir amo/src --artifacts-dir amo/a --filename a.zip +npx --no-install web-ext build --no-config-discovery --source-dir amo/src --artifacts-dir amo/b --filename b.zip +sha256sum build1/kotiko-firefox-1.0.0-rc.1.zip amo/a/a.zip amo/b/b.zip +``` + +**Observed** +``` +4bbc3516743bc79b415f0fe1b31c5e2853abc3debe754bb9fc0815946e4b9cd4 build1/kotiko-firefox-1.0.0-rc.1.zip +6e5842e05ecb0efca4b94cb6eb411110efbc83fc3667365814c1b6c2b7d037eb amo/a/a.zip +a5e34f5fd3a7f5dcc48ee52f8709dad77e983926171cf811d5a387893c08ee5b amo/b/b.zip +same file list +``` + +**Fix:** upload the exact zip through AMO's v5 upload API, or reword both docs to "the same files, repacked by web-ext". + +**Regression test:** a release step that hashes the file actually uploaded and compares it with SHA256SUMS, or a docs grep if the wording changes instead. + +### C-07 · The browser's languages reach storage.sync right after install, but the inventory says they never leave the device · Informational + +**Why:** a fresh install sets the base languages from the browser's languages and writes them, with `baseLangsDetected`, to `storage.sync`. The browser syncs that to the learner's Google or Mozilla account before the learner has confirmed anything. This contradicts: +- `docs/privacy/en.md:38` (listed under "What stays on your device"); +- `docs/privacy/inventory.md:31` ("Leaves the device? Never"). + +The policy's "Browser sync" section does name "the languages you read in". + +**Locations:** `extension/background.js:1261-1267`, `:215-218` (`saveUi` writes `storage.sync`), `:163`. + +**Proof:** `node test/security/poc/sync-browser-langs.mjs` runs the real background.js with Accept-Languages `en-US, de-CH, ja, tr` and fires `onInstalled({reason:"install"})`. + +**Observed:** +``` +storage.sync after install: {"seedSalt":"f57fc02f36b68128b2ff620437f3f25f","ui":{"uiLang":"auto","baseLangs":["en","de","ja"],"baseLangsDetected":["en","de","ja"],"baseLangsConfirmed":false}} +``` + +**Fix:** either sync only once the learner has confirmed and leave out `baseLangsDetected`, or correct the inventory and policy wording. + +**Regression test:** in `test/bg/privacy.test.mjs`, `storage.sync.ui` has no `baseLangsDetected` after install, or a wording test that matches the behaviour chosen. + +### C-08 · The release build runs unseeded property tests and flaky tests, so a tag's release can fail at random (rc.1 did) · Informational + +**Why:** this hurts the availability of the release pipeline, which is how security fixes ship. `release.yml:123` runs `npm run coverage`, and `test/helpers/properties.mjs:14-18` picks a random seed each run. The rc.1 run failed on a flaky DOM test. My own full run failed two different tests: +- `test/dom/welcome.test.mjs:221`, which passed 25/25 on rerun; +- a reproducible property failure: `validateWordsResponse` throws when `contentType` is an object whose `toString` isn't callable (`extension/lib/validate-words.js:146`). `fetch` headers can't produce that input, so this is robustness only, not exploitable. + +**Proof:** +```sh +FC_SEED=504574479 FC_PATH="5:0:2:78:77:77" node --test --import ./test/helpers/assert-mode.mjs test/unit/properties/validate-words.test.mjs +node -e 'require("./extension/lib/validate-words.js"); try{globalThis.WordValidator.validateWordsResponse({contentType:{toString:false}})}catch(e){console.log("THREW",e.constructor.name,e.message)}' +gh run view 37553786338 --repo ScriptKittyOS/kotiko --log-failed | grep -E "not ok|# fail" +``` + +**Observed:** +``` +Counterexample: [{"contentType":{"toString":false}}] # fail 2 +THREW TypeError Cannot convert object to primitive value +not ok 5 - a word on the never-swap list stays, on every page, until it is taken off +# fail 1 +``` + +**Fix:** +- At validate-words.js:146, use `typeof raw?.contentType === "string" ? raw.contentType : ""`. +- Set a fixed `FC_SEED` in the release job; keep random seeds in CI and the weekly run. +- Quarantine or fix the timing-dependent DOM tests. + +**Regression test:** pin the counterexample as a unit test. + +### C-09 · No throttling of failed API-token attempts on the server · Informational + +**Why:** a client on the network can try tokens without limit, and nothing is logged per failure. Tokens are at least 24 characters (`server/lib/kotiko/token.ex:11`), and generated ones are strong, so the real risk is only a weak token the learner chose. + +**Location:** `server/lib/kotiko/router.ex:209-219` (`authorize`), `:221` (`deny`). + +**Proof:** `BASE=http://127.0.0.1:47999 TOKEN=… test/security/poc/server-probe.sh` against my local test server. + +**Observed:** `20 wrong tokens in a row, status codes: 401 … 401` (20 × 401, no 429). + +**Fix:** a per-IP failure counter in ETS (for example, 429 with `retry-after` after 10 failures a minute) plus a rate-limited log line. + +**Regression test:** ExUnit sends 11 bad tokens from one `remote_ip` and expects 429 on the 11th, while a good token still works from another IP. + +### C-10 · Cloudflare changes the published site: the policy page served differs from the repository's text · Informational + +**Why:** Cloudflare sits in front of kotiko.org and changes what visitors get: +- it injects `/cdn-cgi/scripts/…/email-decode.min.js` into `/privacy/`; +- it replaces the contact addresses with `[email protected]` for visitors without JavaScript; +- it adds a NEL `report-to` header pointing to `a.nel.cloudflare.com`. + +So the checklist item "kotiko.org/privacy/ serves the same text as docs/privacy/en.md" is false as served. Any future Cloudflare injection would reach pages the repository's tests never see. `/connect/` is unaffected: 0 scripts, meta CSP `default-src 'none'`, `no-referrer`, no `set-cookie`. + +**Proof:** +```sh +curl -sS https://kotiko.org/privacy/ | grep -oE ']*src="[^"]*"' +curl -sS https://kotiko.org/privacy/ | grep -oE '.{0,20}__cf_email__.{0,60}' +grep -rn "cdn-cgi\|email-decode" site/src +curl -sSI https://kotiko.org/connect/ | grep -i report-to +``` + +**Observed:** +- `