Repository navigation
fix(extension): content scripts can't change settings or queue adds (SCR-448) - #26
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.localby default. A subverted content script could: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)
storage.local.setAccessLevelworks only from Chrome 140 (MDN browser-compat-data, crbug 40949182); slice 11's "102+" was forsessiononly.setAccessLevelisn't implemented at all (bug 1724754).storage.local.Design
extension/lib/settings.js, rows in IndexedDB.storage.localbecomes a mirror: the background writes the trusted copy first, then the mirror.storage.onChangedand 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.settings.set,settings.restore).settings.setworks on an allowlist, each key with its shape and size checked.neverSwapmessage 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 takesuifrom 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).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:test/unit/settings.test.mjscovers the store itself.privacy.spec.mjs: from the real content-script world in Chromium, everything planted is put back, nothing loops, a planted job spends no lookup, andstorage.sync.setis refused.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.setandsettings.restorearefrom: ["page"];restoreSettings→Backup.settingsPatchkeeps the current provider and addresses, with only the model (same provider) and the data-keeping preference coming from the file;neverSwapis the only content-script write, one word at a time.Not closed, documented
maintoo. 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.js100 % / 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,reuseand DCO all pass.🤖 Generated with Claude Code