agent-sanitizer 2.64.2 → 2.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/THREAT-MODEL.md CHANGED
@@ -122,8 +122,23 @@ range is preserved verbatim** (no re-serialization). Unclosed hidden markup
122
122
  extends to the end of the fragment—fail-closed for truncated input.
123
123
 
124
124
  Scripting/resource tags (`script`, `style`, `object`, `embed`, `iframe`, `svg`,
125
- `math`) and `data:` URI resources are **reported, not removed**: their bodies are
126
- page source the model may legitimately need to inspect.
125
+ `math`, `template`) and `data:` URI resources are **reported, not removed**:
126
+ their bodies are page source the model may legitimately need to inspect.
127
+
128
+ A `<template>`'s content is a fragment the browser parses but never renders, so
129
+ instructions placed there reach a model reading the source and no human reading
130
+ the page. It is also where web components and client-side renderers keep their
131
+ UI markup, so the template is reported rather than spliced, and its content is
132
+ scanned like any other markup: a hidden element or comment inside it is
133
+ spliced, a `<script>` inside it reported, a URL inside it read by Layer 3. A
134
+ declarative shadow root (`shadowrootmode="open"`/`"closed"`) is reported too,
135
+ since one the parser fails to attach (a second root on one host, say) is an
136
+ ordinary inert template; its content is laid out in its host's box, so a flex
137
+ host blockifies it as it would a light-DOM child. Slot assignment is not
138
+ modelled: a hidden `<slot>` splices only itself, not the light-DOM children
139
+ the browser renders through it. A `template` inside SVG or MathML (or an HTML
140
+ `template.x`) is not reported: it has no template content, so its children are
141
+ ordinary children and are scanned as such.
127
142
 
128
143
  HTML comments (`<!--…-->`, and the bogus `<!…>`/`<?…?>` forms) are spliced
129
144
  like hidden elements — a human viewing the rendered page never sees them. But
@@ -146,9 +161,12 @@ Report—never rewrite—URLs in markdown links/images/definitions, every HTML/S
146
161
  URL attribute in `src/url-attributes.mjs` (`src`, `href`, `xlink:href`,
147
162
  `srcset`/`imagesrcset`, `ping`, `poster`, `object data`, form
148
163
  `action`/`formaction`, `meta refresh`, …), CSS `url()` in `style` attributes and
149
- `<style>` elements, and the same again inside HTML comments, that are shaped to
150
- carry data off-origin. A URL's scheme is read by the WHATWG URL parser, and
151
- `srcset` and `meta refresh` by ports of the HTML Standard's own parsing steps:
164
+ `<style>` elements, and the same again inside HTML comments and `<template>`
165
+ content, that are shaped to carry data off-origin. A URL inside a comment or an
166
+ inert template is reported as not auto-fetched: nothing there loads until a
167
+ reader or a script acts on it. A URL's scheme is read by the WHATWG URL parser,
168
+ and `srcset` and `meta refresh` by ports of the HTML Standard's own parsing
169
+ steps:
152
170
 
153
171
  - suspicious query/fragment parameters (long base64/hex blobs, credential-shaped
154
172
  tokens), tuned to skip request-signing / pagination / analytics parameters
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sanitizer",
3
- "version": "2.64.2",
3
+ "version": "2.65.0",
4
4
  "description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -174,8 +174,8 @@ export const MAX_HTML_DEPTH = 4096;
174
174
  * the name up to its first `#` or `.`, lowercased.
175
175
  *
176
176
  * WHY IT MATTERS — the converter then recurses into the parse5 node's `content`,
177
- * which only an HTML-namespace `<template>` has. A `template` inside SVG or
178
- * MathML, or an HTML `template.x` tag, has none, the converter throws,
177
+ * which only an HTML-namespace `<template>` has. An `<svg><template>`, a
178
+ * `<math><template>` or an HTML `<template.x>` has none, the converter throws,
179
179
  * and the whole document would be withheld over a benign element.
