agent-sanitizer 2.49.0 → 2.49.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/THREAT-MODEL.md +66 -0
- package/package.json +2 -1
- package/src/confusables.mjs +75 -27
- package/src/html.mjs +52 -12
package/THREAT-MODEL.md
CHANGED
|
@@ -779,6 +779,72 @@ The knob adds no layer and changes no layer's semantics. Ambiguous input still
|
|
|
779
779
|
fails open at the detection level (precision over recall), as it always has —
|
|
780
780
|
that is a separate, and unrelated, sense of the phrase.
|
|
781
781
|
|
|
782
|
+
## Linear-time guarantee
|
|
783
|
+
|
|
784
|
+
Every layer here reads attacker-controlled text inside a hook the user waits on,
|
|
785
|
+
so a cost blow-up is a denial of service against the agent: the sanitizer that
|
|
786
|
+
hangs on a crafted paste has failed as completely as one that misses the
|
|
787
|
+
payload. This is a property of the whole surface, not of any one layer, so it is
|
|
788
|
+
stated once here rather than restated per layer.
|
|
789
|
+
|
|
790
|
+
**The contract.** No entry point costs more than `n log n` in its input: linear
|
|
791
|
+
up to the constant factors a parse pays, and one sort where findings or splice
|
|
792
|
+
ranges arrive in an order the caller does not promise (`foldConfusables`,
|
|
793
|
+
`mergeRanges`). The bound that matters is that nothing is _quadratic_ — a sort
|
|
794
|
+
of the findings in a 1 MB paste is milliseconds, a quadratic rescan of it is
|
|
795
|
+
minutes — and quadratic is what the two gates below are calibrated to catch,
|
|
796
|
+
because neither can see what the other does.
|
|
797
|
+
|
|
798
|
+
**Static — every pattern.** `tests/test_redos_js_static_guard.py` drives
|
|
799
|
+
regexploit over an inventory that `scripts/extract-js-regexes.mjs` extracts by
|
|
800
|
+
walking the TypeScript AST of every shipped `.mjs` (not by matching regex source
|
|
801
|
+
with a regex), and fails on any pattern with super-linear backtracking. A
|
|
802
|
+
`RegExp(…)` built at runtime must be statically resolvable or carry a written
|
|
803
|
+
exemption, so a new dynamic pattern is a red build rather than a hole.
|
|
804
|
+
`tests/secrets/test_redos_static_guard.py` is the twin over the Python engine,
|
|
805
|
+
and `eslint-plugin-redos` reports the same class in the editor, before CI.
|
|
806
|
+
|
|
807
|
+
**Empirical — everything the static gates cannot see.** Two shapes of quadratic
|
|
808
|
+
carry no backtracking at all, so no pattern analyzer will ever report them:
|
|
809
|
+
|
|
810
|
+
- an **unanchored** pattern retried at every start offset, each attempt linear
|
|
811
|
+
and none of them backtracking — `(?=.*[A-Z])(?=.*[0-9])` against a long value
|
|
812
|
+
carrying neither class, or a `[^>]*$` tail rescanned from every `<`;
|
|
813
|
+
- work that is not a pattern at all — a string rebuilt once per finding, a
|
|
814
|
+
prefix re-sliced inside a loop, a re-walk of an already-walked node.
|
|
815
|
+
|
|
816
|
+
`test/algorithmic-complexity.test.mjs` runs an entry point over an adversarial
|
|
817
|
+
input at two sizes 8x apart and asserts the cost grew like the input rather than
|
|
818
|
+
like its square, with a known-quadratic specimen as the control that proves the
|
|
819
|
+
harness can still see one. Each case also asserts, at the size it measures, that
|
|
820
|
+
the input still reaches the code the case is about — a length gate answering
|
|
821
|
+
first is how such a case quietly degrades into timing nothing.
|
|
822
|
+
`tests/secrets/test_algorithmic_complexity.py` is the twin over the Python
|
|
823
|
+
engine. `test/hook-latency.test.mjs` covers the three blocking hook paths
|
|
824
|
+
instead, in cost per calibration pass, with one wall-clock ceiling.
|
|
825
|
+
|
|
826
|
+
**Idioms new code is held to.** Each is the fix for a defect one of the gates
|
|
827
|
+
caught:
|
|
828
|
+
|
|
829
|
+
- Anchor a tail scan, or slice the tail first (`lastIndexOf(">")`) and match a
|
|
830
|
+
fixed-length pattern against it — never `[^x]*$` over the whole input.
|
|
831
|
+
- Spell alternatives disjointly (`\d+(?:\.\d+)?|\.\d+`, not `\d*\.?\d+`) so a
|
|
832
|
+
failing match gives up in constant time per step rather than retrying every
|
|
833
|
+
split of a long run.
|
|
834
|
+
- Bound an unbounded quantifier that can span the input, and stitch the chunks
|
|
835
|
+
(`src/invisible.mjs`'s `LONG_RUN_CHUNK_RE`), so the backtrack stack has a
|
|
836
|
+
ceiling that does not move with the input.
|
|
837
|
+
- Assemble a rewritten string once, from an array of pieces — never
|
|
838
|
+
`text.slice(0, i) + x + text.slice(j)` once per finding.
|
|
839
|
+
- Bound every recursive walk by depth and memo, and thread the wall-clock
|
|
840
|
+
`Deadline` through the layers that can be skipped, so a budget already spent
|
|
841
|
+
refuses the work instead of starting it.
|
|
842
|
+
|
|
843
|
+
The guarantee is about COST, not about verdicts. Where a bound and a detection
|
|
844
|
+
disagree — a value too long to analyze, a walk past `MAX_DEPTH`, a spent
|
|
845
|
+
deadline — the layer takes the false negative and says so, exactly as the
|
|
846
|
+
precision-over-recall doctrine requires everywhere else.
|
|
847
|
+
|
|
782
848
|
## Benchmark (`test/injection-corpus.test.mjs`)
|
|
783
849
|
|
|
784
850
|
The published "invisible prompt injection" corpora fall in two families, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.49.
|
|
3
|
+
"version": "2.49.1",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"c8": "11.0.0",
|
|
60
60
|
"esbuild": "0.28.1",
|
|
61
61
|
"eslint": "10.4.0",
|
|
62
|
+
"eslint-plugin-redos": "4.5.0",
|
|
62
63
|
"fast-check": "4.8.0",
|
|
63
64
|
"globals": "17.6.0",
|
|
64
65
|
"lint-staged": "^17.0.5",
|
package/src/confusables.mjs
CHANGED
|
@@ -198,21 +198,21 @@ function describeFolds(findings) {
|
|
|
198
198
|
|
|
199
199
|
/**
|
|
200
200
|
* Reject a finding that does not describe a real, foldable glyph at its reported
|
|
201
|
-
* offset
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
201
|
+
* offset. Every consumer of a scanner finding runs this BEFORE acting on it, so
|
|
202
|
+
* a buggy or adversarial scanner fails loud instead of silently corrupting (or
|
|
203
|
+
* silently escaping the fold gate in) a path/command. `actual` is the bytes that
|
|
204
|
+
* sit at `finding.index` right now — from the input for an untouched region,
|
|
205
|
+
* from the folded output for one the caller has already rewritten.
|
|
205
206
|
* @param {{ index: number, char: string, latinEquivalent: string }} finding
|
|
207
|
+
* @param {string} actual
|
|
206
208
|
* @returns {void}
|
|
207
209
|
*/
|
|
208
|
-
function assertFinding(
|
|
209
|
-
//
|
|
210
|
-
// a
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
// clamped to 0), and the slice math below then mangles the string instead of
|
|
215
|
-
// throwing — so range-check the index explicitly first.
|
|
210
|
+
function assertFinding(finding, actual) {
|
|
211
|
+
// A negative index is the gap the byte check alone misses: `String.slice`
|
|
212
|
+
// reads a negative offset from the END, so `actual` can be a genuine match
|
|
213
|
+
// taken from the wrong place — `{index: -2, char: "а"}` against `"aаb"`
|
|
214
|
+
// matches, and the splice math then folds a byte the finding never named.
|
|
215
|
+
// Range-check the index explicitly first.
|
|
216
216
|
if (!Number.isInteger(finding.index) || finding.index < 0)
|
|
217
217
|
throw new Error(
|
|
218
218
|
`Confusable finding has an out-of-range index ${finding.index}`,
|
|
@@ -235,7 +235,7 @@ function assertFinding(text, finding) {
|
|
|
235
235
|
throw new Error(
|
|
236
236
|
`Confusable finding at index ${finding.index} names an ASCII char ${JSON.stringify(finding.char)}`,
|
|
237
237
|
);
|
|
238
|
-
if (!
|
|
238
|
+
if (!actual.startsWith(finding.char))
|
|
239
239
|
throw new Error(
|
|
240
240
|
`Confusable finding does not match input at index ${finding.index}: expected ${JSON.stringify(finding.char)}`,
|
|
241
241
|
);
|
|
@@ -264,6 +264,17 @@ function assertFinding(text, finding) {
|
|
|
264
264
|
);
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
/**
|
|
268
|
+
* The bytes `finding.char` claims to sit on, read from `text`. Bounded to the
|
|
269
|
+
* glyph's own length so validating a finding never copies the rest of the input.
|
|
270
|
+
* @param {string} text
|
|
271
|
+
* @param {{ index: number, char: string }} finding
|
|
272
|
+
* @returns {string}
|
|
273
|
+
*/
|
|
274
|
+
function bytesAt(text, finding) {
|
|
275
|
+
return text.slice(finding.index, finding.index + finding.char.length);
|
|
276
|
+
}
|
|
277
|
+
|
|
267
278
|
/**
|
|
268
279
|
* True for a code point that ends a token: any ASCII character that is not a
|
|
269
280
|
* letter or digit (space, `/`, `.`, `-`, `_`, quotes, shell metacharacters). A
|
|
@@ -295,7 +306,8 @@ function isTokenBoundary(ch) {
|
|
|
295
306
|
* @returns {Array<{ index: number, char: string, latinEquivalent: string }>}
|
|
296
307
|
*/
|
|
297
308
|
export function selectFoldableFindings(text, findings) {
|
|
298
|
-
for (const finding of findings)
|
|
309
|
+
for (const finding of findings)
|
|
310
|
+
assertFinding(finding, bytesAt(text, finding));
|
|
299
311
|
|
|
300
312
|
// Every UTF-16 offset a finding covers, not just its start: a scanner is free
|
|
301
313
|
// to report a multi-code-point match, and treating only the first offset as
|
|
@@ -341,10 +353,15 @@ export function selectFoldableFindings(text, findings) {
|
|
|
341
353
|
|
|
342
354
|
// Every offset the finding covers, not just its first: `char` may span a
|
|
343
355
|
// boundary into a token the gate rejected, and folding it would rewrite that
|
|
344
|
-
// token — the exact mangling this gate exists to prevent.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
356
|
+
// token — the exact mangling this gate exists to prevent. Counted in UTF-16
|
|
357
|
+
// units, which is what `foldableAt` is indexed by: an astral glyph occupies
|
|
358
|
+
// two of them, so a code-point walk would leave the tail of a `char` that
|
|
359
|
+
// carries one unchecked.
|
|
360
|
+
return findings.filter((finding) => {
|
|
361
|
+
for (let i = 0; i < finding.char.length; i++)
|
|
362
|
+
if (foldableAt[finding.index + i] !== 1) return false;
|
|
363
|
+
return true;
|
|
364
|
+
});
|
|
348
365
|
}
|
|
349
366
|
|
|
350
367
|
/**
|
|
@@ -365,7 +382,6 @@ export function foldConfusables(text, findings) {
|
|
|
365
382
|
/** @type {string[]} */
|
|
366
383
|
const tail = [];
|
|
367
384
|
let cursor = text.length;
|
|
368
|
-
const rebuild = () => [...tail].reverse().join("");
|
|
369
385
|
for (const finding of [...findings].sort(
|
|
370
386
|
(lhs, rhs) => rhs.index - lhs.index,
|
|
371
387
|
)) {
|
|
@@ -374,21 +390,53 @@ export function foldConfusables(text, findings) {
|
|
|
374
390
|
// a glyph that is not there fails loud. Highest-index-first leaves every
|
|
375
391
|
// offset below `cursor` byte-identical to `text`, so a finding ending there
|
|
376
392
|
// is checked against `text` itself; one reaching PAST `cursor` overlaps a
|
|
377
|
-
// fold already applied, and only the
|
|
378
|
-
// sits on.
|
|
393
|
+
// fold already applied, and only the folded bytes carry what it now sits on.
|
|
379
394
|
if (end <= cursor) {
|
|
380
|
-
assertFinding(text, finding);
|
|
395
|
+
assertFinding(finding, bytesAt(text, finding));
|
|
381
396
|
tail.push(text.slice(end, cursor));
|
|
382
397
|
} else {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
tail
|
|
386
|
-
|
|
398
|
+
// The overlap reaches at most `char.length` past `cursor`, and those bytes
|
|
399
|
+
// are the ones pushed most recently, so take them off the tail instead of
|
|
400
|
+
// re-joining all of it — a whole-tail rebuild per overlapping finding is
|
|
401
|
+
// the O(findings x length) cost this assembly exists to avoid. Consuming
|
|
402
|
+
// them is also what the fold means: `char` replaces those bytes.
|
|
403
|
+
const overlap = takeFromTail(tail, end - cursor);
|
|
404
|
+
assertFinding(finding, text.slice(finding.index, cursor) + overlap);
|
|
387
405
|
}
|
|
388
406
|
tail.push(finding.latinEquivalent);
|
|
389
407
|
cursor = finding.index;
|
|
390
408
|
}
|
|
391
|
-
return text.slice(0, cursor) +
|
|
409
|
+
return text.slice(0, cursor) + tail.reverse().join("");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Remove and return the first `count` characters of the folded tail. The tail is
|
|
414
|
+
* stored back-to-front, so its lowest-offset bytes are the entries pushed most
|
|
415
|
+
* recently and popping walks them in reading order; a partially consumed entry
|
|
416
|
+
* is pushed back minus what was taken.
|
|
417
|
+
* @param {string[]} tail
|
|
418
|
+
* @param {number} count
|
|
419
|
+
* @returns {string}
|
|
420
|
+
*/
|
|
421
|
+
function takeFromTail(tail, count) {
|
|
422
|
+
/** @type {string[]} */
|
|
423
|
+
const taken = [];
|
|
424
|
+
let remaining = count;
|
|
425
|
+
while (remaining > 0) {
|
|
426
|
+
const entry = tail.pop();
|
|
427
|
+
// A tail shorter than `count` means the finding claims bytes past the end of
|
|
428
|
+
// the folded text — a mismatching finding, which the caller's assertion
|
|
429
|
+
// rejects by name once it sees the short result. Stop rather than pretend.
|
|
430
|
+
if (entry === undefined) break;
|
|
431
|
+
if (entry.length > remaining) {
|
|
432
|
+
taken.push(entry.slice(0, remaining));
|
|
433
|
+
tail.push(entry.slice(remaining));
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
taken.push(entry);
|
|
437
|
+
remaining -= entry.length;
|
|
438
|
+
}
|
|
439
|
+
return taken.join("");
|
|
392
440
|
}
|
|
393
441
|
|
|
394
442
|
/**
|
package/src/html.mjs
CHANGED
|
@@ -590,13 +590,19 @@ function hexByte(n) {
|
|
|
590
590
|
* clamps out-of-range) or a percentage `0%..100%` scaled to `0..255`. Returns
|
|
591
591
|
* null (fail open) on any other shape — a `none`/`calc()`/negative channel we
|
|
592
592
|
* cannot resolve to a concrete byte.
|
|
593
|
+
*
|
|
594
|
+
* The number shape here (and in `hueDegrees`/`hslPercent`) spells the integer
|
|
595
|
+
* and leading-`.` forms as disjoint alternatives rather than `\d*\.?\d+`: the
|
|
596
|
+
* latter's `\d*` and `\d+` both match a digit, so every split of a long digit
|
|
597
|
+
* run is retried against the rest of the pattern and a failing token costs
|
|
598
|
+
* O(n^2). Disjoint arms fail each backtrack step in constant time.
|
|
593
599
|
* @param {string} token
|
|
594
600
|
* @returns {number | null}
|
|
595
601
|
*/
|
|
596
602
|
function rgbChannel(token) {
|
|
597
|
-
const pct = token.match(/^\+?(\d
|
|
603
|
+
const pct = token.match(/^\+?(\d+(?:\.\d+)?|\.\d+)%$/);
|
|
598
604
|
if (pct) return (Math.min(100, parseFloat(pct[1])) / 100) * 255;
|
|
599
|
-
const num = token.match(/^\+?(\d
|
|
605
|
+
const num = token.match(/^\+?(\d+(?:\.\d+)?|\.\d+)$/);
|
|
600
606
|
if (num) return parseFloat(num[1]);
|
|
601
607
|
return null;
|
|
602
608
|
}
|
|
@@ -608,7 +614,9 @@ function rgbChannel(token) {
|
|
|
608
614
|
* @returns {number | null}
|
|
609
615
|
*/
|
|
610
616
|
function hueDegrees(token) {
|
|
611
|
-
const match = token.match(
|
|
617
|
+
const match = token.match(
|
|
618
|
+
/^([+-]?(?:\d+(?:\.\d+)?|\.\d+))(deg|grad|rad|turn)?$/,
|
|
619
|
+
);
|
|
612
620
|
if (!match) return null;
|
|
613
621
|
const value = parseFloat(match[1]);
|
|
614
622
|
const unit = match[2] || "deg";
|
|
@@ -630,7 +638,7 @@ function hueDegrees(token) {
|
|
|
630
638
|
* @returns {number | null}
|
|
631
639
|
*/
|
|
632
640
|
function hslPercent(token) {
|
|
633
|
-
const match = token.match(/^\+?(\d
|
|
641
|
+
const match = token.match(/^\+?(\d+(?:\.\d+)?|\.\d+)%?$/);
|
|
634
642
|
return match ? Math.min(100, parseFloat(match[1])) : null;
|
|
635
643
|
}
|
|
636
644
|
|
|
@@ -688,7 +696,11 @@ function canonicalizeColorFunction(value) {
|
|
|
688
696
|
}
|
|
689
697
|
// A literal-zero alpha is fully transparent — bare number (`0`, `0.0`) or the
|
|
690
698
|
// CSS Color 4 percentage form (`0%`), which a browser also renders invisible.
|
|
691
|
-
|
|
699
|
+
// Disjoint arms for the same reason the channel patterns above carry them:
|
|
700
|
+
// `0*` and `0+` both match a zero, so a long run of them would be retried at
|
|
701
|
+
// every split before the match could fail.
|
|
702
|
+
if (alpha !== null && /^\+?(?:0+(?:\.0+)?|\.0+)%?$/.test(alpha))
|
|
703
|
+
return "transparent";
|
|
692
704
|
if (parts.length !== 3) return null;
|
|
693
705
|
if (isRgb) {
|
|
694
706
|
const channels = parts.map(rgbChannel);
|
|
@@ -1806,7 +1818,11 @@ const BOGUS_COMMENT_OPEN_RE = /<[!?]/g;
|
|
|
1806
1818
|
// html-property "second pass changes nothing"). An open/end tag requires a
|
|
1807
1819
|
// name letter after the `<`/`</`, so literal prose like `a < b` or an `i <3 u`
|
|
1808
1820
|
// emoticon is not mistaken for markup.
|
|
1809
|
-
|
|
1821
|
+
// Matched against the slice after the last `>` rather than as a `[^>]*$` tail:
|
|
1822
|
+
// the tail form has to rescan to end-of-input from every `<` in the source, so
|
|
1823
|
+
// a document of many `<` and no `>` costs O(n^2). This form is fixed-length, so
|
|
1824
|
+
// each start offset is constant.
|
|
1825
|
+
const MARKUP_OPENER_RE = /<(?:[!?]|\/?[a-zA-Z])/;
|
|
1810
1826
|
|
|
1811
1827
|
/**
|
|
1812
1828
|
* Fold a raw source slice into the "inside an unterminated tag" state. A `>`
|
|
@@ -1820,8 +1836,10 @@ const UNTERMINATED_MARKUP_TAIL_RE = /<(?:[!?]|\/?[a-zA-Z])[^>]*$/;
|
|
|
1820
1836
|
* @returns {boolean}
|
|
1821
1837
|
*/
|
|
1822
1838
|
function foldAbsorb(absorbing, raw) {
|
|
1823
|
-
|
|
1824
|
-
|
|
1839
|
+
const lastClose = raw.lastIndexOf(">");
|
|
1840
|
+
const opensMarkup = MARKUP_OPENER_RE.test(raw.slice(lastClose + 1));
|
|
1841
|
+
if (lastClose !== -1) return opensMarkup;
|
|
1842
|
+
return absorbing || opensMarkup;
|
|
1825
1843
|
}
|
|
1826
1844
|
|
|
1827
1845
|
/**
|
|
@@ -2499,7 +2517,22 @@ const DIGEST_HEX_LENGTHS = new Set([32, 40, 56, 64, 96, 128]);
|
|
|
2499
2517
|
// the slug benign (no uppercase) while catching the scattered-separator blob the
|
|
2500
2518
|
// run gate missed. Anchored to the whole value for the same RAW-query reason.
|
|
2501
2519
|
const BLOB_VALUE_B64URL_RE = /^[A-Za-z0-9_-]{40,}={0,2}$/;
|
|
2502
|
-
|
|
2520
|
+
|
|
2521
|
+
/**
|
|
2522
|
+
* True when `value` carries at least one uppercase letter AND one digit — the
|
|
2523
|
+
* character mix that separates bulk-encoded bytes from a lowercase word-slug.
|
|
2524
|
+
* Two single-character-class scans rather than one `(?=.*[A-Z])(?=.*[0-9])`
|
|
2525
|
+
* regex: an unanchored lookahead pair costs a full scan at EVERY start offset,
|
|
2526
|
+
* so a long value that carries neither class is quadratic to reject. The two
|
|
2527
|
+
* agree on any value without a newline, which every caller guarantees by
|
|
2528
|
+
* gating on an anchored pattern over a newline-free alphabet first — `.` in
|
|
2529
|
+
* that lookahead does not cross a line, so only there could they differ.
|
|
2530
|
+
* @param {string} value
|
|
2531
|
+
* @returns {boolean}
|
|
2532
|
+
*/
|
|
2533
|
+
function hasUpperAndDigit(value) {
|
|
2534
|
+
return /[A-Z]/.test(value) && /[0-9]/.test(value);
|
|
2535
|
+
}
|
|
2503
2536
|
|
|
2504
2537
|
// A path segment whose whole value is a base64/hex run longer than any standard
|
|
2505
2538
|
// content hash (SHA-512 hex is 128, base64 88; SHA-256 hex 64) is bulk encoded
|
|
@@ -2510,6 +2543,8 @@ const B64URL_MIXED_RE = /(?=.*[A-Z])(?=.*[0-9])/;
|
|
|
2510
2543
|
// standard arm so a long word-slug (`the-secret-history-of-…`) is not mistaken
|
|
2511
2544
|
// for a payload; the url-safe arm re-admits `-`/`_` but, like the query arm
|
|
2512
2545
|
// above, gates on a contiguous 40+ alphanumeric run to keep the slug benign.
|
|
2546
|
+
// The two arms overlap on `[A-Fa-f0-9]` but stay linear: `={0,2}$` fails in
|
|
2547
|
+
// constant time after the first giveback, so each arm costs one pass.
|
|
2513
2548
|
const PATH_BLOB_RE = /^(?:[A-Za-z0-9+/]+={0,2}|[A-Fa-f0-9]+)$/;
|
|
2514
2549
|
// SHA-512 is 128 hex characters and no standard digest is longer, so a run past
|
|
2515
2550
|
// this is bulk encoded data rather than any fingerprint. Shared by the two path
|
|
@@ -2526,7 +2561,7 @@ const DIGEST_MAX_LEN = 128;
|
|
|
2526
2561
|
* @returns {boolean}
|
|
2527
2562
|
*/
|
|
2528
2563
|
function isBase64UrlBlob(value) {
|
|
2529
|
-
return BLOB_VALUE_B64URL_RE.test(value) &&
|
|
2564
|
+
return BLOB_VALUE_B64URL_RE.test(value) && hasUpperAndDigit(value);
|
|
2530
2565
|
}
|
|
2531
2566
|
|
|
2532
2567
|
// `.` and `,` are legal in a URL but sit outside every blob alphabet above, so
|
|
@@ -2570,7 +2605,7 @@ function isChunkedPathBlob(segment) {
|
|
|
2570
2605
|
return (
|
|
2571
2606
|
joined !== null &&
|
|
2572
2607
|
joined.length > DIGEST_MAX_LEN &&
|
|
2573
|
-
((PATH_BLOB_RE.test(joined) &&
|
|
2608
|
+
((PATH_BLOB_RE.test(joined) && hasUpperAndDigit(joined)) ||
|
|
2574
2609
|
isBase64UrlBlob(joined))
|
|
2575
2610
|
);
|
|
2576
2611
|
}
|
|
@@ -2968,7 +3003,12 @@ function parseSrcset(value) {
|
|
|
2968
3003
|
const start = i;
|
|
2969
3004
|
while (i < n && !SRCSET_WS_RE.test(value[i])) i++;
|
|
2970
3005
|
const run = value.slice(start, i);
|
|
2971
|
-
|
|
3006
|
+
// The trailing commas come off by scanning back rather than by replacing
|
|
3007
|
+
// `,+$`: that pattern is unanchored at the start, so a run of commas costs
|
|
3008
|
+
// one match attempt per comma.
|
|
3009
|
+
let cut = run.length;
|
|
3010
|
+
while (cut > 0 && run[cut - 1] === ",") cut--;
|
|
3011
|
+
const url = run.slice(0, cut);
|
|
2972
3012
|
if (url) urls.push(url);
|
|
2973
3013
|
// A URL run ending in a comma is a bare candidate (no descriptor); the
|
|
2974
3014
|
// comma already delimits the next one, so skip descriptor parsing.
|