Skip to content

fix(extension): security review fixes for what web pages can see - #32

Merged
HackTuah merged 11 commits into
mainfrom
fix/sec-page
Oct 7, 2026
Merged

HackTuah merged 11 commits into
mainfrom
fix/sec-page

Conversation

@HackTuah

@HackTuah HackTuah commented Oct 7, 2026

Copy link
Copy Markdown
Member

Page-facing fixes from the pre-release security review (slice 54, SCR-476). Each fix has a regression test that failed before it, and test/security/page.spec.mjs now runs in CI's e2e. Merge last: this branch already contains #31, which contains #29, with their conflicts resolved, so its diff shrinks to its own commits once those are on main.

  • A-01 (medium): a page could read the learner's word list. Planted hidden text plus reading back the swaps recovered a 60,000-word dictionary in about 4 s.
    • Kotiko now swaps only text the learner can see: rendered, on screen or within a screen of it, not clipped away, not placed off the page. An IntersectionObserver keeps this cheap.
    • One page view shows at most 500 different words, each in at most 3 languages. These are new spec rules max_page_concepts and max_page_langs_per_concept.
    • Why 500: on real Wikipedia articles, a typical article's first 800 words give 121–306 distinct swaps, even with a 9,000-word vocabulary.
    • Tests: nine hiding techniques get no swaps, a hidden 20,000-word dictionary reveals nothing, and a visible page with 540 words shows exactly 500.
  • A-02: the popup never shows a page's raw lang text. The tag is canonicalised, and an invalid one counts as undeclared.
  • A-03 / C-04: the page-visible kotiko:handoff event is gone, so pages can't switch Kotiko off. Paused, off and sensitive pages get nothing from Kotiko, not even its stylesheet, which now arrives with the first swap.
  • A-04: the word card ignores synthetic (untrusted) events, so a page's script can't open it or make it speak.
  • Privacy policy, Version 3: "What websites can see" now says exactly what a site can learn, and its limits.
  • Lead's commits:
    • the merges, with conflicts resolved (CHANGELOG, a regenerated extension/spec/spec.js);
    • removal of background.js's dead insertCSS for open tabs;
    • a rename of the content scripts' live-copy global, so it passes the build's new test-hook guard (A-09).

Checks on the combined branch: lint; coverage 94.35 % lines and 84.98 % branches; npm test 2,029 passing, 4 runs in a row; perf within budget; Playwright 113 passed; privacy and spec sync; old-name check; DCO.

Known limits, recorded as residual risks: the cap is per page view (a page that reloads itself gets 500 more each time), tiny or covered text inside the viewport still counts as visible, and while a card is open, window.find can match its text.

🤖 Generated with Claude Code

Ayla Croft and others added 8 commits October 6, 2026 21:39
Security review finding A-02. The content script passed the page's raw
<html lang> attribute to the popup, which showed it verbatim when the
browser had no name for it, so a page could put any sentence it liked
into Kotiko's popup ("This page is in <attacker text>").

The declared language is now canonicalised with Intl.getCanonicalLocales
(at most 35 characters); anything else counts as no declaration. The
popup shows only a name the browser knows for a language tag and drops
the line when there is none, never the raw string.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
Security review finding A-04. The word card's document listeners didn't
check isTrusted, so a page's own script could open the card on a swapped
word with a synthetic click (or speak it with a synthetic "s"), then ask
window.find() about text inside its closed shadow root: romanization,
pronunciation, notes and the word in other languages.

Every document listener of the card (pointerover, pointerout,
pointerdown, click, keydown, contextmenu) now ignores events a script
made. The jsdom tests stand in for the learner through a helper that
dispatches trusted events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
Security review findings A-03 and C-04 (the same issue from two
reviewers). The content script announced itself with a kotiko:handoff
CustomEvent on the page's document and tore down on any such event, so
any page could switch Kotiko off, and could see it installed even where
the learner paused it, switched it off, or on a sensitive site. The
manifest's content.css was also in every page, so a page could style
its own <kotiko-w> and see Kotiko's rules apply.