180
180
  */
181
181
  const CONVERTS_AS_TEMPLATE = /^template(?:[#.]|$)/i;
@@ -189,6 +189,15 @@ const CONVERTS_AS_TEMPLATE = /^template(?:[#.]|$)/i;
189
189
  * changes it (lowercasing, the SVG case map). The element then converts the way
190
190
  * any element without template content does: its children as `children`, where
191
191
  * every scan of the hast tree reads them, which is also where the DOM has them.
192
+ *
193
+ * WHY A MASK, and not an empty parse5 `content` handed to the element so the
194
+ * converter has a fragment to recurse into: hastscript's `h` and `s` (the
195
+ * converter builds every element with one of them) move the children of ANY
196
+ * element they name `template` into the hast node's `content` and leave its
197
+ * `children` empty, and the converter then overwrites that `content` with its
198
+ * conversion of the parse5 fragment. Handed an empty one, the element's
199
+ * children would vanish with no error, and every Layer 2/3 scan in
200
+ * `./html.mjs` would go blind to them.
192
201
  */
193
202
  const TEMPLATE_MASK = " ";
194
203
 
package/src/html.mjs CHANGED
@@ -1502,7 +1502,13 @@ export function isHiddenStyle(styleStr) {
1502
1502
  // Scripting / resource-loading tags whose PRESENCE is reported to the model
1503
1503
  // but whose content is preserved: their bodies are page source the model may
1504
1504
  // legitimately need to inspect (how a page's scripts work, its styles, its
1505
- // SVGs), so unlike hidden elements they are never removed.
1505
+ // SVGs), so unlike hidden elements they are never removed. An HTML `template`
1506
+ // is here because the browser parses its content into a fragment it never
1507
+ // renders — a reader of the source sees it, a reader of the page does not —
1508
+ // while that content is also where a component's UI markup legitimately lives.
1509
+ // A declarative shadow root is reported too: whether the parser attaches one
1510
+ // depends on its host's name and on the host having no earlier root, and a
1511
+ // root that fails to attach is an ordinary inert template.
1506
1512
  export const REPORTED_TAGS = new Set([
1507
1513
  "script",
1508
1514
  "style",
@@ -1511,8 +1517,34 @@ export const REPORTED_TAGS = new Set([
1511
1517
  "iframe",
1512
1518
  "svg",
1513
1519
  "math",
1520
+ "template",
1514
1521
  ]);
1515
1522
 
1523
+ /**
1524
+ * True for a template whose content the page never renders: every one but a
1525
+ * declarative shadow root (`shadowrootmode` of `open` or `closed`), whose
1526
+ * content the browser's parser attaches to the parent as a shadow tree.
1527
+ * @param {any} node a hast element with template content (a `content` root)
1528
+ * @returns {boolean}
1529
+ */
1530
+ function isInertTemplate(node) {
1531
+ const mode = String(node.properties.shadowRootMode ?? "").toLowerCase();
1532
+ return mode !== "open" && mode !== "closed";
1533
+ }
1534
+
1535
+ /**
1536
+ * True when Layer 2 reports `node`'s presence (see {@link REPORTED_TAGS}).
1537
+ * Only an HTML `template` has template content; one inside SVG or MathML, or a
1538
+ * `template.x`, is an ordinary element whose children are scanned as children.
1539
+ * @param {any} node a hast element
1540
+ * @returns {boolean}
1541
+ */
1542
+ function isReportedElement(node) {
1543
+ return node.tagName === "template"
1544
+ ? node.content !== undefined
1545
+ : REPORTED_TAGS.has(node.tagName);
1546
+ }
1547
+
1516
1548
  // HTML void elements: they never carry content and never emit a closing tag, so
1517
1549
  // a hidden one (<img hidden>, <input hidden>, …) must be spliced as a single node
1518
1550
  // — opening a balance region for it would run to the container's end (no close
@@ -1629,8 +1661,27 @@ function elementCascade(node) {
1629
1661
  return cascade;
1630
1662
  }
1631
1663
 
1632
- // True when `node` lays out as a non-replaced INLINE box. A flex/grid `parent`
1633
- // blockifies it; a `display:contents` parent defers to its own parent.
1664
+ // The template whose content each hast `content` root is, recorded by `walk`.
1665
+ const contentOwners = new WeakMap();
1666
+
1667
+ // The box `parent`'s children are laid out in: a `display:contents` parent
1668
+ // defers to its own parent, and a declarative shadow root's content to the
1669
+ // template's parent, the shadow host whose box its children render in.
1670
+ /** @param {any} parent @returns {any} */
1671
+ function layoutContainer(parent) {
1672
+ let container = parent;
1673
+ for (;;) {
1674
+ const template = contentOwners.get(container);
1675
+ if (template && !isInertTemplate(template))
1676
+ container = parents.get(template);
1677
+ else if (elementCascade(container).textOf("display") === "contents")
1678
+ container = parents.get(container);
1679
+ else return container;
1680
+ }
1681
+ }
1682
+
1683
+ // True when `node` lays out as a non-replaced INLINE box. A flex/grid layout
1684
+ // container (see {@link layoutContainer}) blockifies it.
1634
1685
  /** @param {any} node @param {any} [parent] @returns {boolean} */
1635
1686
  function isInlineBox(node, parent) {
1636
1687
  if (!INLINE_ELEMENTS.has(node.tagName)) return false;
@@ -1642,10 +1693,7 @@ function isInlineBox(node, parent) {
1642
1693
  BLOCKIFYING_VALUES.has(textOf("float"))
1643
1694
  )
1644
1695
  return false;
1645
- let container = parent;
1646
- while (elementCascade(container).textOf("display") === "contents")
1647
- container = parents.get(container);
1648
- return !elementCascade(container)
1696
+ return !elementCascade(layoutContainer(parent))
1649
1697
  .textOf("display")
1650
1698
  .split(" ")
1651
1699
  .some((token) => ITEM_BLOCKIFYING_DISPLAYS.has(token));
@@ -1893,6 +1941,13 @@ function hasDataSrc(el) {
1893
1941
  * the `unist-util-visit` contract, which is what the call sites here are
1894
1942
  * written against.
1895
1943
  *
1944
+ * One addition to that contract: an HTML `template`'s content (hast keeps it
1945
+ * as a `content` root beside the element's empty `children`) is walked after
1946
+ * its children, with the template as its parent, and `SKIP` leaves it
1947
+ * unvisited too. Rendered or not (see {@link isInertTemplate}), that markup is
1948
+ * in the source a model reads, so a scan that stopped at `children` would pass
1949
+ * any hidden element, script or URL placed in it unseen.
1950
+ *
1896
1951
  * Spelled out rather than imported because `unist-util-visit` allocates a fresh
1897
1952
  * ancestors array and a closure per node, and on a document that is one long
1898
1953
  * flat sibling list — 32k `<p>` elements per megabyte of ordinary HTML — that
@@ -1921,6 +1976,12 @@ function walk(tree, test, visitor, treeParent = undefined) {
1921
1976
  : undefined;
1922
1977
  if (result === EXIT) return;
1923
1978
  if (result === SKIP) continue;
1979
+ if (node.type === "element" && node.content !== undefined) {
1980
+ contentOwners.set(node.content, node);
1981
+ nodes.push(node.content);
1982
+ indices.push(undefined);
1983
+ parents.push(node);
1984
+ }
1924
1985
  const children = node.children;
1925
1986
  if (children === undefined) continue;
1926
1987
  for (let i = children.length - 1; i >= 0; i--) {
@@ -2163,7 +2224,7 @@ function scanFragmentTree(html, tree) {
2163
2224
  const warned = newWarned();
2164
2225
  /** @param {any} node */
2165
2226
  const countElement = (node) => {
2166
- if (REPORTED_TAGS.has(node.tagName)) countTag(warned, node.tagName);
2227
+ if (isReportedElement(node)) countTag(warned, node.tagName);
2167
2228
  if (hasDataSrc(node)) warned.dataSrc += 1;
2168
2229
  };
2169
2230
  // Subtrees still to scan, each with its parent: the tree itself, then the
@@ -2485,7 +2546,11 @@ function scanInlineChildren(node, text, ranges, warned) {
2485
2546
  // A raw-text open tag starts an opaque region (a self-closing `/>`
2486
2547
  // does not apply to these in HTML — they always open).
2487
2548
  if (RAW_TEXT_ELEMENTS.has(el.tagName)) rawText = el.tagName;
2488
- if (REPORTED_TAGS.has(el.tagName)) countTag(warned, el.tagName);
2549
+ // `el` is one tag parsed alone, so a `<template>` inside an inline
2550
+ // `<svg>`/`<math>` reads as HTML here and is reported, where the
2551
+ // source branch, which sees the namespace, does not: this errs toward
2552
+ // reporting.
2553
+ if (isReportedElement(el)) countTag(warned, el.tagName);
2489
2554
  if (hasDataSrc(el)) warned.dataSrc += 1;
2490
2555
  }
2491
2556
  }
@@ -3628,20 +3693,40 @@ function documentUrls(text, parseMd, parseHtml) {
3628
3693
  });
3629
3694
  /** @type {string[]} */
3630
3695
  const comments = [];
3631
- walk(parseHtml(text), null, (/** @type {any} */ node) => {
3632
- if (node.type === "comment") comments.push(node.value);
3633
- else if (node.type === "element") urls.push(...elementUrls(node));
3634
- });
3696
+ // Nodes inside an inert template's content, where nothing loads, submits or
3697
+ // redirects until a script clones it into the page.
3698
+ const inert = new Set();
3699
+ walk(
3700
+ parseHtml(text),
3701
+ null,
3702
+ (/** @type {any} */ node, _index, /** @type {any} */ parent) => {
3703
+ if (
3704
+ inert.has(parent) ||
3705
+ (parent?.content === node && isInertTemplate(parent))
3706
+ )
3707
+ inert.add(node);
3708
+ if (node.type === "comment") comments.push(node.value);
3709
+ else if (node.type === "element")
3710
+ for (const url of elementUrls(node))
3711
+ urls.push(
3712
+ inert.has(node)
3713
+ ? { ...url, autoFetched: false, context: "resource" }
3714
+ : url,
3715
+ );
3716
+ },
3717
+ );
3635
3718
  return { urls, comments };
3636
3719
  }
3637
3720
 
3638
3721
  // Every URL a Layer-3 detector reads: markdown links/images/definitions and
3639
- // the URL attributes of every element, also inside comments. Both detectors
3640
- // below consume this, so a node type is in scope for all of them or for none;
3641
- // `parseMarkdown` memoizes its tree, so back-to-back detectors share the parse.
3642
- // A comment is invisible on the page but not to a model reading the source:
3643
- // its text is read one level deep with its markup openers defused, and what it
3644
- // names is not fetched and has no off-origin rule.
3722
+ // the URL attributes of every element, also inside comments and template
3723
+ // content. Both detectors below consume this, so a node type is in scope for
3724
+ // all of them or for none; `parseMarkdown` memoizes its tree, so back-to-back
3725
+ // detectors share the parse. A comment is invisible on the page but not to a
3726
+ // model reading the source: its text is read one level deep with its markup
3727
+ // openers defused, and what it names is not fetched and has no off-origin
3728
+ // rule. An inert template's content gets the same two answers: nothing in it
3729
+ // is fetched, submitted or followed until a script clones it into the page.
3645
3730
  /** @param {string} text @returns {CollectedUrl[]} */
3646
3731
  function collectUrls(text) {
3647
3732
  const { urls, comments } = documentUrls(text, parseMarkdown, parseFragment);