agent-sanitizer 2.48.4 → 2.48.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/html.mjs +62 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sanitizer",
|
|
3
|
-
"version": "2.48.
|
|
3
|
+
"version": "2.48.6",
|
|
4
4
|
"description": "Defend an agent against hidden-content injection: strip payload-capable invisible Unicode and ANSI, splice out human-invisible HTML, and flag data-exfil URLs in untrusted text before any model sees it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/html.mjs
CHANGED
|
@@ -2377,7 +2377,7 @@ const RELATIVE_URL_BASE = "http://relative.invalid";
|
|
|
2377
2377
|
// Parameter NAMES that legitimately carry a LONG opaque (base64/hex) value, so
|
|
2378
2378
|
// a blob in one of them is NOT exfil: CDN request-signing (AWS SigV4 /
|
|
2379
2379
|
// CloudFront `X-Amz-*`/`Signature`/`Policy`/`Key-Pair-Id`, GCS `X-Goog-*`,
|
|
2380
|
-
// Azure SAS `sig` only), pagination cursors /
|
|
2380
|
+
// Azure SAS `sig` only, Cloudflare `verify`), pagination cursors /
|
|
2381
2381
|
// continuation tokens, and the long analytics click-IDs. Matched
|
|
2382
2382
|
// case-insensitively against the exact (lowercased) parameter name. Scope is
|
|
2383
2383
|
// deliberately limited to names whose benign value is genuinely a long token —
|
|
@@ -2392,7 +2392,7 @@ const RELATIVE_URL_BASE = "http://relative.invalid";
|
|
|
2392
2392
|
// code, a signed CSRF nonce), so the value shape cannot separate them from a
|
|
2393
2393
|
// payload.
|
|
2394
2394
|
const BENIGN_BLOB_PARAM_RE =
|
|
2395
|
-
/^(?:x-(?:amz|goog|ms|oss|obs)-[a-z0-9-]+|amz-[a-z0-9-]+|utm_[a-z]+|sig|signature|hmac|q-signature|policy|credential|code|state|cursor|after|before|continuation|continuationtoken|continuation_token|pagetoken|page_token|nexttoken|next_token|gclid|fbclid|dclid|gbraid|wbraid|msclkid)$/i;
|
|
2395
|
+
/^(?:x-(?:amz|goog|ms|oss|obs)-[a-z0-9-]+|amz-[a-z0-9-]+|utm_[a-z]+|sig|signature|hmac|verify|q-signature|policy|credential|code|state|cursor|after|before|continuation|continuationtoken|continuation_token|pagetoken|page_token|nexttoken|next_token|gclid|fbclid|dclid|gbraid|wbraid|msclkid)$/i;
|
|
2396
2396
|
|
|
2397
2397
|
// The SHORT-valued companions of a signed-CDN link. Azure SAS spells one long
|
|
2398
2398
|
// `sig` beside a crowd of short fields, and the whole taxonomy is listed here:
|
|
@@ -2466,11 +2466,25 @@ const OPAQUE_TOKEN_RE = /[A-Za-z0-9_]{20,}/g;
|
|
|
2466
2466
|
const VALUE_HAS_DIGIT_RE = /\d/;
|
|
2467
2467
|
|
|
2468
2468
|
// A value that is ENTIRELY a long base64 (40+ chars, optional `=` padding) or
|
|
2469
|
-
// hex
|
|
2470
|
-
//
|
|
2471
|
-
//
|
|
2469
|
+
// hex run. Anchored to the whole value (operating on the RAW, un-decoded query
|
|
2470
|
+
// so a `+` in base64 is not turned into a space), so a benign short value with
|
|
2471
|
+
// an incidental hex word never trips it. Both arms are linear.
|
|
2472
|
+
//
|
|
2473
|
+
// A value that is EXACTLY one digest — all hex, at an MD5/SHA length — is a
|
|
2474
|
+
// FINGERPRINT rather than a payload, and `isBlobValue` below exempts it. A
|
|
2475
|
+
// cache-buster (`?v=<md5>`), an ETag, a request id, a git commit and a
|
|
2476
|
+
// signed-CDN token (imgix `?s=`, KeyCDN `?secure=`) are all a bare digest under
|
|
2477
|
+
// a generic name, and every one of them was reported before: hex is a subset of
|
|
2478
|
+
// base64's alphabet, so the 40-char base64 arm caught them on charset alone.
|
|
2472
2479
|
const BLOB_VALUE_B64_RE = /^[A-Za-z0-9+/]{40,}={0,2}$/;
|
|
2473
|
-
const
|
|
2480
|
+
const HEX_ONLY_RE = /^[A-Fa-f0-9]+$/;
|
|
2481
|
+
// 16 bytes, the floor the hex arm has always carried.
|
|
2482
|
+
const HEX_BLOB_MIN_LEN = 32;
|
|
2483
|
+
// MD5, SHA-1, SHA-224, SHA-256, SHA-384, SHA-512 in hex. Exact lengths, not a
|
|
2484
|
+
// range: bulk data is any length, so only the digest sizes themselves are
|
|
2485
|
+
// ambiguous, and an attacker who pads a payload to exactly 64 hex characters
|
|
2486
|
+
// buys 32 bytes per parameter. A value one character either side is a payload.
|
|
2487
|
+
const DIGEST_HEX_LENGTHS = new Set([32, 40, 56, 64, 96, 128]);
|
|
2474
2488
|
|
|
2475
2489
|
// RFC 4648 §5 url-safe base64 substitutes `-`/`_` for `+`/`/`, so a payload
|
|
2476
2490
|
// encoded url-safe escapes the `[A-Za-z0-9+/]` arms above. Adding `-`/`_` to the
|
|
@@ -2497,7 +2511,10 @@ const B64URL_MIXED_RE = /(?=.*[A-Z])(?=.*[0-9])/;
|
|
|
2497
2511
|
// for a payload; the url-safe arm re-admits `-`/`_` but, like the query arm
|
|
2498
2512
|
// above, gates on a contiguous 40+ alphanumeric run to keep the slug benign.
|
|
2499
2513
|
const PATH_BLOB_RE = /^(?:[A-Za-z0-9+/]+={0,2}|[A-Fa-f0-9]+)$/;
|
|
2500
|
-
|
|
2514
|
+
// SHA-512 is 128 hex characters and no standard digest is longer, so a run past
|
|
2515
|
+
// this is bulk encoded data rather than any fingerprint. Shared by the two path
|
|
2516
|
+
// walks so the segment gate and the chunked-residue gate cannot drift.
|
|
2517
|
+
const DIGEST_MAX_LEN = 128;
|
|
2501
2518
|
|
|
2502
2519
|
/**
|
|
2503
2520
|
* True for an entirely-url-safe-base64 value (≥40 chars) whose character mix —
|
|
@@ -2552,19 +2569,33 @@ function isChunkedPathBlob(segment) {
|
|
|
2552
2569
|
const joined = chunkedBlobResidue(segment);
|
|
2553
2570
|
return (
|
|
2554
2571
|
joined !== null &&
|
|
2555
|
-
joined.length >
|
|
2572
|
+
joined.length > DIGEST_MAX_LEN &&
|
|
2556
2573
|
((PATH_BLOB_RE.test(joined) && B64URL_MIXED_RE.test(joined)) ||
|
|
2557
2574
|
isBase64UrlBlob(joined))
|
|
2558
2575
|
);
|
|
2559
2576
|
}
|
|
2560
2577
|
|
|
2561
|
-
/**
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2578
|
+
/**
|
|
2579
|
+
* Bulk-encoded bytes, with the digest exemption applied. `digestIsBenign` says
|
|
2580
|
+
* whether an exact-digest-length hex value counts as a fingerprint here: true
|
|
2581
|
+
* under a generic parameter name, where a cache-buster or an ETag is the
|
|
2582
|
+
* overwhelmingly likelier reading, and false under a name that already says
|
|
2583
|
+
* credential, where the same 64 characters are as likely 32 bytes of hex-encoded
|
|
2584
|
+
* cookie. The name is what separates them — the value shape cannot.
|
|
2585
|
+
* @param {string} value
|
|
2586
|
+
* @param {boolean} digestIsBenign
|
|
2587
|
+
* @returns {boolean}
|
|
2588
|
+
*/
|
|
2589
|
+
function isBlobValue(value, digestIsBenign) {
|
|
2590
|
+
// An all-hex value keeps its own floor, which sits below base64's: hex costs
|
|
2591
|
+
// two characters per byte, so 32 of them is already 16 bytes of payload. Only
|
|
2592
|
+
// the digest lengths are exempt, never a range around them.
|
|
2593
|
+
if (HEX_ONLY_RE.test(value))
|
|
2594
|
+
return (
|
|
2595
|
+
value.length >= HEX_BLOB_MIN_LEN &&
|
|
2596
|
+
!(digestIsBenign && DIGEST_HEX_LENGTHS.has(value.length))
|
|
2597
|
+
);
|
|
2598
|
+
return BLOB_VALUE_B64_RE.test(value) || isBase64UrlBlob(value);
|
|
2568
2599
|
}
|
|
2569
2600
|
|
|
2570
2601
|
/**
|
|
@@ -2579,16 +2610,17 @@ function isBlobValue(value) {
|
|
|
2579
2610
|
* percent-sequence throws in `decodeURIComponent`; that failure is not a
|
|
2580
2611
|
* blob shape either way, so it fails open (skip the decoded check).
|
|
2581
2612
|
* @param {string} value
|
|
2613
|
+
* @param {boolean} digestIsBenign
|
|
2582
2614
|
* @returns {boolean}
|
|
2583
2615
|
*/
|
|
2584
|
-
function decodedBlobMatch(value) {
|
|
2616
|
+
function decodedBlobMatch(value, digestIsBenign) {
|
|
2585
2617
|
let decoded;
|
|
2586
2618
|
try {
|
|
2587
2619
|
decoded = decodeURIComponent(value);
|
|
2588
2620
|
} catch {
|
|
2589
2621
|
return false;
|
|
2590
2622
|
}
|
|
2591
|
-
return isBlobValue(decoded);
|
|
2623
|
+
return isBlobValue(decoded, digestIsBenign);
|
|
2592
2624
|
}
|
|
2593
2625
|
|
|
2594
2626
|
// A credential rarely travels alone in a parameter: a signed session cookie is
|
|
@@ -2616,7 +2648,7 @@ function containsBlobRun(value) {
|
|
|
2616
2648
|
}
|
|
2617
2649
|
for (const form of decoded === value ? [value] : [value, decoded])
|
|
2618
2650
|
for (const part of form.split(BLOB_RUN_SPLIT_RE))
|
|
2619
|
-
if (part !== form && isBlobValue(part)) return true;
|
|
2651
|
+
if (part !== form && isBlobValue(part, false)) return true;
|
|
2620
2652
|
return false;
|
|
2621
2653
|
}
|
|
2622
2654
|
|
|
@@ -2659,6 +2691,13 @@ function paramExfilReason(name, value, rawName) {
|
|
|
2659
2691
|
if (BENIGN_BLOB_PARAM_RE.test(name)) return null;
|
|
2660
2692
|
const publicKeyId =
|
|
2661
2693
|
PUBLIC_KEY_ID_PARAM_RE.test(name) && value.length < BLOB_VALUE_MIN_LEN;
|
|
2694
|
+
// Every fingerprint the digest exemption exists for sits under a GENERIC name
|
|
2695
|
+
// — a cache-buster `?v=`, an ETag, imgix's `?s=`, a commit id. Under a name
|
|
2696
|
+
// that already says credential the same 64 hex characters read as 32 bytes of
|
|
2697
|
+
// payload, so the exemption stops there.
|
|
2698
|
+
const digestIsBenign = !(
|
|
2699
|
+
KEYWORD_PARAM_NAME_RE.test(name) || matchesSecretHint(name)
|
|
2700
|
+
);
|
|
2662
2701
|
for (const candidate of [rawName, value]) {
|
|
2663
2702
|
if (!candidate) continue;
|
|
2664
2703
|
// A leaked credential is an OPAQUE, separator-free token. Gate the
|
|
@@ -2674,7 +2713,10 @@ function paramExfilReason(name, value, rawName) {
|
|
|
2674
2713
|
)
|
|
2675
2714
|
)
|
|
2676
2715
|
return "credential-shaped token in URL parameter";
|
|
2677
|
-
if (
|
|
2716
|
+
if (
|
|
2717
|
+
isBlobValue(candidate, digestIsBenign) ||
|
|
2718
|
+
decodedBlobMatch(candidate, digestIsBenign)
|
|
2719
|
+
)
|
|
2678
2720
|
return "suspicious query parameter";
|
|
2679
2721
|
}
|
|
2680
2722
|
// A wrapped credential (`?session=s%3A<token>.<mac>`) in a parameter whose
|
|
@@ -2774,7 +2816,7 @@ function checkUrlParams(parsed) {
|
|
|
2774
2816
|
function checkUrlPath(parsed) {
|
|
2775
2817
|
for (const segment of parsed.pathname.split("/")) {
|
|
2776
2818
|
if (
|
|
2777
|
-
(segment.length >
|
|
2819
|
+
(segment.length > DIGEST_MAX_LEN &&
|
|
2778
2820
|
(PATH_BLOB_RE.test(segment) || isBase64UrlBlob(segment))) ||
|
|
2779
2821
|
isChunkedPathBlob(segment)
|
|
2780
2822
|
)
|