From fbd27048d24b35e634f20dadcae3848e596ea991 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 7 Oct 2026 15:47:17 -0400 Subject: [PATCH 1/3] fix(content): open the word card only on a word the learner sees Security review finding E-05 (slice 54, reviewer E). A swapped word sits in the page's own DOM, so a page could restyle it to cover the whole screen almost invisibly. The learner's pointer resting anywhere, or a click on one of the page's own buttons, then landed on the word and opened its card, and the page searched the card's text with window.find. The card now opens only on a word the learner can see: rendered, at least 10 % opaque counting every ancestor's opacity and filter: opacity(), under no clip-path, mask or url() filter, and with a box no more than about twice the size of its text. A hover, click, tap or long press must also land within 4 px of the word's drawn text. Enter on a focused word and the "Show details" command still open it. Kotiko's own text nodes count as the word's text; elements a page puts inside the word don't. Regression tests: test/security/page.spec.mjs (A-04b resting pointer, a click on a page button under a stretched word, a transparent word kept under the pointer, A-04c reading within one frame, and a real hover and click still opening) and test/dom/popover.test.mjs. Each failed before this change. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- extension/content/popover.js | 85 ++++++++++++++++++++++-- test/dom/popover.test.mjs | 107 ++++++++++++++++++++++++++++++ test/security/page.spec.mjs | 123 +++++++++++++++++++++++++++++++++++ 3 files changed, 308 insertions(+), 7 deletions(-) diff --git a/extension/content/popover.js b/extension/content/popover.js index 1dbb672..c60a39e 100644 --- a/extension/content/popover.js +++ b/extension/content/popover.js @@ -6,6 +6,8 @@ // what is inside. It opens on hover intent, click, tap, a long press inside links, Enter on // a focused word and the "Show details" command, and shows the word's pronunciation block // (with 34's speak button), meaning and other candidates in Kotiko's interface language. +// Only the learner's own input opens it, only on a word they can see, and a pointer only +// where the word's text is drawn (security review A-04, E-05). // // const pop = KotikoPopover.createPopover({ infoFor, isWord, ... }); // pop.install(); delegated listeners on document (capture phase) @@ -28,6 +30,12 @@ const TOAST_ACTION_MS = 10_000; const GAP = 10; // word to card: the 6 px arrow plus air const EDGE = 8; // clamp from viewport edges + // A word the learner can see (security review E-05): at least this opaque, counting every + // ancestor's opacity and filter: opacity(); a pointer within NEAR_PX of its drawn text; and + // a box no more than twice its text's size plus STRETCH_PX each way. + const FAINT = 0.1; + const NEAR_PX = 4; + const STRETCH_PX = 32; const HOST_STYLE = [ "all: initial !important", "position: fixed !important", @@ -136,6 +144,34 @@ }; } + // --- What the learner sees (security review E-05) ---------------------------------- + // The parent an element is drawn in: its slot, its parent, or its shadow root's host. + const drawnParent = (e) => e.assignedSlot ?? e.parentElement ?? e.parentNode?.host ?? null; + + // One element's own share of how visible its content is: its opacity times any + // filter: opacity(); 0 for a filter Kotiko can't read (url()), a clip-path or a mask, + // which can hide text in ways a box can't show. engine.js `veil()` applies the same rule + // to what it swaps. + function ownOpacity(st) { + if (st.display === "contents") return 1; + const set = (v) => !!v && v !== "none"; + if (set(st.clipPath) || set(st.maskImage) || set(st.webkitMaskBoxImage)) return 0; + const own = parseFloat(st.opacity); + let o = own >= 0 ? own : 1; + if (set(st.filter)) { + if (/url\(/i.test(st.filter)) return 0; + for (const m of st.filter.matchAll(/opacity\(\s*([\d.]+)(%?)\s*\)/gi)) o *= Math.min(1, parseFloat(m[1]) / (m[2] ? 100 : 1)); + } + return o; + } + + // How opaque el's content is drawn: its own share times every ancestor's. + function drawnOpacity(el, win) { + let o = 1; + for (let e = el; e && o > 0; e = drawnParent(e)) o *= ownOpacity(win.getComputedStyle(e)); + return o; + } + // --- The popover -------------------------------------------------------------------- function createPopover(opts) { const { @@ -463,11 +499,43 @@ } // --- Open and close (19 §3) ------------------------------------------------------- - function canOpenOn(el) { + // Whether the learner can see the word, and for a pointer, whether `at` ({ x, y }) is on + // its drawn text (security review E-05). The is in the page's DOM, so the page + // can restyle it: stretch it invisibly over the screen so a resting pointer or a click on + // the page's own button lands on it, or keep it, transparent, under the pointer. Then it + // could search the open card with window.find. Text in Kotiko's own (27 §2) + // or the word's own text nodes count, not elements the page put inside. + const VISIBLE = { contentVisibilityAuto: true, opacityProperty: true, visibilityProperty: true }; + function seen(w, at) { + const shown = w.querySelector(":scope > kotiko-v") ?? w; + if (typeof w.checkVisibility === "function" ? !w.checkVisibility(VISIBLE) : win.getComputedStyle(w).visibility !== "visible") return false; + if (drawnOpacity(shown, win) < FAINT) return false; + const range = doc.createRange(); + // No layout (jsdom): nothing more to measure. + if (typeof range.getClientRects !== "function") return true; + const rects = []; + let text = null; + for (const n of shown.childNodes) { + if (n.nodeType !== 3) continue; + range.selectNodeContents(n); + for (const r of range.getClientRects()) { + if (r.width <= 0 || r.height <= 0) continue; + rects.push(r); + text = text ? { left: Math.min(text.left, r.left), top: Math.min(text.top, r.top), right: Math.max(text.right, r.right), bottom: Math.max(text.bottom, r.bottom) } : { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; + } + } + if (!text) return false; + // A box far larger than its text: stretched. + const box = w.getBoundingClientRect(); + if (box.width > 2 * (text.right - text.left) + STRETCH_PX || box.height > 2 * (text.bottom - text.top) + STRETCH_PX) return false; + return !at || rects.some((r) => at.x >= r.left - NEAR_PX && at.x <= r.right + NEAR_PX && at.y >= r.top - NEAR_PX && at.y <= r.bottom + NEAR_PX); + } + + function canOpenOn(el, at) { if (!el?.isConnected || editable(el)) return false; const fs = doc.fullscreenElement; if (fs && !fs.contains(el)) return false; - return true; + return seen(el, at); } function saveRanges() { @@ -477,8 +545,9 @@ return ranges; } - function openFor(el, mode = "click") { - if (!canOpenOn(el)) return false; + // `at`: where the learner's pointer is, for hover, click and touch. + function openFor(el, mode = "click", at = null) { + if (!canOpenOn(el, at)) return false; const info = infoFor(el); if (!info?.word) return false; cancelHover(); @@ -592,8 +661,9 @@ const el = hover.el; if (!el.isConnected) return cancelHover(); if (settled()) { + const at = hover.samples.at(-1); cancelHover(); - openFor(el, "hover"); + openFor(el, "hover", at); } else { hover.timer = setT(checkIntent, 50); } @@ -635,8 +705,9 @@ cancelPress(); press = { el: w, x: e.clientX, y: e.clientY, timer: setT(() => { const el = press?.el; + const point = press && { x: press.x, y: press.y }; cancelPress(); - if (el && openFor(el, "tap")) longPressed = { el, at: now() }; + if (el && openFor(el, "tap", point)) longPressed = { el, at: now() }; }, LONG_PRESS_MS) }; doc.addEventListener("pointermove", onPressMove, { capture: true, passive: true }); doc.addEventListener("pointerup", cancelPress, { capture: true, passive: true }); @@ -680,7 +751,7 @@ const sel = win.getSelection?.(); if (sel && !sel.isCollapsed && sel.toString().trim()) return; if (open && open.el === w && open.pinned) return close(); - openFor(w, e.pointerType === "touch" ? "tap" : "click"); + openFor(w, e.pointerType === "touch" ? "tap" : "click", { x: e.clientX, y: e.clientY }); } // --- Keyboard ------------------------------------------------------------------- diff --git a/test/dom/popover.test.mjs b/test/dom/popover.test.mjs index 715f6a2..e4129bf 100644 --- a/test/dom/popover.test.mjs +++ b/test/dom/popover.test.mjs @@ -286,6 +286,113 @@ describe("events a page script makes (security review A-04)", () => { }); }); +// Security review E-05: the swap is in the page's DOM, so the page can restyle it. The card +// opens only on a word the learner can see, and from a pointer only on its drawn text. +// jsdom has no layout: the word's text is drawn at TEXT and each element's box comes from +// its data-box (x,y,w,h), else the text's. +describe("only on a word the learner sees, where it's drawn (security review E-05)", () => { + const TEXT = { left: 100, top: 50, right: 180, bottom: 70, width: 80, height: 20 }; + function layout(p, { text = [TEXT] } = {}) { + const W = p.window; + W.Range.prototype.getClientRects = () => text; + W.Element.prototype.getBoundingClientRect = function () { + if (!this.dataset?.box) return TEXT; + const [x, y, w, h] = this.dataset.box.split(",").map(Number); + return { left: x, top: y, right: x + w, bottom: y + h, width: w, height: h, x, y }; + }; + } + + test("a click or a resting pointer on the word's text opens it; a few pixels off still does", async () => { + const p = await load(); + layout(p); + p.click(p.word("пожалуйста"), { clientX: 120, clientY: 60 }); + assert.equal(p.isOpen(), true); + p.pointer("pointerdown", p.$("p3")); + assert.equal(p.isOpen(), false); + p.pointer("pointerover", p.word("犬"), { clientX: 182, clientY: 72 }); + await sleep(HOVER_MS); + assert.equal(p.isOpen(), true); + }); + + test("a click or rest away from the text, on a box the page stretched over the screen, doesn't", async () => { + const p = await load(); + layout(p); + // The page's own button sits under the word's (stretched) box. + p.click(p.word("пожалуйста"), { clientX: 600, clientY: 400 }); + assert.equal(p.isOpen(), false, "a click elsewhere in its box"); + p.pointer("pointerover", p.word("犬"), { clientX: 600, clientY: 400 }); + await sleep(HOVER_MS); + assert.equal(p.isOpen(), false, "a resting pointer elsewhere in its box"); + // On the text, but the box is the whole screen. + const w = p.word("пожалуйста"); + w.dataset.box = "0,0,1280,720"; + p.click(w, { clientX: 120, clientY: 60 }); + assert.equal(p.isOpen(), false, "stretched"); + w.tabIndex = 0; + p.key(w, "Enter"); + assert.equal(p.isOpen(), false, "stretched, by keyboard"); + delete w.dataset.box; + p.key(w, "Enter"); + assert.equal(p.isOpen(), true, "Enter on the focused word, as drawn"); + }); + + test("a long press opens only on the word's text", async () => { + const p = await load(); + layout(p); + const w = p.$("link").querySelector("kotiko-w"); + p.pointer("pointerdown", w, { pointerType: "touch", clientX: 600, clientY: 400 }); + await sleep(600); + assert.equal(p.isOpen(), false); + p.pointer("pointerup", w, { pointerType: "touch" }); + p.pointer("pointerdown", w, { pointerType: "touch", clientX: 120, clientY: 60 }); + await sleep(600); + assert.equal(p.isOpen(), true); + }); + + test("a word with no drawn text doesn't open", async () => { + const p = await load(); + layout(p, { text: [] }); + p.click(p.word("пожалуйста"), { clientX: 120, clientY: 60 }); + assert.equal(p.isOpen(), false); + layout(p, { text: [{ ...TEXT, width: 0, right: TEXT.left }] }); + p.click(p.word("пожалуйста"), { clientX: 120, clientY: 60 }); + assert.equal(p.isOpen(), false, "an empty rectangle"); + }); + + for (const [label, style] of [ + ["nearly transparent", "opacity: 0.05"], + ["transparent through a filter", "filter: opacity(5%) blur(1px)"], + ["under a filter Kotiko can't read", "filter: url(#hide)"], + ["clipped", "clip-path: inset(100%)"], + ["masked", "mask-image: linear-gradient(transparent, transparent)"], + ["hidden", "visibility: hidden"], + ]) { + test(`a word ${label} doesn't open, by pointer or keyboard`, async () => { + const p = await load(); + const w = p.word("пожалуйста"); + w.parentElement.setAttribute("style", style); + p.click(w); + w.tabIndex = 0; + p.key(w, "Enter"); + p.pointer("pointerover", w); + await sleep(HOVER_MS); + assert.equal(p.isOpen(), false); + }); + } + + test("a faded word the learner can still read opens, and so does one in a display: contents parent", async () => { + const p = await load(); + const w = p.word("пожалуйста"); + w.parentElement.setAttribute("style", "opacity: 0.5; filter: opacity(0.5)"); + p.click(w); + assert.equal(p.isOpen(), true, "a quarter opaque"); + p.pointer("pointerdown", p.$("p3")); + w.parentElement.setAttribute("style", "display: contents; opacity: 0"); + p.click(w); + assert.equal(p.isOpen(), true, "display: contents draws no box of its own"); + }); +}); + describe("opening and closing (19 §3)", () => { test("hover intent: 300 ms of rest opens; a moving pointer doesn't", async () => { const p = await load(); diff --git a/test/security/page.spec.mjs b/test/security/page.spec.mjs index 14f0c9f..f897b80 100644 --- a/test/security/page.spec.mjs +++ b/test/security/page.spec.mjs @@ -242,3 +242,126 @@ test("A-04: a page script can't open the word card or search it with window.find await page.locator("#t kotiko-w").click(); await expect.poll(() => inShadow(page, function () { return this.querySelector(".k-card")?.hidden === false; })).toBe(true); }); + +// E-05 (reviewer E, rc.3): the page restyles its own (it's in the page's DOM) so +// the learner's resting pointer, or their click on the page's own button, lands on it and +// opens the card, which page script then searches with window.find. The card opens only +// where the learner's pointer is on the word's drawn text, and only on a word they can see. +test.describe("E-05: a page can't get the word card opened by stretching or hiding a swap", () => { + const PAGE = `x

