agent-sanitizer 2.0.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/LICENSE +202 -0
- package/README.md +217 -0
- package/SECURITY.md +31 -0
- package/THREAT-MODEL.md +173 -0
- package/bin/sanitize-cli.mjs +423 -0
- package/package.json +157 -0
- package/src/cf-charset.mjs +43 -0
- package/src/confusables.mjs +199 -0
- package/src/gates.mjs +71 -0
- package/src/html.mjs +2233 -0
- package/src/index.mjs +166 -0
- package/src/instructions.mjs +530 -0
- package/src/invisible.mjs +976 -0
- package/src/joining-type.mjs +616 -0
- package/src/layer1.mjs +177 -0
- package/src/output.mjs +788 -0
- package/src/prompt.mjs +154 -0
- package/src/rehydrate.mjs +646 -0
- package/src/standardized-variants.mjs +1335 -0
- package/src/view-map.mjs +354 -0
- package/types/cf-charset.d.mts +14 -0
- package/types/confusables.d.mts +85 -0
- package/types/gates.d.mts +37 -0
- package/types/html.d.mts +117 -0
- package/types/index.d.mts +33 -0
- package/types/instructions.d.mts +137 -0
- package/types/invisible.d.mts +98 -0
- package/types/joining-type.d.mts +22 -0
- package/types/layer1.d.mts +35 -0
- package/types/output.d.mts +195 -0
- package/types/prompt.d.mts +28 -0
- package/types/rehydrate.d.mts +54 -0
- package/types/standardized-variants.d.mts +22 -0
- package/types/view-map.d.mts +170 -0
package/src/html.mjs
ADDED
|
@@ -0,0 +1,2233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hidden-HTML splicing (Layer 2) and exfil-URL detection (Layer 3) for
|
|
3
|
+
* web/HTML ingress.
|
|
4
|
+
*
|
|
5
|
+
* Layer 2 strips exactly what a human viewing the rendered page cannot see —
|
|
6
|
+
* HTML comments and hidden elements (hiding inline styles, `hidden` attr) —
|
|
7
|
+
* by splicing those byte ranges out of the original text and leaving a
|
|
8
|
+
* placeholder; every byte outside a spliced range is preserved verbatim (no
|
|
9
|
+
* re-serialization). Scripting/resource tags (script, style, svg, iframe, …)
|
|
10
|
+
* and `data:` URI resources are REPORTED in the result's `warned` counts but
|
|
11
|
+
* never removed, so fetched page source stays inspectable.
|
|
12
|
+
*
|
|
13
|
+
* Layer 3 reports data-exfil-shaped URLs (suspicious query params, oversized
|
|
14
|
+
* payloads, embedded credentials) without modifying them; the caller surfaces
|
|
15
|
+
* the report as a warning.
|
|
16
|
+
*
|
|
17
|
+
* Split into its own module so it can be lazy-loaded: pulling in the
|
|
18
|
+
* remark/rehype/unified graph costs ~200ms of module-load time, so the main
|
|
19
|
+
* entry `await import()`s this module only when its cheap regex gates match.
|
|
20
|
+
*/
|
|
21
|
+
// @ts-ignore -- css-tree ships no bundled types and @types/css-tree lags the 3.x
|
|
22
|
+
// API (e.g. `ident.decode`); the value AST is walked with local `any` types.
|
|
23
|
+
import * as csstree from "css-tree";
|
|
24
|
+
import { unified } from "unified";
|
|
25
|
+
import remarkParse from "remark-parse";
|
|
26
|
+
import remarkGfm from "remark-gfm";
|
|
27
|
+
import rehypeParse from "rehype-parse";
|
|
28
|
+
import { visit, SKIP, EXIT } from "unist-util-visit";
|
|
29
|
+
import {
|
|
30
|
+
HTML_TAG_PRESENT,
|
|
31
|
+
MD_LINK_HINT,
|
|
32
|
+
SECRET_HINT,
|
|
33
|
+
SECRET_HINT_EXT,
|
|
34
|
+
matchesSecretHint,
|
|
35
|
+
} from "./gates.mjs";
|
|
36
|
+
|
|
37
|
+
// The cheap pre-gates live in the dependency-free `./gates.mjs` so the package
|
|
38
|
+
// root can re-export them without eagerly loading this module's remark/rehype
|
|
39
|
+
// graph. Re-exported here too so the `./html` subpath keeps exposing them.
|
|
40
|
+
export {
|
|
41
|
+
HTML_TAG_PRESENT,
|
|
42
|
+
MD_LINK_HINT,
|
|
43
|
+
SECRET_HINT,
|
|
44
|
+
SECRET_HINT_EXT,
|
|
45
|
+
matchesSecretHint,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ─── Layer 2: hidden-content detection ───────────────────────────────────────
|
|
49
|
+
//
|
|
50
|
+
// Values are tokenized by css-tree (its spec-compliant tokenizer/AST), not by
|
|
51
|
+
// hand-rolled regexes: `parseDeclarations` splits declarations, decodes CSS
|
|
52
|
+
// escapes, and strips `!important` through css-tree, and the structural
|
|
53
|
+
// detectors below inspect the resulting typed value nodes (Number, Dimension,
|
|
54
|
+
// Percentage, Function, …) directly. So an exponent (`scale(1e-3)`), an FF/CR
|
|
55
|
+
// escape terminator, an escaped `!important`, or a `;` inside a quoted value is
|
|
56
|
+
// read exactly as a browser reads it — the tokenizer-divergence bugs a
|
|
57
|
+
// hand-rolled parser kept re-introducing simply cannot arise. Ambiguity still
|
|
58
|
+
// fails OPEN (treated as visible): an unresolved unit, `calc()`, or `var()`
|
|
59
|
+
// never counts as hidden.
|
|
60
|
+
|
|
61
|
+
// A length/opacity/size is "near zero" when its magnitude is below this — a
|
|
62
|
+
// browser renders 0.0001px text or 0.001 opacity as effectively invisible, so
|
|
63
|
+
// requiring an exact 0 lets a trivially-perturbed value slip through.
|
|
64
|
+
const NEAR_ZERO_EPSILON = 0.01;
|
|
65
|
+
|
|
66
|
+
// A negative offset is "offscreen" only when it pushes the element ENTIRELY
|
|
67
|
+
// past the viewport edge — the magnitude that takes depends on the unit. An
|
|
68
|
+
// absolute unit (px and the font/char units) needs a large magnitude
|
|
69
|
+
// (< -900px). A viewport/percent unit clears the screen only at a full
|
|
70
|
+
// viewport-width: -100vw / -100% push a normal-width element fully out, but
|
|
71
|
+
// -50vw / -50% leave roughly half of it on screen, so the threshold is a full
|
|
72
|
+
// -100, not a partial shift. Flagging a partial shift would splice visible
|
|
73
|
+
// text, so this errs toward false-negative.
|
|
74
|
+
const OFFSCREEN_ABSOLUTE_THRESHOLD = -900;
|
|
75
|
+
const OFFSCREEN_VIEWPORT_THRESHOLD = -100;
|
|
76
|
+
|
|
77
|
+
// Absolute length units: a large negative magnitude is needed to clear the
|
|
78
|
+
// viewport. True viewport units clear it at a full -100. A `%` offset is
|
|
79
|
+
// viewport-relative for box offsets (left/top/…) but ELEMENT-relative inside a
|
|
80
|
+
// translate(), so it is handled by the callers, not these sets.
|
|
81
|
+
const ABSOLUTE_UNITS = new Set([
|
|
82
|
+
"px",
|
|
83
|
+
"em",
|
|
84
|
+
"rem",
|
|
85
|
+
"ex",
|
|
86
|
+
"ch",
|
|
87
|
+
"pt",
|
|
88
|
+
"pc",
|
|
89
|
+
"in",
|
|
90
|
+
"cm",
|
|
91
|
+
"mm",
|
|
92
|
+
]);
|
|
93
|
+
const VIEWPORT_UNITS = new Set(["vw", "vh", "vmin", "vmax"]);
|
|
94
|
+
// Angle units for rotateX/rotateY, normalized to degrees by hueDegrees.
|
|
95
|
+
const ANGLE_UNITS = new Set(["deg", "grad", "rad", "turn"]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The meaningful value tokens of a css-tree Value or Function node — its direct
|
|
99
|
+
* children minus the Operator/whitespace separators — in document order.
|
|
100
|
+
* @param {any} node
|
|
101
|
+
* @returns {any[]}
|
|
102
|
+
*/
|
|
103
|
+
function valueTokens(node) {
|
|
104
|
+
/** @type {any[]} */
|
|
105
|
+
const tokens = [];
|
|
106
|
+
if (!node || !node.children) return tokens;
|
|
107
|
+
node.children.forEach((/** @type {any} */ child) => {
|
|
108
|
+
if (child.type !== "Operator" && child.type !== "WhiteSpace")
|
|
109
|
+
tokens.push(child);
|
|
110
|
+
});
|
|
111
|
+
return tokens;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The single meaningful token of a value node, or null when the value is empty
|
|
116
|
+
* or carries more than one token (`left:auto` → the Identifier; `left:1px 2px`
|
|
117
|
+
* → null). A one-token requirement mirrors the old anchored `^…$` regexes.
|
|
118
|
+
* @param {any} node
|
|
119
|
+
* @returns {any | null}
|
|
120
|
+
*/
|
|
121
|
+
function soleToken(node) {
|
|
122
|
+
const tokens = valueTokens(node);
|
|
123
|
+
return tokens.length === 1 ? tokens[0] : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* True when a value is a single length/number/percentage whose magnitude is
|
|
128
|
+
* (near) zero — `font-size:0`, `font-size:0.0001px`, `font-size:0%`. A keyword
|
|
129
|
+
* (`medium`), a multi-token value, or `calc()` fails open.
|
|
130
|
+
* @param {any} node
|
|
131
|
+
* @returns {boolean}
|
|
132
|
+
*/
|
|
133
|
+
function isNearZeroLength(node) {
|
|
134
|
+
const token = soleToken(node);
|
|
135
|
+
if (
|
|
136
|
+
!token ||
|
|
137
|
+
(token.type !== "Number" &&
|
|
138
|
+
token.type !== "Dimension" &&
|
|
139
|
+
token.type !== "Percentage")
|
|
140
|
+
)
|
|
141
|
+
return false;
|
|
142
|
+
return Math.abs(parseFloat(token.value)) < NEAR_ZERO_EPSILON;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Group a Function node's children into its comma-separated argument lists.
|
|
147
|
+
* `translate(0, -9999px)` → `[[Number 0], [Dimension -9999px]]`.
|
|
148
|
+
* @param {any} fn
|
|
149
|
+
* @returns {any[][]}
|
|
150
|
+
*/
|
|
151
|
+
function functionArgs(fn) {
|
|
152
|
+
/** @type {any[][]} */
|
|
153
|
+
const groups = [[]];
|
|
154
|
+
if (fn.children)
|
|
155
|
+
fn.children.forEach((/** @type {any} */ child) => {
|
|
156
|
+
if (child.type === "Operator" && child.value === ",") groups.push([]);
|
|
157
|
+
else if (child.type !== "WhiteSpace")
|
|
158
|
+
groups[groups.length - 1].push(child);
|
|
159
|
+
});
|
|
160
|
+
return groups;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* True when a single length token is far enough offscreen to be fully clipped.
|
|
165
|
+
* `%` counts for box offsets (`allowPercent`, viewport-relative) but not inside
|
|
166
|
+
* a translate() (element-relative — unresolvable, fail open). A unitless Number
|
|
167
|
+
* (invalid CSS), an unknown unit, `calc()`, and `auto` all fail open.
|
|
168
|
+
* @param {any} token a css-tree value token
|
|
169
|
+
* @param {boolean} allowPercent
|
|
170
|
+
* @returns {boolean}
|
|
171
|
+
*/
|
|
172
|
+
function isOffscreenLength(token, allowPercent) {
|
|
173
|
+
if (token.type === "Dimension") {
|
|
174
|
+
const n = parseFloat(token.value);
|
|
175
|
+
if (ABSOLUTE_UNITS.has(token.unit)) return n < OFFSCREEN_ABSOLUTE_THRESHOLD;
|
|
176
|
+
if (VIEWPORT_UNITS.has(token.unit))
|
|
177
|
+
return n <= OFFSCREEN_VIEWPORT_THRESHOLD;
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
if (token.type === "Percentage" && allowPercent)
|
|
181
|
+
return parseFloat(token.value) <= OFFSCREEN_VIEWPORT_THRESHOLD;
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Like isOffscreenLength but for a box offset (`left`/`top`/…/`text-indent`):
|
|
187
|
+
* the value must be a single token, and `%` is viewport-relative here so it
|
|
188
|
+
* counts.
|
|
189
|
+
* @param {any} node value node for the offset property
|
|
190
|
+
* @returns {boolean}
|
|
191
|
+
*/
|
|
192
|
+
function isOffscreenOffset(node) {
|
|
193
|
+
const token = soleToken(node);
|
|
194
|
+
return token ? isOffscreenLength(token, true) : false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* True when a `transform` renders text invisible: scaled to (near) nothing,
|
|
199
|
+
* rotated edge-on (an odd quarter-turn around X or Y projects to zero area), or
|
|
200
|
+
* translated far off any viewport. Walks the transform-function list so any
|
|
201
|
+
* hiding function anywhere in the list is caught.
|
|
202
|
+
* @param {any} node value node for `transform`
|
|
203
|
+
* @returns {boolean}
|
|
204
|
+
*/
|
|
205
|
+
function isHidingTransform(node) {
|
|
206
|
+
if (!node) return false;
|
|
207
|
+
for (const fn of valueTokens(node)) {
|
|
208
|
+
if (fn.type !== "Function") continue;
|
|
209
|
+
const name = fn.name.toLowerCase();
|
|
210
|
+
const args = valueTokens(fn);
|
|
211
|
+
if (/^(?:scale|scale3d|scalex|scaley|matrix|matrix3d)$/.test(name)) {
|
|
212
|
+
// scale/matrix collapse to nothing when EITHER axis factor is (near-)zero —
|
|
213
|
+
// `scale(1,0)` / `scale3d(1,0,1)` collapse the Y axis, `matrix(1,0,0,0,…)`
|
|
214
|
+
// sets scaleY (d) to 0 — so testing only the first factor missed the
|
|
215
|
+
// multi-arg Y-collapse forms. css-tree reads the exponent form (`1e-3`) at
|
|
216
|
+
// full value; scale()/matrix() factors are <number>s (never lengths). For
|
|
217
|
+
// `matrix(a,b,c,d,…)` the two scale factors are a (index 0) and d (index 3).
|
|
218
|
+
// The two scale factors' positions differ per function: scale/scale3d/
|
|
219
|
+
// scaleX/scaleY carry them first (a single scaleX(0)/scaleY(0) sits at 0);
|
|
220
|
+
// `matrix(a,b,c,d,…)` puts scaleX=a (0) and scaleY=d (3); `matrix3d` puts
|
|
221
|
+
// scaleX=m11 (0) and scaleY=m22 (5). Do NOT use index 3 for matrix3d — that
|
|
222
|
+
// is m14, which is legitimately 0 on the identity matrix (false positive).
|
|
223
|
+
const numbers = args.filter(
|
|
224
|
+
(/** @type {any} */ a) => a.type === "Number",
|
|
225
|
+
);
|
|
226
|
+
const factorIdx =
|
|
227
|
+
name === "matrix" ? [0, 3] : name === "matrix3d" ? [0, 5] : [0, 1];
|
|
228
|
+
if (
|
|
229
|
+
factorIdx.some((/** @type {number} */ i) => {
|
|
230
|
+
const f = numbers[i];
|
|
231
|
+
return f && Math.abs(parseFloat(f.value)) < NEAR_ZERO_EPSILON;
|
|
232
|
+
})
|
|
233
|
+
)
|
|
234
|
+
return true;
|
|
235
|
+
} else if (name === "rotatex" || name === "rotatey") {
|
|
236
|
+
// An axis rotation collapses the box to a line at an odd quarter-turn.
|
|
237
|
+
// Only the axis-specific rotations collapse; a plain rotate()/rotateZ()
|
|
238
|
+
// spins in-plane and stays visible. The angle needs an explicit unit (a
|
|
239
|
+
// unitless nonzero angle is invalid CSS a browser drops); hueDegrees
|
|
240
|
+
// normalizes deg/grad/rad/turn to [0,360), and a near-90/270 band absorbs
|
|
241
|
+
// the float drift of rad→deg.
|
|
242
|
+
const a = args[0];
|
|
243
|
+
if (
|
|
244
|
+
a &&
|
|
245
|
+
a.type === "Dimension" &&
|
|
246
|
+
ANGLE_UNITS.has(a.unit.toLowerCase())
|
|
247
|
+
) {
|
|
248
|
+
const degrees = hueDegrees(`${a.value}${a.unit}`.toLowerCase());
|
|
249
|
+
if (
|
|
250
|
+
degrees !== null &&
|
|
251
|
+
(Math.abs(degrees - 90) < NEAR_ZERO_EPSILON ||
|
|
252
|
+
Math.abs(degrees - 270) < NEAR_ZERO_EPSILON)
|
|
253
|
+
)
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
} else if (
|
|
257
|
+
name === "translate" ||
|
|
258
|
+
name === "translatex" ||
|
|
259
|
+
name === "translatey"
|
|
260
|
+
) {
|
|
261
|
+
// A two-axis translate hides when EITHER axis clears the viewport. A `%`
|
|
262
|
+
// translate is element-relative (unresolvable), so it fails open.
|
|
263
|
+
for (const group of functionArgs(fn))
|
|
264
|
+
if (group.length === 1 && isOffscreenLength(group[0], false))
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* True when a `filter` renders content invisible: an `opacity()` function drops
|
|
273
|
+
* the element to fully transparent. The amount is a <number-percentage>; a
|
|
274
|
+
* percentage is divided to a fraction before the near-zero test. Other filter
|
|
275
|
+
* functions keep content visible; an unresolvable amount fails OPEN.
|
|
276
|
+
* @param {any} node value node for `filter`
|
|
277
|
+
* @returns {boolean}
|
|
278
|
+
*/
|
|
279
|
+
function isHidingFilter(node) {
|
|
280
|
+
if (!node) return false;
|
|
281
|
+
for (const fn of valueTokens(node)) {
|
|
282
|
+
if (fn.type !== "Function" || fn.name.toLowerCase() !== "opacity") continue;
|
|
283
|
+
const amount = valueTokens(fn)[0];
|
|
284
|
+
if (!amount) continue;
|
|
285
|
+
if (
|
|
286
|
+
amount.type === "Number" &&
|
|
287
|
+
parseFloat(amount.value) < NEAR_ZERO_EPSILON
|
|
288
|
+
)
|
|
289
|
+
return true;
|
|
290
|
+
if (
|
|
291
|
+
amount.type === "Percentage" &&
|
|
292
|
+
parseFloat(amount.value) / 100 < NEAR_ZERO_EPSILON
|
|
293
|
+
)
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// One `clip: rect(...)` edge as a number and unit, or null for `auto`/any
|
|
300
|
+
// non-length token (unresolvable → fail open). A bare Number carries unit "".
|
|
301
|
+
/** @param {any} token @returns {{ num: number, unit: string } | null} */
|
|
302
|
+
function clipEdge(token) {
|
|
303
|
+
if (token.type === "Dimension")
|
|
304
|
+
return { num: parseFloat(token.value), unit: token.unit };
|
|
305
|
+
if (token.type === "Number")
|
|
306
|
+
return { num: parseFloat(token.value), unit: "" };
|
|
307
|
+
if (token.type === "Percentage")
|
|
308
|
+
return { num: parseFloat(token.value), unit: "%" };
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* True when a legacy `clip: rect(top, right, bottom, left)` clips the element to
|
|
314
|
+
* ~ZERO AREA — the window's width (`right - left`) or height (`bottom - top`)
|
|
315
|
+
* collapses to near nothing. Parses ALL FOUR edges (checking only the first
|
|
316
|
+
* spliced a visible `rect(0px,100px,100px,0px)`); an `auto`/unresolvable edge,
|
|
317
|
+
* a wrong edge count, or a pair in mismatched units fails OPEN.
|
|
318
|
+
* @param {any} node value node for `clip`
|
|
319
|
+
* @returns {boolean}
|
|
320
|
+
*/
|
|
321
|
+
function isClipRectHidden(node) {
|
|
322
|
+
if (!node) return false;
|
|
323
|
+
const rect = valueTokens(node).find(
|
|
324
|
+
(t) => t.type === "Function" && t.name.toLowerCase() === "rect",
|
|
325
|
+
);
|
|
326
|
+
if (!rect) return false;
|
|
327
|
+
const edges = valueTokens(rect).map(clipEdge);
|
|
328
|
+
if (edges.length !== 4 || edges.some((edge) => edge === null)) return false;
|
|
329
|
+
const [top, right, bottom, left] = /** @type {{num:number,unit:string}[]} */ (
|
|
330
|
+
edges
|
|
331
|
+
);
|
|
332
|
+
/**
|
|
333
|
+
* @param {{ num: number, unit: string }} a
|
|
334
|
+
* @param {{ num: number, unit: string }} b
|
|
335
|
+
*/
|
|
336
|
+
const collapsed = (a, b) =>
|
|
337
|
+
a.unit === b.unit && Math.abs(a.num - b.num) < NEAR_ZERO_EPSILON;
|
|
338
|
+
return collapsed(left, right) || collapsed(top, bottom);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* @param {(key: string) => any} nodeOf value node for a property, or null
|
|
343
|
+
* @param {(key: string) => string} textOf decoded/lowercased text for a property
|
|
344
|
+
* @returns {boolean}
|
|
345
|
+
*/
|
|
346
|
+
function isPositionedOffscreen(nodeOf, textOf) {
|
|
347
|
+
const position = textOf("position");
|
|
348
|
+
// `relative`/`sticky` shift the rendered box off its normal spot just like
|
|
349
|
+
// `absolute`/`fixed` do, so a `left:-9999px` on any of them pushes the text
|
|
350
|
+
// off any viewport. `static` ignores offsets and is excluded.
|
|
351
|
+
if (!/\babsolute\b|\bfixed\b|\brelative\b|\bsticky\b/.test(position))
|
|
352
|
+
return false;
|
|
353
|
+
for (const side of ["left", "top", "right", "bottom"])
|
|
354
|
+
if (isOffscreenOffset(nodeOf(side))) return true;
|
|
355
|
+
// The legacy `clip` property only clips ABSOLUTELY-positioned boxes
|
|
356
|
+
// (absolute/fixed); a relative/sticky element ignores it, so reading its
|
|
357
|
+
// rect() as a hide there would splice visible text (fail open).
|
|
358
|
+
if (!/\babsolute\b|\bfixed\b/.test(position)) return false;
|
|
359
|
+
return isClipRectHidden(nodeOf("clip"));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// The full CSS named-color set canonicalized to `#rrggbb`, so any two identical
|
|
363
|
+
// resolvable named colors (`color:blue;background:blue`) — not just the handful
|
|
364
|
+
// that back white-on-white text — compare equal for the same-color hide test.
|
|
365
|
+
// `transparent` maps to itself (the sentinel isConcreteColor also accepts).
|
|
366
|
+
// var()/inherit/currentColor are deliberately absent: they resolve via the
|
|
367
|
+
// cascade and must fail OPEN, handled by isConcreteColor at the compare.
|
|
368
|
+
/** @type {Record<string, string>} */
|
|
369
|
+
// Stryker disable all — static CSS color data table (147 canonical name→hex
|
|
370
|
+
// entries). Mutating each hex/name literal yields hundreds of low-value,
|
|
371
|
+
// largely-equivalent mutants (no test can meaningfully pin every color) that
|
|
372
|
+
// balloon the html shard past its CI timeout. The canonicalization LOGIC that
|
|
373
|
+
// consumes this table stays under mutation. Same idiom as the Unicode data
|
|
374
|
+
// tables in standardized-variants.mjs/joining-type.mjs/cf-charset.mjs.
|
|
375
|
+
const NAMED_COLORS = {
|
|
376
|
+
aliceblue: "#f0f8ff",
|
|
377
|
+
antiquewhite: "#faebd7",
|
|
378
|
+
aqua: "#00ffff",
|
|
379
|
+
aquamarine: "#7fffd4",
|
|
380
|
+
azure: "#f0ffff",
|
|
381
|
+
beige: "#f5f5dc",
|
|
382
|
+
bisque: "#ffe4c4",
|
|
383
|
+
black: "#000000",
|
|
384
|
+
blanchedalmond: "#ffebcd",
|
|
385
|
+
blue: "#0000ff",
|
|
386
|
+
blueviolet: "#8a2be2",
|
|
387
|
+
brown: "#a52a2a",
|
|
388
|
+
burlywood: "#deb887",
|
|
389
|
+
cadetblue: "#5f9ea0",
|
|
390
|
+
chartreuse: "#7fff00",
|
|
391
|
+
chocolate: "#d2691e",
|
|
392
|
+
coral: "#ff7f50",
|
|
393
|
+
cornflowerblue: "#6495ed",
|
|
394
|
+
cornsilk: "#fff8dc",
|
|
395
|
+
crimson: "#dc143c",
|
|
396
|
+
cyan: "#00ffff",
|
|
397
|
+
darkblue: "#00008b",
|
|
398
|
+
darkcyan: "#008b8b",
|
|
399
|
+
darkgoldenrod: "#b8860b",
|
|
400
|
+
darkgray: "#a9a9a9",
|
|
401
|
+
darkgreen: "#006400",
|
|
402
|
+
darkgrey: "#a9a9a9",
|
|
403
|
+
darkkhaki: "#bdb76b",
|
|
404
|
+
darkmagenta: "#8b008b",
|
|
405
|
+
darkolivegreen: "#556b2f",
|
|
406
|
+
darkorange: "#ff8c00",
|
|
407
|
+
darkorchid: "#9932cc",
|
|
408
|
+
darkred: "#8b0000",
|
|
409
|
+
darksalmon: "#e9967a",
|
|
410
|
+
darkseagreen: "#8fbc8f",
|
|
411
|
+
darkslateblue: "#483d8b",
|
|
412
|
+
darkslategray: "#2f4f4f",
|
|
413
|
+
darkslategrey: "#2f4f4f",
|
|
414
|
+
darkturquoise: "#00ced1",
|
|
415
|
+
darkviolet: "#9400d3",
|
|
416
|
+
deeppink: "#ff1493",
|
|
417
|
+
deepskyblue: "#00bfff",
|
|
418
|
+
dimgray: "#696969",
|
|
419
|
+
dimgrey: "#696969",
|
|
420
|
+
dodgerblue: "#1e90ff",
|
|
421
|
+
firebrick: "#b22222",
|
|
422
|
+
floralwhite: "#fffaf0",
|
|
423
|
+
forestgreen: "#228b22",
|
|
424
|
+
fuchsia: "#ff00ff",
|
|
425
|
+
gainsboro: "#dcdcdc",
|
|
426
|
+
ghostwhite: "#f8f8ff",
|
|
427
|
+
gold: "#ffd700",
|
|
428
|
+
goldenrod: "#daa520",
|
|
429
|
+
gray: "#808080",
|
|
430
|
+
green: "#008000",
|
|
431
|
+
greenyellow: "#adff2f",
|
|
432
|
+
grey: "#808080",
|
|
433
|
+
honeydew: "#f0fff0",
|
|
434
|
+
hotpink: "#ff69b4",
|
|
435
|
+
indianred: "#cd5c5c",
|
|
436
|
+
indigo: "#4b0082",
|
|
437
|
+
ivory: "#fffff0",
|
|
438
|
+
khaki: "#f0e68c",
|
|
439
|
+
lavender: "#e6e6fa",
|
|
440
|
+
lavenderblush: "#fff0f5",
|
|
441
|
+
lawngreen: "#7cfc00",
|
|
442
|
+
lemonchiffon: "#fffacd",
|
|
443
|
+
lightblue: "#add8e6",
|
|
444
|
+
lightcoral: "#f08080",
|
|
445
|
+
lightcyan: "#e0ffff",
|
|
446
|
+
lightgoldenrodyellow: "#fafad2",
|
|
447
|
+
lightgray: "#d3d3d3",
|
|
448
|
+
lightgreen: "#90ee90",
|
|
449
|
+
lightgrey: "#d3d3d3",
|
|
450
|
+
lightpink: "#ffb6c1",
|
|
451
|
+
lightsalmon: "#ffa07a",
|
|
452
|
+
lightseagreen: "#20b2aa",
|
|
453
|
+
lightskyblue: "#87cefa",
|
|
454
|
+
lightslategray: "#778899",
|
|
455
|
+
lightslategrey: "#778899",
|
|
456
|
+
lightsteelblue: "#b0c4de",
|
|
457
|
+
lightyellow: "#ffffe0",
|
|
458
|
+
lime: "#00ff00",
|
|
459
|
+
limegreen: "#32cd32",
|
|
460
|
+
linen: "#faf0e6",
|
|
461
|
+
magenta: "#ff00ff",
|
|
462
|
+
maroon: "#800000",
|
|
463
|
+
mediumaquamarine: "#66cdaa",
|
|
464
|
+
mediumblue: "#0000cd",
|
|
465
|
+
mediumorchid: "#ba55d3",
|
|
466
|
+
mediumpurple: "#9370db",
|
|
467
|
+
mediumseagreen: "#3cb371",
|
|
468
|
+
mediumslateblue: "#7b68ee",
|
|
469
|
+
mediumspringgreen: "#00fa9a",
|
|
470
|
+
mediumturquoise: "#48d1cc",
|
|
471
|
+
mediumvioletred: "#c71585",
|
|
472
|
+
midnightblue: "#191970",
|
|
473
|
+
mintcream: "#f5fffa",
|
|
474
|
+
mistyrose: "#ffe4e1",
|
|
475
|
+
moccasin: "#ffe4b5",
|
|
476
|
+
navajowhite: "#ffdead",
|
|
477
|
+
navy: "#000080",
|
|
478
|
+
oldlace: "#fdf5e6",
|
|
479
|
+
olive: "#808000",
|
|
480
|
+
olivedrab: "#6b8e23",
|
|
481
|
+
orange: "#ffa500",
|
|
482
|
+
orangered: "#ff4500",
|
|
483
|
+
orchid: "#da70d6",
|
|
484
|
+
palegoldenrod: "#eee8aa",
|
|
485
|
+
palegreen: "#98fb98",
|
|
486
|
+
paleturquoise: "#afeeee",
|
|
487
|
+
palevioletred: "#db7093",
|
|
488
|
+
papayawhip: "#ffefd5",
|
|
489
|
+
peachpuff: "#ffdab9",
|
|
490
|
+
peru: "#cd853f",
|
|
491
|
+
pink: "#ffc0cb",
|
|
492
|
+
plum: "#dda0dd",
|
|
493
|
+
powderblue: "#b0e0e6",
|
|
494
|
+
purple: "#800080",
|
|
495
|
+
rebeccapurple: "#663399",
|
|
496
|
+
red: "#ff0000",
|
|
497
|
+
rosybrown: "#bc8f8f",
|
|
498
|
+
royalblue: "#4169e1",
|
|
499
|
+
saddlebrown: "#8b4513",
|
|
500
|
+
salmon: "#fa8072",
|
|
501
|
+
sandybrown: "#f4a460",
|
|
502
|
+
seagreen: "#2e8b57",
|
|
503
|
+
seashell: "#fff5ee",
|
|
504
|
+
sienna: "#a0522d",
|
|
505
|
+
silver: "#c0c0c0",
|
|
506
|
+
skyblue: "#87ceeb",
|
|
507
|
+
slateblue: "#6a5acd",
|
|
508
|
+
slategray: "#708090",
|
|
509
|
+
slategrey: "#708090",
|
|
510
|
+
snow: "#fffafa",
|
|
511
|
+
springgreen: "#00ff7f",
|
|
512
|
+
steelblue: "#4682b4",
|
|
513
|
+
tan: "#d2b48c",
|
|
514
|
+
teal: "#008080",
|
|
515
|
+
thistle: "#d8bfd8",
|
|
516
|
+
tomato: "#ff6347",
|
|
517
|
+
transparent: "transparent",
|
|
518
|
+
turquoise: "#40e0d0",
|
|
519
|
+
violet: "#ee82ee",
|
|
520
|
+
wheat: "#f5deb3",
|
|
521
|
+
white: "#ffffff",
|
|
522
|
+
whitesmoke: "#f5f5f5",
|
|
523
|
+
yellow: "#ffff00",
|
|
524
|
+
yellowgreen: "#9acd32",
|
|
525
|
+
};
|
|
526
|
+
// Stryker restore all
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* True when a canonicalized color is a concrete value we can compare for
|
|
530
|
+
* equality — a resolved `#rrggbb` hex or `transparent`. `var(--x)`/`inherit`/
|
|
531
|
+
* `currentColor` canonicalize to their raw token and are NOT concrete: their
|
|
532
|
+
* effective color depends on the cascade, so a same-color hide can't be proven.
|
|
533
|
+
* @param {string} canonical
|
|
534
|
+
* @returns {boolean}
|
|
535
|
+
*/
|
|
536
|
+
function isConcreteColor(canonical) {
|
|
537
|
+
return canonical === "transparent" || /^#[0-9a-f]{6}$/.test(canonical);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** @param {number} n @returns {string} clamped two-hex-digit byte */
|
|
541
|
+
function hexByte(n) {
|
|
542
|
+
return Math.max(0, Math.min(255, Math.round(n)))
|
|
543
|
+
.toString(16)
|
|
544
|
+
.padStart(2, "0");
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Parse one rgb() channel: an integer/number `0..255` (clamped, as a browser
|
|
549
|
+
* clamps out-of-range) or a percentage `0%..100%` scaled to `0..255`. Returns
|
|
550
|
+
* null (fail open) on any other shape — a `none`/`calc()`/negative channel we
|
|
551
|
+
* cannot resolve to a concrete byte.
|
|
552
|
+
* @param {string} token
|
|
553
|
+
* @returns {number | null}
|
|
554
|
+
*/
|
|
555
|
+
function rgbChannel(token) {
|
|
556
|
+
const pct = token.match(/^\+?(\d*\.?\d+)%$/);
|
|
557
|
+
if (pct) return (Math.min(100, parseFloat(pct[1])) / 100) * 255;
|
|
558
|
+
const num = token.match(/^\+?(\d*\.?\d+)$/);
|
|
559
|
+
if (num) return parseFloat(num[1]);
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Parse an hsl() hue as degrees (a `<number>` or an `<angle>` in
|
|
565
|
+
* deg/grad/rad/turn), normalized to `[0,360)`. Returns null on anything else.
|
|
566
|
+
* @param {string} token
|
|
567
|
+
* @returns {number | null}
|
|
568
|
+
*/
|
|
569
|
+
function hueDegrees(token) {
|
|
570
|
+
const match = token.match(/^([+-]?\d*\.?\d+)(deg|grad|rad|turn)?$/);
|
|
571
|
+
if (!match) return null;
|
|
572
|
+
const value = parseFloat(match[1]);
|
|
573
|
+
const unit = match[2] || "deg";
|
|
574
|
+
const deg =
|
|
575
|
+
unit === "grad"
|
|
576
|
+
? (value * 360) / 400
|
|
577
|
+
: unit === "rad"
|
|
578
|
+
? (value * 180) / Math.PI
|
|
579
|
+
: unit === "turn"
|
|
580
|
+
? value * 360
|
|
581
|
+
: value;
|
|
582
|
+
return ((deg % 360) + 360) % 360;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Parse an hsl() saturation/lightness: a percentage or (CSS Color 4) a bare
|
|
587
|
+
* number, both read as `0..100` (clamped high). Returns null on any other shape.
|
|
588
|
+
* @param {string} token
|
|
589
|
+
* @returns {number | null}
|
|
590
|
+
*/
|
|
591
|
+
function hslPercent(token) {
|
|
592
|
+
const match = token.match(/^\+?(\d*\.?\d+)%?$/);
|
|
593
|
+
return match ? Math.min(100, parseFloat(match[1])) : null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Convert HSL (`h` in degrees, `s`/`l` in `0..100`) to lowercase `#rrggbb`.
|
|
598
|
+
* @param {number} h @param {number} s @param {number} l @returns {string}
|
|
599
|
+
*/
|
|
600
|
+
function hslToHex(h, s, l) {
|
|
601
|
+
const sat = s / 100;
|
|
602
|
+
const light = l / 100;
|
|
603
|
+
const c = (1 - Math.abs(2 * light - 1)) * sat;
|
|
604
|
+
const hp = h / 60;
|
|
605
|
+
const x = c * (1 - Math.abs((hp % 2) - 1));
|
|
606
|
+
const [r, g, b] =
|
|
607
|
+
hp < 1
|
|
608
|
+
? [c, x, 0]
|
|
609
|
+
: hp < 2
|
|
610
|
+
? [x, c, 0]
|
|
611
|
+
: hp < 3
|
|
612
|
+
? [0, c, x]
|
|
613
|
+
: hp < 4
|
|
614
|
+
? [0, x, c]
|
|
615
|
+
: hp < 5
|
|
616
|
+
? [x, 0, c]
|
|
617
|
+
: [c, 0, x];
|
|
618
|
+
const m = light - c / 2;
|
|
619
|
+
return `#${hexByte((r + m) * 255)}${hexByte((g + m) * 255)}${hexByte((b + m) * 255)}`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Resolve an `rgb()/rgba()/hsl()/hsla()` function to `#rrggbb`, or
|
|
624
|
+
* `"transparent"` when its alpha channel is a literal zero (fully transparent
|
|
625
|
+
* text is invisible), or null when any component is unresolvable (fail open).
|
|
626
|
+
* Accepts the legacy comma form and the CSS Color 4 space/`/`-alpha form
|
|
627
|
+
* (`rgb(255 255 255 / 0.5)`, `hsl(0 0% 100%)`) and percentage channels.
|
|
628
|
+
* @param {string} value lowercased, trimmed
|
|
629
|
+
* @returns {string | null}
|
|
630
|
+
*/
|
|
631
|
+
function canonicalizeColorFunction(value) {
|
|
632
|
+
const outer = value.match(/^(rgba?|hsla?)\(([^()]*)\)$/);
|
|
633
|
+
if (!outer) return null;
|
|
634
|
+
const isRgb = outer[1].startsWith("rgb");
|
|
635
|
+
let inner = outer[2].trim();
|
|
636
|
+
// Split the CSS Color 4 `<color> / <alpha>` form; a literal-zero alpha is
|
|
637
|
+
// fully transparent regardless of the color channels.
|
|
638
|
+
const slash = inner.split("/");
|
|
639
|
+
if (slash.length > 2) return null;
|
|
640
|
+
let alpha = slash.length === 2 ? slash[1].trim() : null;
|
|
641
|
+
if (slash.length === 2) inner = slash[0].trim();
|
|
642
|
+
const parts = inner.split(/[\s,]+/).filter(Boolean);
|
|
643
|
+
// The legacy comma form carries alpha as a 4th channel.
|
|
644
|
+
if (alpha === null && parts.length === 4) {
|
|
645
|
+
alpha = parts[3];
|
|
646
|
+
parts.length = 3;
|
|
647
|
+
}
|
|
648
|
+
// A literal-zero alpha is fully transparent — bare number (`0`, `0.0`) or the
|
|
649
|
+
// CSS Color 4 percentage form (`0%`), which a browser also renders invisible.
|
|
650
|
+
if (alpha !== null && /^\+?0*\.?0+%?$/.test(alpha)) return "transparent";
|
|
651
|
+
if (parts.length !== 3) return null;
|
|
652
|
+
if (isRgb) {
|
|
653
|
+
const channels = parts.map(rgbChannel);
|
|
654
|
+
if (channels.some((c) => c === null)) return null;
|
|
655
|
+
return `#${channels.map((c) => hexByte(/** @type {number} */ (c))).join("")}`;
|
|
656
|
+
}
|
|
657
|
+
const h = hueDegrees(parts[0]);
|
|
658
|
+
const s = hslPercent(parts[1]);
|
|
659
|
+
const l = hslPercent(parts[2]);
|
|
660
|
+
if (h === null || s === null || l === null) return null;
|
|
661
|
+
return hslToHex(h, s, l);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Canonicalize a CSS color to lowercase `#rrggbb` so `white`, `#FFF`,
|
|
666
|
+
* `#ffffff`, `rgb(255, 255, 255)`, `rgb(255 255 255)`, `rgb(100% 100% 100%)`,
|
|
667
|
+
* and `hsl(0 0% 100%)` all compare equal. Returns the trimmed lowercased input
|
|
668
|
+
* unchanged when it is not a form we recognize; callers gate the same-color
|
|
669
|
+
* compare on isConcreteColor so an unresolved token (`var()`, `inherit`) never
|
|
670
|
+
* falsely reads as a same-color hide.
|
|
671
|
+
* @param {string} raw
|
|
672
|
+
* @returns {string}
|
|
673
|
+
*/
|
|
674
|
+
function canonicalizeColor(raw) {
|
|
675
|
+
const value = raw.trim().toLowerCase();
|
|
676
|
+
if (!value) return "";
|
|
677
|
+
// Own-key only: `in` would match inherited members, so a CSS value of
|
|
678
|
+
// `__proto__`/`constructor`/`toString` returns an object or function here
|
|
679
|
+
// (poisoning isHiddenStyle's return) instead of falling through as a plain
|
|
680
|
+
// string.
|
|
681
|
+
if (Object.hasOwn(NAMED_COLORS, value)) return NAMED_COLORS[value];
|
|
682
|
+
const shortHex = value.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
|
|
683
|
+
if (shortHex)
|
|
684
|
+
return `#${shortHex[1]}${shortHex[1]}${shortHex[2]}${shortHex[2]}${shortHex[3]}${shortHex[3]}`;
|
|
685
|
+
if (/^#[0-9a-f]{6}$/.test(value)) return value;
|
|
686
|
+
return canonicalizeColorFunction(value) ?? value;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// The leading color token of a `background` shorthand (the first token that
|
|
690
|
+
// canonicalizes to a real color), so `background:#fff` still compares. Returns
|
|
691
|
+
// "" (fail open, no same-color hide) when the shorthand carries an IMAGE layer
|
|
692
|
+
// — `url(...)`, a gradient, or `image-set(...)`: the painted image can make
|
|
693
|
+
// same-colored text perfectly readable over it (and if it fails to load the
|
|
694
|
+
// element's own background shows through), so the flat color token is not
|
|
695
|
+
// provably the rendered backdrop.
|
|
696
|
+
/** @param {string} shorthand @returns {string} */
|
|
697
|
+
function backgroundColor(shorthand) {
|
|
698
|
+
if (/\burl\(|gradient\(|image-set\(/i.test(shorthand)) return "";
|
|
699
|
+
for (const token of shorthand.split(/\s+/)) {
|
|
700
|
+
const color = canonicalizeColor(token);
|
|
701
|
+
if (color && (color.startsWith("#") || color === "transparent"))
|
|
702
|
+
return color;
|
|
703
|
+
}
|
|
704
|
+
return "";
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// One resolved `inset()` edge collapses the box only when it is a percentage of
|
|
708
|
+
// at least 50%: opposing edges (top/bottom, left/right) then sum to >=100% and
|
|
709
|
+
// leave zero area. A length (`inset(200px)`), `calc()`, or `0` cannot be proven
|
|
710
|
+
// to collapse without the box size, so it fails open (not collapsing).
|
|
711
|
+
/** @param {any} edge value token @returns {boolean} */
|
|
712
|
+
function isCollapsingInsetEdge(edge) {
|
|
713
|
+
return edge.type === "Percentage" && parseFloat(edge.value) >= 50;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Expand an `inset()`'s 1–4 edge tokens to `[top, right, bottom, left]` using
|
|
717
|
+
// the CSS margin-style shorthand rules.
|
|
718
|
+
/** @param {any[]} parts @returns {any[]} */
|
|
719
|
+
function expandInsetEdges(parts) {
|
|
720
|
+
const [t, r = t, b = t, l = r] = parts;
|
|
721
|
+
return [t, r, b, l];
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// The edge tokens of an `inset()`, stopping at a `round <border-radius>` suffix.
|
|
725
|
+
/** @param {any} fn `inset` Function node @returns {any[]} */
|
|
726
|
+
function insetEdges(fn) {
|
|
727
|
+
/** @type {any[]} */
|
|
728
|
+
const edges = [];
|
|
729
|
+
for (const token of valueTokens(fn)) {
|
|
730
|
+
if (token.type === "Identifier" && token.name.toLowerCase() === "round")
|
|
731
|
+
break;
|
|
732
|
+
edges.push(token);
|
|
733
|
+
}
|
|
734
|
+
return edges;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* True when a `clip-path` clips the element to nothing: `circle(0)` (zero
|
|
739
|
+
* radius, in any unit), or an `inset()` whose FOUR resolved edges ALL collapse
|
|
740
|
+
* (each a percentage >=50%). A partial inset that leaves any edge open
|
|
741
|
+
* (`inset(50% 0 0 0)` — bottom half visible) is NOT hidden: inspecting only the
|
|
742
|
+
* first value would over-splice it. Decorative clips (`circle(50%)`, small
|
|
743
|
+
* insets, polygons) render content and are left alone.
|
|
744
|
+
* @param {any} node value node for `clip-path`
|
|
745
|
+
* @returns {boolean}
|
|
746
|
+
*/
|
|
747
|
+
function isClipPathHidden(node) {
|
|
748
|
+
if (!node) return false;
|
|
749
|
+
for (const fn of valueTokens(node)) {
|
|
750
|
+
if (fn.type !== "Function") continue;
|
|
751
|
+
const name = fn.name.toLowerCase();
|
|
752
|
+
if (name === "circle") {
|
|
753
|
+
const radius = valueTokens(fn)[0];
|
|
754
|
+
if (
|
|
755
|
+
radius &&
|
|
756
|
+
(radius.type === "Number" ||
|
|
757
|
+
radius.type === "Dimension" ||
|
|
758
|
+
radius.type === "Percentage") &&
|
|
759
|
+
parseFloat(radius.value) === 0
|
|
760
|
+
)
|
|
761
|
+
return true;
|
|
762
|
+
} else if (name === "inset") {
|
|
763
|
+
const edges = insetEdges(fn);
|
|
764
|
+
if (
|
|
765
|
+
edges.length >= 1 &&
|
|
766
|
+
edges.length <= 4 &&
|
|
767
|
+
expandInsetEdges(edges).every(isCollapsingInsetEdge)
|
|
768
|
+
)
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// The color painted by `-webkit-text-stroke` (the `<color>` token of the
|
|
776
|
+
// shorthand, or the `-webkit-text-stroke-color` longhand), canonicalized — or
|
|
777
|
+
// "" when it does not resolve to a concrete color. The longhand is a whole
|
|
778
|
+
// color value so canonicalizeColor handles it directly; the shorthand is
|
|
779
|
+
// `<line-width> || <color>`, so the width token (a length) is skipped and the
|
|
780
|
+
// remaining color token canonicalized.
|
|
781
|
+
/** @param {(key: string) => string} val @returns {string} */
|
|
782
|
+
function textStrokeColor(val) {
|
|
783
|
+
const longhand = canonicalizeColor(val("-webkit-text-stroke-color"));
|
|
784
|
+
if (isConcreteColor(longhand)) return longhand;
|
|
785
|
+
for (const token of val("-webkit-text-stroke").split(/\s+/).filter(Boolean)) {
|
|
786
|
+
const color = canonicalizeColor(token);
|
|
787
|
+
if (isConcreteColor(color)) return color;
|
|
788
|
+
}
|
|
789
|
+
return "";
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Gradient-clipped / outlined headings are VISIBLE despite an effectively
|
|
793
|
+
// transparent fill: `background-clip:text` (or its `-webkit-` alias) paints the
|
|
794
|
+
// background through the glyph shapes, and `-webkit-text-stroke` paints a visible
|
|
795
|
+
// outline around the (transparent-filled) glyphs. Either means the transparent
|
|
796
|
+
// paint is not the whole story, so the same-`transparent` hide must fail open.
|
|
797
|
+
// A concrete `-webkit-text-fill-color` is NOT checked here: the caller resolves
|
|
798
|
+
// the EFFECTIVE fill (fill override ?? color) before this runs, so a concrete
|
|
799
|
+
// fill already keeps `effectiveColor` non-transparent and never reaches here.
|
|
800
|
+
/** @param {(key: string) => string} val @returns {boolean} */
|
|
801
|
+
function isTextPaintedVisible(val) {
|
|
802
|
+
if (
|
|
803
|
+
val("background-clip") === "text" ||
|
|
804
|
+
val("-webkit-background-clip") === "text"
|
|
805
|
+
)
|
|
806
|
+
return true;
|
|
807
|
+
const stroke = textStrokeColor(val);
|
|
808
|
+
return isConcreteColor(stroke) && stroke !== "transparent";
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// True when the element paints a background IMAGE layer — a `background-image`
|
|
812
|
+
// longhand set to anything but `none`, or a `background` shorthand carrying
|
|
813
|
+
// `url(...)`, a gradient, or `image-set(...)`. A same-color text/background hide
|
|
814
|
+
// CANNOT be proven when an image layer is present: the painted image can make
|
|
815
|
+
// same-colored text readable, and if it fails to load the element's own
|
|
816
|
+
// background shows through. Centralized so EVERY hide branch consults one
|
|
817
|
+
// image-layer check — the `background` shorthand path already failed open via
|
|
818
|
+
// {@link backgroundColor}, but the `background-color` longhand path inspected
|
|
819
|
+
// only the flat color and missed a co-declared `background-image`, splicing
|
|
820
|
+
// visible text. `background-clip:text` is NOT an image layer here — it paints
|
|
821
|
+
// the background THROUGH the glyphs and is handled by {@link isTextPaintedVisible}.
|
|
822
|
+
/** @param {(key: string) => string} textOf @returns {boolean} */
|
|
823
|
+
function hasImageLayer(textOf) {
|
|
824
|
+
const img = textOf("background-image");
|
|
825
|
+
if (img && img !== "none") return true;
|
|
826
|
+
return /\burl\(|gradient\(|image-set\(/i.test(textOf("background"));
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* @param {(key: string) => any} nodeOf value node for a property, or null
|
|
831
|
+
* @param {(key: string) => string} textOf decoded/lowercased text for a property
|
|
832
|
+
* @returns {boolean}
|
|
833
|
+
*/
|
|
834
|
+
function isOverflowHidden(nodeOf, textOf) {
|
|
835
|
+
if (textOf("overflow") !== "hidden") return false;
|
|
836
|
+
for (const dim of ["height", "width", "max-height", "max-width"])
|
|
837
|
+
// Near-zero (epsilon band), not exact 0, so `height:0.0001px` still counts —
|
|
838
|
+
// matching the standalone size checks a browser renders as invisible.
|
|
839
|
+
if (isNearZeroLength(nodeOf(dim))) return true;
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// The length units that denote a font-size in a `font` SHORTHAND. Only a length
|
|
844
|
+
// with one of these units is a size (a bare number there is a weight, a `%` a
|
|
845
|
+
// stretch), so those never misread as a zero size. `q` (quarter-mm) is a font
|
|
846
|
+
// length unit too, though it never gates an offset above.
|
|
847
|
+
const FONT_SIZE_UNITS = new Set([...ABSOLUTE_UNITS, "q"]);
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* True when a `font` SHORTHAND's font-size collapses the text to nothing. The
|
|
851
|
+
* font-size is the FIRST length token in the shorthand (it precedes an optional
|
|
852
|
+
* `/line-height` and the family); a near-zero one hides the text just like the
|
|
853
|
+
* `font-size` longhand.
|
|
854
|
+
* @param {any} node value node for `font`
|
|
855
|
+
* @returns {boolean}
|
|
856
|
+
*/
|
|
857
|
+
function isFontShorthandHidden(node) {
|
|
858
|
+
if (!node) return false;
|
|
859
|
+
for (const token of valueTokens(node))
|
|
860
|
+
if (token.type === "Dimension" && FONT_SIZE_UNITS.has(token.unit))
|
|
861
|
+
return Math.abs(parseFloat(token.value)) < NEAR_ZERO_EPSILON;
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// A CSS property-name ident AFTER escape decoding: up to two leading hyphens
|
|
866
|
+
// (vendor prefix / custom property), then a letter or underscore, then letters,
|
|
867
|
+
// digits, hyphens, or underscores. css-tree will accept an escaped ident as a
|
|
868
|
+
// declaration property (e.g. `\3a` decoding to `:`); this gate rejects anything
|
|
869
|
+
// a real browser's ident tokenizer would reject so a decoded non-ident property
|
|
870
|
+
// never drives a hidden verdict.
|
|
871
|
+
const CSS_PROPERTY_IDENT_RE = /^-{0,2}[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Reconstruct a declaration's decoded value as a string for keyword/color
|
|
875
|
+
* comparisons. Identifier tokens are escape-decoded through css-tree's ident
|
|
876
|
+
* decoder (so `no\6e e`/`hi\64 den` read as `none`/`hidden`, with FF/CR/CRLF
|
|
877
|
+
* terminators and invalid codepoints handled per the CSS spec); every other
|
|
878
|
+
* token is re-serialized. A whole-value `Raw` (an unparsed value) is returned
|
|
879
|
+
* verbatim — it never matches a hiding keyword, so it fails open.
|
|
880
|
+
* @param {any} valueNode
|
|
881
|
+
* @returns {string}
|
|
882
|
+
*/
|
|
883
|
+
function declText(valueNode) {
|
|
884
|
+
if (!valueNode) return "";
|
|
885
|
+
if (valueNode.type === "Raw") return valueNode.value;
|
|
886
|
+
/** @type {string[]} */
|
|
887
|
+
const parts = [];
|
|
888
|
+
if (valueNode.children)
|
|
889
|
+
valueNode.children.forEach((/** @type {any} */ child) =>
|
|
890
|
+
parts.push(
|
|
891
|
+
child.type === "Identifier"
|
|
892
|
+
? csstree.ident.decode(child.name)
|
|
893
|
+
: csstree.generate(child),
|
|
894
|
+
),
|
|
895
|
+
);
|
|
896
|
+
return parts.join(" ");
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Parse a style string into a map of decoded lowercase property name -> parsed
|
|
901
|
+
* value node, via css-tree's tolerant declaration-list parser. This replaces the
|
|
902
|
+
* hand-rolled declaration splitter, per-declaration salvage, escape decoder, and
|
|
903
|
+
* `!important` stripper in one pass: css-tree recovers per-declaration exactly
|
|
904
|
+
* as a browser does (a bogus declaration is dropped, the rest kept), keeps a `;`
|
|
905
|
+
* inside a string/`url()`/paren as part of the value, and exposes `!important`
|
|
906
|
+
* as `node.important` (so an escaped spelling `none!\69mportant` is stripped for
|
|
907
|
+
* free). Property names are escape-decoded and gated to real CSS idents;
|
|
908
|
+
* anything else is dropped (fail open). Later declarations win, per the cascade.
|
|
909
|
+
* @param {string} styleStr
|
|
910
|
+
* @returns {Map<string, any>}
|
|
911
|
+
*/
|
|
912
|
+
function parseDeclarations(styleStr) {
|
|
913
|
+
/** @type {Map<string, any>} */
|
|
914
|
+
const decls = new Map();
|
|
915
|
+
let ast;
|
|
916
|
+
try {
|
|
917
|
+
ast = csstree.parse(styleStr, {
|
|
918
|
+
context: "declarationList",
|
|
919
|
+
parseValue: true,
|
|
920
|
+
parseCustomProperty: false,
|
|
921
|
+
onParseError() {},
|
|
922
|
+
});
|
|
923
|
+
/* c8 ignore start -- the only reachable throw path is a non-string / deeply
|
|
924
|
+
pathological input (css-tree's onParseError recovers ordinary bad CSS);
|
|
925
|
+
this fail-open upholds the module's never-throws contract for those. */
|
|
926
|
+
} catch {
|
|
927
|
+
return decls;
|
|
928
|
+
}
|
|
929
|
+
/* c8 ignore stop */
|
|
930
|
+
csstree.walk(ast, {
|
|
931
|
+
visit: "Declaration",
|
|
932
|
+
enter(/** @type {any} */ node) {
|
|
933
|
+
// ident.decode is pure string iteration and cannot throw on a real ident
|
|
934
|
+
// token; property is escape-decoded then gated to a clean CSS ident.
|
|
935
|
+
const property = csstree.ident.decode(node.property).trim().toLowerCase();
|
|
936
|
+
if (!CSS_PROPERTY_IDENT_RE.test(property)) return;
|
|
937
|
+
decls.set(property, node.value);
|
|
938
|
+
},
|
|
939
|
+
});
|
|
940
|
+
return decls;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* @param {string} styleStr
|
|
945
|
+
* @returns {boolean}
|
|
946
|
+
*/
|
|
947
|
+
export function isHiddenStyle(styleStr) {
|
|
948
|
+
const decls = parseDeclarations(styleStr);
|
|
949
|
+
if (decls.size === 0) return false;
|
|
950
|
+
|
|
951
|
+
/** @param {string} key */
|
|
952
|
+
const nodeOf = (key) => decls.get(key) ?? null;
|
|
953
|
+
// `!important` is already excluded by css-tree; escapes are decoded in
|
|
954
|
+
// declText. Trim/lowercase for the case-insensitive keyword compares.
|
|
955
|
+
/** @param {string} key */
|
|
956
|
+
const textOf = (key) => declText(decls.get(key)).trim().toLowerCase();
|
|
957
|
+
|
|
958
|
+
if (textOf("display") === "none") return true;
|
|
959
|
+
if (textOf("visibility") === "hidden" || textOf("visibility") === "collapse")
|
|
960
|
+
return true;
|
|
961
|
+
// `content-visibility:hidden` skips rendering the element's contents entirely
|
|
962
|
+
// (not even laid out), so the text is invisible to a human but present in the
|
|
963
|
+
// source. `auto`/`visible` keep it rendered and must not match.
|
|
964
|
+
if (textOf("content-visibility") === "hidden") return true;
|
|
965
|
+
|
|
966
|
+
// CSS clamps opacity to [0,1], so any NEGATIVE value renders fully
|
|
967
|
+
// transparent — `< EPSILON` (no `Math.abs`) treats `-1`/`-0.5` as hidden.
|
|
968
|
+
// `opacity` is a <number> or <percentage>; any other token (`0px`, a bare
|
|
969
|
+
// Dimension) is an INVALID declaration a browser ignores (element stays
|
|
970
|
+
// visible), so fail open on anything that isn't a single Number/Percentage.
|
|
971
|
+
const opacity = soleToken(nodeOf("opacity"));
|
|
972
|
+
if (opacity) {
|
|
973
|
+
let fraction = null;
|
|
974
|
+
if (opacity.type === "Number") fraction = parseFloat(opacity.value);
|
|
975
|
+
else if (opacity.type === "Percentage")
|
|
976
|
+
fraction = parseFloat(opacity.value) / 100;
|
|
977
|
+
if (fraction !== null && fraction < NEAR_ZERO_EPSILON) return true;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// `height`/`width` are deliberately NOT tested standalone here: with the
|
|
981
|
+
// default `overflow:visible`, a zero-sized box still paints its overflowing
|
|
982
|
+
// children, so a bare `width:0`/`height:0` leaves content on screen.
|
|
983
|
+
// `isOverflowHidden` below already covers the case where a zero dimension
|
|
984
|
+
// DOES hide content — gated on `overflow:hidden` also being present.
|
|
985
|
+
// `font-size:0`, in contrast, reliably collapses text to nothing on its own.
|
|
986
|
+
if (isNearZeroLength(nodeOf("font-size"))) return true;
|
|
987
|
+
// The `font` shorthand also carries the font-size, so a `font:0px/1 serif`
|
|
988
|
+
// collapses text just like the longhand — check its size token too.
|
|
989
|
+
if (isFontShorthandHidden(nodeOf("font"))) return true;
|
|
990
|
+
|
|
991
|
+
if (isPositionedOffscreen(nodeOf, textOf)) return true;
|
|
992
|
+
|
|
993
|
+
if (isOffscreenOffset(nodeOf("text-indent"))) return true;
|
|
994
|
+
|
|
995
|
+
// Clipped to nothing: the modern equivalent of the legacy `clip: rect(0…)`.
|
|
996
|
+
if (isClipPathHidden(nodeOf("clip-path"))) return true;
|
|
997
|
+
if (isHidingTransform(nodeOf("transform"))) return true;
|
|
998
|
+
if (isHidingFilter(nodeOf("filter"))) return true;
|
|
999
|
+
|
|
1000
|
+
// Same-color text on its background (white-on-white) and fully transparent
|
|
1001
|
+
// text are invisible to a human but plain text to the model. Colors are
|
|
1002
|
+
// canonicalized so `white`/`#fff`/`rgb(255,255,255)` mixes still compare.
|
|
1003
|
+
// The color actually PAINTED onto the glyphs: `-webkit-text-fill-color`
|
|
1004
|
+
// overrides `color` for the fill when it is concrete, so both hide branches
|
|
1005
|
+
// must reason about this effective fill, not the raw `color` property — else a
|
|
1006
|
+
// `color:#fff;-webkit-text-fill-color:#000` element (black text) is compared as
|
|
1007
|
+
// white and spliced white-on-white.
|
|
1008
|
+
const color = canonicalizeColor(textOf("color"));
|
|
1009
|
+
const fillOverride = canonicalizeColor(textOf("-webkit-text-fill-color"));
|
|
1010
|
+
const effectiveColor = isConcreteColor(fillOverride) ? fillOverride : color;
|
|
1011
|
+
// `color:transparent` (or a transparent fill override) hides text — UNLESS the
|
|
1012
|
+
// glyphs are painted by a background-clip:text gradient, a concrete
|
|
1013
|
+
// -webkit-text-fill-color, or a text stroke, in which case the text is visible.
|
|
1014
|
+
if (effectiveColor === "transparent" && !isTextPaintedVisible(textOf))
|
|
1015
|
+
return true;
|
|
1016
|
+
const background =
|
|
1017
|
+
canonicalizeColor(textOf("background-color")) ||
|
|
1018
|
+
backgroundColor(textOf("background"));
|
|
1019
|
+
// Only flag same-color when BOTH sides resolve to a concrete color (`#rrggbb`
|
|
1020
|
+
// or `transparent`), AND no background IMAGE layer is present (an image can
|
|
1021
|
+
// make same-colored text readable). `var(--x)`, `inherit`, and `currentColor`
|
|
1022
|
+
// canonicalize to their raw token, so two identical unresolved tokens (e.g. the
|
|
1023
|
+
// ubiquitous `color:var(--fg);background:var(--fg)`, which resolve to DIFFERENT
|
|
1024
|
+
// effective colors) would otherwise read as hidden and splice out visible text.
|
|
1025
|
+
// Fail open on anything we can't resolve.
|
|
1026
|
+
if (
|
|
1027
|
+
effectiveColor &&
|
|
1028
|
+
effectiveColor === background &&
|
|
1029
|
+
isConcreteColor(effectiveColor) &&
|
|
1030
|
+
!hasImageLayer(textOf)
|
|
1031
|
+
)
|
|
1032
|
+
return true;
|
|
1033
|
+
|
|
1034
|
+
return isOverflowHidden(nodeOf, textOf);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Scripting / resource-loading tags whose PRESENCE is reported to the model
|
|
1038
|
+
// but whose content is preserved: their bodies are page source the model may
|
|
1039
|
+
// legitimately need to inspect (how a page's scripts work, its styles, its
|
|
1040
|
+
// SVGs), so unlike hidden elements they are never removed.
|
|
1041
|
+
export const REPORTED_TAGS = new Set([
|
|
1042
|
+
"script",
|
|
1043
|
+
"style",
|
|
1044
|
+
"object",
|
|
1045
|
+
"embed",
|
|
1046
|
+
"iframe",
|
|
1047
|
+
"svg",
|
|
1048
|
+
"math",
|
|
1049
|
+
]);
|
|
1050
|
+
|
|
1051
|
+
// HTML void elements: they never carry content and never emit a closing tag, so
|
|
1052
|
+
// a hidden one (<img hidden>, <input hidden>, …) must be spliced as a single node
|
|
1053
|
+
// — opening a balance region for it would run to the container's end (no close
|
|
1054
|
+
// ever arrives) and delete the visible text that follows.
|
|
1055
|
+
const VOID_ELEMENTS = new Set([
|
|
1056
|
+
"area",
|
|
1057
|
+
"base",
|
|
1058
|
+
"br",
|
|
1059
|
+
"col",
|
|
1060
|
+
"embed",
|
|
1061
|
+
"hr",
|
|
1062
|
+
"img",
|
|
1063
|
+
"input",
|
|
1064
|
+
"link",
|
|
1065
|
+
"meta",
|
|
1066
|
+
"param",
|
|
1067
|
+
"source",
|
|
1068
|
+
"track",
|
|
1069
|
+
"wbr",
|
|
1070
|
+
]);
|
|
1071
|
+
|
|
1072
|
+
// Elements whose content is RAW TEXT / RCDATA / script data: parse5 recognizes
|
|
1073
|
+
// NO markup inside them (a `<!…` is not a comment, a `<b>` is not a tag) until
|
|
1074
|
+
// the matching end tag. The per-tag balance walk must model this or it would
|
|
1075
|
+
// splice a `<!…>` inside `<style>`/`<script>` as a bogus comment — mangling
|
|
1076
|
+
// source these tags are meant to preserve verbatim (and diverging from the
|
|
1077
|
+
// flow/source branch, which parse5 handles correctly). `noscript` is omitted:
|
|
1078
|
+
// under fragment parsing (scripting disabled) parse5 parses its content as
|
|
1079
|
+
// normal markup, so scanning it is correct. Once `plaintext` opens it runs to
|
|
1080
|
+
// EOF and never closes.
|
|
1081
|
+
const RAW_TEXT_ELEMENTS = new Set([
|
|
1082
|
+
"script",
|
|
1083
|
+
"style",
|
|
1084
|
+
"textarea",
|
|
1085
|
+
"title",
|
|
1086
|
+
"xmp",
|
|
1087
|
+
"iframe",
|
|
1088
|
+
"noembed",
|
|
1089
|
+
"noframes",
|
|
1090
|
+
"plaintext",
|
|
1091
|
+
]);
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* True for an element a rendered page would not show: `hidden` attribute or a
|
|
1095
|
+
* hiding inline style. Works on both hast nodes and parseHtmlTag results.
|
|
1096
|
+
* @param {any} node
|
|
1097
|
+
* @returns {boolean}
|
|
1098
|
+
*/
|
|
1099
|
+
export function isHiddenElement(node) {
|
|
1100
|
+
if (node.type !== "element") return false;
|
|
1101
|
+
const { properties = {} } = node;
|
|
1102
|
+
if (properties.hidden !== undefined && properties.hidden !== null)
|
|
1103
|
+
return true;
|
|
1104
|
+
// `aria-hidden="true"` is deliberately NOT treated as a hiding signal: it
|
|
1105
|
+
// removes the element only from the ACCESSIBILITY TREE, not the rendered
|
|
1106
|
+
// page — a sighted human viewing the page still sees it (it's routinely
|
|
1107
|
+
// used on decorative icons and icon-font glyphs, and on visible text
|
|
1108
|
+
// duplicated for screen-reader dedup). Splicing on it would delete content
|
|
1109
|
+
// a human plainly sees, which is real harm under the precision-over-recall
|
|
1110
|
+
// doctrine for this layer.
|
|
1111
|
+
if (properties.style && isHiddenStyle(properties.style)) return true;
|
|
1112
|
+
return false;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** @param {any} el */
|
|
1116
|
+
function hasDataSrc(el) {
|
|
1117
|
+
return (
|
|
1118
|
+
typeof el.properties?.src === "string" &&
|
|
1119
|
+
el.properties.src.startsWith("data:")
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* @param {string} htmlValue
|
|
1125
|
+
* @returns {any}
|
|
1126
|
+
*/
|
|
1127
|
+
function parseHtmlTag(htmlValue) {
|
|
1128
|
+
const tree = unified().use(rehypeParse, { fragment: true }).parse(htmlValue);
|
|
1129
|
+
/** @type {any} */
|
|
1130
|
+
let firstElement = null;
|
|
1131
|
+
visit(tree, "element", (node) => {
|
|
1132
|
+
firstElement = node;
|
|
1133
|
+
return EXIT;
|
|
1134
|
+
});
|
|
1135
|
+
return firstElement;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Returns null on a closing tag: `</x>` alone can never be the *start* of a
|
|
1139
|
+
// hidden element, so only opens drive the surrounding loop's removal mode.
|
|
1140
|
+
/**
|
|
1141
|
+
* @param {string} htmlValue
|
|
1142
|
+
* @returns {string | null}
|
|
1143
|
+
*/
|
|
1144
|
+
export function isHiddenOpen(htmlValue) {
|
|
1145
|
+
if (htmlValue.startsWith("</")) return null;
|
|
1146
|
+
const el = parseHtmlTag(htmlValue);
|
|
1147
|
+
if (!el) return null;
|
|
1148
|
+
if (isHiddenElement(el)) return el.tagName;
|
|
1149
|
+
return null;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// The lowercased name of an HTML closing tag (`</div>` -> "div"), or null when
|
|
1153
|
+
// the value isn't a well-formed closing tag. The charset spans HTML custom-
|
|
1154
|
+
// element and namespaced names (hyphens, dots, colons) so a close like
|
|
1155
|
+
// `</foo-bar>` balances its matching open instead of throwing on a null match;
|
|
1156
|
+
// callers treat null as "not the tag we're closing" and strip it as part of the
|
|
1157
|
+
// surrounding removal region.
|
|
1158
|
+
/**
|
|
1159
|
+
* @param {string} htmlValue
|
|
1160
|
+
* @returns {string | null}
|
|
1161
|
+
*/
|
|
1162
|
+
export function closingTagName(htmlValue) {
|
|
1163
|
+
// The charset is a superset of CommonMark's closing-tag grammar, so remark
|
|
1164
|
+
// never emits a `</…>` html node this fails to match; the null guard below is
|
|
1165
|
+
// defense-in-depth against a future parser/grammar change (hence unreachable).
|
|
1166
|
+
const match = htmlValue.match(/^<\/(?<tagName>[a-zA-Z][a-zA-Z0-9:._-]*)\s*>/);
|
|
1167
|
+
/* c8 ignore next */
|
|
1168
|
+
if (!match?.groups) return null;
|
|
1169
|
+
return match.groups.tagName.toLowerCase();
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// ─── Layer 2: splice engine ──────────────────────────────────────────────────
|
|
1173
|
+
|
|
1174
|
+
export const COMMENT_PLACEHOLDER = "[HTML comment removed]";
|
|
1175
|
+
export const HIDDEN_PLACEHOLDER = "[hidden HTML removed]";
|
|
1176
|
+
// Shown when the remark/rehype parse itself fails (e.g. pathologically nested
|
|
1177
|
+
// markup overflows the recursive tree walk with a RangeError). The top-level
|
|
1178
|
+
// `sanitize`/`sanitizeText` contract is "never throws, `cleaned` is always a
|
|
1179
|
+
// string", and this module is the only seam those callers own — so the HTML
|
|
1180
|
+
// layer must fail CLOSED here: withhold the whole unparseable input behind one
|
|
1181
|
+
// placeholder rather than let the exception escape and suppress all tool
|
|
1182
|
+
// output. Withholding (not passing through) is the safe choice — content we
|
|
1183
|
+
// could not inspect for hidden payloads is treated as if it were hidden.
|
|
1184
|
+
export const UNPARSEABLE_PLACEHOLDER = "[HTML unparseable — withheld]";
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Replace each range of `text` with its kind's placeholder, preserving every
|
|
1188
|
+
* byte outside the ranges verbatim. Overlapping/nested ranges are merged
|
|
1189
|
+
* (defense-in-depth — the scanners emit disjoint ranges).
|
|
1190
|
+
* @param {string} text
|
|
1191
|
+
* @param {Array<{start: number, end: number, kind: "comment" | "hidden"}>} ranges
|
|
1192
|
+
* @returns {string}
|
|
1193
|
+
*/
|
|
1194
|
+
export function spliceRanges(text, ranges) {
|
|
1195
|
+
const sorted = [...ranges].sort(
|
|
1196
|
+
(left, right) => left.start - right.start || left.end - right.end,
|
|
1197
|
+
);
|
|
1198
|
+
/** @type {typeof ranges} */
|
|
1199
|
+
const merged = [];
|
|
1200
|
+
for (const range of sorted) {
|
|
1201
|
+
const last = merged[merged.length - 1];
|
|
1202
|
+
if (last && range.start < last.end) {
|
|
1203
|
+
if (range.end > last.end) last.end = range.end;
|
|
1204
|
+
// A hidden range absorbed into a comment range (the comment sorts first
|
|
1205
|
+
// on a tie) must keep the hidden label — hidden content placeholdered as
|
|
1206
|
+
// "[HTML comment removed]" would understate what was stripped. Hidden
|
|
1207
|
+
// dominates: if either side is hidden, the union is hidden.
|
|
1208
|
+
if (range.kind === "hidden") last.kind = "hidden";
|
|
1209
|
+
} else {
|
|
1210
|
+
merged.push({ ...range });
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
let out = "";
|
|
1214
|
+
let cursor = 0;
|
|
1215
|
+
for (const range of merged) {
|
|
1216
|
+
out +=
|
|
1217
|
+
text.slice(cursor, range.start) +
|
|
1218
|
+
(range.kind === "comment" ? COMMENT_PLACEHOLDER : HIDDEN_PLACEHOLDER);
|
|
1219
|
+
cursor = range.end;
|
|
1220
|
+
}
|
|
1221
|
+
return out + text.slice(cursor);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/** @returns {{ tags: Record<string, number>, dataSrc: number }} */
|
|
1225
|
+
function newWarned() {
|
|
1226
|
+
return { tags: {}, dataSrc: 0 };
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* @param {ReturnType<typeof newWarned>} warned
|
|
1231
|
+
* @param {string} tagName
|
|
1232
|
+
*/
|
|
1233
|
+
function countTag(warned, tagName) {
|
|
1234
|
+
warned.tags[tagName] = (warned.tags[tagName] || 0) + 1;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* @param {ReturnType<typeof newWarned>} into
|
|
1239
|
+
* @param {ReturnType<typeof newWarned>} from
|
|
1240
|
+
*/
|
|
1241
|
+
function mergeWarned(into, from) {
|
|
1242
|
+
for (const [tag, count] of Object.entries(from.tags))
|
|
1243
|
+
into.tags[tag] = (into.tags[tag] || 0) + count;
|
|
1244
|
+
into.dataSrc += from.dataSrc;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** @param {ReturnType<typeof newWarned>} warned */
|
|
1248
|
+
function hasWarned(warned) {
|
|
1249
|
+
return warned.dataSrc > 0 || Object.keys(warned.tags).length > 0;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Scan raw HTML for hidden content to strip and preserved tags to report.
|
|
1254
|
+
* Returned ranges are offsets into `html`; comments and hidden elements span
|
|
1255
|
+
* the whole element including its content (rehype positions cover open tag
|
|
1256
|
+
* through matching close, and parse5 extends an unclosed element to the end
|
|
1257
|
+
* of the fragment — fail-closed for truncated markup).
|
|
1258
|
+
* @param {string} html
|
|
1259
|
+
* @returns {{ ranges: Array<{start: number, end: number, kind: "comment" | "hidden"}>, warned: ReturnType<typeof newWarned> }}
|
|
1260
|
+
*/
|
|
1261
|
+
export function scanHtmlFragment(html) {
|
|
1262
|
+
const tree = unified().use(rehypeParse, { fragment: true }).parse(html);
|
|
1263
|
+
/** @type {Array<{start: number, end: number, kind: "comment" | "hidden"}>} */
|
|
1264
|
+
const ranges = [];
|
|
1265
|
+
const warned = newWarned();
|
|
1266
|
+
// @ts-ignore -- visit callback returns EXIT/SKIP only on matches; implicit undefined return is intentional
|
|
1267
|
+
// eslint-disable-next-line consistent-return
|
|
1268
|
+
visit(tree, (/** @type {any} */ node) => {
|
|
1269
|
+
const isComment = node.type === "comment";
|
|
1270
|
+
if (isComment || isHiddenElement(node)) {
|
|
1271
|
+
/* c8 ignore start -- parse5 omits positions only on recovery-synthesized
|
|
1272
|
+
elements (tbody and friends), which carry no attributes and so can
|
|
1273
|
+
never be hidden; fail closed on the whole fragment if that assumption
|
|
1274
|
+
ever breaks. */
|
|
1275
|
+
if (!node.position) {
|
|
1276
|
+
ranges.length = 0;
|
|
1277
|
+
ranges.push({ start: 0, end: html.length, kind: "hidden" });
|
|
1278
|
+
return EXIT;
|
|
1279
|
+
}
|
|
1280
|
+
/* c8 ignore stop */
|
|
1281
|
+
ranges.push({
|
|
1282
|
+
start: node.position.start.offset,
|
|
1283
|
+
end: node.position.end.offset,
|
|
1284
|
+
kind: isComment ? "comment" : "hidden",
|
|
1285
|
+
});
|
|
1286
|
+
return SKIP; // children are inside the spliced range
|
|
1287
|
+
}
|
|
1288
|
+
if (node.type !== "element") return; // eslint-disable-line consistent-return -- unist visit: undefined return means "continue", same as falling off the end
|
|
1289
|
+
if (REPORTED_TAGS.has(node.tagName)) countTag(warned, node.tagName);
|
|
1290
|
+
if (hasDataSrc(node)) warned.dataSrc += 1;
|
|
1291
|
+
});
|
|
1292
|
+
return { ranges, warned };
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const mdParser = unified().use(remarkParse).use(remarkGfm);
|
|
1296
|
+
|
|
1297
|
+
// A markup-declaration-open (`<!`) or processing-instruction-ish (`<?`) start.
|
|
1298
|
+
// Inside an inline html node these begin a *bogus comment* unless they open a
|
|
1299
|
+
// proper `<!--…-->` comment (handled on the fast path) — `<!bogus>`, `<?php?>`,
|
|
1300
|
+
// `<![CDATA[…]]>` all tokenize to comments the HTML-source branch already
|
|
1301
|
+
// strips. The prose branch matched only literal `<!--`, so the bogus forms
|
|
1302
|
+
// leaked through; this finds the candidates to validate.
|
|
1303
|
+
const BOGUS_COMMENT_OPEN_RE = /<[!?]/g;
|
|
1304
|
+
|
|
1305
|
+
// Raw source ending in UNTERMINATED markup — a `<` that opens a construct with
|
|
1306
|
+
// no closing `>` yet: an open tag (`<span`), an end tag (`</A`), or a bogus
|
|
1307
|
+
// comment / declaration (`<!`, `<?`). Per the HTML tokenizer such a construct
|
|
1308
|
+
// keeps consuming the input stream until the next `>`, so it absorbs the
|
|
1309
|
+
// following inline-html node (an open tag swallows it as bogus attributes; a
|
|
1310
|
+
// bogus end tag / `<!…` opens a bogus comment). parse5 (the flow/source branch,
|
|
1311
|
+
// via rehype) models this; the per-tag balance walk below does not, so without
|
|
1312
|
+
// this a fragment parses differently as a flow block than as a paragraph —
|
|
1313
|
+
// breaking idempotency once a first pass demotes a block to phrasing (see
|
|
1314
|
+
// html-property "second pass changes nothing"). An open/end tag requires a
|
|
1315
|
+
// name letter after the `<`/`</`, so literal prose like `a < b` or an `i <3 u`
|
|
1316
|
+
// emoticon is not mistaken for markup.
|
|
1317
|
+
const UNTERMINATED_MARKUP_TAIL_RE = /<(?:[!?]|\/?[a-zA-Z])[^>]*$/;
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* Fold a raw source slice into the "inside an unterminated tag" state. A `>`
|
|
1321
|
+
* closes any open construct (so only the tail after the last `>` can leave one
|
|
1322
|
+
* open); with no `>` an already-open construct stays open. Operating on the
|
|
1323
|
+
* RAW source — not mdast node values — means markdown constructs that restructure
|
|
1324
|
+
* the character stream (code spans, emphasis, escapes) are seen exactly as
|
|
1325
|
+
* parse5 sees them, since only the literal `<`/`>` bytes matter.
|
|
1326
|
+
* @param {boolean} absorbing
|
|
1327
|
+
* @param {string} raw
|
|
1328
|
+
* @returns {boolean}
|
|
1329
|
+
*/
|
|
1330
|
+
function foldAbsorb(absorbing, raw) {
|
|
1331
|
+
if (raw.includes(">")) return UNTERMINATED_MARKUP_TAIL_RE.test(raw);
|
|
1332
|
+
return absorbing || UNTERMINATED_MARKUP_TAIL_RE.test(raw);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* Map of comment start-offset -> end-offset (exclusive) for EVERY comment the
|
|
1337
|
+
* HTML tokenizer finds in `value`, from a SINGLE rehype parse. Validated against
|
|
1338
|
+
* the real tokenizer (parse5) rather than a hand-rolled bogus-comment state
|
|
1339
|
+
* machine, so a bogus comment (`<!bogus>`, `<?php?>`, `<![CDATA[…]]>`) is spliced
|
|
1340
|
+
* to exactly the span a browser hides and a `<Foo>` element, a `<!doctype>`, or
|
|
1341
|
+
* visible prose never is. Replaces a per-candidate parse: the whole value is
|
|
1342
|
+
* tokenized once and every span read from that tree.
|
|
1343
|
+
* @param {string} value
|
|
1344
|
+
* @returns {Map<number, number>}
|
|
1345
|
+
*/
|
|
1346
|
+
function commentSpans(value) {
|
|
1347
|
+
const tree = unified().use(rehypeParse, { fragment: true }).parse(value);
|
|
1348
|
+
/** @type {Map<number, number>} */
|
|
1349
|
+
const spans = new Map();
|
|
1350
|
+
visit(tree, "comment", (/** @type {any} */ node) => {
|
|
1351
|
+
if (node.position)
|
|
1352
|
+
spans.set(node.position.start.offset, node.position.end.offset);
|
|
1353
|
+
});
|
|
1354
|
+
return spans;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Append comment ranges found in `value` to `ranges`.
|
|
1359
|
+
*
|
|
1360
|
+
* Proper `<!--…-->` comments are located with linear indexOf scanning (a lazy
|
|
1361
|
+
* `<!--[\s\S]*?-->` regex backtracks polynomially on crafted input); the close
|
|
1362
|
+
* search starts 2 chars in so spec-abrupt closes (`<!-->`, `<!--->`) terminate
|
|
1363
|
+
* their own comment. Other `<!`/`<?` starts are bogus comments, spliced to the
|
|
1364
|
+
* exact span the HTML tokenizer assigns them so the prose branch reaches parity
|
|
1365
|
+
* with the HTML-source branch (which strips them via parse5).
|
|
1366
|
+
* @param {string} value
|
|
1367
|
+
* @param {number} base absolute offset of the start of `value`
|
|
1368
|
+
* @param {number} nodeEnd absolute offset of the end of the containing node
|
|
1369
|
+
* @param {Array<{start: number, end: number, kind: "comment" | "hidden"}>} ranges
|
|
1370
|
+
*/
|
|
1371
|
+
function collectCommentRanges(value, base, nodeEnd, ranges) {
|
|
1372
|
+
BOGUS_COMMENT_OPEN_RE.lastIndex = 0;
|
|
1373
|
+
// Tokenized bogus-comment spans, parsed once on first need (many values carry
|
|
1374
|
+
// a proper `<!--` handled below without ever touching the tree).
|
|
1375
|
+
/** @type {Map<number, number> | null} */
|
|
1376
|
+
let spans = null;
|
|
1377
|
+
for (let match; (match = BOGUS_COMMENT_OPEN_RE.exec(value));) {
|
|
1378
|
+
const open = match.index;
|
|
1379
|
+
if (value.startsWith("<!--", open)) {
|
|
1380
|
+
const close = value.indexOf("-->", open + 2);
|
|
1381
|
+
/* c8 ignore start -- micromark only tokenizes inline comments WITH a
|
|
1382
|
+
terminator (an unterminated `<!--` in phrasing context stays literal
|
|
1383
|
+
text, visible to a human reader), so this is fail-closed
|
|
1384
|
+
defense-in-depth against a future tokenizer change. Unterminated
|
|
1385
|
+
comments in flow blocks are covered — parse5 handles them in
|
|
1386
|
+
scanHtmlFragment. */
|
|
1387
|
+
if (close === -1) {
|
|
1388
|
+
ranges.push({ start: base + open, end: nodeEnd, kind: "comment" });
|
|
1389
|
+
break;
|
|
1390
|
+
}
|
|
1391
|
+
/* c8 ignore stop */
|
|
1392
|
+
ranges.push({
|
|
1393
|
+
start: base + open,
|
|
1394
|
+
end: base + close + 3,
|
|
1395
|
+
kind: "comment",
|
|
1396
|
+
});
|
|
1397
|
+
BOGUS_COMMENT_OPEN_RE.lastIndex = close + 3;
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
if (!spans) spans = commentSpans(value);
|
|
1401
|
+
const end = spans.get(open);
|
|
1402
|
+
// Not a comment (a `<Foo>` element, a `<!doctype>`, visible prose): leave
|
|
1403
|
+
// it untouched and resume scanning just past this `<`.
|
|
1404
|
+
if (end === undefined) continue;
|
|
1405
|
+
ranges.push({ start: base + open, end: base + end, kind: "comment" });
|
|
1406
|
+
BOGUS_COMMENT_OPEN_RE.lastIndex = end;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Update hidden-region state for one html node while inside a tracked region.
|
|
1412
|
+
*
|
|
1413
|
+
* Mutates `state` in place. A closing tag for the tracked element decrements
|
|
1414
|
+
* depth; reaching zero closes the range. A nested open of the same tag
|
|
1415
|
+
* increments depth. Any other close is swallowed inside the region.
|
|
1416
|
+
* @param {{ tag: string | null, depth: number, regionStart: number }} state
|
|
1417
|
+
* @param {string} value
|
|
1418
|
+
* @param {number} nodeEnd absolute end offset of this node
|
|
1419
|
+
* @param {Array<{start: number, end: number, kind: "comment" | "hidden"}>} ranges
|
|
1420
|
+
*/
|
|
1421
|
+
function updateHiddenState(state, value, nodeEnd, ranges) {
|
|
1422
|
+
if (value.startsWith("</")) {
|
|
1423
|
+
if (closingTagName(value) !== state.tag) return;
|
|
1424
|
+
state.depth--;
|
|
1425
|
+
if (state.depth === 0) {
|
|
1426
|
+
ranges.push({ start: state.regionStart, end: nodeEnd, kind: "hidden" });
|
|
1427
|
+
state.tag = null;
|
|
1428
|
+
}
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
const el = parseHtmlTag(value);
|
|
1432
|
+
if (el && el.tagName === state.tag) state.depth++;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// The block-level phrasing containers whose inline html the balance walk owns.
|
|
1436
|
+
// (Nested phrasing — emphasis, links — is reached by recursing from these; html
|
|
1437
|
+
// directly under a flow parent like listItem/blockquote is owned by the flow
|
|
1438
|
+
// branch instead.)
|
|
1439
|
+
const PHRASING_ROOTS = new Set(["paragraph", "heading", "tableCell"]);
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* Yield the `html` leaf nodes of a phrasing subtree in document order,
|
|
1443
|
+
* descending through nested inline containers (emphasis, links, …) but NOT into
|
|
1444
|
+
* flow parents (their html belongs to the flow/source branch).
|
|
1445
|
+
* @param {any} node
|
|
1446
|
+
* @returns {Generator<any>}
|
|
1447
|
+
*/
|
|
1448
|
+
function* inlineHtmlLeaves(node) {
|
|
1449
|
+
for (const child of node.children) {
|
|
1450
|
+
if (child.type === "html") yield child;
|
|
1451
|
+
else if (
|
|
1452
|
+
Array.isArray(child.children) &&
|
|
1453
|
+
!FLOW_HTML_PARENTS.has(child.type)
|
|
1454
|
+
)
|
|
1455
|
+
yield* inlineHtmlLeaves(child);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/** @param {any} node @returns {boolean} */
|
|
1460
|
+
function hasHtmlLeaf(node) {
|
|
1461
|
+
for (const _ of inlineHtmlLeaves(node)) return true;
|
|
1462
|
+
return false;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* Balance-walk a markdown phrasing root's html leaves in document order: a
|
|
1467
|
+
* hidden open tag starts a removal region that runs to its matching close (or
|
|
1468
|
+
* the container's end when unbalanced — fail-closed), comments become
|
|
1469
|
+
* single-node ranges, and preserved tags are counted. Inline html is tokenized
|
|
1470
|
+
* per TAG (an element's content sits in sibling text nodes), which is why this
|
|
1471
|
+
* walk exists instead of handing the value to rehype.
|
|
1472
|
+
*
|
|
1473
|
+
* The absorb state is folded from the RAW source between html nodes (not from
|
|
1474
|
+
* mdast node values), so markdown constructs that reshuffle the character
|
|
1475
|
+
* stream — code spans, emphasis, escapes — are seen exactly as parse5 sees
|
|
1476
|
+
* them. The root is walked in full document order (descending through nested
|
|
1477
|
+
* emphasis/links) so an unterminated tag in one node absorbs markup in a
|
|
1478
|
+
* sibling/nested node the way it does in the flat token stream.
|
|
1479
|
+
* @param {any} node
|
|
1480
|
+
* @param {string} text the full document source, for raw-slice absorb folding
|
|
1481
|
+
* @param {Array<{start: number, end: number, kind: "comment" | "hidden"}>} ranges
|
|
1482
|
+
* @param {ReturnType<typeof newWarned>} warned
|
|
1483
|
+
*/
|
|
1484
|
+
function scanInlineChildren(node, text, ranges, warned) {
|
|
1485
|
+
const state =
|
|
1486
|
+
/** @type {{ tag: string | null, depth: number, regionStart: number }} */ ({
|
|
1487
|
+
tag: null,
|
|
1488
|
+
depth: 0,
|
|
1489
|
+
regionStart: 0,
|
|
1490
|
+
});
|
|
1491
|
+
// "Inside an unterminated tag / bogus comment" — parse5 absorbs following
|
|
1492
|
+
// markup into it until the next `>`.
|
|
1493
|
+
let absorbing = false;
|
|
1494
|
+
// Non-null while inside a raw-text element (its lowercased tag name); content
|
|
1495
|
+
// is opaque until the matching end tag.
|
|
1496
|
+
let rawText = /** @type {string | null} */ (null);
|
|
1497
|
+
// End offset of the last html node processed; the raw slice from here to the
|
|
1498
|
+
// next html node is what parse5 tokenizes between them.
|
|
1499
|
+
let prevEnd = node.position.start.offset;
|
|
1500
|
+
for (const child of inlineHtmlLeaves(node)) {
|
|
1501
|
+
const value = child.value;
|
|
1502
|
+
const base = child.position.start.offset;
|
|
1503
|
+
const end = child.position.end.offset;
|
|
1504
|
+
// Fold the inter-node source (markdown text, code spans, emphasis markers)
|
|
1505
|
+
// into the absorb state before deciding what to do with this html node.
|
|
1506
|
+
absorbing = foldAbsorb(absorbing, text.slice(prevEnd, base));
|
|
1507
|
+
|
|
1508
|
+
if (rawText) {
|
|
1509
|
+
// Raw-text content is opaque; only the matching end tag ends the region.
|
|
1510
|
+
if (new RegExp(`</${rawText}(?![a-z0-9-])`, "i").test(value))
|
|
1511
|
+
rawText = null;
|
|
1512
|
+
} else if (state.depth > 0) {
|
|
1513
|
+
updateHiddenState(state, value, end, ranges);
|
|
1514
|
+
} else if (!absorbing) {
|
|
1515
|
+
// Not absorbed into a preceding unterminated tag — scan normally.
|
|
1516
|
+
// Comments can share an inline html node with neighboring constructs
|
|
1517
|
+
// (e.g. in a list item, `<!-- c -->!` is ONE node), so comment spans are
|
|
1518
|
+
// located within the value and spliced individually rather than assuming
|
|
1519
|
+
// the node IS the comment.
|
|
1520
|
+
collectCommentRanges(value, base, end, ranges);
|
|
1521
|
+
const tagName = isHiddenOpen(value);
|
|
1522
|
+
if (tagName) {
|
|
1523
|
+
// A void element never emits a matching close, so a balance region
|
|
1524
|
+
// would extend to the container end and splice out following visible
|
|
1525
|
+
// text. Emit a single-node range instead (the source branch does too).
|
|
1526
|
+
if (VOID_ELEMENTS.has(tagName))
|
|
1527
|
+
ranges.push({ start: base, end, kind: "hidden" });
|
|
1528
|
+
else {
|
|
1529
|
+
state.tag = tagName;
|
|
1530
|
+
state.depth = 1;
|
|
1531
|
+
state.regionStart = base;
|
|
1532
|
+
}
|
|
1533
|
+
} else if (!value.startsWith("</")) {
|
|
1534
|
+
const el = parseHtmlTag(value);
|
|
1535
|
+
if (el) {
|
|
1536
|
+
// A raw-text open tag starts an opaque region (a self-closing `/>`
|
|
1537
|
+
// does not apply to these in HTML — they always open).
|
|
1538
|
+
if (RAW_TEXT_ELEMENTS.has(el.tagName)) rawText = el.tagName;
|
|
1539
|
+
if (REPORTED_TAGS.has(el.tagName)) countTag(warned, el.tagName);
|
|
1540
|
+
if (hasDataSrc(el)) warned.dataSrc += 1;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
// else: absorbed into a preceding unterminated tag — parse5 treats it as
|
|
1545
|
+
// tag soup, not a comment/element, so leave it untouched (fail open).
|
|
1546
|
+
|
|
1547
|
+
absorbing = foldAbsorb(absorbing, value);
|
|
1548
|
+
prevEnd = end;
|
|
1549
|
+
}
|
|
1550
|
+
if (state.depth > 0) {
|
|
1551
|
+
ranges.push({
|
|
1552
|
+
start: state.regionStart,
|
|
1553
|
+
end: node.position.end.offset,
|
|
1554
|
+
kind: "hidden",
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// Containers whose direct html children are flow BLOCKS (complete markup —
|
|
1560
|
+
// tags and content in one node value), as opposed to the phrasing containers
|
|
1561
|
+
// (paragraph, heading, tableCell, emphasis, …) whose html children are
|
|
1562
|
+
// per-tag fragments needing the balance walk.
|
|
1563
|
+
const FLOW_HTML_PARENTS = new Set([
|
|
1564
|
+
"root",
|
|
1565
|
+
"blockquote",
|
|
1566
|
+
"listItem",
|
|
1567
|
+
"footnoteDefinition",
|
|
1568
|
+
]);
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* @param {string} text
|
|
1572
|
+
* @returns {{ ranges: Array<{start: number, end: number, kind: "comment" | "hidden"}>, warned: ReturnType<typeof newWarned> }}
|
|
1573
|
+
*/
|
|
1574
|
+
function scanMarkdown(text) {
|
|
1575
|
+
const tree = mdParser.parse(text);
|
|
1576
|
+
/** @type {Array<{start: number, end: number, kind: "comment" | "hidden"}>} */
|
|
1577
|
+
const ranges = [];
|
|
1578
|
+
const warned = newWarned();
|
|
1579
|
+
|
|
1580
|
+
// Flow html blocks carry complete markup, so rehype locates comments/hidden
|
|
1581
|
+
// elements precisely within them; block-local offsets are shifted to
|
|
1582
|
+
// document coordinates.
|
|
1583
|
+
visit(tree, "html", (/** @type {any} */ node, _index, parent) => {
|
|
1584
|
+
if (!FLOW_HTML_PARENTS.has(parent?.type)) return;
|
|
1585
|
+
const base = node.position.start.offset;
|
|
1586
|
+
const sub = scanHtmlFragment(text.slice(base, node.position.end.offset));
|
|
1587
|
+
for (const range of sub.ranges) {
|
|
1588
|
+
ranges.push({
|
|
1589
|
+
start: base + range.start,
|
|
1590
|
+
end: base + range.end,
|
|
1591
|
+
kind: range.kind,
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
mergeWarned(warned, sub.warned);
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
// Every phrasing ROOT that holds inline html (paragraph, heading, tableCell,
|
|
1598
|
+
// …) gets the balance walk — not just paragraphs, so a hidden span inside a
|
|
1599
|
+
// heading cannot slip through. Nested inline containers (emphasis, links, …)
|
|
1600
|
+
// are walked as part of their root in document order, so the walk is skipped
|
|
1601
|
+
// for them here to avoid double-scanning and to keep the absorb state flowing
|
|
1602
|
+
// across those boundaries.
|
|
1603
|
+
visit(tree, (/** @type {any} */ node) => {
|
|
1604
|
+
if (!PHRASING_ROOTS.has(node.type)) return;
|
|
1605
|
+
if (!hasHtmlLeaf(node)) return;
|
|
1606
|
+
scanInlineChildren(node, text, ranges, warned);
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
return { ranges, warned };
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// 30%-of-lines heuristic: HTML *source* gets scanned as one rehype fragment;
|
|
1613
|
+
// inline tags scattered in prose go through the markdown branch instead.
|
|
1614
|
+
/**
|
|
1615
|
+
* @param {string} text
|
|
1616
|
+
* @returns {boolean}
|
|
1617
|
+
*/
|
|
1618
|
+
export function looksLikeHtmlSource(text) {
|
|
1619
|
+
const lines = text.split("\n");
|
|
1620
|
+
if (lines.length < 5) return false;
|
|
1621
|
+
let htmlLines = 0;
|
|
1622
|
+
for (const line of lines) {
|
|
1623
|
+
if (/<\/?[a-zA-Z][^<>]*>/.test(line)) htmlLines++;
|
|
1624
|
+
}
|
|
1625
|
+
return htmlLines / lines.length > 0.3;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
/**
|
|
1629
|
+
* Layer 2 over web-ingress text: splice out HTML comments and hidden elements
|
|
1630
|
+
* (placeholders mark the cuts; all other bytes are preserved verbatim) and
|
|
1631
|
+
* count preserved scripting/resource tags for the caller's warning. Returns
|
|
1632
|
+
* null when there is nothing to strip and nothing to report.
|
|
1633
|
+
* @param {string} text
|
|
1634
|
+
* @returns {{ text: string, removed: { comments: number, hidden: number }, warned: { tags: Record<string, number>, dataSrc: number } } | null}
|
|
1635
|
+
*/
|
|
1636
|
+
export function sanitizeHtml(text) {
|
|
1637
|
+
if (!HTML_TAG_PRESENT.test(text)) return null;
|
|
1638
|
+
/** @type {{ ranges: Array<{start: number, end: number, kind: "comment" | "hidden"}>, warned: ReturnType<typeof newWarned> }} */
|
|
1639
|
+
let scan;
|
|
1640
|
+
try {
|
|
1641
|
+
scan = looksLikeHtmlSource(text)
|
|
1642
|
+
? scanHtmlFragment(text)
|
|
1643
|
+
: scanMarkdown(text);
|
|
1644
|
+
} catch {
|
|
1645
|
+
// The parse/visit blew up (stack overflow on pathological nesting, or any
|
|
1646
|
+
// other parser error). Fail CLOSED at this boundary so `sanitize`/
|
|
1647
|
+
// `sanitizeText` keep their never-throw contract: withhold the whole input
|
|
1648
|
+
// behind a placeholder and report it as hidden content removed.
|
|
1649
|
+
return {
|
|
1650
|
+
text: UNPARSEABLE_PLACEHOLDER,
|
|
1651
|
+
removed: { comments: 0, hidden: 1 },
|
|
1652
|
+
warned: newWarned(),
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
const { ranges, warned } = scan;
|
|
1656
|
+
if (ranges.length === 0 && !hasWarned(warned)) return null;
|
|
1657
|
+
const removed = { comments: 0, hidden: 0 };
|
|
1658
|
+
for (const range of ranges)
|
|
1659
|
+
removed[range.kind === "comment" ? "comments" : "hidden"]++;
|
|
1660
|
+
return {
|
|
1661
|
+
text: ranges.length > 0 ? spliceRanges(text, ranges) : text,
|
|
1662
|
+
removed,
|
|
1663
|
+
warned,
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// ─── Layer 3: markdown/URL exfiltration detection ────────────────────────────
|
|
1668
|
+
|
|
1669
|
+
// Template-injection indicators, applied to the whole URL so they fire even
|
|
1670
|
+
// when it is too malformed for `new URL()` to parse (e.g. a non-ASCII host).
|
|
1671
|
+
// These are name-independent shapes — server-/client-side template syntax that
|
|
1672
|
+
// only appears in a URL when something is interpolating untrusted data — so
|
|
1673
|
+
// they carry signal on their own and need no value-shape gate.
|
|
1674
|
+
//
|
|
1675
|
+
// Keyword-PARAM detection (`?token=…`, `…#secret=…`) was REMOVED from this list
|
|
1676
|
+
// (finding #20): firing on the parameter NAME alone flagged every `?session=ok`
|
|
1677
|
+
// / `?key=pk_public_mapkey` / `?d=3`, drowning the real signal. A keyword
|
|
1678
|
+
// param is now flagged only when its VALUE is payload-shaped, via the
|
|
1679
|
+
// value-gated raw scan below (rawUrlKeywordExfil) which reuses the same
|
|
1680
|
+
// blob/credential shape test as the post-parse param walk — see
|
|
1681
|
+
// paramExfilReason. The raw scan keeps the pre-parse / fragment coverage the
|
|
1682
|
+
// old name arm had (an unparseable host means `new URL()` throws and the
|
|
1683
|
+
// post-parse walk never runs).
|
|
1684
|
+
const EXFIL_INDICATORS = [/\$\{[^{}]+\}/, /\{\{[^{}]+\}\}/];
|
|
1685
|
+
|
|
1686
|
+
// Parameter NAMES whose presence used to flag on sight; now they only gate
|
|
1687
|
+
// WHICH raw params the value-shape test is applied to before the URL is parsed.
|
|
1688
|
+
// Kept narrow (the historically over-eager set) so the raw pre-parse pass stays
|
|
1689
|
+
// cheap; any non-keyword param is still value-gated post-parse by the walk.
|
|
1690
|
+
const KEYWORD_PARAM_NAME_RE =
|
|
1691
|
+
/^(?:data|d|payload|exfil|leak|steal|secret|token|key|env|password|pwd|cookie|session|auth)$/i;
|
|
1692
|
+
|
|
1693
|
+
const LONG_QUERY_THRESHOLD = 200;
|
|
1694
|
+
|
|
1695
|
+
// A `data:` URI carries its payload inline instead of pointing at a host, so
|
|
1696
|
+
// the query/credential/fragment checks below never fire on it. Active-content
|
|
1697
|
+
// types (HTML, SVG, JS) are a script-injection vector; an oversized blob of any
|
|
1698
|
+
// type is an inline exfil/injection payload. A small inline image (icon) is
|
|
1699
|
+
// left alone so the common case isn't drowned in noise.
|
|
1700
|
+
const DATA_URI_ACTIVE_RE =
|
|
1701
|
+
/^\s*data:(?:text\/html|image\/svg\+xml|application\/(?:javascript|ecmascript|xhtml\+xml))[;,]/i;
|
|
1702
|
+
export const DATA_URI_LENGTH_THRESHOLD = 4096;
|
|
1703
|
+
|
|
1704
|
+
// javascript:/vbscript: URIs execute on navigation/load, never a legitimate
|
|
1705
|
+
// link target in fetched content — flagged regardless of payload.
|
|
1706
|
+
const SCRIPT_URI_RE = /^\s*(?:javascript|vbscript):/i;
|
|
1707
|
+
|
|
1708
|
+
const RELATIVE_URL_BASE = "http://relative.invalid";
|
|
1709
|
+
|
|
1710
|
+
// Parameter NAMES that legitimately carry a LONG opaque (base64/hex) value, so
|
|
1711
|
+
// a blob in one of them is NOT exfil: CDN request-signing (AWS SigV4 /
|
|
1712
|
+
// CloudFront `X-Amz-*`/`Signature`/`Policy`/`Key-Pair-Id`, GCS `X-Goog-*`,
|
|
1713
|
+
// Azure SAS `sv/sr/sig/se/sp/st/spr/skoid/sktid`), pagination cursors /
|
|
1714
|
+
// continuation tokens, and the long analytics click-IDs. Matched
|
|
1715
|
+
// case-insensitively against the exact (lowercased) parameter name. Scope is
|
|
1716
|
+
// deliberately limited to names whose benign value is genuinely a long token —
|
|
1717
|
+
// generic short params (`page`, `limit`, `v`, `t`, `cb`, …) are NOT listed,
|
|
1718
|
+
// since their values never reach the blob threshold anyway and listing them
|
|
1719
|
+
// would only widen the rename-dodge surface. A blob or credential-shaped value
|
|
1720
|
+
// in any OTHER parameter still fires — this allowlist trades a narrow dodge
|
|
1721
|
+
// (`?sig=<stolen>`) for not drowning the model in false positives on ordinary
|
|
1722
|
+
// fetched pages.
|
|
1723
|
+
const BENIGN_BLOB_PARAM_RE =
|
|
1724
|
+
/^(?:x-(?:amz|goog|ms|oss|obs)-[a-z0-9-]+|amz-[a-z0-9-]+|utm_[a-z]+|sig|signature|hmac|policy|credential|expires|key-pair-id|se|sp|sr|sv|st|spr|si|skoid|sktid|cursor|after|before|continuation|continuationtoken|continuation_token|pagetoken|page_token|nexttoken|next_token|gclid|fbclid|dclid|msclkid|gbraid|wbraid|_ga|_gl|mc_eid|mc_cid)$/i;
|
|
1725
|
+
|
|
1726
|
+
// matchesSecretHint is a deliberately broad PRE-gate whose bare-keyword arms
|
|
1727
|
+
// (`token`, `secret`, `authorization`, …) also match ordinary hyphen/word
|
|
1728
|
+
// delimited prose, and with no secret-redaction engine to refine the verdict
|
|
1729
|
+
// here a weak digit proxy isn't enough: `login-authenticate-2024` and
|
|
1730
|
+
// `the-secret-recipe-2024` clear "has a digit." A leaked credential is an
|
|
1731
|
+
// OPAQUE, separator-free token, so the value must additionally contain a
|
|
1732
|
+
// contiguous 20+ char `[A-Za-z0-9_]` run (no hyphen/space — that's what splits
|
|
1733
|
+
// the prose runs below the bar) AND a digit before it counts as one.
|
|
1734
|
+
const OPAQUE_TOKEN_RE = /[A-Za-z0-9_]{20,}/g;
|
|
1735
|
+
const VALUE_HAS_DIGIT_RE = /\d/;
|
|
1736
|
+
|
|
1737
|
+
// A value that is ENTIRELY a long base64 (40+ chars, optional `=` padding) or
|
|
1738
|
+
// hex (32+ chars) run. Anchored to the whole value (operating on the RAW,
|
|
1739
|
+
// un-decoded query so a `+` in base64 is not turned into a space), so a benign
|
|
1740
|
+
// short value with an incidental hex word never trips it. Both arms are linear.
|
|
1741
|
+
const BLOB_VALUE_B64_RE = /^[A-Za-z0-9+/]{40,}={0,2}$/;
|
|
1742
|
+
const BLOB_VALUE_HEX_RE = /^[A-Fa-f0-9]{32,}$/;
|
|
1743
|
+
|
|
1744
|
+
// RFC 4648 §5 url-safe base64 substitutes `-`/`_` for `+`/`/`, so a payload
|
|
1745
|
+
// encoded url-safe escapes the `[A-Za-z0-9+/]` arms above. Adding `-`/`_` to the
|
|
1746
|
+
// charset would re-admit a long hyphenated word-slug (`the-secret-history-of-…`)
|
|
1747
|
+
// as a "blob", so this arm distinguishes the two by CHARACTER MIX rather than a
|
|
1748
|
+
// contiguous run: bulk-encoded bytes drawn from base64url's 64-symbol alphabet
|
|
1749
|
+
// almost always carry BOTH an uppercase letter and a digit, whereas a human slug
|
|
1750
|
+
// is lowercase dictionary words joined by separators and shows neither. The
|
|
1751
|
+
// earlier contiguous-40-run gate was fragile — ordinary base64url scatters a
|
|
1752
|
+
// `-`/`_` roughly every ~30 chars, breaking any 40-char run, so a real beacon
|
|
1753
|
+
// (`?d=<200-char base64url of cookies>`) routinely dodged it. The mix test keeps
|
|
1754
|
+
// the slug benign (no uppercase) while catching the scattered-separator blob the
|
|
1755
|
+
// run gate missed. Anchored to the whole value for the same RAW-query reason.
|
|
1756
|
+
const BLOB_VALUE_B64URL_RE = /^[A-Za-z0-9_-]{40,}={0,2}$/;
|
|
1757
|
+
const B64URL_MIXED_RE = /(?=.*[A-Z])(?=.*[0-9])/;
|
|
1758
|
+
|
|
1759
|
+
// A path segment whose whole value is a base64/hex run longer than any standard
|
|
1760
|
+
// content hash (SHA-512 hex is 128, base64 88; SHA-256 hex 64) is bulk encoded
|
|
1761
|
+
// data — a beacon URL that smuggles its payload in the path to dodge the query
|
|
1762
|
+
// walk — rather than an asset fingerprint. The threshold sits just above the
|
|
1763
|
+
// SHA-512-hex ceiling so every real fingerprint clears it while a ~150-char
|
|
1764
|
+
// base64 of stolen cookies does not. Hyphens/underscores are excluded from the
|
|
1765
|
+
// standard arm so a long word-slug (`the-secret-history-of-…`) is not mistaken
|
|
1766
|
+
// for a payload; the url-safe arm re-admits `-`/`_` but, like the query arm
|
|
1767
|
+
// above, gates on a contiguous 40+ alphanumeric run to keep the slug benign.
|
|
1768
|
+
const PATH_BLOB_RE = /^(?:[A-Za-z0-9+/]+={0,2}|[A-Fa-f0-9]+)$/;
|
|
1769
|
+
const PATH_BLOB_MIN_LEN = 128;
|
|
1770
|
+
|
|
1771
|
+
/**
|
|
1772
|
+
* True for an entirely-url-safe-base64 value (≥40 chars) whose character mix —
|
|
1773
|
+
* at least one uppercase letter AND one digit — marks it as bulk-encoded bytes
|
|
1774
|
+
* rather than a lowercase hyphenated word-slug. Shared by the query and path
|
|
1775
|
+
* blob detectors. Precision-first: a value missing either class is treated as a
|
|
1776
|
+
* benign slug and passes (a false negative, per the detection-layer doctrine).
|
|
1777
|
+
* @param {string} value
|
|
1778
|
+
* @returns {boolean}
|
|
1779
|
+
*/
|
|
1780
|
+
function isBase64UrlBlob(value) {
|
|
1781
|
+
return BLOB_VALUE_B64URL_RE.test(value) && B64URL_MIXED_RE.test(value);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/** @param {string} value @returns {boolean} */
|
|
1785
|
+
function isBlobValue(value) {
|
|
1786
|
+
return (
|
|
1787
|
+
BLOB_VALUE_B64_RE.test(value) ||
|
|
1788
|
+
BLOB_VALUE_HEX_RE.test(value) ||
|
|
1789
|
+
isBase64UrlBlob(value)
|
|
1790
|
+
);
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* True when the percent-DECODED form of `value` is blob-shaped, even though
|
|
1795
|
+
* the raw value isn't (e.g. `A%41A%41…` decodes to a run of `A`s). This is a
|
|
1796
|
+
* REPORT-ONLY check — `paramExfilReason` never rewrites the URL, it only
|
|
1797
|
+
* names a reason for the caller's warning — so the false-positive cost of
|
|
1798
|
+
* decoding is much lower than it would be in the splicing layer. Applied IN
|
|
1799
|
+
* ADDITION to the raw-value test (never instead of it): the raw scan stays
|
|
1800
|
+
* the primary signal since `URLSearchParams`-style decoding elsewhere in this
|
|
1801
|
+
* file is deliberately avoided (it mangles `+` in base64). A malformed
|
|
1802
|
+
* percent-sequence throws in `decodeURIComponent`; that failure is not a
|
|
1803
|
+
* blob shape either way, so it fails open (skip the decoded check).
|
|
1804
|
+
* @param {string} value
|
|
1805
|
+
* @returns {boolean}
|
|
1806
|
+
*/
|
|
1807
|
+
function decodedBlobMatch(value) {
|
|
1808
|
+
let decoded;
|
|
1809
|
+
try {
|
|
1810
|
+
decoded = decodeURIComponent(value);
|
|
1811
|
+
} catch {
|
|
1812
|
+
return false;
|
|
1813
|
+
}
|
|
1814
|
+
return isBlobValue(decoded);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
/**
|
|
1818
|
+
* RAW (un-decoded) `name=value` pairs of a query/fragment string, split on `&`
|
|
1819
|
+
* and `;`. URLSearchParams is avoided on purpose: it percent-/`+`-decodes
|
|
1820
|
+
* values, turning a `+`-bearing base64 blob into a space-broken string that the
|
|
1821
|
+
* anchored blob regexes would miss.
|
|
1822
|
+
* @param {string} qs
|
|
1823
|
+
* @returns {Array<[string, string]>}
|
|
1824
|
+
*/
|
|
1825
|
+
function rawParams(qs) {
|
|
1826
|
+
/** @type {Array<[string, string]>} */
|
|
1827
|
+
const pairs = [];
|
|
1828
|
+
for (const pair of qs.split(/[&;]/)) {
|
|
1829
|
+
if (!pair) continue;
|
|
1830
|
+
const eq = pair.indexOf("=");
|
|
1831
|
+
const name = eq === -1 ? pair : pair.slice(0, eq);
|
|
1832
|
+
const value = eq === -1 ? "" : pair.slice(eq + 1);
|
|
1833
|
+
pairs.push([name.toLowerCase(), value]);
|
|
1834
|
+
}
|
|
1835
|
+
return pairs;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
/**
|
|
1839
|
+
* Exfil reason for one URL parameter, or null. A credential-shaped value in any
|
|
1840
|
+
* non-allowlisted parameter (reusing the secret-shape gate), or a long
|
|
1841
|
+
* base64/hex blob in one. Allowlisted signing/pagination/analytics parameters
|
|
1842
|
+
* are skipped entirely (see BENIGN_BLOB_PARAM_RE).
|
|
1843
|
+
* @param {string} name lowercased parameter name
|
|
1844
|
+
* @param {string} value RAW (un-decoded) value
|
|
1845
|
+
* @returns {string | null}
|
|
1846
|
+
*/
|
|
1847
|
+
function paramExfilReason(name, value) {
|
|
1848
|
+
if (BENIGN_BLOB_PARAM_RE.test(name)) return null;
|
|
1849
|
+
// A leaked credential is an OPAQUE, separator-free token. Gate the
|
|
1850
|
+
// secret-shape/digit test on the CONTIGUOUS opaque run(s) of the value, not on
|
|
1851
|
+
// the whole prose value: a benign path-like value (`?redirect=/authorization-
|
|
1852
|
+
// service/…abcdefghij1234567890`) otherwise matches "authorization" in one
|
|
1853
|
+
// place and a 20-char run in another and false-fires. Requiring both on the
|
|
1854
|
+
// SAME run keeps `ghp_…`-style contiguous tokens firing while dropping prose.
|
|
1855
|
+
const opaqueRuns = value.match(OPAQUE_TOKEN_RE);
|
|
1856
|
+
if (
|
|
1857
|
+
opaqueRuns?.some(
|
|
1858
|
+
(run) => VALUE_HAS_DIGIT_RE.test(run) && matchesSecretHint(run),
|
|
1859
|
+
)
|
|
1860
|
+
)
|
|
1861
|
+
return "credential-shaped token in URL parameter";
|
|
1862
|
+
if (isBlobValue(value) || decodedBlobMatch(value))
|
|
1863
|
+
return "suspicious query parameter";
|
|
1864
|
+
return null;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/**
|
|
1868
|
+
* Pre-parse, value-GATED keyword-parameter scan over the RAW URL string. Splits
|
|
1869
|
+
* off the query (`?…`) and fragment (`#…`) and applies the same blob/credential
|
|
1870
|
+
* value-shape test as the post-parse walk, but only to keyword-named params
|
|
1871
|
+
* (KEYWORD_PARAM_NAME_RE). This is the precision fix for finding #20: a keyword
|
|
1872
|
+
* param flags only when its value is actually payload-shaped, so `?session=ok`
|
|
1873
|
+
* and `?key=pk_public_mapkey` no longer fire. It runs BEFORE `new URL()` so a
|
|
1874
|
+
* blob in an unparseable-host URL (which the post-parse walk never reaches) is
|
|
1875
|
+
* still caught, preserving the coverage the old name-only arm had.
|
|
1876
|
+
* @param {string} url
|
|
1877
|
+
* @returns {string | null}
|
|
1878
|
+
*/
|
|
1879
|
+
function rawUrlKeywordExfil(url) {
|
|
1880
|
+
// Strip the scheme+authority+path prefix: everything up to the first `?`/`#`.
|
|
1881
|
+
const qIdx = url.search(/[?#]/);
|
|
1882
|
+
if (qIdx === -1) return null;
|
|
1883
|
+
for (const segment of url.slice(qIdx + 1).split("#")) {
|
|
1884
|
+
for (const [name, value] of rawParams(segment)) {
|
|
1885
|
+
if (!KEYWORD_PARAM_NAME_RE.test(name)) continue;
|
|
1886
|
+
const reason = paramExfilReason(name, value);
|
|
1887
|
+
if (reason) return reason;
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
return null;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
/**
|
|
1894
|
+
* True when every parameter of the parsed URL's query is in the benign
|
|
1895
|
+
* allowlist. Used to suppress the coarse long-query-string heuristic for
|
|
1896
|
+
* signed-CDN links, which are long by design. Only ever called once the query
|
|
1897
|
+
* is known to be long (and thus non-empty), so the vacuous-true empty case
|
|
1898
|
+
* cannot arise here.
|
|
1899
|
+
* @param {URL} parsed
|
|
1900
|
+
* @returns {boolean}
|
|
1901
|
+
*/
|
|
1902
|
+
function allParamsBenign(parsed) {
|
|
1903
|
+
return rawParams(parsed.search.slice(1)).every(([name]) =>
|
|
1904
|
+
BENIGN_BLOB_PARAM_RE.test(name),
|
|
1905
|
+
);
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
/**
|
|
1909
|
+
* Walk the query and fragment parameters of a parsed URL for an exfil reason.
|
|
1910
|
+
* @param {URL} parsed
|
|
1911
|
+
* @returns {string | null}
|
|
1912
|
+
*/
|
|
1913
|
+
function checkUrlParams(parsed) {
|
|
1914
|
+
for (const [name, value] of rawParams(parsed.search.slice(1))) {
|
|
1915
|
+
const reason = paramExfilReason(name, value);
|
|
1916
|
+
if (reason) return reason;
|
|
1917
|
+
}
|
|
1918
|
+
// The fragment carries the same `key=value` channel (`#token=…`); a bare
|
|
1919
|
+
// anchor (`#section-2`) yields one empty-value param that trips nothing.
|
|
1920
|
+
for (const [name, value] of rawParams(parsed.hash.slice(1))) {
|
|
1921
|
+
const reason = paramExfilReason(name, value);
|
|
1922
|
+
if (reason) return reason;
|
|
1923
|
+
}
|
|
1924
|
+
return null;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* A bulk encoded-data blob smuggled in a path segment (a beacon URL that avoids
|
|
1929
|
+
* query strings entirely), or null.
|
|
1930
|
+
* @param {URL} parsed
|
|
1931
|
+
* @returns {string | null}
|
|
1932
|
+
*/
|
|
1933
|
+
function checkUrlPath(parsed) {
|
|
1934
|
+
for (const segment of parsed.pathname.split("/")) {
|
|
1935
|
+
if (
|
|
1936
|
+
segment.length > PATH_BLOB_MIN_LEN &&
|
|
1937
|
+
(PATH_BLOB_RE.test(segment) || isBase64UrlBlob(segment))
|
|
1938
|
+
)
|
|
1939
|
+
return "encoded data blob in path segment";
|
|
1940
|
+
}
|
|
1941
|
+
return null;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/**
|
|
1945
|
+
* @param {string} url
|
|
1946
|
+
* @returns {string | null}
|
|
1947
|
+
*/
|
|
1948
|
+
export function checkExfilUrl(url) {
|
|
1949
|
+
// A browser strips tab/newline/CR ANYWHERE in a URL before resolving its
|
|
1950
|
+
// scheme, so `java\tscript:alert(1)` navigates as `javascript:`. Strip them
|
|
1951
|
+
// for the scheme tests (the payload/length checks below keep the raw string).
|
|
1952
|
+
const schemeUrl = url.replace(/[\t\n\r]/g, "");
|
|
1953
|
+
if (/^\s*data:/i.test(schemeUrl)) {
|
|
1954
|
+
if (DATA_URI_ACTIVE_RE.test(schemeUrl)) return "active-content data: URI";
|
|
1955
|
+
if (url.length > DATA_URI_LENGTH_THRESHOLD)
|
|
1956
|
+
return "oversized inline data: payload";
|
|
1957
|
+
return null;
|
|
1958
|
+
}
|
|
1959
|
+
if (SCRIPT_URI_RE.test(schemeUrl)) return "script-executing URI";
|
|
1960
|
+
// Template-injection shapes (`${…}`, `{{…}}`) only in the query/fragment: a
|
|
1961
|
+
// brace in the PATH or host is a legitimate templated doc URL
|
|
1962
|
+
// (`/api/{{version}}/guide`), and flagging it both false-positives and
|
|
1963
|
+
// mislabels the location. Sliced from the raw string so an unparseable-host
|
|
1964
|
+
// URL is still covered before `new URL()` would throw.
|
|
1965
|
+
const qfIdx = url.search(/[?#]/);
|
|
1966
|
+
const queryAndFragment = qfIdx === -1 ? "" : url.slice(qfIdx);
|
|
1967
|
+
if (
|
|
1968
|
+
queryAndFragment &&
|
|
1969
|
+
EXFIL_INDICATORS.some((pattern) => pattern.test(queryAndFragment))
|
|
1970
|
+
)
|
|
1971
|
+
return "suspicious query parameter";
|
|
1972
|
+
// Value-gated keyword params, scanned on the RAW string so a blob in an
|
|
1973
|
+
// unparseable-host URL is caught before `new URL()` would throw.
|
|
1974
|
+
const keywordReason = rawUrlKeywordExfil(url);
|
|
1975
|
+
if (keywordReason) return keywordReason;
|
|
1976
|
+
// Userinfo and an oversized fragment are exfil channels the param walk misses:
|
|
1977
|
+
// credentials smuggled as `user:secret@host`, or a payload tucked in `#<blob>`.
|
|
1978
|
+
// Parse against a sentinel base so relative URLs don't throw.
|
|
1979
|
+
let parsed;
|
|
1980
|
+
try {
|
|
1981
|
+
parsed = new URL(url, RELATIVE_URL_BASE);
|
|
1982
|
+
} catch {
|
|
1983
|
+
return null;
|
|
1984
|
+
}
|
|
1985
|
+
if (parsed.username || parsed.password) return "embedded credentials";
|
|
1986
|
+
// A long query string is only suspicious when it carries a non-allowlisted
|
|
1987
|
+
// parameter — a signed-CDN URL is long by design (all `X-Amz-*`/SAS params).
|
|
1988
|
+
// Measure the query from `parsed.search` (the parser's query span), NOT a raw
|
|
1989
|
+
// indexOf("?") into the whole URL: a `?` inside the FRAGMENT (`/p#a?<blob>`)
|
|
1990
|
+
// would otherwise be read as the query start, leaving `parsed.search` empty so
|
|
1991
|
+
// `allParamsBenign` runs `[].every(...)` → vacuously true and suppresses the
|
|
1992
|
+
// flag. The fragment is length-checked separately just below.
|
|
1993
|
+
if (parsed.search.length > LONG_QUERY_THRESHOLD && !allParamsBenign(parsed))
|
|
1994
|
+
return "unusually long query string";
|
|
1995
|
+
if (parsed.hash.length > LONG_QUERY_THRESHOLD)
|
|
1996
|
+
return "unusually long fragment";
|
|
1997
|
+
return checkUrlParams(parsed) || checkUrlPath(parsed);
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
/**
|
|
2001
|
+
* Host of a flagged URL — enough for the warning to name the destination
|
|
2002
|
+
* without echoing the payload-bearing query/fragment.
|
|
2003
|
+
* @param {string} url
|
|
2004
|
+
* @returns {string}
|
|
2005
|
+
*/
|
|
2006
|
+
export function urlHost(url) {
|
|
2007
|
+
// A `data:` URI has no host; name the channel rather than echoing the payload.
|
|
2008
|
+
if (/^\s*data:/i.test(url)) return "(inline data: URI)";
|
|
2009
|
+
let parsed;
|
|
2010
|
+
try {
|
|
2011
|
+
parsed = new URL(url, RELATIVE_URL_BASE);
|
|
2012
|
+
} catch {
|
|
2013
|
+
// checkExfilUrl flags via regex before parsing, so it can hand us a URL
|
|
2014
|
+
// WHATWG rejects (e.g. a non-ASCII host).
|
|
2015
|
+
return "(unparsable URL)";
|
|
2016
|
+
}
|
|
2017
|
+
if (
|
|
2018
|
+
parsed.origin === RELATIVE_URL_BASE &&
|
|
2019
|
+
!url.startsWith(RELATIVE_URL_BASE)
|
|
2020
|
+
) {
|
|
2021
|
+
return "(relative URL)";
|
|
2022
|
+
}
|
|
2023
|
+
return parsed.host;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
/**
|
|
2027
|
+
* True when `url` is an absolute, off-origin target (an authority that is not
|
|
2028
|
+
* the relative-resolution sentinel). Used for form `action`/`formaction` and
|
|
2029
|
+
* `meta refresh` URLs, where pointing off the page's own origin is the
|
|
2030
|
+
* exfil/redirect signal regardless of the query shape.
|
|
2031
|
+
* @param {string} url
|
|
2032
|
+
* @returns {boolean}
|
|
2033
|
+
*/
|
|
2034
|
+
function isOffOrigin(url) {
|
|
2035
|
+
let parsed;
|
|
2036
|
+
try {
|
|
2037
|
+
parsed = new URL(url, RELATIVE_URL_BASE);
|
|
2038
|
+
} catch {
|
|
2039
|
+
return false;
|
|
2040
|
+
}
|
|
2041
|
+
return (
|
|
2042
|
+
parsed.origin !== RELATIVE_URL_BASE || url.startsWith(RELATIVE_URL_BASE)
|
|
2043
|
+
);
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/**
|
|
2047
|
+
* The redirect URL of a `<meta http-equiv="refresh">` content value
|
|
2048
|
+
* (`"5; url=https://…"`), or null when it carries no `url=` target.
|
|
2049
|
+
* @param {string} content
|
|
2050
|
+
* @returns {string | null}
|
|
2051
|
+
*/
|
|
2052
|
+
function metaRefreshUrl(content) {
|
|
2053
|
+
// Do NOT exclude `;` from the URL run: the `;` separates the timeout from
|
|
2054
|
+
// `url=` BEFORE the target, while WITHIN the target it is a legal query
|
|
2055
|
+
// sub-delimiter — excluding it truncated a `?a=1;b=<blob>` exfil tail. The
|
|
2056
|
+
// optional leading quote is consumed and the run then stops at the closing
|
|
2057
|
+
// quote (quoted) or at whitespace (unquoted); a single group keeps both forms
|
|
2058
|
+
// without an unreachable no-match arm.
|
|
2059
|
+
const match = /** @type {{ groups: { url: string } } | null} */ (
|
|
2060
|
+
content.match(/url\s*=\s*['"]?(?<url>[^'"\s]+)/i)
|
|
2061
|
+
);
|
|
2062
|
+
return match ? match.groups.url : null;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// HTML whitespace per the `srcset` grammar (ASCII whitespace).
|
|
2066
|
+
const SRCSET_WS_RE = /[ \t\n\f\r]/;
|
|
2067
|
+
|
|
2068
|
+
/**
|
|
2069
|
+
* URLs of a `srcset` value, parsed per the WHATWG "parse a srcset attribute"
|
|
2070
|
+
* grammar rather than a naive `split(",")`: a candidate's URL is a run of
|
|
2071
|
+
* non-whitespace characters, so a URL that itself contains commas (a `data:`
|
|
2072
|
+
* URI, or a query with `,`) is kept intact. A comma only separates candidates
|
|
2073
|
+
* when it trails the URL run or follows the (paren-aware) descriptor. Trailing
|
|
2074
|
+
* commas on the URL run mark a candidate with no descriptor.
|
|
2075
|
+
* @param {string} value
|
|
2076
|
+
* @returns {string[]}
|
|
2077
|
+
*/
|
|
2078
|
+
function parseSrcset(value) {
|
|
2079
|
+
/** @type {string[]} */ const urls = [];
|
|
2080
|
+
let i = 0;
|
|
2081
|
+
const n = value.length;
|
|
2082
|
+
while (i < n) {
|
|
2083
|
+
while (i < n && (SRCSET_WS_RE.test(value[i]) || value[i] === ",")) i++;
|
|
2084
|
+
const start = i;
|
|
2085
|
+
while (i < n && !SRCSET_WS_RE.test(value[i])) i++;
|
|
2086
|
+
const run = value.slice(start, i);
|
|
2087
|
+
const url = run.replace(/,+$/, "");
|
|
2088
|
+
if (url) urls.push(url);
|
|
2089
|
+
// A URL run ending in a comma is a bare candidate (no descriptor); the
|
|
2090
|
+
// comma already delimits the next one, so skip descriptor parsing.
|
|
2091
|
+
if (run.endsWith(",")) continue;
|
|
2092
|
+
// Otherwise consume the descriptor up to the first unparenthesized comma.
|
|
2093
|
+
let depth = 0;
|
|
2094
|
+
while (i < n) {
|
|
2095
|
+
const c = value[i];
|
|
2096
|
+
if (c === "(") depth++;
|
|
2097
|
+
else if (c === ")" && depth > 0) depth--;
|
|
2098
|
+
else if (c === "," && depth === 0) {
|
|
2099
|
+
i++;
|
|
2100
|
+
break;
|
|
2101
|
+
}
|
|
2102
|
+
i++;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
return urls;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/**
|
|
2109
|
+
* Candidate URLs of a `srcset` (a "url descriptor" string parsed per the HTML
|
|
2110
|
+
* grammar) or `ping` (a space-separated url list rehype delivers as an array)
|
|
2111
|
+
* attribute. An absent attribute (neither string nor array) yields none.
|
|
2112
|
+
* @param {unknown} value
|
|
2113
|
+
* @returns {string[]}
|
|
2114
|
+
*/
|
|
2115
|
+
function multiUrlAttr(value) {
|
|
2116
|
+
if (Array.isArray(value))
|
|
2117
|
+
return value
|
|
2118
|
+
.map((candidate) => String(candidate).trim().split(/\s+/)[0])
|
|
2119
|
+
.filter(Boolean);
|
|
2120
|
+
if (typeof value === "string") return parseSrcset(value);
|
|
2121
|
+
return [];
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/**
|
|
2125
|
+
* URL-bearing attributes of every HTML element in `text`, parsed with rehype so
|
|
2126
|
+
* quoting/casing/entities are handled correctly (no hand-rolled tag regex).
|
|
2127
|
+
* `context` selects the per-URL check the caller applies: resource URLs get the
|
|
2128
|
+
* exfil-shape test; form-submission and meta-refresh targets additionally flag
|
|
2129
|
+
* any absolute off-origin destination.
|
|
2130
|
+
* @param {string} text
|
|
2131
|
+
* @returns {Array<{ url: string, isImage: boolean, context: "resource" | "form" | "refresh" }>}
|
|
2132
|
+
*/
|
|
2133
|
+
function extractHtmlUrls(text) {
|
|
2134
|
+
const tree = unified().use(rehypeParse, { fragment: true }).parse(text);
|
|
2135
|
+
/** @type {Array<{ url: string, isImage: boolean, context: "resource" | "form" | "refresh" }>} */
|
|
2136
|
+
const urls = [];
|
|
2137
|
+
visit(tree, "element", (/** @type {any} */ node) => {
|
|
2138
|
+
// hast element nodes always carry a `properties` object (parse5 sets it).
|
|
2139
|
+
const props = node.properties;
|
|
2140
|
+
const isImage = node.tagName === "img";
|
|
2141
|
+
for (const key of ["src", "href", "background"])
|
|
2142
|
+
if (typeof props[key] === "string")
|
|
2143
|
+
urls.push({ url: props[key], isImage, context: "resource" });
|
|
2144
|
+
for (const key of ["srcSet", "ping"])
|
|
2145
|
+
for (const url of multiUrlAttr(props[key]))
|
|
2146
|
+
urls.push({ url, isImage, context: "resource" });
|
|
2147
|
+
for (const key of ["action", "formAction"])
|
|
2148
|
+
if (typeof props[key] === "string")
|
|
2149
|
+
urls.push({ url: props[key], isImage: false, context: "form" });
|
|
2150
|
+
// rehype delivers `http-equiv` as an array (comma-separated); join it back
|
|
2151
|
+
// so a `refresh` directive is matched regardless of how it was tokenized.
|
|
2152
|
+
const httpEquiv = Array.isArray(props.httpEquiv)
|
|
2153
|
+
? props.httpEquiv.join(",").toLowerCase()
|
|
2154
|
+
: "";
|
|
2155
|
+
if (
|
|
2156
|
+
node.tagName === "meta" &&
|
|
2157
|
+
httpEquiv.includes("refresh") &&
|
|
2158
|
+
typeof props.content === "string"
|
|
2159
|
+
) {
|
|
2160
|
+
const url = metaRefreshUrl(props.content);
|
|
2161
|
+
if (url) urls.push({ url, isImage: false, context: "refresh" });
|
|
2162
|
+
}
|
|
2163
|
+
});
|
|
2164
|
+
return urls;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
// Reason for an off-origin submission/redirect target by context; null leaves
|
|
2168
|
+
// the URL to the exfil-shape check alone.
|
|
2169
|
+
const OFF_ORIGIN_REASON = {
|
|
2170
|
+
form: "off-origin form action",
|
|
2171
|
+
refresh: "off-origin meta-refresh redirect",
|
|
2172
|
+
};
|
|
2173
|
+
|
|
2174
|
+
/**
|
|
2175
|
+
* Layer 3: report data-exfil-shaped URLs in markdown links/images/definitions
|
|
2176
|
+
* and HTML attributes (src/href/background/srcset/ping, form action/formaction,
|
|
2177
|
+
* meta-refresh). Detection only — the text is never modified; the caller
|
|
2178
|
+
* surfaces the threats as a warning.
|
|
2179
|
+
* @param {string} text
|
|
2180
|
+
* @returns {Array<{ isImage: boolean, reason: string, target: string }> | null}
|
|
2181
|
+
*/
|
|
2182
|
+
export function detectExfil(text) {
|
|
2183
|
+
if (!MD_LINK_HINT.test(text) && !HTML_TAG_PRESENT.test(text)) return null;
|
|
2184
|
+
|
|
2185
|
+
/** @type {Array<{ isImage: boolean, reason: string, target: string }>} */
|
|
2186
|
+
const threats = [];
|
|
2187
|
+
|
|
2188
|
+
try {
|
|
2189
|
+
// Remark AST handles markdown links/images/definitions (balanced parens,
|
|
2190
|
+
// reference links) correctly, unlike a hand-rolled regex.
|
|
2191
|
+
const tree = mdParser.parse(text);
|
|
2192
|
+
visit(tree, (node) => {
|
|
2193
|
+
if (
|
|
2194
|
+
node.type !== "link" &&
|
|
2195
|
+
node.type !== "image" &&
|
|
2196
|
+
node.type !== "definition"
|
|
2197
|
+
)
|
|
2198
|
+
return;
|
|
2199
|
+
const reason = checkExfilUrl(node.url);
|
|
2200
|
+
if (!reason) return;
|
|
2201
|
+
threats.push({
|
|
2202
|
+
isImage: node.type === "image",
|
|
2203
|
+
reason,
|
|
2204
|
+
target: urlHost(node.url),
|
|
2205
|
+
});
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
// HTML attributes (not AST nodes in remark).
|
|
2209
|
+
for (const { url, isImage, context } of extractHtmlUrls(text)) {
|
|
2210
|
+
const reason =
|
|
2211
|
+
checkExfilUrl(url) ||
|
|
2212
|
+
(context !== "resource" && isOffOrigin(url)
|
|
2213
|
+
? OFF_ORIGIN_REASON[context]
|
|
2214
|
+
: null);
|
|
2215
|
+
if (!reason) continue;
|
|
2216
|
+
threats.push({ isImage, reason, target: urlHost(url) });
|
|
2217
|
+
}
|
|
2218
|
+
} catch {
|
|
2219
|
+
// The parse/visit blew up (stack overflow on pathological nesting). Fail
|
|
2220
|
+
// CLOSED so the never-throw contract holds: report one sentinel threat so
|
|
2221
|
+
// the caller still warns rather than crashing, since an input too nested to
|
|
2222
|
+
// scan could itself be hiding an exfil URL.
|
|
2223
|
+
return [
|
|
2224
|
+
{
|
|
2225
|
+
isImage: false,
|
|
2226
|
+
reason: "input too deeply nested to scan for exfil URLs",
|
|
2227
|
+
target: "(unparseable HTML)",
|
|
2228
|
+
},
|
|
2229
|
+
];
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
return threats.length > 0 ? threats : null;
|
|
2233
|
+
}
|