Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions extension/src/lib/subtree-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,14 @@ const IGNORE_TAGS: ReadonlySet<string> = new Set(["STYLE", "BR"]);
// (Pattern from Ghostery's adblocker DOMMonitor.)
const BURST_FLUSH_THRESHOLD = 512;

// `id` and `class` are the only attributes the selector-token-index
// dispatcher keys on. Limiting the filter at the MO level keeps the
// burst from page JS toggling unrelated attributes (style, data-*,
// aria-*) off the hot path.
const OBSERVED_ATTRIBUTES = ["id", "class"];
// `id` and `class` drive the selector-token-index dispatcher; `content`
// drives meta-injection-strip's in-place re-scrub when a framework or
// page script overwrites a `<meta>`'s `content=` after we blanked it
// (since #176 stopped detaching the meta, an unobserved overwrite would
// leave the new payload visible until the next route change). Limiting
// the filter at the MO level keeps the burst from page JS toggling
// unrelated attributes (style, data-*, aria-*) off the hot path.
const OBSERVED_ATTRIBUTES = ["id", "class", "content"];

interface SubtreeWatcherOptions {
// Called once per throttle window with all the (still-connected) subtree
Expand Down
40 changes: 40 additions & 0 deletions extension/src/rules/__tests__/meta-injection-strip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,46 @@ describe("meta-injection-strip lazy subtrees", () => {
expect(meta.getAttribute("content")).toBe("");
});

it("re-scrubs a meta whose content is overwritten in place after blanking", async () => {
// Initial scrub blanks the existing payload but leaves the element
// attached. A subsequent framework / page-script write to `content=`
// would, pre-fix, sit visible until the next route change. With
// `content` in OBSERVED_ATTRIBUTES + observeAttributes on the watcher,
// the rewrite reaches scrubMeta and the new payload is blanked too.
const meta = appendMetaToHead({
name: "description",
content: FIXTURES.IGNORE_HACKED,
});

metaInjectionStripRule.apply(document.body);
expect(meta.getAttribute("content")).toBe("");

meta.setAttribute("content", FIXTURES.DAN);

await flushMutations();
jest.advanceTimersByTime(MUTATION_THROTTLE_MS);

expect(meta.isConnected).toBe(true);
expect(meta.getAttribute("content")).toBe("");
});

it("leaves a meta with a clean in-place content rewrite alone", async () => {
// The attribute-mutation hook is opt-in for the rule, not for the
// page — a benign rewrite stays exactly as the page wrote it.
const meta = appendMetaToHead({
name: "description",
content: "RiverMart skillets",
});

metaInjectionStripRule.apply(document.body);
meta.setAttribute("content", "RiverMart cookware");

await flushMutations();
jest.advanceTimersByTime(MUTATION_THROTTLE_MS);

expect(meta.getAttribute("content")).toBe("RiverMart cookware");
});

it("teardown stops both watchers", async () => {
metaInjectionStripRule.apply(document.body);
metaInjectionStripRule.teardown();
Expand Down
23 changes: 23 additions & 0 deletions extension/src/rules/__tests__/noscript-strip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,29 @@ describe("noscript-strip", () => {
expect(noscript?.textContent).toBe("");
});

it("re-blanks a noscript when a child is later rendered into it", async () => {
// After initial blanking the <noscript> is kept attached. If a
// framework re-renders content INTO it, the watcher delivers the new
// child as the added subtree root — closest("noscript") walks back up
// to the kept wrapper so the new fallback gets blanked too.
document.body.innerHTML = `<noscript>initial</noscript>`;
noscriptStripRule.apply(document.body);

const noscript = document.body.querySelector("noscript");
expect(noscript).not.toBeNull();
expect(noscript?.textContent).toBe("");

const lateChild = document.createElement("span");
lateChild.textContent = "re-rendered fallback";
noscript?.append(lateChild);

await flushMutations();
jest.advanceTimersByTime(MUTATION_THROTTLE_MS);

expect(noscript?.isConnected).toBe(true);
expect(noscript?.textContent).toBe("");
});

it("teardown stops the observer", async () => {
noscriptStripRule.apply(document.body);
noscriptStripRule.teardown();
Expand Down
7 changes: 7 additions & 0 deletions extension/src/rules/meta-injection-strip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,18 @@ function scrub(root: ParentNode): void {
}
}

// observeAttributes catches in-place `content=` rewrites on a meta we
// already blanked. Without it, a framework or page script that later
// overwrites `content=` lands a new payload that stays visible until the
// next subtree addition or route change re-triggers a scan. `content` is
// in `OBSERVED_ATTRIBUTES` so the shared MO actually delivers the records.
const bodyWatcher = createSubtreeWatcher({
onSubtrees: (roots) => {
for (const root of roots) {
scrub(root);
}
},
observeAttributes: true,
});

const headWatcher = createSubtreeWatcher({
Expand All @@ -81,6 +87,7 @@ const headWatcher = createSubtreeWatcher({
scrub(root);
}
},
observeAttributes: true,
});

function apply(root: ParentNode): void {
Expand Down
19 changes: 10 additions & 9 deletions extension/src/rules/noscript-strip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,16 @@ function blankNoscript(element: Element): void {
}

function stripNoscript(root: ParentNode): void {
// We grab from the root rather than rely on `root.querySelectorAll` alone
// because a watcher subtree may *be* a noscript element rather than
// contain one.
if (
root.nodeType === Node.ELEMENT_NODE &&
(root as Element).tagName === "NOSCRIPT"
) {
blankNoscript(root as Element);
return;
// A watcher subtree may BE a `<noscript>`, CONTAIN one, or be a
// descendant of one we already blanked (e.g., a framework re-rendered
// children into a kept noscript). `closest()` handles self/ancestor;
// `querySelectorAll` handles descendants.
if (root.nodeType === Node.ELEMENT_NODE) {
const ancestorOrSelf = (root as Element).closest("noscript");
if (ancestorOrSelf) {
blankNoscript(ancestorOrSelf);
return;
}
}
for (const element of root.querySelectorAll("noscript")) {
blankNoscript(element);
Expand Down
Loading