Please come in, and thanks for the book.

`; + // Text that is only in the card, never on the page: the romanization, the respellings, + // the other language's word in "Also", the note. + const CARD_ONLY = ["pozhaluysta", "pa-ZHAL-sta", "pa-ZHA-lu-sta", "xièxie", "shyeh4-shyeh", "spasibo", "warmer"]; + const STRETCH = "#t kotiko-w:first-of-type{position:fixed!important;inset:0!important;width:100vw!important;height:100vh!important;z-index:2147483646!important;opacity:0.001!important;display:block!important}"; + const cardOpen = (page) => inShadow(page, function () { return this.querySelector(".k-card")?.hidden === false; }).then((v) => !!v); + const found = (page) => + page.evaluate((gs) => gs.filter((g) => { + getSelection().removeAllRanges(); + const hit = window.find(g, true, false, true, false, false, false); + getSelection().removeAllRanges(); + return hit; + }), CARD_ONLY); + const restyle = (page, css) => page.evaluate((c) => { + const s = document.createElement("style"); + s.textContent = c; + document.head.append(s); + }, css); + + test("A-04b: the learner's pointer resting anywhere on the page doesn't open it", async ({ context, serviceWorker, server }) => { + await setup({ server, serviceWorker, context }); + const page = await hostile(context, server, "hover.html", PAGE); + await expect(page.locator("#t kotiko-w").first()).toBeVisible(); + await restyle(page, STRETCH); + await page.mouse.move(600, 400, { steps: 5 }); + await page.mouse.move(602, 401, { steps: 2 }); + await page.waitForTimeout(1000); + expect(await cardOpen(page)).toBe(false); + expect(await found(page)).toEqual([]); + }); + + test("the learner's click on the page's own button under a stretched swap doesn't open it", async ({ context, serviceWorker, server }) => { + await setup({ server, serviceWorker, context }); + const page = await hostile(context, server, "jack.html", PAGE); + await expect(page.locator("#t kotiko-w").first()).toBeVisible(); + await restyle(page, STRETCH); + const box = await page.locator("#go").boundingBox(); + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + await page.waitForTimeout(500); + expect(await cardOpen(page)).toBe(false); + expect(await found(page)).toEqual([]); + }); + + test("a transparent swap the page keeps under the pointer doesn't open it either", async ({ context, serviceWorker, server }) => { + await setup({ server, serviceWorker, context }); + const page = await hostile(context, server, "follow.html", PAGE); + await expect(page.locator("#t kotiko-w").first()).toBeVisible(); + // Word-sized, its text right under the pointer, nearly transparent. + await page.evaluate(() => { + const w = document.querySelector("#t kotiko-w"); + w.style.cssText = "position:fixed!important;z-index:2147483646!important;opacity:0.01!important"; + const text = document.createRange(); + text.selectNodeContents(w); + document.addEventListener("pointermove", (e) => { + const r = text.getBoundingClientRect(); + w.style.left = `${parseFloat(w.style.left || 0) + e.clientX - (r.left + r.width / 2)}px`; + w.style.top = `${parseFloat(w.style.top || 0) + e.clientY - (r.top + r.height / 2)}px`; + }, true); + }); + await page.mouse.move(600, 400, { steps: 5 }); + await page.mouse.move(602, 401, { steps: 2 }); + await page.waitForTimeout(1000); + const at = await page.evaluate(() => { + const text = document.createRange(); + text.selectNodeContents(document.querySelector("#t kotiko-w")); + const r = text.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height, top: document.elementFromPoint(602, 401)?.localName }; + }); + expect(at.x < 602 && at.x + at.w > 602 && at.y < 401 && at.y + at.h > 401, `the swap is under the pointer: ${JSON.stringify(at)}`).toBe(true); + expect(await cardOpen(page)).toBe(false); + await page.mouse.down(); + await page.mouse.up(); + await page.waitForTimeout(300); + expect(await cardOpen(page)).toBe(false); + expect(await found(page)).toEqual([]); + }); + + test("A-04c: a page that reads the card within the frame it opens finds nothing", async ({ context, serviceWorker, server }) => { + await setup({ server, serviceWorker, context }); + const page = await hostile(context, server, "cover.html", PAGE.replace("", `
Loading…
`)); + await expect(page.locator("#t kotiko-w").first()).toBeVisible(); + await restyle(page, STRETCH); + await page.evaluate((gs) => { + window.__read = []; + const tick = () => { + if (document.querySelector("kotiko-popover")?.matches(":popover-open")) { + window.__read = gs.filter((g) => { + getSelection().removeAllRanges(); + return window.find(g, true, false, true, false, false, false); + }); + document.getElementById("mine").showPopover(); + return; + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }, CARD_ONLY); + await page.mouse.move(600, 400, { steps: 5 }); + await page.mouse.move(602, 401, { steps: 2 }); + await page.waitForTimeout(1000); + expect(await page.evaluate(() => window.__read)).toEqual([]); + expect(await cardOpen(page)).toBe(false); + }); + + test("the learner's own hover and click on a swap they see still open it", async ({ context, serviceWorker, server }) => { + await setup({ server, serviceWorker, context }); + const page = await hostile(context, server, "real.html", PAGE); + const w = page.locator("#t kotiko-w").first(); + await expect(w).toBeVisible(); + await w.hover(); + await expect.poll(() => cardOpen(page)).toBe(true); + await page.mouse.move(5, 5); + await expect.poll(() => cardOpen(page)).toBe(false); + await w.click(); + await expect.poll(() => cardOpen(page)).toBe(true); + }); +}); From a66033619aaf62b2dbf538f4f23e38bb28bfcd97 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 7 Oct 2026 15:47:28 -0400 Subject: [PATCH 2/3] fix(content): don't swap text a filter, clip-path or mask hides Security review finding E-04 (slice 54, reviewer E). checkVisibility() doesn't see text hidden with filter: opacity(0), a clip-path that clips everything or a fully transparent mask, so Kotiko swapped all of it and a page could read the learner's words there: 300 of 300 in reviewer E's test, in Chromium and Firefox. Text is now swapped only when it is at least 10 % opaque, counting every ancestor's opacity and filter: opacity(), and under no clip-path, mask or url() filter. A clip-path's shape can't be measured cheaply and a mask's image can't be read at all, so text under any of them waits, even where it would show; it is looked at again after a transition, an animation, or the learner's click, key or scroll, as hidden text already was. Each element's style is read once per 8 ms slice. Measured in Chromium: about 0.7 microseconds more per text node on the 100,000-node page and 7 on a page 30 elements deep, with no change in the first swap or the longest task on either page. No text in the e2e corpus pages is affected. Regression tests: test/security/page.spec.mjs (filter: opacity(0), clip-path inset(100%) and circle(0), a transparent mask, opacity 0.01: 0 of 300 words each; the same probe in plain view swaps, and swaps once the clip-path goes) and test/dom/content.test.mjs. Each failed before this change. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- extension/content/engine.js | 64 ++++++++++++++++++++++++++++++++---- test/dom/content.test.mjs | 31 ++++++++++++++++++ test/security/page.spec.mjs | 65 ++++++++++++++++++++++++++++++++++++- 3 files changed, 153 insertions(+), 7 deletions(-) diff --git a/extension/content/engine.js b/extension/content/engine.js index 6ef5faf..9a2b57f 100644 --- a/extension/content/engine.js +++ b/extension/content/engine.js @@ -40,11 +40,18 @@ const HYDRATION_MARKERS = "#__next, [data-reactroot], #__nuxt, [data-sveltekit-hydrated], [ng-version], [data-server-rendered]"; // Only text the learner can see is swapped (security review A-01): a page reads its own // DOM, so a swap in text nobody sees would tell it the learner's words for nothing. Text - // is swapped when it's rendered (no display:none, visibility, zero opacity or - // content-visibility) and its block is on screen or within a screen of it, at least 2 px - // each way after every ancestor's clipping; the rest waits until it is (lazily, which is - // also what keeps very long pages fast). + // is swapped when it's rendered (no display:none, visibility, or content-visibility), at + // least FAINT opaque (every ancestor's opacity and filter: opacity() multiplied), under + // no clip-path, mask or url() filter (veil(), E-04), and its block is on screen or within + // a screen of it, at least 2 px each way after every ancestor's overflow clipping; the + // rest waits until it is (lazily, which is also what keeps very long pages fast). const SHOWN = { contentVisibilityAuto: true, opacityProperty: true, visibilityProperty: true }; + // Security review E-04: checkVisibility() misses text a filter, clip-path or mask hides + // (a page got all 300 of 300 words that way). A clip-path's shape or a mask's image can't + // be measured cheaply, a mask image not at all, so text under any clip-path or mask isn't + // swapped, even where the shape leaves it in view (decorative angled sections, fade-out + // edges); a filter with url() likewise. Faint text (under 10 % opacity) can't be read. + const FAINT = 0.1; const MIN_PX = 2; const NEAR = "100% 0px"; // Whether a block is near comes from an IntersectionObserver, which costs no layout. A @@ -200,6 +207,9 @@ let io = null; let maskCheck = 0; let syncSpent = 0; + // element -> how opaque its content is drawn (veil()), for one slice: no page script runs + // within a slice, so no style changes either. + const veils = new Map(); // Roots the site just added or changed (handle()), and whether the walk is in one. const fromSite = new WeakSet(); let siteChange = false; @@ -282,11 +292,52 @@ return ok; } + // The parent an element is drawn in: its slot, its parent, or its shadow root's host. + const drawnParent = (e) => e.assignedSlot ?? e.parentElement ?? e.parentNode?.host ?? null; + + // One element's own share of how visible its content is (popover.js `ownOpacity()` is the + // same rule for opening the word card, E-05). + function ownOpacity(st) { + if (st.display === "contents") return 1; + const set = (v) => !!v && v !== "none"; + if (set(st.clipPath) || set(st.maskImage) || set(st.webkitMaskBoxImage)) return 0; + const own = parseFloat(st.opacity); + let o = own >= 0 ? own : 1; + if (set(st.filter)) { + if (/url\(/i.test(st.filter)) return 0; + for (const m of st.filter.matchAll(/opacity\(\s*([\d.]+)(%?)\s*\)/gi)) o *= Math.min(1, parseFloat(m[1]) / (m[2] ? 100 : 1)); + } + return o; + } + + // How opaque el's content is drawn: its own share times every ancestor's, 0 under a + // clip-path or mask. Each element's style is read once a slice, so text nodes sharing + // ancestors cost one read of each. + function veil(el) { + const chain = []; + let o = 1; + for (let e = el; e; e = drawnParent(e)) { + const known = veils.get(e); + if (known !== undefined) { + o = known; + break; + } + chain.push(e); + } + for (let i = chain.length - 1; i >= 0; i--) { + if (o > 0) o *= ownOpacity(win.getComputedStyle(chain[i])); + veils.set(chain[i], o); + } + return o; + } + + const styleHides = (el) => !el.checkVisibility(SHOWN) || veil(el) < FAINT; + // "shown", or why not: "hidden" (a style hides it) or "away" (not near, too small, or // not known yet). function sight(el) { if (!sees) return "shown"; - if (!el.checkVisibility(SHOWN)) return "hidden"; + if (styleHides(el)) return "hidden"; const b = blockOf(el); const v = near.get(b); // Text the site just added is measured even in a block that was near: the page may @@ -307,7 +358,7 @@ function wait(el, why) { if (why === "hidden") { let top = el; - for (let a = el.parentElement; a && a !== doc.body && a !== doc.documentElement && !a.checkVisibility(SHOWN); a = a.parentElement) top = a; + for (let a = el.parentElement; a && a !== doc.body && a !== doc.documentElement && styleHides(a); a = a.parentElement) top = a; // The observer already said it's near: a style still hides it. if (near.get(top) === true || near.get(blockOf(el)) === true) mask(top, "style"); else watch(top); @@ -458,6 +509,7 @@ // Runs queued work until the deadline; true when the queue is empty. function run(deadline) { syncSpent = 0; + veils.clear(); for (;;) { // Blocks that just came near go first: they're what the learner is about to see. // A long walk (a big page's first pass) waits for them. diff --git a/test/dom/content.test.mjs b/test/dom/content.test.mjs index de1662f..8d2fc2b 100644 --- a/test/dom/content.test.mjs +++ b/test/dom/content.test.mjs @@ -866,6 +866,37 @@ describe("only text the learner sees is swapped (A-01)", () => { assert.ok(![...io.observed].some((el) => $("none").contains(el) && el !== $("none"))); }); + // Security review E-04: checkVisibility() doesn't see these. + test("text a filter, clip-path or mask hides, or too faint to read, isn't swapped; once that's gone, it is", async () => { + let io; + const { dom, $, spans } = await load( + `

