agent-sanitizer 2.64.0 → 2.64.1

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/src/html.mjs CHANGED
@@ -50,10 +50,14 @@ import cssWalk from "css-tree/walker";
50
50
  import cssGenerate from "css-tree/generator";
51
51
  // @ts-ignore -- see above.
52
52
  import { ident as cssIdent } from "css-tree/utils";
53
+ // @ts-ignore -- see above. Built over ./css-syntax-data.mjs (bundle size).
54
+ import { Lexer as CssLexer } from "css-tree/lexer";
55
+ import { CSS_SYNTAX } from "./css-syntax-data.mjs";
53
56
  import { unified } from "unified";
54
57
  import remarkParse from "remark-parse";
55
58
  import remarkGfm from "./gfm.mjs";
56
59
  import { parseHtmlFragment } from "./html-tree-adapter.mjs";
60
+ import { URL_ATTRIBUTES } from "./url-attributes.mjs";
57
61
  import { SKIP, EXIT } from "unist-util-visit";
58
62
  import {
59
63
  HTML_TAG_PRESENT,
@@ -343,14 +347,14 @@ function isHidingFilter(node) {
343
347
 
344
348
  // One `clip: rect(...)` edge as a number and unit, or null for `auto`/any
345
349
  // non-length token (unresolvable → fail open). A bare Number carries unit "".
350
+ // A `%` edge is invalid (rect() takes <length> | auto), so the cascade has
351
+ // already dropped the declaration and it is a non-length here too.
346
352
  /** @param {any} token @returns {{ num: number, unit: string } | null} */
347
353
  function clipEdge(token) {
348
354
  if (token.type === "Dimension")
349
355
  return { num: parseFloat(token.value), unit: token.unit };
350
356
  if (token.type === "Number")
351
357
  return { num: parseFloat(token.value), unit: "" };
352
- if (token.type === "Percentage")
353
- return { num: parseFloat(token.value), unit: "%" };
354
358
  return null;
355
359
  }
356
360
 
@@ -867,9 +871,7 @@ function isClipPathHidden(node) {
867
871
  const radius = valueTokens(fn)[0];
868
872
  if (
869
873
  radius &&
870
- (radius.type === "Number" ||
871
- radius.type === "Dimension" ||
872
- radius.type === "Percentage") &&
874
+ (radius.type === "Number" || radius.type === "Dimension") &&
873
875
  parseFloat(radius.value) === 0
874
876
  )
875
877
  return true;
@@ -1193,38 +1195,34 @@ function canonicalizeValue(valueNode) {
1193
1195
  });
1194
1196
  }
1195
1197
 
1196
- /**
1197
- * Parse a style string into a map of decoded lowercase property name -> parsed
1198
- * and canonicalized value node, via css-tree's tolerant declaration-list
1199
- * parser. This replaces the hand-rolled declaration splitter, per-declaration
1200
- * salvage, escape decoder, and `!important` stripper in one pass: css-tree
1201
- * recovers per-declaration exactly as a browser does (a bogus declaration is
1202
- * dropped, the rest kept), keeps a `;` inside a string/`url()`/paren as part of
1203
- * the value, and exposes `!important` as `node.important` (so an escaped
1204
- * spelling `none!\69mportant` is stripped for free). Property names are
1205
- * escape-decoded and gated to real CSS idents; anything else is dropped (fail
1206
- * open). Later declarations win, per the cascade.
1207
- * @param {string} styleStr
1208
- * @returns {Map<string, any>}
1209
- */
1210
- function parseDeclarations(styleStr) {
1211
- /** @type {Map<string, any>} */
1212
- const decls = new Map();
1213
- let ast;
1198
+ // css-tree's tolerant parse, or null (fail open, never throw) on the throw only
1199
+ // a pathological input reaches: onParseError recovers ordinary bad CSS.
1200
+ /** @param {string} css @param {"declarationList" | "stylesheet"} context */
1201
+ function parseCss(css, context) {
1214
1202
  try {
1215
- ast = cssParse(styleStr, {
1216
- context: "declarationList",
1217
- parseValue: true,
1203
+ return cssParse(css, {
1204
+ context,
1218
1205
  parseCustomProperty: false,
1219
1206
  onParseError() {},
1220
1207
  });
1221
- /* c8 ignore start -- the only reachable throw path is a non-string / deeply
1222
- pathological input (css-tree's onParseError recovers ordinary bad CSS);
1223
- this fail-open upholds the module's never-throws contract for those. */
1208
+ /* c8 ignore start */
1224
1209
  } catch {
1225
- return decls;
1210
+ return null;
1226
1211
  }
1227
1212
  /* c8 ignore stop */
1213
+ }
1214
+
1215
+ /** @typedef {{ property: string, value: any, important: boolean, index: number }} StyleDeclaration */
1216
+
1217
+ // A style string's declarations in source order. Property names are
1218
+ // escape-decoded and gated to real CSS idents (else dropped, fail open).
1219
+ /** @param {string} styleStr @returns {StyleDeclaration[]} */
1220
+ function parseDeclarations(styleStr) {
1221
+ /** @type {StyleDeclaration[]} */
1222
+ const declarations = [];
1223
+ const ast = parseCss(styleStr, "declarationList");
1224
+ /* c8 ignore next -- see parseCss */
1225
+ if (!ast) return declarations;
1228
1226
  cssWalk(ast, {
1229
1227
  visit: "Declaration",
1230
1228
  enter(/** @type {any} */ node) {
@@ -1232,105 +1230,273 @@ function parseDeclarations(styleStr) {
1232
1230
  // token; property is escape-decoded then gated to a clean CSS ident.
1233
1231
  const property = cssIdent.decode(node.property).trim().toLowerCase();
1234
1232
  if (!CSS_PROPERTY_IDENT_RE.test(property)) return;
1233
+ // css-tree accepts ANY ident after `!`; a browser accepts only
1234
+ // `important` and drops the whole declaration otherwise (`none!imp`).
1235
+ const flag =
1236
+ typeof node.important === "string"
1237
+ ? cssIdent.decode(node.important).toLowerCase()
1238
+ : "";
1239
+ if (flag !== "" && flag !== "important") return;
1235
1240
  canonicalizeValue(node.value);
1236
- decls.set(property, node.value);
1241
+ declarations.push({
1242
+ property,
1243
+ value: node.value,
1244
+ important: node.important === true || flag === "important",
1245
+ index: declarations.length,
1246
+ });
1237
1247
  },
1238
1248
  });
1239
- return decls;
1240
- }
1249
+ return declarations;
1250
+ }
1251
+
1252
+ // A longer value is not validated (and so counts as valid): css-tree's matcher
1253
+ // backtracks over `||`/`&&` grammars and gives up at 15000 steps.
1254
+ const LEXER_TOKEN_LIMIT = 64;
1255
+
1256
+ // Lexer validations per style attribute, shared by every property so repeating
1257
+ // ONE property cannot outrun it; a style that exhausts it reads as hidden.
1258
+ const VALIDATION_BUDGET = 128;
1259
+
1260
+ /** @type {any} */
1261
+ let cssLexer = null;
1262
+
1263
+ // True when a browser drops this declaration as invalid, so it takes no part
1264
+ // in the cascade (`display:none;display:bogus` renders hidden). Only a lexer
1265
+ // MISMATCH drops; whatever it cannot decide stays in — a `var()`, an unknown
1266
+ // property, a long value, a `Raw` value, or a `url(` spelled with an escape
1267
+ // (`\75 rl(`), which a browser decodes and css-tree does not.
1268
+ /** @param {StyleDeclaration} declaration @returns {boolean} */
1269
+ function isDroppedDeclaration(declaration) {
1270
+ let tokens = 0;
1271
+ let unreadable = false;
1272
+ cssWalk(declaration.value, (/** @type {any} */ node) => {
1273
+ tokens++;
1274
+ if (
1275
+ node.type === "Raw" ||
1276
+ (node.type === "Function" && node.name === "url")
1277
+ )
1278
+ unreadable = true;
1279
+ });
1280
+ if (unreadable || tokens > LEXER_TOKEN_LIMIT) return false;
1281
+ cssLexer ??= new CssLexer(
1282
+ { generic: true, ...CSS_SYNTAX },
1283
+ { generate: cssGenerate },
1284
+ );
1285
+ const { error } = cssLexer.matchProperty(
1286
+ declaration.property,
1287
+ declaration.value,
1288
+ );
1289
+ return error?.rawMessage === "Mismatch";
1290
+ }
1291
+
1292
+ // The shorthands that RESET a longhand read here (`font-size:0;font:14px serif`
1293
+ // renders 14px text); `all` resets every one and is appended to each family.
1294
+ /** @type {Record<string, string[]>} */
1295
+ const RESETTING_SHORTHANDS = {
1296
+ "font-size": ["font"],
1297
+ "background-color": ["background"],
1298
+ "background-image": ["background"],
1299
+ "background-clip": ["background"],
1300
+ "-webkit-text-stroke-color": ["-webkit-text-stroke"],
1301
+ left: ["inset"],
1302
+ top: ["inset"],
1303
+ right: ["inset"],
1304
+ bottom: ["inset"],
1305
+ };
1241
1306
 
1242
1307
  /**
1243
- * @param {string} styleStr
1244
- * @returns {boolean}
1245
- */
1246
- export function isHiddenStyle(styleStr) {
1247
- const decls = parseDeclarations(styleStr);
1248
- if (decls.size === 0) return false;
1308
+ * `winnerOf`: the declaration applied (its own, a shorthand's or `all`'s);
1309
+ * `nodeOf`/`textOf`: its value when it is the property's OWN (else null/"");
1310
+ * `exhausted`: a lookup ran out of {@link VALIDATION_BUDGET}.
1311
+ * @typedef {{
1312
+ * winnerOf: (property: string) => StyleDeclaration | null,
1313
+ * nodeOf: (property: string) => any,
1314
+ * textOf: (property: string) => string,
1315
+ * exhausted: boolean,
1316
+ * }} Cascade
1317
+ */
1318
+
1319
+ // The {@link Cascade} of a style: invalid declarations drop out, `!important`
1320
+ // beats normal, later beats earlier. Lazy, so only what is read is validated.
1321
+ /** @param {StyleDeclaration[]} declarations @returns {Cascade} */
1322
+ function resolveCascade(declarations) {
1323
+ /** @type {Map<string, StyleDeclaration[]>} */
1324
+ const byProperty = new Map();
1325
+ for (const declaration of declarations) {
1326
+ const list = byProperty.get(declaration.property) ?? [];
1327
+ list.push(declaration);
1328
+ byProperty.set(declaration.property, list);
1329
+ }
1330
+ /** @type {Map<StyleDeclaration, boolean>} */
1331
+ const dropped = new Map();
1332
+ /** @type {Map<string, StyleDeclaration | null>} */
1333
+ const winners = new Map();
1334
+ let budget = VALIDATION_BUDGET;
1335
+ /** @param {string} property */
1336
+ const winnerOf = (property) => {
1337
+ const known = winners.get(property);
1338
+ if (known !== undefined) return known;
1339
+ const candidates = [
1340
+ property,
1341
+ ...(RESETTING_SHORTHANDS[property] ?? []),
1342
+ "all",
1343
+ ]
1344
+ .flatMap((member) => byProperty.get(member) ?? [])
1345
+ .sort(
1346
+ (left, right) =>
1347
+ Number(right.important) - Number(left.important) ||
1348
+ right.index - left.index,
1349
+ );
1350
+ let winner = null;
1351
+ for (const candidate of candidates) {
1352
+ if (!dropped.has(candidate)) {
1353
+ if (budget === 0) {
1354
+ cascade.exhausted = true;
1355
+ break;
1356
+ }
1357
+ budget--;
1358
+ dropped.set(candidate, isDroppedDeclaration(candidate));
1359
+ }
1360
+ if (!dropped.get(candidate)) {
1361
+ winner = candidate;
1362
+ break;
1363
+ }
1364
+ }
1365
+ winners.set(property, winner);
1366
+ return winner;
1367
+ };
1368
+ /** @type {Cascade} */
1369
+ const cascade = {
1370
+ winnerOf,
1371
+ nodeOf(property) {
1372
+ const winner = winnerOf(property);
1373
+ return winner?.property === property ? winner.value : null;
1374
+ },
1375
+ textOf: (property) =>
1376
+ declText(cascade.nodeOf(property)).trim().toLowerCase(),
1377
+ exhausted: false,
1378
+ };
1379
+ return cascade;
1380
+ }
1249
1381
 
1250
- /** @param {string} key */
1251
- const nodeOf = (key) => decls.get(key) ?? null;
1252
- // `!important` is already excluded by css-tree; escapes are decoded in
1253
- // declText. Trim/lowercase for the case-insensitive keyword compares.
1254
- /** @param {string} key */
1255
- const textOf = (key) => declText(decls.get(key)).trim().toLowerCase();
1382
+ const NO_CASCADE = resolveCascade([]);
1256
1383
 
1257
- if (textOf("display") === "none") return true;
1258
- if (textOf("visibility") === "hidden" || textOf("visibility") === "collapse")
1259
- return true;
1384
+ // The inherited hides, which a descendant can set back (see
1385
+ // {@link regionVerdicts}); every other hide is of the BOX and final.
1386
+ /** @typedef {"font-size" | "visibility" | "color" | "text-indent"} InheritedHide */
1387
+
1388
+ // How one element's style hides its text: `"box"` (or an exhausted budget),
1389
+ // else the inherited kinds (empty when visible). The box-only hides do not
1390
+ // apply to a non-replaced `inlineBox` (`<span style="transform:scale(0)">`).
1391
+ /** @param {Cascade} cascade @param {boolean} inlineBox @returns {Set<"box" | InheritedHide>} */
1392
+ function styleHides(cascade, inlineBox) {
1393
+ const { nodeOf, textOf } = cascade;
1394
+ const BOX = new Set(/** @type {const} */ (["box"]));
1395
+
1396
+ if (textOf("display") === "none") return BOX;
1260
1397
  // `content-visibility:hidden` skips rendering the element's contents entirely
1261
1398
  // (not even laid out), so the text is invisible to a human but present in the
1262
1399
  // source. `auto`/`visible` keep it rendered and must not match.
1263
- if (textOf("content-visibility") === "hidden") return true;
1400
+ if (!inlineBox && textOf("content-visibility") === "hidden") return BOX;
1264
1401
 
1265
1402
  // CSS clamps opacity to [0,1], so any NEGATIVE value renders fully
1266
1403
  // transparent — `< EPSILON` (no `Math.abs`) treats `-1`/`-0.5` as hidden.
1267
- // `opacity` is a <number> or <percentage>; any other token (`0px`, a bare
1268
- // Dimension) is an INVALID declaration a browser ignores (element stays
1269
- // visible), so fail open on anything that isn't a single Number/Percentage.
1270
1404
  const opacity = soleToken(nodeOf("opacity"));
1271
1405
  if (opacity) {
1272
1406
  let fraction = null;
1273
1407
  if (opacity.type === "Number") fraction = parseFloat(opacity.value);
1274
1408
  else if (opacity.type === "Percentage")
1275
1409
  fraction = parseFloat(opacity.value) / 100;
1276
- if (fraction !== null && fraction < NEAR_ZERO_EPSILON) return true;
1410
+ if (fraction !== null && fraction < NEAR_ZERO_EPSILON) return BOX;
1277
1411
  }
1278
1412
 
1279
- // `height`/`width` are deliberately NOT tested standalone here: with the
1280
- // default `overflow:visible`, a zero-sized box still paints its overflowing
1281
- // children, so a bare `width:0`/`height:0` leaves content on screen.
1282
- // `isOverflowHidden` below already covers the case where a zero dimension
1283
- // DOES hide content — gated on `overflow:hidden` also being present.
1284
- // `font-size:0`, in contrast, reliably collapses text to nothing on its own.
1285
- if (isNearZeroLength(nodeOf("font-size"))) return true;
1286
- // The `font` shorthand also carries the font-size, so a `font:0px/1 serif`
1287
- // collapses text just like the longhand — check its size token too.
1288
- if (isFontShorthandHidden(nodeOf("font"))) return true;
1413
+ if (isPositionedOffscreen(nodeOf, textOf)) return BOX;
1414
+ // Clipped to nothing: the modern equivalent of the legacy `clip: rect(0…)`.
1415
+ if (isClipPathHidden(nodeOf("clip-path"))) return BOX;
1416
+ if (!inlineBox && isHidingTransform(nodeOf("transform"))) return BOX;
1417
+ if (isHidingFilter(nodeOf("filter"))) return BOX;
1418
+ if (!inlineBox && isOverflowHidden(nodeOf, textOf)) return BOX;
1419
+
1420
+ /** @type {Set<"box" | InheritedHide>} */
1421
+ const hides = new Set();
1422
+ if (textOf("visibility") === "hidden" || textOf("visibility") === "collapse")
1423
+ hides.add("visibility");
1424
+ // A bare `width:0`/`height:0` still paints overflowing children (the
1425
+ // `overflow:hidden` case is above); `font-size:0` collapses text on its own.
1426
+ if (
1427
+ isNearZeroLength(nodeOf("font-size")) ||
1428
+ isFontShorthandHidden(nodeOf("font"))
1429
+ )
1430
+ hides.add("font-size");
1431
+ if (!inlineBox && isOffscreenOffset(nodeOf("text-indent")))
1432
+ hides.add("text-indent");
1433
+ if (isColorHidden(colorState(cascade, null))) hides.add("color");
1434
+ return cascade.exhausted ? BOX : hides;
1435
+ }
1289
1436
 
1290
- if (isPositionedOffscreen(nodeOf, textOf)) return true;
1437
+ const INHERITED_COLOR_KEYWORDS = new Set(["inherit", "unset", "currentcolor"]);
1291
1438
 
1292
- if (isOffscreenOffset(nodeOf("text-indent"))) return true;
1439
+ /**
1440
+ * `color` and a concrete `-webkit-text-fill-color` (`textFill`) inherit
1441
+ * SEPARATELY, so a child's `color` does not undo an inherited transparent fill.
1442
+ * @typedef {{ color: string, textFill: string, backdrop: string, imageLayer: boolean, paintedVisible: boolean }} ColorState
1443
+ */
1293
1444
 
1294
- // Clipped to nothing: the modern equivalent of the legacy `clip: rect(0…)`.
1295
- if (isClipPathHidden(nodeOf("clip-path"))) return true;
1296
- if (isHidingTransform(nodeOf("transform"))) return true;
1297
- if (isHidingFilter(nodeOf("filter"))) return true;
1298
-
1299
- // Same-color text on its background (white-on-white) and fully transparent
1300
- // text are invisible to a human but plain text to the model. Colors are
1301
- // canonicalized so `white`/`#fff`/`rgb(255,255,255)` mixes still compare.
1302
- // The color actually PAINTED onto the glyphs: `-webkit-text-fill-color`
1303
- // overrides `color` for the fill when it is concrete, so both hide branches
1304
- // must reason about this effective fill, not the raw `color` property — else a
1305
- // `color:#fff;-webkit-text-fill-color:#000` element (black text) is compared as
1306
- // white and spliced white-on-white.
1307
- const color = canonicalizeColor(textOf("color"));
1308
- const fillOverride = canonicalizeColor(textOf("-webkit-text-fill-color"));
1309
- const effectiveColor = isConcreteColor(fillOverride) ? fillOverride : color;
1310
- // `color:transparent` (or a transparent fill override) hides text — UNLESS the
1311
- // glyphs are painted by a background-clip:text gradient, a concrete
1312
- // -webkit-text-fill-color, or a text stroke, in which case the text is visible.
1313
- if (effectiveColor === "transparent" && !isTextPaintedVisible(textOf))
1314
- return true;
1315
- const background =
1445
+ // One element's color state, layered over its parent's (`inherited`).
1446
+ /** @param {Cascade} cascade @param {ColorState | null} inherited @returns {ColorState} */
1447
+ function colorState({ nodeOf, textOf }, inherited) {
1448
+ const color = textOf("color");
1449
+ const textFill = textOf("-webkit-text-fill-color");
1450
+ const ownTextFill = canonicalizeColor(textFill);
1451
+ const ownBackdrop =
1316
1452
  canonicalizeColor(textOf("background-color")) ||
1317
1453
  backgroundColor(nodeOf("background"));
1318
- // Only flag same-color when BOTH sides resolve to a concrete color (`#rrggbb`
1319
- // or `transparent`), AND no background IMAGE layer is present (an image can
1320
- // make same-colored text readable). `var(--x)`, `inherit`, and `currentColor`
1321
- // canonicalize to their raw token, so two identical unresolved tokens (e.g. the
1322
- // ubiquitous `color:var(--fg);background:var(--fg)`, which resolve to DIFFERENT
1323
- // effective colors) would otherwise read as hidden and splice out visible text.
1324
- // Fail open on anything we can't resolve.
1325
- if (
1326
- effectiveColor &&
1327
- effectiveColor === background &&
1328
- isConcreteColor(effectiveColor) &&
1329
- !hasImageLayer(nodeOf)
1330
- )
1331
- return true;
1454
+ let inheritedTextFill = "";
1455
+ if (textFill === "" || INHERITING_KEYWORDS.has(textFill))
1456
+ inheritedTextFill = inherited?.textFill ?? "";
1457
+ return {
1458
+ // `color` is inherited, so these spellings all take the parent's.
1459
+ color:
1460
+ (INHERITED_COLOR_KEYWORDS.has(color) ? "" : canonicalizeColor(color)) ||
1461
+ (inherited?.color ?? ""),
1462
+ textFill: isConcreteColor(ownTextFill) ? ownTextFill : inheritedTextFill,
1463
+ backdrop:
1464
+ ownBackdrop && ownBackdrop !== "transparent"
1465
+ ? ownBackdrop
1466
+ : (inherited?.backdrop ?? ownBackdrop),
1467
+ imageLayer: hasImageLayer(nodeOf) || (inherited?.imageLayer ?? false),
1468
+ paintedVisible: isTextPaintedVisible(textOf),
1469
+ };
1470
+ }
1471
+
1472
+ // True for a transparent fill not painted some other way, or a fill equal to
1473
+ // its backdrop. Same-color needs both sides concrete and no image layer: two
1474
+ // identical unresolved tokens (`color:var(--fg);background:var(--fg)`) can
1475
+ // resolve to DIFFERENT colors.
1476
+ /** @param {ColorState} state @returns {boolean} */
1477
+ function isColorHidden(state) {
1478
+ const fill = state.textFill || state.color;
1479
+ if (fill === "transparent" && !state.paintedVisible) return true;
1480
+ return (
1481
+ fill !== "" &&
1482
+ fill === state.backdrop &&
1483
+ isConcreteColor(fill) &&
1484
+ !state.imageLayer
1485
+ );
1486
+ }
1332
1487
 
1333
- return isOverflowHidden(nodeOf, textOf);
1488
+ /**
1489
+ * True when an inline style hides its element's text, read as a block box
1490
+ * with nothing inherited.
1491
+ * @param {string} styleStr
1492
+ * @returns {boolean}
1493
+ */
1494
+ export function isHiddenStyle(styleStr) {
1495
+ const declarations = parseDeclarations(styleStr);
1496
+ return (
1497
+ declarations.length > 0 &&
1498
+ styleHides(resolveCascade(declarations), false).size > 0
1499
+ );
1334
1500
  }
1335
1501
 
1336
1502
  // Scripting / resource-loading tags whose PRESENCE is reported to the model
@@ -1406,33 +1572,317 @@ const RAW_TEXT_ELEMENTS = new Set([
1406
1572
  "plaintext",
1407
1573
  ]);
1408
1574
 
1575
+ // Keywords that take the parent's value of an inherited property: `revert`
1576
+ // reaches a UA style that sets none read here, and `revert-layer` is read alike.
1577
+ const INHERITING_KEYWORDS = new Set(
1578
+ "inherit unset revert revert-layer".split(" "),
1579
+ );
1580
+
1581
+ // The non-replaced elements the UA leaves `display:inline`. Custom elements are
1582
+ // left out: a component stylesheet this layer cannot see often blockifies one.
1583
+ const INLINE_ELEMENTS = new Set(
1584
+ `a abbr acronym b bdi bdo big cite code data del dfn em font i ins kbd
1585
+ label mark nobr q s samp small span strike strong sub sup time tt u
1586
+ var`.split(/\s+/),
1587
+ );
1588
+
1589
+ // `display` values that leave an inline element an inline box (an `inherit`
1590
+ // from an unseen parent reads as inline, keeping rendered text).
1591
+ const INLINE_DISPLAYS = new Set(
1592
+ ["", "inline", "inline flow", "contents", "initial"].concat(
1593
+ ...INHERITING_KEYWORDS,
1594
+ ),
1595
+ );
1596
+ // Absolute positioning, floating (CSS 2 §9.7) and being a flex/grid item
1597
+ // BLOCKIFY an inline element; `position` and `float` share one value set.
1598
+ const BLOCKIFYING_VALUES = new Set(
1599
+ "absolute fixed left right inline-start inline-end".split(" "),
1600
+ );
1601
+ const ITEM_BLOCKIFYING_DISPLAYS = new Set(
1602
+ "flex grid inline-flex inline-grid".split(" "),
1603
+ );
1604
+
1605
+ // Replaced elements: only `visibility` and the box hides reach the picture.
1606
+ const REPLACED_ELEMENTS = new Set(["img", "video", "audio", "canvas", "embed"]);
1607
+ /** @type {InheritedHide[]} */
1608
+ const TEXT_ONLY_HIDES = ["font-size", "color", "text-indent"];
1609
+
1610
+ /** @type {WeakMap<object, Cascade>} */
1611
+ const elementCascades = new WeakMap();
1612
+ // Each element's parent as a scan met it (hast keeps no parent links).
1613
+ /** @type {WeakMap<object, any>} */
1614
+ const parents = new WeakMap();
1615
+
1616
+ // An element's style cascade, memoized: it is read again as a parent.
1617
+ /** @param {any} node @returns {Cascade} */
1618
+ function elementCascade(node) {
1619
+ if (node?.type !== "element") return NO_CASCADE;
1620
+ let cascade = elementCascades.get(node);
1621
+ if (!cascade) {
1622
+ const style = node.properties?.style;
1623
+ const declarations =
1624
+ typeof style === "string" ? parseDeclarations(style) : [];
1625
+ cascade =
1626
+ declarations.length > 0 ? resolveCascade(declarations) : NO_CASCADE;
1627
+ elementCascades.set(node, cascade);
1628
+ }
1629
+ return cascade;
1630
+ }
1631
+
1632
+ // True when `node` lays out as a non-replaced INLINE box. A flex/grid `parent`
1633
+ // blockifies it; a `display:contents` parent defers to its own parent.
1634
+ /** @param {any} node @param {any} [parent] @returns {boolean} */
1635
+ function isInlineBox(node, parent) {
1636
+ if (!INLINE_ELEMENTS.has(node.tagName)) return false;
1637
+ const { textOf } = elementCascade(node);
1638
+ const display = textOf("display");
1639
+ if (!INLINE_DISPLAYS.has(display) && !display.includes("var(")) return false;
1640
+ if (
1641
+ BLOCKIFYING_VALUES.has(textOf("position")) ||
1642
+ BLOCKIFYING_VALUES.has(textOf("float"))
1643
+ )
1644
+ return false;
1645
+ let container = parent;
1646
+ while (elementCascade(container).textOf("display") === "contents")
1647
+ container = parents.get(container);
1648
+ return !elementCascade(container)
1649
+ .textOf("display")
1650
+ .split(" ")
1651
+ .some((token) => ITEM_BLOCKIFYING_DISPLAYS.has(token));
1652
+ }
1653
+
1654
+ // {@link styleHides} in element context. `aria-hidden` is deliberately NOT a
1655
+ // hide: it leaves the accessibility tree only, and a sighted reader sees it.
1656
+ /** @param {any} node @param {any} [parent] @returns {Set<"box" | InheritedHide>} */
1657
+ function elementHides(node, parent) {
1658
+ if (node.type !== "element") return new Set();
1659
+ if (parent) parents.set(node, parent);
1660
+ const { properties = {} } = node;
1661
+ if (properties.hidden !== undefined && properties.hidden !== null)
1662
+ return new Set(["box"]);
1663
+ const cascade = elementCascade(node);
1664
+ if (cascade === NO_CASCADE) return new Set();
1665
+ const hides = styleHides(cascade, isInlineBox(node, parent));
1666
+ if (REPLACED_ELEMENTS.has(node.tagName))
1667
+ for (const kind of TEXT_ONLY_HIDES) hides.delete(kind);
1668
+ return hides;
1669
+ }
1670
+
1409
1671
  /**
1410
- * True for an element a rendered page would not show: `hidden` attribute or a
1411
- * hiding inline style. Works on both hast nodes and parseHtmlTag results.
1672
+ * True for an element a rendered page would not show (`hidden` or a hiding
1673
+ * inline style, in element context). Takes hast nodes and parseHtmlTag results.
1412
1674
  * @param {any} node
1675
+ * @param {any} [parent] the element's parent, when known
1413
1676
  * @returns {boolean}
1414
1677
  */
1415
- export function isHiddenElement(node) {
1416
- if (node.type !== "element") return false;
1417
- const { properties = {} } = node;
1418
- if (properties.hidden !== undefined && properties.hidden !== null)
1419
- return true;
1420
- // `aria-hidden="true"` is deliberately NOT treated as a hiding signal: it
1421
- // removes the element only from the ACCESSIBILITY TREE, not the rendered
1422
- // page — a sighted human viewing the page still sees it (it's routinely
1423
- // used on decorative icons and icon-font glyphs, and on visible text
1424
- // duplicated for screen-reader dedup). Splicing on it would delete content
1425
- // a human plainly sees, which is real harm under the precision-over-recall
1426
- // doctrine for this layer.
1427
- if (properties.style && isHiddenStyle(properties.style)) return true;
1428
- return false;
1678
+ export function isHiddenElement(node, parent) {
1679
+ return elementHides(node, parent).size > 0;
1680
+ }
1681
+
1682
+ // What a zero parent font-size passes through unchanged.
1683
+ const PARENT_RELATIVE_FONT_UNITS = new Set(["em", "ex", "ch", "cap", "ic"]);
1684
+ const PARENT_RELATIVE_FONT_KEYWORDS = new Set(["smaller", "larger"]);
1685
+ const MATH_FUNCTIONS = new Set(["calc", "min", "max", "clamp"]);
1686
+
1687
+ // True when `kind`, in force on the parent, still hides the element's text:
1688
+ // only its own (non-inheriting) declaration can bring the text back.
1689
+ /** @param {InheritedHide} kind @param {Cascade} cascade the element's @param {boolean} inlineBox @param {ColorState} color the element's, layered over its parent's @returns {boolean} */
1690
+ function keepsHide(kind, cascade, inlineBox, color) {
1691
+ if (kind === "color") return isColorHidden(color);
1692
+ // An inline box sits on its parent's shifted first line whatever it sets.
1693
+ if (kind === "text-indent" && inlineBox) return true;
1694
+ const winner = cascade.winnerOf(kind);
1695
+ const text = declText(winner?.value).trim().toLowerCase();
1696
+ if (!winner || INHERITING_KEYWORDS.has(text)) return true;
1697
+ if (kind === "visibility")
1698
+ return (
1699
+ winner.property === kind && (text === "hidden" || text === "collapse")
1700
+ );
1701
+ if (kind === "text-indent")
1702
+ return winner.property === kind && isOffscreenOffset(winner.value);
1703
+ if (winner.property === "all") return false;
1704
+ // A `font` shorthand's size is its first non-Number size token (a bare
1705
+ // number there is a weight).
1706
+ const size =
1707
+ winner.property === "font"
1708
+ ? valueTokens(winner.value).find(
1709
+ (/** @type {any} */ token) =>
1710
+ token.type === "Dimension" ||
1711
+ token.type === "Percentage" ||
1712
+ (token.type === "Function" && MATH_FUNCTIONS.has(token.name)) ||
1713
+ (token.type === "Identifier" &&
1714
+ PARENT_RELATIVE_FONT_KEYWORDS.has(token.name)),
1715
+ )
1716
+ : soleToken(winner.value);
1717
+ // Relative sizes (`larger`, `150%`, `1.2em`, `calc(1em)`) stay zero.
1718
+ if (!size) return false;
1719
+ if (size.type === "Identifier")
1720
+ return PARENT_RELATIVE_FONT_KEYWORDS.has(size.name);
1721
+ if (size.type === "Number")
1722
+ return Math.abs(parseFloat(size.value)) < NEAR_ZERO_EPSILON;
1723
+ let keeps = true;
1724
+ cssWalk(size, (/** @type {any} */ node) => {
1725
+ keeps &&=
1726
+ ["Percentage", "Number", "Operator", "Parentheses"].includes(node.type) ||
1727
+ (node.type === "Function" && MATH_FUNCTIONS.has(node.name)) ||
1728
+ (node.type === "Dimension" &&
1729
+ (PARENT_RELATIVE_FONT_UNITS.has(node.unit) ||
1730
+ Math.abs(parseFloat(node.value)) < NEAR_ZERO_EPSILON));
1731
+ });
1732
+ return keeps;
1733
+ }
1734
+
1735
+ /** @typedef {{ kinds: Set<InheritedHide>, color: ColorState }} RegionState */
1736
+ // `neutral`: nothing to see either way; `mixed`: hidden and visible content.
1737
+ /** @typedef {"hidden" | "neutral" | "visible" | "mixed"} RegionVerdict */
1738
+
1739
+ // The {@link RegionVerdict} of every node under `root`, an element hidden by
1740
+ // the inherited `kinds` alone. Iterative (a preorder list read back to front
1741
+ // settles children first), so a deep region costs no stack.
1742
+ /** @param {any} root @param {Set<InheritedHide>} kinds @returns {Map<any, RegionVerdict>} */
1743
+ function regionVerdicts(root, kinds) {
1744
+ /** @type {Map<any, RegionVerdict>} */
1745
+ const verdicts = new Map();
1746
+ /** @type {Array<[any, any, RegionState]>} */
1747
+ const stack = [];
1748
+ /** @param {any} node @param {RegionState} state */
1749
+ const pushChildren = (node, state) => {
1750
+ for (const child of node.children) stack.push([child, node, state]);
1751
+ };
1752
+ pushChildren(root, {
1753
+ kinds,
1754
+ color: colorState(elementCascade(root), null),
1755
+ });
1756
+ const order = [root];
1757
+ while (stack.length > 0) {
1758
+ const [node, parent, state] = /** @type {[any, any, RegionState]} */ (
1759
+ stack.pop()
1760
+ );
1761
+ order.push(node);
1762
+ // A placeholder an earlier splice round left is nothing to see:
1763
+ // re-splicing one would count it twice.
1764
+ if (node.type === "text")
1765
+ verdicts.set(
1766
+ node,
1767
+ node.value.replace(LAYER2_PLACEHOLDER_RE, "").trim() === ""
1768
+ ? "neutral"
1769
+ : "hidden",
1770
+ );
1771
+ else if (node.type !== "element") verdicts.set(node, "neutral");
1772
+ else {
1773
+ // Only an element's own context-free hides are ever added on the way
1774
+ // down, so a region never hides more than its elements would alone.
1775
+ const own = elementHides(node, parent);
1776
+ if (own.has("box")) {
1777
+ verdicts.set(node, "hidden");
1778
+ continue;
1779
+ }
1780
+ const inForce = /** @type {Set<InheritedHide>} */ (own);
1781
+ const cascade = elementCascade(node);
1782
+ const color = colorState(cascade, state.color);
1783
+ const inlineBox = isInlineBox(node, parent);
1784
+ for (const kind of state.kinds)
1785
+ if (keepsHide(kind, cascade, inlineBox, color)) inForce.add(kind);
1786
+ if (REPLACED_ELEMENTS.has(node.tagName))
1787
+ verdicts.set(node, inForce.has("visibility") ? "hidden" : "visible");
1788
+ else if (inForce.size === 0) verdicts.set(node, "visible");
1789
+ else pushChildren(node, { kinds: inForce, color });
1790
+ }
1791
+ }
1792
+ for (let i = order.length - 1; i >= 0; i--) {
1793
+ const node = order[i];
1794
+ if (verdicts.has(node)) continue;
1795
+ let hidden = false;
1796
+ let shown = false;
1797
+ for (const child of node.children) {
1798
+ const verdict = verdicts.get(child);
1799
+ if (verdict === "hidden") hidden = true;
1800
+ else if (verdict === "visible" || verdict === "mixed") shown = true;
1801
+ }
1802
+ verdicts.set(node, shown ? "mixed" : hidden ? "hidden" : "neutral");
1803
+ }
1804
+ return verdicts;
1805
+ }
1806
+
1807
+ // The splice of an element hidden by inherited `kinds` alone whose descendant
1808
+ // overrides them (`<span style="font-size:0">a<b style="font-size:9px">`): one
1809
+ // range per maximal run of hidden siblings, comments outside a run keeping their
1810
+ // own, other elements going back to `rescan`; `mixed` is the elements left
1811
+ // standing. Null when nothing overrides or a position is missing or out of
1812
+ // source order (parse5 recovery): the caller splices the whole element.
1813
+ /** @param {any} root @param {Set<InheritedHide>} kinds @returns {{ ranges: SpliceRange[], rescan: Array<[any, any]>, mixed: any[] } | null} */
1814
+ function inheritedRegionSplice(root, kinds) {
1815
+ const verdicts = regionVerdicts(root, kinds);
1816
+ if (verdicts.get(root) !== "mixed") return null;
1817
+ /** @type {SpliceRange[]} */
1818
+ const ranges = [];
1819
+ /** @type {Array<[any, any]>} */
1820
+ const rescan = [];
1821
+ const mixed = [];
1822
+ const stack = [root];
1823
+ while (stack.length > 0) {
1824
+ const node = stack.pop();
1825
+ mixed.push(node);
1826
+ let lastEnd = node.position.start.offset;
1827
+ let runStart = -1;
1828
+ let runEnd = -1;
1829
+ // Neutral siblings after an open run: absorbed if another hidden one follows.
1830
+ /** @type {SpliceRange[]} */
1831
+ let trailingComments = [];
1832
+ /** @type {Array<[any, any]>} */
1833
+ let trailingElements = [];
1834
+ const closeRun = () => {
1835
+ if (runStart !== -1)
1836
+ ranges.push({ start: runStart, end: runEnd, kind: "hidden" });
1837
+ ranges.push(...trailingComments);
1838
+ rescan.push(...trailingElements);
1839
+ runStart = -1;
1840
+ trailingComments = [];
1841
+ trailingElements = [];
1842
+ };
1843
+ for (const child of node.children) {
1844
+ const position = child.position;
1845
+ if (
1846
+ !position ||
1847
+ position.start.offset < lastEnd ||
1848
+ position.end.offset > node.position.end.offset
1849
+ )
1850
+ return null;
1851
+ lastEnd = position.end.offset;
1852
+ const verdict = verdicts.get(child);
1853
+ if (verdict === "hidden") {
1854
+ if (runStart === -1) {
1855
+ closeRun(); // what precedes the run stays outside it
1856
+ runStart = position.start.offset;
1857
+ }
1858
+ runEnd = position.end.offset;
1859
+ trailingComments = [];
1860
+ trailingElements = [];
1861
+ } else if (verdict === "mixed") {
1862
+ closeRun();
1863
+ stack.push(child);
1864
+ } else if (verdict === "visible") {
1865
+ closeRun();
1866
+ rescan.push([child, node]);
1867
+ } else if (child.type === "comment") {
1868
+ trailingComments.push({
1869
+ start: position.start.offset,
1870
+ end: position.end.offset,
1871
+ kind: "comment",
1872
+ });
1873
+ } else if (child.type === "element") {
1874
+ trailingElements.push([child, node]);
1875
+ }
1876
+ }
1877
+ closeRun();
1878
+ }
1879
+ return { ranges, rescan, mixed };
1429
1880
  }
1430
1881
 
1431
1882
  /** @param {any} el */
1432
1883
  function hasDataSrc(el) {
1433
- return (
1434
- typeof el.properties?.src === "string" &&
1435
- el.properties.src.startsWith("data:")
1884
+ return elementUrls(el).some(
1885
+ ({ url, autoFetched }) => autoFetched && resolveUrl(url)?.scheme === "data",
1436
1886
  );
1437
1887
  }
1438
1888
 
@@ -1452,14 +1902,15 @@ function hasDataSrc(el) {
1452
1902
  * @param {any} tree
1453
1903
  * @param {string | null} test
1454
1904
  * @param {(node: any, index: number | undefined, parent: any) => unknown} visitor
1905
+ * @param {any} [treeParent] the parent `tree` is visited with, for a subtree
1455
1906
  */
1456
- function walk(tree, test, visitor) {
1907
+ function walk(tree, test, visitor, treeParent = undefined) {
1457
1908
  /** @type {any[]} */
1458
1909
  const nodes = [tree];
1459
1910
  /** @type {Array<number | undefined>} */
1460
1911
  const indices = [undefined];
1461
1912
  /** @type {any[]} */
1462
- const parents = [undefined];
1913
+ const parents = [treeParent];
1463
1914
  while (nodes.length > 0) {
1464
1915
  const node = nodes.pop();
1465
1916
  const index = indices.pop();
@@ -1710,21 +2161,43 @@ function scanFragmentTree(html, tree) {
1710
2161
  /** @type {SpliceRange[]} */
1711
2162
  const ranges = [];
1712
2163
  const warned = newWarned();
1713
- // @ts-ignore -- visit callback returns EXIT/SKIP only on matches; implicit undefined return is intentional
1714
- // eslint-disable-next-line consistent-return
1715
- walk(tree, null, (/** @type {any} */ node) => {
2164
+ /** @param {any} node */
2165
+ const countElement = (node) => {
2166
+ if (REPORTED_TAGS.has(node.tagName)) countTag(warned, node.tagName);
2167
+ if (hasDataSrc(node)) warned.dataSrc += 1;
2168
+ };
2169
+ // Subtrees still to scan, each with its parent: the tree itself, then the
2170
+ // visible parts of every region hidden by inherited kinds alone.
2171
+ /** @type {Array<[any, any]>} */
2172
+ const pending = [[tree, undefined]];
2173
+ let unpositioned = false;
2174
+ /** @param {any} node @param {number | undefined} _index @param {any} parent */
2175
+ const visitor = (node, _index, parent) => {
1716
2176
  const isComment = node.type === "comment";
1717
- if (isComment || isHiddenElement(node)) {
2177
+ const hides = isComment ? null : elementHides(node, parent);
2178
+ if (isComment || (hides && hides.size > 0)) {
1718
2179
  /* c8 ignore start -- parse5 omits positions only on recovery-synthesized
1719
2180
  elements (tbody and friends), which carry no attributes and so can
1720
2181
  never be hidden; fail closed on the whole fragment if that assumption
1721
2182
  ever breaks. */
1722
2183
  if (!node.position) {
1723
- ranges.length = 0;
1724
- ranges.push({ start: 0, end: html.length, kind: "hidden" });
2184
+ unpositioned = true;
1725
2185
  return EXIT;
1726
2186
  }
1727
2187
  /* c8 ignore stop */
2188
+ const region =
2189
+ hides && !hides.has("box")
2190
+ ? inheritedRegionSplice(
2191
+ node,
2192
+ /** @type {Set<InheritedHide>} */ (hides),
2193
+ )
2194
+ : null;
2195
+ if (region) {
2196
+ ranges.push(...region.ranges);
2197
+ region.mixed.forEach(countElement);
2198
+ pending.push(...region.rescan);
2199
+ return SKIP;
2200
+ }
1728
2201
  ranges.push({
1729
2202
  start: node.position.start.offset,
1730
2203
  end: node.position.end.offset,
@@ -1732,10 +2205,16 @@ function scanFragmentTree(html, tree) {
1732
2205
  });
1733
2206
  return SKIP; // children are inside the spliced range
1734
2207
  }
1735
- if (node.type !== "element") return; // eslint-disable-line consistent-return -- unist visit: undefined return means "continue", same as falling off the end
1736
- if (REPORTED_TAGS.has(node.tagName)) countTag(warned, node.tagName);
1737
- if (hasDataSrc(node)) warned.dataSrc += 1;
1738
- });
2208
+ if (node.type === "element") countElement(node);
2209
+ return undefined;
2210
+ };
2211
+ while (pending.length > 0 && !unpositioned) {
2212
+ const [node, parent] = /** @type {[any, any]} */ (pending.pop());
2213
+ walk(node, null, visitor, parent);
2214
+ }
2215
+ /* c8 ignore next 2 -- the fail-closed half of the unpositioned guard above */
2216
+ if (unpositioned)
2217
+ return { ranges: [{ start: 0, end: html.length, kind: "hidden" }], warned };
1739
2218
  return { ranges, warned };
1740
2219
  }
1741
2220
 
@@ -2165,9 +2644,10 @@ const MAX_SPLICE_ROUNDS = 8;
2165
2644
  *
2166
2645
  * Every offset outside a placeholder maps by the length the splices before it
2167
2646
  * removed. No offset ever falls INSIDE one: a placeholder holds neither `<`
2168
- * nor `>`, and a scanner range always opens on a `<` and closes after a `>` or
2169
- * at the end of the text — so a placeholder edge is the closest an offset gets,
2170
- * and the same shift is exact there.
2647
+ * nor `>`, so it lies wholly inside one text node, and every scanner range edge
2648
+ * is a node edge (a tag's, a comment's, a text node's) or the end of the text —
2649
+ * so a placeholder edge is the closest an offset gets, and the same shift is
2650
+ * exact there.
2171
2651
  * @param {SpliceRange[]} ranges
2172
2652
  * @param {SpliceRange[]} merged the spliced spans, sorted, one per pair
2173
2653
  * @param {SplicePair[]} pairs
@@ -2264,7 +2744,8 @@ export function sanitizeHtml(text) {
2264
2744
  ? scanFragmentTree(spliced.text, sourceTree)
2265
2745
  : scanMarkdown(spliced.text);
2266
2746
  } catch {
2267
- // The parse/visit blew up (stack overflow on pathological nesting, or any
2747
+ // The parse/visit blew up (HTML nested past MAX_HTML_DEPTH, markdown
2748
+ // nested deep enough to overflow a remark plugin's recursive walk, or any
2268
2749
  // other parser error). Fail CLOSED at this boundary so `sanitize`/
2269
2750
  // `sanitizeText` keep their never-throw contract: withhold the whole input
2270
2751
  // behind a placeholder and report it as hidden content removed.
@@ -2339,16 +2820,46 @@ const LONG_QUERY_THRESHOLD = 200;
2339
2820
  // types (HTML, SVG, JS) are a script-injection vector; an oversized blob of any
2340
2821
  // type is an inline exfil/injection payload. A small inline image (icon) is
2341
2822
  // left alone so the common case isn't drowned in noise.
2342
- const DATA_URI_ACTIVE_RE =
2343
- /^\s*data:(?:text\/html|image\/svg\+xml|application\/(?:javascript|ecmascript|xhtml\+xml))[;,]/i;
2823
+ const ACTIVE_DATA_TYPES = new Set([
2824
+ "text/html",
2825
+ "image/svg+xml",
2826
+ "application/javascript",
2827
+ "application/ecmascript",
2828
+ "application/xhtml+xml",
2829
+ ]);
2344
2830
  export const DATA_URI_LENGTH_THRESHOLD = 4096;
2345
2831
 
2346
2832
  // javascript:/vbscript: URIs execute on navigation/load, never a legitimate
2347
2833
  // link target in fetched content — flagged regardless of payload.
2348
- const SCRIPT_URI_RE = /^\s*(?:javascript|vbscript):/i;
2834
+ const SCRIPT_SCHEMES = new Set(["javascript", "vbscript"]);
2349
2835
 
2350
2836
  const RELATIVE_URL_BASE = "http://relative.invalid";
2351
2837
 
2838
+ // `url` through the WHATWG URL parser, the one place a scheme is read: it
2839
+ // strips C0 controls, spaces and tabs/newlines as a browser does
2840
+ // (`&#1;javascript:`, `java\tscript:`). `scheme` is null for a page-relative
2841
+ // URL, `relative` true when it names no host either; null if unparsable.
2842
+ /** @param {string} url @returns {{ scheme: string | null, parsed: URL, relative: boolean } | null} */
2843
+ function resolveUrl(url) {
2844
+ const absolute = parseUrl(url);
2845
+ const parsed = absolute ?? parseUrl(url, RELATIVE_URL_BASE);
2846
+ if (!parsed) return null;
2847
+ return {
2848
+ scheme: absolute ? absolute.protocol.slice(0, -1) : null,
2849
+ parsed,
2850
+ relative: !absolute && parsed.origin === RELATIVE_URL_BASE,
2851
+ };
2852
+ }
2853
+
2854
+ /** @param {string} url @param {string} [base] @returns {URL | null} */
2855
+ function parseUrl(url, base) {
2856
+ try {
2857
+ return new URL(url, base);
2858
+ } catch {
2859
+ return null; // the parser's only failure signal is the throw
2860
+ }
2861
+ }
2862
+
2352
2863
  // Parameter NAMES that legitimately carry a LONG opaque (base64/hex) value, so
2353
2864
  // a blob in one of them is NOT exfil: CDN request-signing (AWS SigV4 /
2354
2865
  // CloudFront `X-Amz-*`/`Signature`/`Policy`/`Key-Pair-Id`, GCS `X-Goog-*`,
@@ -2839,17 +3350,19 @@ function checkUrlPath(parsed) {
2839
3350
  */
2840
3351
  export function checkExfilUrl(url, options = {}) {
2841
3352
  const { flagDigestValues = false } = options;
2842
- // A browser strips tab/newline/CR ANYWHERE in a URL before resolving its
2843
- // scheme, so `java\tscript:alert(1)` navigates as `javascript:`. Strip them
2844
- // for the scheme tests (the payload/length checks below keep the raw string).
2845
- const schemeUrl = url.replace(/[\t\n\r]/g, "");
2846
- if (/^\s*data:/i.test(schemeUrl)) {
2847
- if (DATA_URI_ACTIVE_RE.test(schemeUrl)) return "active-content data: URI";
3353
+ const resolved = resolveUrl(url);
3354
+ if (resolved?.scheme === "data") {
3355
+ // The MIME type essence, per the Fetch Standard's data: URL processor.
3356
+ const { href, hash } = resolved.parsed;
3357
+ const mimeType = href.slice("data:".length, href.length - hash.length);
3358
+ const essence = mimeType.split(",")[0].split(";")[0].trim().toLowerCase();
3359
+ if (ACTIVE_DATA_TYPES.has(essence)) return "active-content data: URI";
2848
3360
  if (url.length > DATA_URI_LENGTH_THRESHOLD)
2849
3361
  return "oversized inline data: payload";
2850
3362
  return null;
2851
3363
  }
2852
- if (SCRIPT_URI_RE.test(schemeUrl)) return "script-executing URI";
3364
+ if (resolved?.scheme && SCRIPT_SCHEMES.has(resolved.scheme))
3365
+ return "script-executing URI";
2853
3366
  // Template-injection shapes (`${…}`, `{{…}}`) only in the query/fragment: a
2854
3367
  // brace in the PATH or host is a legitimate templated doc URL
2855
3368
  // (`/api/{{version}}/guide`), and flagging it both false-positives and
@@ -2868,13 +3381,8 @@ export function checkExfilUrl(url, options = {}) {
2868
3381
  if (keywordReason) return keywordReason;
2869
3382
  // Userinfo and an oversized fragment are exfil channels the param walk misses:
2870
3383
  // credentials smuggled as `user:secret@host`, or a payload tucked in `#<blob>`.
2871
- // Parse against a sentinel base so relative URLs don't throw.
2872
- let parsed;
2873
- try {
2874
- parsed = new URL(url, RELATIVE_URL_BASE);
2875
- } catch {
2876
- return null;
2877
- }
3384
+ if (!resolved) return null;
3385
+ const { parsed } = resolved;
2878
3386
  if (parsed.username || parsed.password) return "embedded credentials";
2879
3387
  // A long query string is only suspicious when it carries a non-allowlisted
2880
3388
  // parameter — a signed-CDN URL is long by design (all `X-Amz-*`/SAS params).
@@ -2897,197 +3405,193 @@ export function checkExfilUrl(url, options = {}) {
2897
3405
  * @returns {string}
2898
3406
  */
2899
3407
  export function urlHost(url) {
3408
+ const resolved = resolveUrl(url);
3409
+ // checkExfilUrl's raw-string tests can flag a URL the parser rejects.
3410
+ if (!resolved) return "(unparsable URL)";
2900
3411
  // A `data:` URI has no host; name the channel rather than echoing the payload.
2901
- if (/^\s*data:/i.test(url)) return "(inline data: URI)";
2902
- let parsed;
2903
- try {
2904
- parsed = new URL(url, RELATIVE_URL_BASE);
2905
- } catch {
2906
- // checkExfilUrl flags via regex before parsing, so it can hand us a URL
2907
- // WHATWG rejects (e.g. a non-ASCII host).
2908
- return "(unparsable URL)";
2909
- }
2910
- if (
2911
- parsed.origin === RELATIVE_URL_BASE &&
2912
- !url.startsWith(RELATIVE_URL_BASE)
2913
- ) {
2914
- return "(relative URL)";
3412
+ if (resolved.scheme === "data") return "(inline data: URI)";
3413
+ if (resolved.relative) return "(relative URL)";
3414
+ return resolved.parsed.host;
3415
+ }
3416
+
3417
+ // The first index at or after `from` that is not ASCII whitespace.
3418
+ /** @param {string} value @param {number} from */
3419
+ function skipWhitespace(value, from) {
3420
+ let at = from;
3421
+ while (/[\t\n\f\r ]/.test(value.charAt(at))) at++;
3422
+ return at;
3423
+ }
3424
+
3425
+ // The redirect URL of a meta refresh `content`, or null when a browser would
3426
+ // not redirect: a port of the HTML Standard's "shared declarative refresh
3427
+ // steps" (the `url=` label and the quotes are optional).
3428
+ /** @param {string} content @returns {string | null} */
3429
+ function refreshUrl(content) {
3430
+ let at = skipWhitespace(content, 0);
3431
+ const delayStart = at;
3432
+ while (/[\d.]/.test(content.charAt(at))) at++;
3433
+ // The delay must be followed by a separator; `charAt` past the end is "".
3434
+ if (at === delayStart || !/[;,\t\n\f\r ]/.test(content.charAt(at)))
3435
+ return null;
3436
+ at = skipWhitespace(content, at);
3437
+ if (content[at] === ";" || content[at] === ",")
3438
+ at = skipWhitespace(content, at + 1);
3439
+ if (at === content.length) return null;
3440
+ // A `url =` label; a prefix of it that stops short is part of the URL.
3441
+ let urlStart = at;
3442
+ if (content.slice(at, at + 3).toLowerCase() === "url") {
3443
+ const afterLabel = skipWhitespace(content, at + 3);
3444
+ if (content[afterLabel] === "=")
3445
+ urlStart = skipWhitespace(content, afterLabel + 1);
2915
3446
  }
2916
- return parsed.host;
2917
- }
2918
-
2919
- /**
2920
- * True when `url` is an absolute, off-origin target (an authority that is not
2921
- * the relative-resolution sentinel). Used for form `action`/`formaction` and
2922
- * `meta refresh` URLs, where pointing off the page's own origin is the
2923
- * exfil/redirect signal regardless of the query shape.
2924
- * @param {string} url
2925
- * @returns {boolean}
2926
- */
2927
- function isOffOrigin(url) {
2928
- let parsed;
2929
- try {
2930
- parsed = new URL(url, RELATIVE_URL_BASE);
2931
- } catch {
2932
- return false;
3447
+ const quote = content[urlStart];
3448
+ if (quote !== "'" && quote !== '"') return content.slice(urlStart);
3449
+ const closing = content.indexOf(quote, urlStart + 1);
3450
+ return content.slice(urlStart + 1, closing === -1 ? undefined : closing);
3451
+ }
3452
+
3453
+ // A valid floating-point number (HTML Standard §2.3.4.3), a density's numeral.
3454
+ const FLOAT_RE = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?$/;
3455
+
3456
+ // True when a browser keeps a srcset candidate with these descriptors ("parse
3457
+ // a srcset attribute", step 13): one `w` or `x` at most, `h` only beside `w`.
3458
+ /** @param {string[]} descriptors @returns {boolean} */
3459
+ function isValidSrcsetDescriptors(descriptors) {
3460
+ const units = new Set();
3461
+ for (const descriptor of descriptors) {
3462
+ const numeral = descriptor.slice(0, -1);
3463
+ const unit = descriptor.slice(-1);
3464
+ const valid =
3465
+ unit === "x"
3466
+ ? FLOAT_RE.test(numeral) && Number(numeral) >= 0
3467
+ : (unit === "w" || unit === "h") &&
3468
+ /^\d+$/.test(numeral) &&
3469
+ Number(numeral) > 0;
3470
+ if (!valid || units.has(unit)) return false;
3471
+ units.add(unit);
2933
3472
  }
2934
- return (
2935
- parsed.origin !== RELATIVE_URL_BASE || url.startsWith(RELATIVE_URL_BASE)
2936
- );
2937
- }
2938
-
2939
- /**
2940
- * The redirect URL of a `<meta http-equiv="refresh">` content value
2941
- * (`"5; url=https://…"`), or null when it carries no `url=` target.
2942
- * @param {string} content
2943
- * @returns {string | null}
2944
- */
2945
- function metaRefreshUrl(content) {
2946
- // Do NOT exclude `;` from the URL run: the `;` separates the timeout from
2947
- // `url=` BEFORE the target, while WITHIN the target it is a legal query
2948
- // sub-delimiter — excluding it truncated a `?a=1;b=<blob>` exfil tail. The
2949
- // optional leading quote is consumed and the run then stops at the closing
2950
- // quote (quoted) or at whitespace (unquoted); a single group keeps both forms
2951
- // without an unreachable no-match arm.
2952
- const match = /** @type {{ groups: { url: string } } | null} */ (
2953
- content.match(/url\s*=\s*['"]?(?<url>[^'"\s]+)/i)
2954
- );
2955
- return match ? match.groups.url : null;
3473
+ if (units.has("x") && units.size > 1) return false;
3474
+ return !units.has("h") || units.has("w");
2956
3475
  }
2957
3476
 
2958
- // HTML whitespace per the `srcset` grammar (ASCII whitespace).
2959
- const SRCSET_WS_RE = /[ \t\n\f\r]/;
3477
+ // A candidate's URL, then its descriptors: up to the next comma outside a
3478
+ // paren group, which closes at the FIRST `)` (an unclosed one runs to the end).
3479
+ const SRCSET_URL_RE = /[\t\n\f\r ,]*([^\t\n\f\r ,][^\t\n\f\r ]*)?/y;
3480
+ const SRCSET_DESCRIPTORS_RE = /(?:[^,(]|\([^)]*(?:\)|$))*/y;
2960
3481
 
2961
- /**
2962
- * URLs of a `srcset` value, parsed per the WHATWG "parse a srcset attribute"
2963
- * grammar rather than a naive `split(",")`: a candidate's URL is a run of
2964
- * non-whitespace characters, so a URL that itself contains commas (a `data:`
2965
- * URI, or a query with `,`) is kept intact. A comma only separates candidates
2966
- * when it trails the URL run or follows the (paren-aware) descriptor. Trailing
2967
- * commas on the URL run mark a candidate with no descriptor.
2968
- * @param {string} value
2969
- * @returns {string[]}
2970
- */
2971
- function parseSrcset(value) {
2972
- /** @type {string[]} */ const urls = [];
2973
- let i = 0;
2974
- const n = value.length;
2975
- while (i < n) {
2976
- while (i < n && (SRCSET_WS_RE.test(value[i]) || value[i] === ",")) i++;
2977
- const start = i;
2978
- while (i < n && !SRCSET_WS_RE.test(value[i])) i++;
2979
- const run = value.slice(start, i);
2980
- // The trailing commas come off by scanning back rather than by replacing
2981
- // `,+$`: that pattern is unanchored at the start, so a run of commas costs
2982
- // one match attempt per comma.
2983
- let cut = run.length;
2984
- while (cut > 0 && run[cut - 1] === ",") cut--;
2985
- const url = run.slice(0, cut);
2986
- if (url) urls.push(url);
2987
- // A URL run ending in a comma is a bare candidate (no descriptor); the
2988
- // comma already delimits the next one, so skip descriptor parsing.
2989
- if (run.endsWith(",")) continue;
2990
- // Otherwise consume the descriptor up to the first unparenthesized comma.
2991
- let depth = 0;
2992
- while (i < n) {
2993
- const c = value[i];
2994
- if (c === "(") depth++;
2995
- else if (c === ")" && depth > 0) depth--;
2996
- else if (c === "," && depth === 0) {
2997
- i++;
2998
- break;
2999
- }
3000
- i++;
3482
+ // The URLs a browser keeps from a `srcset`: a port of the HTML Standard's
3483
+ // "parse a srcset attribute" (a comma inside a URL run stays in it).
3484
+ /** @param {string} value @returns {string[]} */
3485
+ function srcsetUrls(value) {
3486
+ /** @type {string[]} */
3487
+ const urls = [];
3488
+ let at = 0;
3489
+ while (at < value.length) {
3490
+ SRCSET_URL_RE.lastIndex = at;
3491
+ let url =
3492
+ /** @type {RegExpExecArray} */ (SRCSET_URL_RE.exec(value))[1] ?? "";
3493
+ at = SRCSET_URL_RE.lastIndex;
3494
+ let descriptors = "";
3495
+ if (url.endsWith(",")) {
3496
+ // Scanned back: an unanchored `,+$` costs one match attempt per comma.
3497
+ let cut = url.length;
3498
+ while (cut > 0 && url[cut - 1] === ",") cut--;
3499
+ url = url.slice(0, cut);
3500
+ } else {
3501
+ SRCSET_DESCRIPTORS_RE.lastIndex = at;
3502
+ descriptors = /** @type {RegExpExecArray} */ (
3503
+ SRCSET_DESCRIPTORS_RE.exec(value)
3504
+ )[0];
3505
+ at = SRCSET_DESCRIPTORS_RE.lastIndex + 1; // past the comma
3001
3506
  }
3507
+ const tokens = descriptors.split(/[\t\n\f\r ]+/).filter(Boolean);
3508
+ if (url && isValidSrcsetDescriptors(tokens)) urls.push(url);
3002
3509
  }
3003
3510
  return urls;
3004
3511
  }
3005
3512
 
3006
- /**
3007
- * Candidate URLs of a `srcset` (a "url descriptor" string parsed per the HTML
3008
- * grammar) or `ping` (a space-separated url list hast delivers as an array)
3009
- * attribute. An absent attribute (neither string nor array) yields none.
3010
- * @param {unknown} value
3011
- * @returns {string[]}
3012
- */
3013
- function multiUrlAttr(value) {
3014
- if (Array.isArray(value))
3015
- return value
3016
- .map((candidate) => String(candidate).trim().split(/\s+/)[0])
3017
- .filter(Boolean);
3018
- if (typeof value === "string") return parseSrcset(value);
3019
- return [];
3020
- }
3513
+ // Functions whose string arguments are URLs: css-tree reads an escaped
3514
+ // `\75 rl("…")` as a plain function named `url`.
3515
+ const STRING_URL_FUNCTIONS = new Set(["url", "image-set", "-webkit-image-set"]);
3021
3516
 
3022
- /**
3023
- * URL-bearing attributes of every HTML element in `text`, parsed with the real
3024
- * HTML tokenizer so quoting/casing/entities are handled correctly (no
3025
- * hand-rolled tag regex).
3026
- * `context` selects the per-URL check the caller applies: resource URLs get the
3027
- * exfil-shape test; form-submission and meta-refresh targets additionally flag
3028
- * any absolute off-origin destination.
3029
- *
3030
- * `autoFetched` says whether reaching this URL takes a deliberate act. Exactly
3031
- * one attribute here does: `href` on an `<a>`, which somebody has to follow.
3032
- * Every other one is fetched by the renderer on sight (`src`, `srcset`,
3033
- * `background`, and `href` on a `<link>`), fires on a click aimed at something
3034
- * else (`ping`), or navigates on its own (a form action, a meta refresh). The
3035
- * distinction is the caller's severity line, not a detection line — the URL is
3036
- * reported either way (see the exfil tier in ../src/output.mjs).
3037
- * @param {string} text
3038
- * @returns {Array<{ url: string, isImage: boolean, autoFetched: boolean, context: "resource" | "form" | "refresh" }>}
3039
- */
3040
- function extractHtmlUrls(text) {
3041
- const tree = parseFragment(text);
3042
- /** @type {Array<{ url: string, isImage: boolean, autoFetched: boolean, context: "resource" | "form" | "refresh" }>} */
3517
+ // The URLs a CSS source loads: `url()`, `image-set()` strings and `@import`.
3518
+ /** @param {string} css @param {"declarationList" | "stylesheet"} context @returns {string[]} */
3519
+ function cssUrls(css, context) {
3520
+ /** @type {string[]} */
3043
3521
  const urls = [];
3044
- walk(tree, "element", (/** @type {any} */ node) => {
3045
- // hast element nodes always carry a `properties` object (parse5 sets it).
3046
- const props = node.properties;
3047
- const isImage = node.tagName === "img";
3048
- const isAnchor = node.tagName === "a";
3049
- for (const key of ["src", "href", "background"])
3050
- if (typeof props[key] === "string")
3051
- urls.push({
3052
- url: props[key],
3053
- isImage,
3054
- autoFetched: !(key === "href" && isAnchor),
3055
- context: "resource",
3056
- });
3057
- for (const key of ["srcSet", "ping"])
3058
- for (const url of multiUrlAttr(props[key]))
3059
- urls.push({ url, isImage, autoFetched: true, context: "resource" });
3060
- for (const key of ["action", "formAction"])
3061
- if (typeof props[key] === "string")
3062
- urls.push({
3063
- url: props[key],
3064
- isImage: false,
3065
- autoFetched: true,
3066
- context: "form",
3067
- });
3068
- // hast delivers `http-equiv` as an array (comma-separated); join it back
3069
- // so a `refresh` directive is matched regardless of how it was tokenized.
3070
- const httpEquiv = Array.isArray(props.httpEquiv)
3071
- ? props.httpEquiv.join(",").toLowerCase()
3072
- : "";
3073
- if (
3074
- node.tagName === "meta" &&
3075
- httpEquiv.includes("refresh") &&
3076
- typeof props.content === "string"
3077
- ) {
3078
- const url = metaRefreshUrl(props.content);
3079
- if (url)
3080
- urls.push({
3081
- url,
3082
- isImage: false,
3083
- autoFetched: true,
3084
- context: "refresh",
3085
- });
3086
- }
3522
+ const ast = parseCss(css, context);
3523
+ /* c8 ignore next -- see parseCss */
3524
+ if (!ast) return urls;
3525
+ cssWalk(ast, (/** @type {any} */ node) => {
3526
+ if (node.type === "Url") urls.push(node.value);
3527
+ const name =
3528
+ node.type === "Function" || node.type === "Atrule"
3529
+ ? cssIdent.decode(node.name).toLowerCase()
3530
+ : "";
3531
+ const takesStrings =
3532
+ (node.type === "Function" && STRING_URL_FUNCTIONS.has(name)) ||
3533
+ (node.type === "Atrule" && name === "import");
3534
+ if (!takesStrings) return;
3535
+ const strings = node.type === "Atrule" ? node.prelude : node;
3536
+ strings?.children?.forEach((/** @type {any} */ child) => {
3537
+ if (child.type === "String") urls.push(child.value);
3538
+ });
3087
3539
  });
3088
3540
  return urls;
3089
3541
  }
3090
3542
 
3543
+ // The URLs in a value of each URL_ATTRIBUTES grammar. A value of another shape
3544
+ // is a table bug and throws rather than reading as "no URL".
3545
+ /** @type {Record<string, (value: any, properties: any) => string[]>} */
3546
+ const GRAMMAR_READERS = {
3547
+ url: (value) => [value],
3548
+ urls: (value) => value.map(String),
3549
+ srcset: (value) => srcsetUrls(value),
3550
+ refresh(value, properties) {
3551
+ // hast delivers `http-equiv` as a list, which String() joins back.
3552
+ const directive = String(properties.httpEquiv ?? "");
3553
+ const url =
3554
+ directive.trim().toLowerCase() === "refresh" ? refreshUrl(value) : null;
3555
+ return url === null ? [] : [url];
3556
+ },
3557
+ css: (value) => cssUrls(value, "declarationList"),
3558
+ };
3559
+
3560
+ /** @typedef {{ url: string, isImage: boolean, autoFetched: boolean, context: "resource" | "form" | "refresh" }} CollectedUrl */
3561
+
3562
+ // Every URL one element carries, through {@link URL_ATTRIBUTES} (plus a
3563
+ // `<style>`'s stylesheet): the one place an element's URLs are read.
3564
+ // `autoFetched` is false when reaching it takes a deliberate act (a link).
3565
+ /** @param {any} node a hast element @returns {CollectedUrl[]} */
3566
+ function elementUrls(node) {
3567
+ // hast element nodes always carry a `properties` object (parse5 sets it).
3568
+ const properties = node.properties;
3569
+ const isImage = node.tagName === "img" || node.tagName === "image";
3570
+ /** @type {CollectedUrl[]} */
3571
+ const urls = [];
3572
+ for (const attribute of URL_ATTRIBUTES) {
3573
+ const value = properties[attribute.property];
3574
+ if (value === undefined || value === null) continue;
3575
+ if (attribute.elements && !attribute.elements.includes(node.tagName))
3576
+ continue;
3577
+ const autoFetched =
3578
+ attribute.fetched && !attribute.notFetchedOn.includes(node.tagName);
3579
+ for (const url of GRAMMAR_READERS[attribute.grammar](value, properties))
3580
+ urls.push({ url, isImage, autoFetched, context: attribute.context });
3581
+ }
3582
+ if (node.tagName === "style")
3583
+ for (const child of node.children)
3584
+ if (child.type === "text")
3585
+ for (const url of cssUrls(child.value, "stylesheet"))
3586
+ urls.push({
3587
+ url,
3588
+ isImage: false,
3589
+ autoFetched: true,
3590
+ context: "resource",
3591
+ });
3592
+ return urls;
3593
+ }
3594
+
3091
3595
  // Reason for an off-origin submission/redirect target by context; null leaves
3092
3596
  // the URL to the exfil-shape check alone.
3093
3597
  const OFF_ORIGIN_REASON = {
@@ -3095,31 +3599,18 @@ const OFF_ORIGIN_REASON = {
3095
3599
  refresh: "off-origin meta-refresh redirect",
3096
3600
  };
3097
3601
 
3098
- /**
3099
- * @typedef {{ url: string, isImage: boolean, autoFetched: boolean, context: "resource" | "form" | "refresh" }} CollectedUrl
3100
- */
3602
+ // `<!`, `<?` and `</` inside a comment would hide the rest of its text.
3603
+ const COMMENT_INTERIOR_OPENER_RE = /<(?=[!?/])/g;
3101
3604
 
3102
- /**
3103
- * Every URL a Layer-3 detector reads: markdown links/images/definitions plus the
3104
- * URL-bearing HTML attributes `extractHtmlUrls` covers. Both detectors below
3105
- * consume this rather than deciding for themselves what counts as a URL, so a
3106
- * node type is either in scope for all of them or for none — a second walk is
3107
- * how one detector silently stops covering a shape the other still does.
3108
- * `parseMarkdown` memoizes its last tree, so the detectors running back to back
3109
- * over one document share the parse as well as the definition.
3110
- *
3111
- * A markdown node is reported with `context: "resource"`, which is the context
3112
- * that applies no off-origin rule — markdown carries no form or refresh target.
3113
- * @param {string} text
3114
- * @returns {CollectedUrl[]}
3115
- */
3116
- function collectUrls(text) {
3605
+ // The URLs of one document, and the text of every HTML comment in it.
3606
+ /** @param {string} text @param {(text: string) => any} parseMd @param {(text: string) => any} parseHtml @returns {{ urls: CollectedUrl[], comments: string[] }} */
3607
+ function documentUrls(text, parseMd, parseHtml) {
3117
3608
  /** @type {CollectedUrl[]} */
3118
3609
  const urls = [];
3119
3610
  // Remark AST handles markdown links/images/definitions (balanced parens,
3120
3611
  // reference links, GFM autolink literals) correctly, unlike a hand-rolled
3121
3612
  // regex.
3122
- walk(parseMarkdown(text), null, (node) => {
3613
+ walk(parseMd(text), null, (node) => {
3123
3614
  if (
3124
3615
  node.type !== "link" &&
3125
3616
  node.type !== "image" &&
@@ -3135,16 +3626,42 @@ function collectUrls(text) {
3135
3626
  context: "resource",
3136
3627
  });
3137
3628
  });
3138
- // HTML attributes (not AST nodes in remark).
3139
- urls.push(...extractHtmlUrls(text));
3629
+ /** @type {string[]} */
3630
+ const comments = [];
3631
+ walk(parseHtml(text), null, (/** @type {any} */ node) => {
3632
+ if (node.type === "comment") comments.push(node.value);
3633
+ else if (node.type === "element") urls.push(...elementUrls(node));
3634
+ });
3635
+ return { urls, comments };
3636
+ }
3637
+
3638
+ // Every URL a Layer-3 detector reads: markdown links/images/definitions and
3639
+ // the URL attributes of every element, also inside comments. Both detectors
3640
+ // below consume this, so a node type is in scope for all of them or for none;
3641
+ // `parseMarkdown` memoizes its tree, so back-to-back detectors share the parse.
3642
+ // A comment is invisible on the page but not to a model reading the source:
3643
+ // its text is read one level deep with its markup openers defused, and what it
3644
+ // names is not fetched and has no off-origin rule.
3645
+ /** @param {string} text @returns {CollectedUrl[]} */
3646
+ function collectUrls(text) {
3647
+ const { urls, comments } = documentUrls(text, parseMarkdown, parseFragment);
3648
+ for (const comment of comments) {
3649
+ const interior = comment.replace(COMMENT_INTERIOR_OPENER_RE, "< ");
3650
+ const inner = documentUrls(
3651
+ interior,
3652
+ (md) => mdParser.parse(md),
3653
+ parseHtmlFragment,
3654
+ );
3655
+ for (const { url, isImage } of inner.urls)
3656
+ urls.push({ url, isImage, autoFetched: false, context: "resource" });
3657
+ }
3140
3658
  return urls;
3141
3659
  }
3142
3660
 
3143
3661
  /**
3144
- * Layer 3: report data-exfil-shaped URLs in markdown links/images/definitions
3145
- * and HTML attributes (src/href/background/srcset/ping, form action/formaction,
3146
- * meta-refresh). Detection only — the text is never modified; the caller
3147
- * surfaces the threats as a warning.
3662
+ * Layer 3: report data-exfil-shaped URLs among the ones {@link collectUrls}
3663
+ * reads. Detection only — the text is never modified; the caller surfaces the
3664
+ * threats as a warning.
3148
3665
  *
3149
3666
  * `autoFetched` marks a threat that needs no deliberate act to fire — a
3150
3667
  * rendered image, a stylesheet, a form target, a meta refresh — as opposed to a
@@ -3161,9 +3678,11 @@ export function detectExfil(text, options = {}) {
3161
3678
  /** @type {Array<{ isImage: boolean, autoFetched: boolean, reason: string, target: string, unscannable?: true }>} */
3162
3679
  const threats = [];
3163
3680
  for (const { url, isImage, autoFetched, context } of collectUrls(text)) {
3681
+ // A form or meta-refresh target that names a scheme or host of its own
3682
+ // leaves the page's origin: the exfil/redirect signal whatever its query.
3164
3683
  const reason =
3165
3684
  checkExfilUrl(url, options) ||
3166
- (context !== "resource" && isOffOrigin(url)
3685
+ (context !== "resource" && resolveUrl(url)?.relative === false
3167
3686
  ? OFF_ORIGIN_REASON[context]
3168
3687
  : null);
3169
3688
  if (!reason) continue;