Skip to content

fix(extension): content scripts can't change settings or queue adds (SCR-448) - #26

Merged
HackTuah merged 5 commits into
mainfrom
fix/content-script-settings
Oct 6, 2026
Merged

HackTuah merged 5 commits into
mainfrom
fix/content-script-settings

Conversation

@HackTuah

@HackTuah HackTuah commented Oct 6, 2026

Copy link
Copy Markdown
Member

What (Linear SCR-448, input to the slice 54 security review)

Web pages' content scripts could still write Kotiko's settings, because Chrome and Firefox give them storage.local by default. A subverted content script could:

  • turn Kotiko off, pause sites or hide languages;
  • edit the pages' word list;
  • plant an add job, which the queue would look up with the learner's own service, spending lookups and changing their list;
  • change the base languages and trigger a sync that pushes them to the learner's own server.

Nothing reached a new destination, since slice 28 already bound those, but the learner's settings and list weren't protected.

Research (primary sources, 2026-10-06)

  • Chrome: storage.local.setAccessLevel works only from Chrome 140 (MDN browser-compat-data, crbug 40949182); slice 11's "102+" was for session only.
  • Firefox: setAccessLevel isn't implemented at all (bug 1724754).
  • So the fix can't rely on locking storage.local.

Design

  • A trusted copy the background owns: extension/lib/settings.js, rows in IndexedDB.
    • storage.local becomes a mirror: the background writes the trusted copy first, then the mirror.
    • On every storage.onChanged and at each start, the mirror is compared with the trusted copy. Changed values go back, removed keys are restored, and unknown keys are deleted. The comparison sorts object keys, so it doesn't loop.
  • Pages change settings only through page-only messages (settings.set, settings.restore).
    • settings.set works on an allowlist, each key with its shape and size checked.
    • Where requests go (lookup service, addresses, keys) is not on it; only the existing bound-route paths change those.
  • Content scripts write nothing. The word card's "Don't swap this word" is a neverSwap message for one word at a time.
  • storage.sync: on Chrome 140+ it's locked to trusted contexts and accepted. Elsewhere this browser keeps its own copy and never takes ui from sync. Trade-off: on Firefox and older Chrome, base and interface languages no longer follow between devices automatically (in the CHANGELOG and the security requirements).
  • Migration: an existing install's settings and queue are taken over once, so nothing is lost.
    • A planted 0.2 token on a new install now drops every adopted setting and route, not only the server route.
    • A pre-28 hidden hosted lookup address is dropped; addresses the learner typed and can see are kept.

Tests

  • test/bg/settings.test.mjs (18) covers each attack, and 16 of 17 originals failed on the old code; the 17th was made deterministic and then failed too. It also covers:
    • Chrome versus Firefox sync handling;
    • pages can still set, merge, add and remove;
    • disallowed keys and bad shapes are refused;
    • backup restore never sets addresses;
    • upgrade paths;
    • the Connect OpenRouter result surviving a content-script rewrite.
  • test/unit/settings.test.mjs covers the store itself.
  • e2e privacy.spec.mjs: from the real content-script world in Chromium, everything planted is put back, nothing loops, a planted job spends no lookup, and storage.sync.set is refused.
  • The screenshot scripts in test/visual/ now seed through the background. Store screenshots were regenerated and checked by eye; the content is unchanged, so the PNGs aren't recommitted.

Lead review

I checked:

  • settings.set and settings.restore are from: ["page"];
  • the allowlist has no destination keys;
  • restoreSettings → Backup.settingsPatch keeps the current provider and addresses, with only the model (same provider) and the data-keeping preference coming from the file;
  • neverSwap is the only content-script write, one word at a time.

Not closed, documented

  • A content script can still add "don't swap" words, which are visible in Settings.
  • Other open pages may flicker briefly on a planted value before the heal; nothing is saved or sent.
  • Three older visual scripts (popup, dashboard, popover) stop partway on main too. That's unrelated, and it's a separate follow-up.