my house

+

my house

+

my house

+

my house

+

a house

thanks

+

my house

+

my house

+

my house

+

my house

`, + { beforeInject: (d) => (io = layout(d)) }, + ); + assert.equal($("seen").textContent, "my дом"); + for (const id of ["filtered", "percent", "svg", "clip", "masked", "faint"]) assert.equal($(id).querySelectorAll("kotiko-w").length, 0, id); + assert.equal($("dim").textContent, "my дом", "a quarter opaque is readable"); + assert.equal($("contents").textContent, "my дом", "display: contents draws no box, so its opacity doesn't apply"); + assert.equal(spans().length, 3); + // The clipped block waits as a whole; the observer said it's near, so the next click + // (or transition, animation, key or scroll) looks again. + assert.ok(io.observed.has($("clip"))); + io.fire([$("clip")]); + assert.equal($("clipped").textContent, "a house"); + $("clip").removeAttribute("style"); + $("clip").dispatchEvent(new dom.window.Event("transitionend", { bubbles: true })); + for (let i = 0; i < 100 && $("clipped").textContent === "a house"; i++) await sleep(10); + assert.equal($("clipped").textContent, "a дом"); + }); + test("text swaps once it shows: a menu opening, or fading in", async () => { let io; const { dom, $ } = await load(`

