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/LICENSE-THIRD-PARTY +27 -0
- package/THREAT-MODEL.md +7 -3
- package/package.json +1 -1
- package/src/css-syntax-data.mjs +274 -0
- package/src/html-tree-adapter.mjs +113 -1
- package/src/html.mjs +873 -354
- package/src/url-attributes.mjs +76 -0
- package/types/css-syntax-data.d.mts +6 -0
- package/types/html-tree-adapter.d.mts +43 -0
- package/types/html.d.mts +43 -7
- package/types/url-attributes.d.mts +11 -0
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
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
1216
|
-
context
|
|
1217
|
-
parseValue: true,
|
|
1203
|
+
return cssParse(css, {
|
|
1204
|
+
context,
|
|
1218
1205
|
parseCustomProperty: false,
|
|
1219
1206
|
onParseError() {},
|
|
1220
1207
|
});
|
|
1221
|
-
/* c8 ignore start
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
1244
|
-
*
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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
|
|
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
|
|
1410
|
+
if (fraction !== null && fraction < NEAR_ZERO_EPSILON) return BOX;
|
|
1277
1411
|
}
|
|
1278
1412
|
|
|
1279
|
-
|
|
1280
|
-
//
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
if (
|
|
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
|
-
|
|
1437
|
+
const INHERITED_COLOR_KEYWORDS = new Set(["inherit", "unset", "currentcolor"]);
|
|
1291
1438
|
|
|
1292
|
-
|
|
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
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
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
|
|
1411
|
-
*
|
|
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
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
return
|
|
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
|
-
|
|
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 = [
|
|
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
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1736
|
-
|
|
1737
|
-
|
|
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 `>`,
|
|
2169
|
-
*
|
|
2170
|
-
* and the same shift is
|
|
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 (
|
|
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
|
|
2343
|
-
|
|
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
|
|
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
|
+
// (`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
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
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 (
|
|
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
|
-
|
|
2872
|
-
|
|
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 (
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
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
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2959
|
-
|
|
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
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
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
|
-
|
|
3008
|
-
|
|
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
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
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
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
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
|
-
|
|
3100
|
-
*/
|
|
3602
|
+
// `<!`, `<?` and `</` inside a comment would hide the rest of its text.
|
|
3603
|
+
const COMMENT_INTERIOR_OPENER_RE = /<(?=[!?/])/g;
|
|
3101
3604
|
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
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(
|
|
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
|
-
|
|
3139
|
-
|
|
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
|
|
3145
|
-
*
|
|
3146
|
-
*
|
|
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" &&
|
|
3685
|
+
(context !== "resource" && resolveUrl(url)?.relative === false
|
|
3167
3686
|
? OFF_ORIGIN_REASON[context]
|
|
3168
3687
|
: null);
|
|
3169
3688
|
if (!reason) continue;
|