Checked (after merging main)

  • npm run lint: 0 errors. npm run coverage: 1972 tests, 94.25 % lines, 84.98 % branches; settings.js 100 % / 99 %.
  • npm run perf: all ok; two new budgets are about 20 ms each against 50.
  • npx playwright test: 101 passed, 4 skipped.
  • check-old-name, check-base-neutral, bestpractices-links, sync-extension, sync-privacy, reuse and DCO all pass.

🤖 Generated with Claude Code

Ayla Croft and others added 5 commits October 6, 2026 19:11
…SCR-448)

Browsers let Kotiko's content scripts write storage.local and storage.sync,
and Firefox has no way to stop them. Slice 28 bound where requests go, but a
subverted content script could still turn Kotiko off, pause sites, hide
languages, edit the words pages show, queue an add (spending the learner's
lookups and changing their list), pick another model or data policy, or change
the bases that a sync then pushed to the learner's server as their profile.

The background now keeps the real copy of every setting, of the pages' word
list and of the add queue in its own IndexedDB store (lib/settings.js, rows
`area:<key>` in `meta`) and reads only that. storage.local is a mirror: a
change the background didn't make is put back, and a key it doesn't keep is
removed, on every storage event and at each start. Kotiko's pages change
settings with the page-only `settings.set` and `settings.restore` messages,
checked against an allowlist of keys and shapes; the word card's "Don't swap
this word" is the one change a content script may ask for (`neverSwap`).

In Chrome 140+ the background closes storage.sync to content scripts with
setAccessLevel and then takes changes from the learner's other browsers;
elsewhere it keeps its own copy. Existing installs keep their settings: they
are taken over once on update. Updating from before slice 28, a lookup address
saved for a hosted service, which no page can set, is dropped.

Tests: test/bg/settings.test.mjs (one per attack, failing on the old code),
test/unit/settings.test.mjs, a Chromium case in test/e2e/privacy.spec.mjs,
and two perf budgets for the trusted-copy write and check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
The privacy inventory's section 3, the assurance case (a sub-claim, the
storage trust boundary, the settings input, and the residual risks that
remain), the security requirements, the architecture overview, slice 28's
implementation notes with the browser research and its sources, notes in
slices 11 and 24, and the changelog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
Brings in Connect OpenRouter (#25) and the server file modes (#24). The
CHANGELOG and slice 11's notes had independent additions on both sides; both
are kept. #25's pages only send `oauth.start` and keep their waiting state in
memory; the key's arrival is the background's `oauth.code`, which already
writes the chosen service through the trusted copy and the bound routes. A new
test in test/bg/settings.test.mjs checks that path: the pages' copy shows
OpenRouter and the saved key, and a content script's rewrite of either is
put back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
Since SCR-448 storage.local is only the background's mirror, and anything
else written there is put back. The screenshot scripts now seed settings
in the service worker with `globalThis.__kotiko.seed` (and clear with
`__kotiko.area.remove`), change page settings with `settings.set`, mark
jobs seen with `jobs.seen`, and set the lookup service through
`backend.set` and `secrets.set`, which bind where requests go.

The store, welcome, local and readme-demo scripts run through. The store
screenshots regenerate with the same content as the committed ones (01, 03
and 05 byte-identical; 02 and 04 differ only in pixels), so the PNGs are
unchanged. The popup, dashboard and popover scripts stop at the same steps
on origin/main as here (an add that never finishes in the popup, the
inspector's pronunciation field, a word the popover page no longer swaps),
which predates this branch.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Ayla Croft <dev@scriptkittyos.com>
@HackTuah
HackTuah requested a review from minitru as a code owner October 6, 2026 23:35
@HackTuah
HackTuah merged commit 471ee18 into main Oct 6, 2026
14 checks passed
@HackTuah
HackTuah deleted the fix/content-script-settings branch October 6, 2026 23:50
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