my house

thanks

`, { diff --git a/test/security/page.spec.mjs b/test/security/page.spec.mjs index f897b80..bd4074a 100644 --- a/test/security/page.spec.mjs +++ b/test/security/page.spec.mjs @@ -5,7 +5,9 @@ // turned into a regression test: a hostile web page against the unpacked extension, using // only page-world script, as a real site would. A-01: reading the word list from text nobody // sees; A-02: the page's lang attribute in the popup; A-03 and C-04: switching Kotiko off, -// and seeing it where it's paused or off; A-04: opening the word card with synthetic events. +// and seeing it where it's paused or off; A-04: opening the word card with synthetic events; +// E-05: getting the learner's own input to open it on a stretched or hidden swap; E-04: text +// hidden by a filter, clip-path or mask. // Pages are served by page.route on the fixture server's origin; nothing leaves the machine. import fs from "node:fs"; import { test, expect, connectServer } from "../e2e/fixtures.mjs"; @@ -365,3 +367,64 @@ test.describe("E-05: a page can't get the word card opened by stretching or hidi await expect.poll(() => cardOpen(page)).toBe(true); }); }); + +// E-04 (reviewer E, rc.3): text a page hides with filter: opacity(), clip-path or a mask got +// swaps, 300 of 300 of the learner's words. A page reads its own DOM, so it learned them. +test.describe("E-04: text hidden by filter, clip-path or a mask gets no swaps", () => { + const letters = (i) => `q${(i + 26 * 26).toString(26).replace(/./g, (c) => "abcdefghijklmnopqrstuvwxyz"[parseInt(c, 26)])}`; + const decoy = (i) => `zq${i.toString(36).replace(/\d/g, (d) => "abcdefghij"[d])}`; + const LEARNER = Array.from({ length: 300 }, (_, i) => ({ id: 1000 + i, lang: "ru", language: "Russian", native: `дом${i}`, romanization: null, english: letters(i), forms: [letters(i)], note: null, base_lang: "en" })); + // The learner's meanings among as many made-up words, 100 to a paragraph. + const DICT = LEARNER.flatMap((w, i) => [w.english, decoy(i)]); + const paragraphs = () => { + const ps = []; + for (let i = 0; i < DICT.length; i += 100) ps.push(`

