agent-sanitizer 2.41.2 → 2.41.4
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/package.json +1 -1
- package/src/html.mjs +143 -39
- package/types/html.d.mts +7 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.41.
|
|
3
|
+
"version": "2.41.4",
|
|
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": {
|
package/src/html.mjs
CHANGED
|
@@ -1585,19 +1585,12 @@ export const COMMENT_PLACEHOLDER = "[HTML comment removed";
|
|
|
1585
1585
|
export const UNPARSEABLE_PLACEHOLDER = "[HTML unparseable — withheld]";
|
|
1586
1586
|
|
|
1587
1587
|
/**
|
|
1588
|
-
*
|
|
1589
|
-
*
|
|
1590
|
-
* (defense-in-depth — the scanners emit disjoint ranges).
|
|
1591
|
-
*
|
|
1592
|
-
* Returns the spliced text plus `pairs`, one per emitted placeholder in output
|
|
1593
|
-
* order, each pairing the placeholder with the ORIGINAL bytes it replaced and
|
|
1594
|
-
* its start offset in the RETURNED text (UTF-16 code-unit string indices, the
|
|
1595
|
-
* same space as `ranges`) — everything a rehydrator needs to undo the splice.
|
|
1596
|
-
* @param {string} text
|
|
1588
|
+
* `ranges` sorted by start with every overlap unioned — one entry per
|
|
1589
|
+
* placeholder {@link spliceRanges} emits, in the same order as its `pairs`.
|
|
1597
1590
|
* @param {SpliceRange[]} ranges
|
|
1598
|
-
* @returns {
|
|
1591
|
+
* @returns {SpliceRange[]}
|
|
1599
1592
|
*/
|
|
1600
|
-
|
|
1593
|
+
function mergeRanges(ranges) {
|
|
1601
1594
|
const sorted = [...ranges].sort(
|
|
1602
1595
|
(left, right) => left.start - right.start || left.end - right.end,
|
|
1603
1596
|
);
|
|
@@ -1616,6 +1609,24 @@ export function spliceRanges(text, ranges) {
|
|
|
1616
1609
|
merged.push({ ...range });
|
|
1617
1610
|
}
|
|
1618
1611
|
}
|
|
1612
|
+
return merged;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/**
|
|
1616
|
+
* Replace each range of `text` with its kind's keyed placeholder, preserving
|
|
1617
|
+
* every byte outside the ranges verbatim. Overlapping/nested ranges are merged
|
|
1618
|
+
* (defense-in-depth — the scanners emit disjoint ranges).
|
|
1619
|
+
*
|
|
1620
|
+
* Returns the spliced text plus `pairs`, one per emitted placeholder in output
|
|
1621
|
+
* order, each pairing the placeholder with the ORIGINAL bytes it replaced and
|
|
1622
|
+
* its start offset in the RETURNED text (UTF-16 code-unit string indices, the
|
|
1623
|
+
* same space as `ranges`) — everything a rehydrator needs to undo the splice.
|
|
1624
|
+
* @param {string} text
|
|
1625
|
+
* @param {SpliceRange[]} ranges
|
|
1626
|
+
* @returns {{ text: string, pairs: SplicePair[] }}
|
|
1627
|
+
*/
|
|
1628
|
+
export function spliceRanges(text, ranges) {
|
|
1629
|
+
const merged = mergeRanges(ranges);
|
|
1619
1630
|
let out = "";
|
|
1620
1631
|
let cursor = 0;
|
|
1621
1632
|
/** @type {SplicePair[]} */
|
|
@@ -2122,6 +2133,50 @@ export function looksLikeHtmlSource(text) {
|
|
|
2122
2133
|
return htmlSourceTree(text) !== null;
|
|
2123
2134
|
}
|
|
2124
2135
|
|
|
2136
|
+
// How many scan/splice rounds one `sanitizeHtml` call may spend before it
|
|
2137
|
+
// withholds the document instead. Every input measured settles in at most 2;
|
|
2138
|
+
// the headroom is for a shape the measurement did not reach, and the ceiling is
|
|
2139
|
+
// what keeps a crafted reveal-chain from buying one whole reparse per node.
|
|
2140
|
+
const MAX_SPLICE_ROUNDS = 8;
|
|
2141
|
+
|
|
2142
|
+
/**
|
|
2143
|
+
* `ranges`, which index a text spliced at `merged` (with the resulting
|
|
2144
|
+
* `pairs`), translated back into coordinates of the source that was spliced.
|
|
2145
|
+
*
|
|
2146
|
+
* Every offset outside a placeholder maps by the length the splices before it
|
|
2147
|
+
* removed. No offset ever falls INSIDE one: a placeholder holds neither `<`
|
|
2148
|
+
* nor `>`, and a scanner range always opens on a `<` and closes after a `>` or
|
|
2149
|
+
* at the end of the text — so a placeholder edge is the closest an offset gets,
|
|
2150
|
+
* and the same shift is exact there.
|
|
2151
|
+
* @param {SpliceRange[]} ranges
|
|
2152
|
+
* @param {SpliceRange[]} merged the spliced spans, sorted, one per pair
|
|
2153
|
+
* @param {SplicePair[]} pairs
|
|
2154
|
+
* @returns {SpliceRange[]}
|
|
2155
|
+
*/
|
|
2156
|
+
function toSourceRanges(ranges, merged, pairs) {
|
|
2157
|
+
// Placeholder ends ascend across `pairs`, so the splices before an offset are
|
|
2158
|
+
// found by bisection. Walking `pairs` per offset instead costs the product of
|
|
2159
|
+
// the two lists, which on a document of 10k hidden elements is 10^8 steps.
|
|
2160
|
+
const ends = pairs.map((pair) => pair.start + pair.placeholder.length);
|
|
2161
|
+
const shifts = merged.map((range, index) => range.end - ends[index]);
|
|
2162
|
+
/** @param {number} offset @returns {number} */
|
|
2163
|
+
const toSource = (offset) => {
|
|
2164
|
+
let low = 0;
|
|
2165
|
+
let high = ends.length;
|
|
2166
|
+
while (low < high) {
|
|
2167
|
+
const mid = Math.floor((low + high) / 2);
|
|
2168
|
+
if (ends[mid] <= offset) low = mid + 1;
|
|
2169
|
+
else high = mid;
|
|
2170
|
+
}
|
|
2171
|
+
return low === 0 ? offset : offset + shifts[low - 1];
|
|
2172
|
+
};
|
|
2173
|
+
return ranges.map((range) => ({
|
|
2174
|
+
start: toSource(range.start),
|
|
2175
|
+
end: toSource(range.end),
|
|
2176
|
+
kind: range.kind,
|
|
2177
|
+
}));
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2125
2180
|
/**
|
|
2126
2181
|
* Layer 2 over web-ingress text: splice out HTML comments and hidden elements
|
|
2127
2182
|
* (keyed placeholders mark the cuts; all other bytes are preserved verbatim)
|
|
@@ -2139,41 +2194,90 @@ export function looksLikeHtmlSource(text) {
|
|
|
2139
2194
|
* located, so nothing is recoverable per-splice (the caller's pre-splice
|
|
2140
2195
|
* `reveal` is the only copy).
|
|
2141
2196
|
*
|
|
2142
|
-
* Idempotent over its own output
|
|
2143
|
-
* re-run
|
|
2144
|
-
*
|
|
2197
|
+
* Idempotent over its own output, and by CONSTRUCTION rather than by argument:
|
|
2198
|
+
* the scan is re-run over the spliced text until it finds nothing more, so the
|
|
2199
|
+
* returned text is a fixed point. One pass is not enough on its own, because
|
|
2200
|
+
* removing an element changes how parse5 reparents the bytes around it, which
|
|
2201
|
+
* can flip {@link htmlSourceTree}'s markdown/source verdict for the next run.
|
|
2202
|
+
* A document that has not settled within {@link MAX_SPLICE_ROUNDS} rounds is
|
|
2203
|
+
* withheld whole, on the same fail-closed path a parser blow-up takes.
|
|
2145
2204
|
* @param {string} text
|
|
2146
2205
|
* @returns {{ text: string, removed: { comments: number, hidden: number }, warned: { tags: Record<string, number>, dataSrc: number }, splices: SplicePair[], unparseable?: true } | null}
|
|
2147
2206
|
*/
|
|
2148
2207
|
export function sanitizeHtml(text) {
|
|
2149
2208
|
if (!HTML_TAG_PRESENT.test(text)) return null;
|
|
2150
|
-
/** @type {
|
|
2151
|
-
let
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
//
|
|
2161
|
-
//
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2209
|
+
/** @type {SpliceRange[]} Every span found so far, in SOURCE coordinates. */
|
|
2210
|
+
let ranges = [];
|
|
2211
|
+
let spliced = { text, pairs: /** @type {SplicePair[]} */ ([]) };
|
|
2212
|
+
const removed = { comments: 0, hidden: 0 };
|
|
2213
|
+
/** @type {ReturnType<typeof newWarned>} */
|
|
2214
|
+
let warned;
|
|
2215
|
+
// Each round splices at least one more span of `text`, so the covered length
|
|
2216
|
+
// grows strictly and the loop terminates. A placeholder holds no `<`, so no
|
|
2217
|
+
// round can find a span inside one and re-cover ground already covered.
|
|
2218
|
+
for (let round = 0; ; round++) {
|
|
2219
|
+
// Termination alone is not a cost bound: each round is a whole reparse, so
|
|
2220
|
+
// an input that reveals one more node per round would pay one per node.
|
|
2221
|
+
// This refuses past the ceiling instead, the way MAX_DEPTH in output.mjs
|
|
2222
|
+
// refuses past a nesting depth.
|
|
2223
|
+
/* c8 ignore start -- no input is known to reach round MAX_SPLICE_ROUNDS:
|
|
2224
|
+
370k adversarial draws over the shapes that DO chain (a stray `<td>` the
|
|
2225
|
+
source branch drops, a bogus comment, the adoption-agency reparent that
|
|
2226
|
+
flips the branch) settle in at most 2. Defense in depth on the
|
|
2227
|
+
web-ingress boundary, not a path with a fixture. */
|
|
2228
|
+
if (round === MAX_SPLICE_ROUNDS)
|
|
2229
|
+
return {
|
|
2230
|
+
text: UNPARSEABLE_PLACEHOLDER,
|
|
2231
|
+
removed: { comments: 0, hidden: 1 },
|
|
2232
|
+
warned: newWarned(),
|
|
2233
|
+
splices: [],
|
|
2234
|
+
unparseable: true,
|
|
2235
|
+
};
|
|
2236
|
+
/* c8 ignore stop */
|
|
2237
|
+
/** @type {{ ranges: SpliceRange[], warned: ReturnType<typeof newWarned> }} */
|
|
2238
|
+
let scan;
|
|
2239
|
+
try {
|
|
2240
|
+
// One parse decides the branch AND feeds it, so the source branch does not
|
|
2241
|
+
// tokenize the input twice.
|
|
2242
|
+
const sourceTree = htmlSourceTree(spliced.text);
|
|
2243
|
+
scan = sourceTree
|
|
2244
|
+
? scanFragmentTree(spliced.text, sourceTree)
|
|
2245
|
+
: scanMarkdown(spliced.text);
|
|
2246
|
+
} catch {
|
|
2247
|
+
// The parse/visit blew up (stack overflow on pathological nesting, or any
|
|
2248
|
+
// other parser error). Fail CLOSED at this boundary so `sanitize`/
|
|
2249
|
+
// `sanitizeText` keep their never-throw contract: withhold the whole input
|
|
2250
|
+
// behind a placeholder and report it as hidden content removed.
|
|
2251
|
+
return {
|
|
2252
|
+
text: UNPARSEABLE_PLACEHOLDER,
|
|
2253
|
+
removed: { comments: 0, hidden: 1 },
|
|
2254
|
+
warned: newWarned(),
|
|
2255
|
+
splices: [],
|
|
2256
|
+
unparseable: true,
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
// The last scan saw exactly the text being returned, so its tag counts are
|
|
2260
|
+
// the ones that describe that text.
|
|
2261
|
+
warned = scan.warned;
|
|
2262
|
+
if (scan.ranges.length === 0) break;
|
|
2263
|
+
// Re-splice from the SOURCE every round: pairs then carry original bytes
|
|
2264
|
+
// and offsets into the returned text, never a nested earlier placeholder.
|
|
2265
|
+
const grown = mergeRanges([
|
|
2266
|
+
...ranges,
|
|
2267
|
+
...toSourceRanges(scan.ranges, ranges, spliced.pairs),
|
|
2268
|
+
]);
|
|
2269
|
+
const next = spliceRanges(text, grown);
|
|
2270
|
+
/* c8 ignore next 4 -- unreachable: every range holds a `<` and no
|
|
2271
|
+
placeholder does, so each round covers source bytes the last one did
|
|
2272
|
+
not and the text must change. Kept because the alternative to this
|
|
2273
|
+
stop is a hook that spins on untrusted input. */
|
|
2274
|
+
if (next.text === spliced.text) break;
|
|
2275
|
+
for (const range of scan.ranges)
|
|
2276
|
+
removed[range.kind === "comment" ? "comments" : "hidden"]++;
|
|
2277
|
+
ranges = grown;
|
|
2278
|
+
spliced = next;
|
|
2169
2279
|
}
|
|
2170
|
-
const { ranges, warned } = scan;
|
|
2171
2280
|
if (ranges.length === 0 && !hasWarned(warned)) return null;
|
|
2172
|
-
const removed = { comments: 0, hidden: 0 };
|
|
2173
|
-
for (const range of ranges)
|
|
2174
|
-
removed[range.kind === "comment" ? "comments" : "hidden"]++;
|
|
2175
|
-
const spliced =
|
|
2176
|
-
ranges.length > 0 ? spliceRanges(text, ranges) : { text, pairs: [] };
|
|
2177
2281
|
return {
|
|
2178
2282
|
text: spliced.text,
|
|
2179
2283
|
removed,
|
package/types/html.d.mts
CHANGED
|
@@ -87,9 +87,13 @@ export function looksLikeHtmlSource(text: string): boolean;
|
|
|
87
87
|
* located, so nothing is recoverable per-splice (the caller's pre-splice
|
|
88
88
|
* `reveal` is the only copy).
|
|
89
89
|
*
|
|
90
|
-
* Idempotent over its own output
|
|
91
|
-
* re-run
|
|
92
|
-
*
|
|
90
|
+
* Idempotent over its own output, and by CONSTRUCTION rather than by argument:
|
|
91
|
+
* the scan is re-run over the spliced text until it finds nothing more, so the
|
|
92
|
+
* returned text is a fixed point. One pass is not enough on its own, because
|
|
93
|
+
* removing an element changes how parse5 reparents the bytes around it, which
|
|
94
|
+
* can flip {@link htmlSourceTree}'s markdown/source verdict for the next run.
|
|
95
|
+
* A document that has not settled within {@link MAX_SPLICE_ROUNDS} rounds is
|
|
96
|
+
* withheld whole, on the same fail-closed path a parser blow-up takes.
|
|
93
97
|
* @param {string} text
|
|
94
98
|
* @returns {{ text: string, removed: { comments: number, hidden: number }, warned: { tags: Record<string, number>, dataSrc: number }, splices: SplicePair[], unparseable?: true } | null}
|
|
95
99
|
*/
|