diff --git a/extension/jest.config.cjs b/extension/jest.config.cjs index e36c00a..83b04e3 100644 --- a/extension/jest.config.cjs +++ b/extension/jest.config.cjs @@ -80,20 +80,22 @@ module.exports = { // path-specific thresholds (./src/rules/) are evaluated against those files // only; "global" applies to the rest (lib/, options/parse-config, scripts/, // data/). The two ranges differ because src/rules/ is the security surface - // and held to a higher bar. Ratchet up as untested rules - // (cross-origin-frame-hide, irrelevant-sections-hide) get tests. + // and held to a higher bar. Ratchet up as `irrelevant-sections-redact` (the + // last untested rule) gets tests. Note: scripts/ is mostly codegen with + // ~0% coverage and drags the global average down; that's why global sits + // well below the all-files summary number. coverageThreshold: { global: { - statements: 60, - branches: 50, - functions: 55, - lines: 60, + statements: 62, + branches: 58, + functions: 58, + lines: 61, }, "./src/rules/": { - statements: 75, - branches: 58, - functions: 75, - lines: 75, + statements: 84, + branches: 71, + functions: 88, + lines: 84, }, }, }; diff --git a/extension/src/rules/__tests__/cross-origin-frame-redact.test.ts b/extension/src/rules/__tests__/cross-origin-frame-redact.test.ts new file mode 100644 index 0000000..a56be9b --- /dev/null +++ b/extension/src/rules/__tests__/cross-origin-frame-redact.test.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +import { REVEALED_ATTR, RULE_ATTR } from "../../lib/dom-markers"; +import { PLACEHOLDER_CLASS } from "../../lib/placeholder"; +import { crossOriginFrameRedactRule } from "../cross-origin-frame-redact"; + +const RULE_ID = "cross-origin-frame-redact"; +const MUTATION_THROTTLE_MS = 250; + +async function flushMutations(): Promise { + await Promise.resolve(); +} + +beforeEach(() => { + document.body.innerHTML = ""; + jest.useFakeTimers(); +}); + +afterEach(() => { + crossOriginFrameRedactRule.teardown(); + jest.useRealTimers(); +}); + +function getPlaceholder(): HTMLElement | null { + return document.querySelector( + `.${PLACEHOLDER_CLASS}[${RULE_ATTR}="${RULE_ID}"]`, + ); +} + +describe("crossOriginFrameRedactRule", () => { + // jsdom defaults `location.origin` to `http://localhost`, so anything else + // counts as cross-origin. + + it("replaces a cross-origin https iframe with a placeholder", () => { + document.body.innerHTML = ` + + `; + crossOriginFrameRedactRule.apply(document.body); + + expect(document.querySelector("iframe")).toBeNull(); + const placeholder = getPlaceholder(); + expect(placeholder).not.toBeNull(); + // Origin is surfaced to the user so they know whose content is hidden. + expect(placeholder?.textContent).toContain("https://example.com"); + }); + + it("replaces a cross-origin http iframe", () => { + document.body.innerHTML = ` + + `; + crossOriginFrameRedactRule.apply(document.body); + + expect(document.querySelector("iframe")).toBeNull(); + expect(getPlaceholder()).not.toBeNull(); + }); + + it("leaves a same-origin absolute iframe alone", () => { + document.body.innerHTML = ` + + `; + crossOriginFrameRedactRule.apply(document.body); + + expect(document.querySelector("iframe")).not.toBeNull(); + expect(getPlaceholder()).toBeNull(); + }); + + it("leaves a same-origin relative iframe alone (resolved against baseURI)", () => { + document.body.innerHTML = ` + + `; + crossOriginFrameRedactRule.apply(document.body); + + expect(document.querySelector("iframe")).not.toBeNull(); + expect(getPlaceholder()).toBeNull(); + }); + + it("leaves a srcdoc iframe alone — inherits the embedding origin", () => { + document.body.innerHTML = ` + + `; + crossOriginFrameRedactRule.apply(document.body); + + expect(document.querySelector("iframe")).not.toBeNull(); + expect(getPlaceholder()).toBeNull(); + }); + + // Even if a srcdoc iframe *also* declares a cross-origin src, srcdoc wins: + // the browser ignores src when srcdoc is present and renders the inline + // doc in the embedding origin. + it("leaves a srcdoc iframe alone even if it also has a cross-origin src", () => { + document.body.innerHTML = ` + + `; + crossOriginFrameRedactRule.apply(document.body); + + expect(document.querySelector("iframe")).not.toBeNull(); + expect(getPlaceholder()).toBeNull(); + }); + + it("leaves an iframe with no src alone", () => { + document.body.innerHTML = ``; + crossOriginFrameRedactRule.apply(document.body); + + expect(document.querySelector("iframe")).not.toBeNull(); + expect(getPlaceholder()).toBeNull(); + }); + + // Non-http(s) protocols either inherit the parent origin or are inert. + // Hiding them would just create noise. + it.each([ + ["about:blank", "about:blank"], + ["data: URL", "data:text/html,

hi

"], + ["javascript: URL", "javascript:void(0)"], + ["blob: URL", "blob:http://localhost/abc-123"], + ])("leaves an iframe with a %s alone", (_label, src) => { + document.body.innerHTML = ``; + crossOriginFrameRedactRule.apply(document.body); + + expect(document.querySelector("iframe")).not.toBeNull(); + expect(getPlaceholder()).toBeNull(); + }); + + it("leaves an iframe with an invalid src URL alone", () => { + // `new URL()` throws on garbage like this; the rule swallows it and skips. + document.body.innerHTML = ``; + expect(() => { + crossOriginFrameRedactRule.apply(document.body); + }).not.toThrow(); + + expect(document.querySelector("iframe")).not.toBeNull(); + expect(getPlaceholder()).toBeNull(); + }); + + it("replaces multiple cross-origin iframes in one pass", () => { + document.body.innerHTML = ` + + + + `; + crossOriginFrameRedactRule.apply(document.body); + + expect(document.querySelectorAll("iframe")).toHaveLength(1); + expect(document.querySelectorAll(`.${PLACEHOLDER_CLASS}`)).toHaveLength(2); + }); + + it("skips an iframe the user has already revealed for this rule", () => { + // The rule stamps REVEALED_ATTR when its placeholder is clicked, so the + // observer doesn't immediately re-hide the just-restored iframe. + document.body.innerHTML = ` + + `; + crossOriginFrameRedactRule.apply(document.body); + + expect(document.querySelector("iframe")).not.toBeNull(); + expect(getPlaceholder()).toBeNull(); + }); + + it("still hides an iframe revealed for a *different* rule", () => { + // Reveal markers are scoped per-rule. A reveal stamp from some other + // rule should not protect a cross-origin iframe. + document.body.innerHTML = ` + + `; + crossOriginFrameRedactRule.apply(document.body); + + expect(document.querySelector("iframe")).toBeNull(); + expect(getPlaceholder()).not.toBeNull(); + }); + + describe("lazily-injected iframes", () => { + // Vendor scripts typically inject `