${DICT.slice(i, i + 100).join(" ")}.

`); + return ps.join(""); + }; + // The probe sits outside
, so the made-up words don't change the page's language. + const doc = (style) => + `News

Please read this note about the ${letters(0)} today.

The weather is fine today, and the people in the town are going to the market to buy bread and fruit.

${paragraphs()}
`; + // Distinct swapped words in the probe, as the page's own script counts them. + const revealed = (page) => page.evaluate(() => new Set([...document.querySelectorAll("#probe kotiko-w")].map((w) => w.textContent)).size); + const settled = async (page) => { + let last = -1; + await expect.poll(async () => { + const n = await page.locator("kotiko-w").count(); + const same = n === last; + last = n; + return same; + }, { intervals: [400] }).toBe(true); + }; + + const hidden = { + "filter: opacity(0)": "filter:opacity(0)", + "clip-path: inset(100%)": "clip-path:inset(100%)", + "clip-path: circle(0)": "clip-path:circle(0)", + "a fully transparent mask": "mask-image:linear-gradient(transparent,transparent);-webkit-mask-image:linear-gradient(transparent,transparent)", + "opacity 0.01": "opacity:0.01", + }; + for (const [label, style] of Object.entries(hidden)) { + test(`${label}: none of 300 words revealed`, async ({ context, serviceWorker, server }) => { + await setup({ server, serviceWorker, context }, { words: LEARNER }); + const page = await hostile(context, server, "e04.html", doc(style)); + await expect(page.locator("#seen kotiko-w").first()).toBeVisible(); + await settled(page); + expect(await revealed(page)).toBe(0); + }); + } + + test("the same probe in plain view is swapped, and a clip-path the page takes away swaps once the learner clicks", async ({ context, serviceWorker, server }) => { + await setup({ server, serviceWorker, context }, { words: LEARNER }); + const plain = await hostile(context, server, "e04-plain.html", doc("")); + await expect(plain.locator("#probe kotiko-w").first()).toBeVisible(); + await settled(plain); + expect(await revealed(plain)).toBeGreaterThan(0); + const page = await hostile(context, server, "e04-reveal.html", doc("clip-path:inset(100%)")); + await expect(page.locator("#seen kotiko-w").first()).toBeVisible(); + await settled(page); + expect(await revealed(page)).toBe(0); + await page.evaluate(() => (document.getElementById("probe").style.clipPath = "none")); + await page.mouse.click(5, 5); + await expect.poll(() => revealed(page)).toBeGreaterThan(0); + }); +}); From a5c61de89cd6e37454a5de91e7ba37dbe7ec2fbd Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 7 Oct 2026 15:47:34 -0400 Subject: [PATCH 3/3] docs(privacy): say which hidden text is never swapped and when the card opens For security review findings E-04 and E-05 (slice 54, reviewer E). The privacy policy moves to version 5. "What websites can see" now lists the hidden text Kotiko never swaps: text not displayed, see-through or nearly so (a filter included), clipped or masked. It also says what still counts as shown: very small, background-coloured, blurred or covered text. And it says the card opens only from the learner's own input on a word they can see, where it's drawn. A page that disguises a word under a resting pointer could still get its card opened. The inventory, the assurance case's residual risks and the changelog say the same. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- CHANGELOG.md | 13 +++++++++++++ docs/privacy/en.md | 24 ++++++++++++++++++------ docs/privacy/inventory.md | 15 ++++++++++----- docs/security/assurance-case.md | 21 +++++++++++++++------ extension/privacy/en.md | 24 ++++++++++++++++++------ 5 files changed, 74 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b03c88d..5c41100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ Conventional Commits by release-please. ## Unreleased +- Security: a web page could restyle a swapped word (it sits in the page) so that it + covered the page invisibly; then your pointer resting anywhere, or your click on one of + the page's own buttons, opened the word's card, and the page could search the card's + text for guesses. The card now opens only on a swapped word you can see, and from the + mouse or a tap only when you point at the word itself. Enter on a focused word still + opens it. The privacy policy (version 5) says so. + +- Security: Kotiko swapped words in text a page had hidden with a see-through filter, a + clipping shape or a mask, so the page could read those swaps and learn your words. Text + like that, and text faded to under 10 % opacity, is no longer swapped. Kotiko also + leaves text under any clipping shape or mask alone, even where you can see it, since it + can't tell how much of it shows. + - Security: Kotiko no longer sends your server's access key; it signs each request. Your server signs each answer too, and Kotiko uses an answer only when it carries that signature, so if another program takes your server's place (say, while the server diff --git a/docs/privacy/en.md b/docs/privacy/en.md index ee2fd18..7df2bf2 100644 --- a/docs/privacy/en.md +++ b/docs/privacy/en.md @@ -1,6 +1,6 @@ # Kotiko privacy policy -Version 4 · 7 October 2026 +Version 5 · 7 October 2026 Kotiko is a browser extension that swaps words on the web pages you read for words you're learning in other languages. This page says exactly what it keeps, what it sends, and to @@ -110,11 +110,20 @@ Kotiko and, for each word it swapped, which of the site's own words you have a w that word, and its language. Kotiko limits this to what it shows you. It swaps only text that is on your screen or -near it, never text the page hides, and in one visit to a page it swaps at most 500 -different words, each in at most 3 of your languages. So a site can't read your whole -word list at once, but one you visit often, or that keeps reloading itself, can learn -more of it over time. Sites can't open a word's card. While you have one open, a site -that searches its page for a word it guesses could find the card's text. +near it, never text the page hides: not text it doesn't display, makes see-through or +nearly so (also with a filter), or puts under a clipping shape or a mask (Kotiko leaves +text under those alone even where you can see it). In one visit to a page it swaps at +most 500 different words, each in at most 3 of your languages. Text you can't make out +still counts as shown: very small text, text in the page's background colour, blurred +text, or text under something else. So a site can't read your whole word list at once, +but one you visit often, or that keeps reloading itself, can learn more of it over time. + +Only you open a word's card: by pointing at, clicking, tapping or pressing Enter on a +swapped word you can see, where it's drawn. A site can't open one by itself, or by +stretching an invisible word over its page; one that moves a word under your pointer and +disguises it (in the page's background colour, or under something else) could still get +its card opened while your pointer rests there. While you have a card open, a site that +searches its page for a word it guesses could find the card's text. On a site you paused, on sites Kotiko leaves alone (such as banks), and while Kotiko is turned off, it adds nothing to the page and changes nothing on it, so the site has nothing @@ -183,6 +192,9 @@ everything in Kotiko's Settings. ## Changelog +- **Version 5, 7 October 2026.** "What websites can see" now says which hidden text + Kotiko never swaps (also text made see-through with a filter, clipped or masked), what + text still counts as shown, and when a word's card opens. - **Version 4, 7 October 2026.** Your server's access key no longer leaves your browser: Kotiko signs each request to your server with it instead of sending it. - **Version 3, 6 October 2026.** "What websites can see" now says what a site can learn diff --git a/docs/privacy/inventory.md b/docs/privacy/inventory.md index c9f467e..db9a2a3 100644 --- a/docs/privacy/inventory.md +++ b/docs/privacy/inventory.md @@ -47,17 +47,22 @@ dictionaries, the model list and words are data, checked by the word spec before element and its `lang` attribute that Kotiko is installed and which language each word is in. The page knows its own text, so each swap also tells it which of its words the learner has a word for (security review A-01). -- How much: only text the learner can see is swapped (rendered, on screen or within a - screen of it, not clipped away; `content/engine.js` `sight()`), so text a page hides - reveals nothing. One page view swaps at most `max_page_concepts` (500) distinct +- How much: only text the learner can see is swapped (rendered, at least 10 % opaque + counting `filter: opacity()`, under no `clip-path`, mask or `url()` filter, on screen or + within a screen of it, not clipped away; `content/engine.js` `sight()`, security review + E-04), so text a page hides reveals nothing; text the learner can't make out (tiny, the + background's color, blurred, covered) still counts as shown. One page view swaps at most `max_page_concepts` (500) distinct concepts, each in at most `max_page_langs_per_concept` (3) languages (`spec/rules.json`, `content.js` `reveal()`). The cap is per page view: a page that reloads itself, or one the learner visits often, can learn more over time. Checked by `test/security/page.spec.mjs` (A-01) and `test/dom/content.test.mjs`. - Not the swaps' details: the swaps carry only `lang`, `dir`, `translate="no"` and `class="notranslate"`; the word card is a `` element with a closed - shadow root (slice 19) that only the learner's own input opens (A-04). While the - learner has a card open, `window.find()` on the page can still match its text. Checked + shadow root (slice 19) that only the learner's own input opens (A-04), and only on a + swap they can see, from a pointer only on its drawn text (E-05; `content/popover.js` + `seen()`). A page that keeps a disguised swap under the pointer (the background's + color, covered) can still have it opened. While the learner has a card open, + `window.find()` on the page can still match its text. Checked by `test/e2e/popover.spec.mjs`, `test/e2e/privacy.spec.mjs` and `test/security/page.spec.mjs`. - The swap stylesheet (`ui/swap-style.js`) is added with the first swap and removed when diff --git a/docs/security/assurance-case.md b/docs/security/assurance-case.md index be07564..bde4990 100644 --- a/docs/security/assurance-case.md +++ b/docs/security/assurance-case.md @@ -212,13 +212,22 @@ These match "What you can't expect" in the [requirements](requirements.md). - **The model provider sees every lookup**, under its own terms. - **Pages can see swapped words** in their own DOM, and, since they know their own text, which of their words the learner has a word for (security review A-01). Kotiko swaps - only text the learner can see (rendered, on screen or within a screen of it, not - clipped away) and at most 500 distinct concepts, each in at most 3 languages, per page + only text the learner can see (rendered; at least 10 % opaque counting every + ancestor's `opacity` and `filter: opacity()`; under no `clip-path`, mask or `url()` + filter, E-04; on screen or within a screen of it; not clipped away by an ancestor's + overflow) and at most 500 distinct concepts, each in at most 3 languages, per page view (`spec/rules.json` `max_page_*`). What's left: a page that shows text the learner - can't make out (tiny, the color of the background, under something else, or shown for - one frame) still gets swaps within that cap; the cap is per page view, so a page that - reloads itself or a site visited often can learn more of a long list over time; and - while the learner has a word card open, `window.find()` can match its text. Where + can't make out (tiny, the color of the background, blurred or otherwise filtered + without `opacity()`, under something else, or shown for one frame) still gets swaps + within that cap; the cap is per page view, so a page that reloads itself or a site + visited often can learn more of a long list over time. The word card opens only from + the learner's own input on a swap drawn at least 10 % opaque, under no clip-path or + mask, whose box isn't stretched far past its text, and from a pointer only within 4 px + of that text (E-05), so a page can't stretch an invisible swap under the learner's + pointer or click; one that keeps a swap under the pointer disguised another way (the + background's color, under an element with `pointer-events: none`) can still have its + card opened while the pointer rests there, and while a card is open, `window.find()` + can match its text. Where Kotiko is paused, off or stepping back from a sensitive site it adds nothing to the page (A-03, C-04); where it swaps, the swaps and their stylesheet show it's installed. - **A subverted content script** (code running in Kotiko's content-script world) can't diff --git a/extension/privacy/en.md b/extension/privacy/en.md index ee2fd18..7df2bf2 100644 --- a/extension/privacy/en.md +++ b/extension/privacy/en.md @@ -1,6 +1,6 @@ # Kotiko privacy policy -Version 4 · 7 October 2026 +Version 5 · 7 October 2026 Kotiko is a browser extension that swaps words on the web pages you read for words you're learning in other languages. This page says exactly what it keeps, what it sends, and to @@ -110,11 +110,20 @@ Kotiko and, for each word it swapped, which of the site's own words you have a w that word, and its language. Kotiko limits this to what it shows you. It swaps only text that is on your screen or -near it, never text the page hides, and in one visit to a page it swaps at most 500 -different words, each in at most 3 of your languages. So a site can't read your whole -word list at once, but one you visit often, or that keeps reloading itself, can learn -more of it over time. Sites can't open a word's card. While you have one open, a site -that searches its page for a word it guesses could find the card's text. +near it, never text the page hides: not text it doesn't display, makes see-through or +nearly so (also with a filter), or puts under a clipping shape or a mask (Kotiko leaves +text under those alone even where you can see it). In one visit to a page it swaps at +most 500 different words, each in at most 3 of your languages. Text you can't make out +still counts as shown: very small text, text in the page's background colour, blurred +text, or text under something else. So a site can't read your whole word list at once, +but one you visit often, or that keeps reloading itself, can learn more of it over time. + +Only you open a word's card: by pointing at, clicking, tapping or pressing Enter on a +swapped word you can see, where it's drawn. A site can't open one by itself, or by +stretching an invisible word over its page; one that moves a word under your pointer and +disguises it (in the page's background colour, or under something else) could still get +its card opened while your pointer rests there. While you have a card open, a site that +searches its page for a word it guesses could find the card's text. On a site you paused, on sites Kotiko leaves alone (such as banks), and while Kotiko is turned off, it adds nothing to the page and changes nothing on it, so the site has nothing @@ -183,6 +192,9 @@ everything in Kotiko's Settings. ## Changelog +- **Version 5, 7 October 2026.** "What websites can see" now says which hidden text + Kotiko never swaps (also text made see-through with a filter, clipped or masked), what + text still counts as shown, and when a word's card opens. - **Version 4, 7 October 2026.** Your server's access key no longer leaves your browser: Kotiko signs each request to your server with it instead of sending it. - **Version 3, 6 October 2026.** "What websites can see" now says what a site can learn