- The handoff between content-script copies goes through the script
  world they share (a global the page can't see); a copy left from
  before an update tears down at its next DOM change, which the new
  copy causes only on pages it swaps anyway. No event is dispatched.
- The swap stylesheet moves to ui/swap-style.js and is added (as a
  constructed sheet, or a <style> where that isn't supported) with the
  first swap, and removed when Kotiko is paused or off there.
- Old pre-0.3 spans are put back only on pages Kotiko swaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
…page

Security review finding A-01 (medium). Kotiko swapped every matching
text node, hidden or not, and a page reads its own DOM: a site could
plant a display:none div of dictionary words and read the learner's
whole word list (meanings, target words and languages) back from the
<kotiko-w> swaps in about four seconds, with nothing on screen.

- Only text the learner can see is swapped: rendered (checkVisibility
  with opacity, visibility and content-visibility), its block on screen
  or within a screen of it, and at least 2 px each way after every
  ancestor's clipping. Blocks are watched by an IntersectionObserver
  (no layout reads); text a site adds is measured at once, within 4 ms
  a slice, so it's still swapped before it's painted. Hidden text waits
  until it shows (an opening menu, a fade-in, scrolling to it). On
  big.html (100,000 text nodes) the first swap lands as fast as before
  and the rest of the page swaps as it scrolls near.
- One page view shows at most 500 distinct concepts, and at most 3
  languages of one concept ("mix within the page"), new rules
  max_page_concepts and max_page_langs_per_concept in spec/rules.json,
  written for the content scripts to extension/spec/page.js. 500 covers
  every 800-word article measured even with a 9,000-word vocabulary
  (at most 306), and full Wikipedia articles with 1,000 words (277-552).
  A page can still see the swaps it shows; the cap bounds what one view
  reveals, however it shows its text.

The reviewers' proofs are now regression tests in test/security/,
which the Playwright config runs with the e2e suite (A-01 to A-04).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
Security review findings A-01, A-03, A-04 and C-04. The policy said
sites "can't see the original words, their meanings or your word list",
which wasn't true: a page knows its own text, so each swap tells it one
of the learner's words. "What websites can see" now says what a site
learns from the swaps, the limits Kotiko puts on that (only text on
screen, at most 500 different words in one visit, each in at most 3
languages), what remains (a site visited often or reloading itself can
learn more over time; an open word card's text can be found with
window.find), and that Kotiko adds nothing where it's paused or off.

Also: the data inventory's section 2, the assurance case's residual
risks and threat model, slice 15's implementation notes, and the
changelog. The bundled copy is synced (scripts/sync-privacy.mjs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
…-page

Signed-off-by: Ayla Croft <dev@scriptkittyos.com>

# Conflicts:
#	CHANGELOG.md
#	extension/spec/spec.js
The manifest no longer lists content.css (the swap style now comes with
the first swap, A-03/C-04), so the insertCSS call for tabs open at an
install or update never ran. The comment now describes the handoff as it
is: the new copy finds the old one itself, with nothing a page can see.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
…s test-hook guard

The release build now refuses any `__kotiko` outside a test-only block
(A-09), and the content scripts' handoff (A-03/C-04) named its live copy
`__kotikoContent`. It lives in Kotiko's own script world, where pages
can't see it, so only the name changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
HackTuah and others added 3 commits October 7, 2026 09:04
The language a word shows in is drawn per page per day (slice 18). Three
new tests in this branch expected the Russian word for "thanks", which
the draw picks on some days only; on 2026-10-07 it drew Mandarin, and CI
failed. #30 fixed one such test by hand; this pins the page's clock to
noon on 2026-10-06 for every test page, with time still running on, so
no content test can depend on the day again. Without it, three tests
fail under TZ=UTC today; with it, all pass in four time zones covering 6,
7 and 8 October.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
…rops the learner's words

Slice 54's A-06 fix keeps what storage.local held only on an update from
0.1 or 0.2, but kept no record that it had decided. A browser update
(onInstalled "chrome_update") later in the same worker ran the check
again, saw no update from 0.2, and dropped the words, the server and its
token, and the first-run state that the update had just kept. The
decision is now made once, by the first event after the set-up.

Also, on an update the drop now runs before the upgrade steps
(onboarding, bases, hidden languages, loopback addresses), so none of
them writes something the drop then erases. Both races made two welcome
tests time out about one run in four, on main as well.

A new test updates from 0.2, then fires a browser update: before the
fix the words were gone every time (5/5); after it they stay. The
welcome tests pass 40 runs out of 40.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
@HackTuah
HackTuah merged commit 7045c01 into main Oct 7, 2026
14 checks passed
@HackTuah
HackTuah deleted the fix/sec-page branch October 7, 2026 13:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant