diff --git a/extension/src/rules/__tests__/attribute-injection-sanitize.property.test.ts b/extension/src/rules/__tests__/attribute-injection-sanitize.property.test.ts new file mode 100644 index 0000000..522fab9 --- /dev/null +++ b/extension/src/rules/__tests__/attribute-injection-sanitize.property.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Property-based tests for `attribute-injection-sanitize`. Fuzzes across +// the allowlisted attributes × the shared injection fixture set, plus +// random benign prefix/suffix noise to confirm the regex set still fires +// when the payload is embedded inside surrounding text. A negative +// property covers attributes outside the allowlist (the rule must not +// touch them) and another covers enabled-input `value` (only `disabled` +// inputs get their value stripped). + +import fc from "fast-check"; + +import { attributeInjectionSanitizeRule } from "../attribute-injection-sanitize"; +import { FIXTURES } from "./injection-fixtures"; + +const ALLOWLISTED_ATTRS = [ + "aria-label", + "aria-description", + "alt", + "title", + "placeholder", + "data-tooltip", +] as const; + +const TAG_FOR_ATTR: Record<(typeof ALLOWLISTED_ATTRS)[number], string> = { + "aria-label": "button", + "aria-description": "div", + alt: "img", + title: "span", + placeholder: "input", + "data-tooltip": "div", +}; + +// Subset of FIXTURES that unambiguously match a current injection +// pattern. Skip `BENIGN_LLM` etc. which are intentionally non-matching. +const ADVERSARIAL = fc.constantFrom( + FIXTURES.IGNORE_HACKED, + FIXTURES.DISREGARD, + FIXTURES.DAN, + FIXTURES.DEV_MODE, + FIXTURES.NEW_INSTRUCTIONS, + FIXTURES.OVERRIDE_GUARDRAILS, + FIXTURES.PLEASE_IGNORE, + FIXTURES.IGNORE_ALL, +); + +// Hex-character noise — can't form English imperatives so it won't +// accidentally trip an injection pattern when used as prefix/suffix. +// We require word-boundary separation from the payload (trailing space +// on prefix, leading space on suffix) because the injection patterns +// anchor with `\b`; concatenating a word character directly against +// the payload defeats the boundary by design. +const prefixArb = fc.oneof( + fc.constant(""), + fc.stringMatching(/^[0-9a-f]{1,30}$/).map((s) => `${s} `), +); +const suffixArb = fc.oneof( + fc.constant(""), + fc.stringMatching(/^[0-9a-f]{1,30}$/).map((s) => ` ${s}`), +); + +const attributeArb = fc.constantFrom(...ALLOWLISTED_ATTRS); + +function buildElement( + tag: string, + attribute: string, + value: string, +): HTMLElement { + document.body.innerHTML = ""; + const element = document.createElement(tag); + element.setAttribute(attribute, value); + document.body.append(element); + return element; +} + +afterEach(() => { + attributeInjectionSanitizeRule.teardown(); + document.body.innerHTML = ""; +}); + +describe("attribute-injection-sanitize (property)", () => { + it("strips any allowlisted attribute when its value carries an injection payload, possibly embedded in surrounding noise", () => { + fc.assert( + fc.property( + attributeArb, + ADVERSARIAL, + prefixArb, + suffixArb, + (attribute, payload, prefix, suffix) => { + const tag = TAG_FOR_ATTR[attribute]; + const element = buildElement( + tag, + attribute, + `${prefix}${payload}${suffix}`, + ); + attributeInjectionSanitizeRule.apply(document.body); + expect(element.hasAttribute(attribute)).toBe(false); + }, + ), + ); + }); + + it("preserves non-allowlisted attributes even when they carry an injection payload", () => { + fc.assert( + fc.property(ADVERSARIAL, (payload) => { + // `data-foo` and `name` are not in the allowlist. The rule + // should treat them as page data and leave them alone. + const element = buildElement("div", "data-foo", payload); + element.setAttribute("name", payload); + attributeInjectionSanitizeRule.apply(document.body); + expect(element.dataset.foo).toBe(payload); + expect(element.getAttribute("name")).toBe(payload); + }), + ); + }); + + it("strips value on a disabled input carrying injection, leaves enabled inputs alone", () => { + fc.assert( + fc.property(ADVERSARIAL, (payload) => { + document.body.innerHTML = ""; + const disabled = document.createElement("input"); + disabled.setAttribute("disabled", ""); + disabled.setAttribute("value", payload); + const enabled = document.createElement("input"); + enabled.setAttribute("value", payload); + document.body.append(disabled, enabled); + + attributeInjectionSanitizeRule.apply(document.body); + + expect(disabled.hasAttribute("value")).toBe(false); + expect(enabled.getAttribute("value")).toBe(payload); + }), + ); + }); + + it("preserves clean (non-injection) values on allowlisted attributes", () => { + const cleanArb = fc.constantFrom( + "Add to cart", + "Product photo", + "Posted 3 days ago", + "Search products", + "Apply coupon", + "Free shipping over $25", + ); + fc.assert( + fc.property(attributeArb, cleanArb, (attribute, value) => { + const tag = TAG_FOR_ATTR[attribute]; + const element = buildElement(tag, attribute, value); + attributeInjectionSanitizeRule.apply(document.body); + expect(element.getAttribute(attribute)).toBe(value); + }), + ); + }); +}); diff --git a/extension/src/rules/__tests__/meta-injection-strip.property.test.ts b/extension/src/rules/__tests__/meta-injection-strip.property.test.ts new file mode 100644 index 0000000..91954c9 --- /dev/null +++ b/extension/src/rules/__tests__/meta-injection-strip.property.test.ts @@ -0,0 +1,134 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Property-based tests for `meta-injection-strip`. Confirms that the +// remove-vs-blank asymmetry holds across the injection fixture set: +// `` is removed entirely (a content-less meta has no value), while +// `` is kept as an element with empty text (so `document.title` +// returns "" rather than the payload — code reading title still works). + +import fc from "fast-check"; + +import { metaInjectionStripRule } from "../meta-injection-strip"; +import { FIXTURES } from "./injection-fixtures"; + +const ADVERSARIAL = fc.constantFrom( + FIXTURES.IGNORE_HACKED, + FIXTURES.DISREGARD, + FIXTURES.DAN, + FIXTURES.DEV_MODE, + FIXTURES.NEW_INSTRUCTIONS, + FIXTURES.OVERRIDE_GUARDRAILS, + FIXTURES.PLEASE_IGNORE, + FIXTURES.IGNORE_ALL, +); + +// Common meta naming conventions — `name=` (HTML), `property=` (OG / +// Twitter), `itemprop=` (schema.org), `http-equiv=` (legacy). The rule +// must remove the meta regardless of which attribute names it. +const NAME_ATTRS = fc.constantFrom( + "name", + "property", + "itemprop", + "http-equiv", +); +const NAME_VALUES = fc.constantFrom( + "description", + "og:description", + "twitter:description", + "og:title", + "keywords", + "summary", +); + +function resetHead(): void { + for (const element of [ + ...document.head.querySelectorAll("meta"), + ...document.head.querySelectorAll("title"), + ]) { + element.remove(); + } +} + +afterEach(() => { + metaInjectionStripRule.teardown(); + document.body.innerHTML = ""; + resetHead(); +}); + +describe("meta-injection-strip (property)", () => { + it("removes any <meta content> carrying injection text regardless of name attribute", () => { + fc.assert( + fc.property( + ADVERSARIAL, + NAME_ATTRS, + NAME_VALUES, + (payload, nameAttribute, nameValue) => { + resetHead(); + const meta = document.createElement("meta"); + meta.setAttribute(nameAttribute, nameValue); + meta.setAttribute("content", payload); + document.head.append(meta); + + metaInjectionStripRule.apply(document.body); + + const remaining = document.head.querySelector( + `meta[${nameAttribute}="${nameValue}"]`, + ); + expect(remaining).toBeNull(); + }, + ), + ); + }); + + it("blanks <title> text but keeps the element when title carries injection", () => { + fc.assert( + fc.property(ADVERSARIAL, (payload) => { + resetHead(); + const title = document.createElement("title"); + title.textContent = payload; + document.head.append(title); + + metaInjectionStripRule.apply(document.body); + + // Element survives so consumers of `document.title` still get + // a string (empty), not undefined. + const remaining = document.head.querySelector("title"); + expect(remaining).not.toBeNull(); + expect(remaining?.textContent).toBe(""); + expect(document.title).toBe(""); + }), + ); + }); + + it("preserves benign meta and title content", () => { + const cleanArb = fc.constantFrom( + "Shop the latest sneakers and apparel", + "RiverMart — everything from cookware to compute", + "Free returns on orders over $25", + FIXTURES.BENIGN_LLM, + ); + fc.assert( + fc.property(cleanArb, cleanArb, (metaContent, titleText) => { + resetHead(); + const meta = document.createElement("meta"); + meta.setAttribute("name", "description"); + meta.setAttribute("content", metaContent); + const title = document.createElement("title"); + title.textContent = titleText; + document.head.append(meta, title); + + metaInjectionStripRule.apply(document.body); + + expect( + document.head + .querySelector('meta[name="description"]') + ?.getAttribute("content"), + ).toBe(metaContent); + expect(document.head.querySelector("title")?.textContent).toBe( + titleText, + ); + }), + ); + }); +});