@patchstack/connect 0.5.15 → 0.5.16
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/AGENT-INSTALL.md +37 -11
- package/dist/cli.js +654 -174
- package/dist/cli.js.map +1 -1
- package/dist/protect/templates/demo-rules.json +2 -2
- package/dist/protect.cjs +1384 -747
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.cts +110 -16
- package/dist/protect.d.ts +110 -16
- package/dist/protect.edge.js +1372 -735
- package/dist/protect.edge.js.map +3 -3
- package/dist/protect.js +1372 -735
- package/dist/protect.js.map +1 -1
- package/package.json +1 -1
package/dist/protect.js
CHANGED
|
@@ -376,18 +376,124 @@ function appendOwn(target, key, value) {
|
|
|
376
376
|
return setOwn(target, key, Array.isArray(previous) ? [...previous, value] : [previous, value]);
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
// src/protect/engine/
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
379
|
+
// src/protect/engine/cookies.js
|
|
380
|
+
function parseCookieHeader(header) {
|
|
381
|
+
const cookies = {};
|
|
382
|
+
if (typeof header !== "string" || header === "") return cookies;
|
|
383
|
+
const repeated = /* @__PURE__ */ new Map();
|
|
384
|
+
for (const pair of header.split(";")) {
|
|
385
|
+
const eq = pair.indexOf("=");
|
|
386
|
+
if (eq === -1) continue;
|
|
387
|
+
const name = pair.slice(0, eq).trim();
|
|
388
|
+
if (name === "") continue;
|
|
389
|
+
let value = pair.slice(eq + 1).trim();
|
|
390
|
+
if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') value = value.slice(1, -1);
|
|
391
|
+
if (!Object.hasOwn(cookies, name)) {
|
|
392
|
+
setOwn(cookies, name, value);
|
|
393
|
+
} else {
|
|
394
|
+
let values = repeated.get(name);
|
|
395
|
+
if (values === void 0) {
|
|
396
|
+
values = [cookies[name]];
|
|
397
|
+
repeated.set(name, values);
|
|
398
|
+
setOwn(cookies, name, values);
|
|
399
|
+
}
|
|
400
|
+
values.push(value);
|
|
401
|
+
}
|
|
387
402
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
403
|
+
return cookies;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/protect/engine/normalizer.js
|
|
407
|
+
var HTML_ENTITIES = {
|
|
408
|
+
"&": "&",
|
|
409
|
+
"<": "<",
|
|
410
|
+
">": ">",
|
|
411
|
+
""": '"',
|
|
412
|
+
"'": "'",
|
|
413
|
+
"'": "'",
|
|
414
|
+
" ": " ",
|
|
415
|
+
"'": "'",
|
|
416
|
+
""": '"',
|
|
417
|
+
"<": "<",
|
|
418
|
+
">": ">",
|
|
419
|
+
"&": "&",
|
|
420
|
+
""": '"',
|
|
421
|
+
"<": "<",
|
|
422
|
+
">": ">",
|
|
423
|
+
"&": "&"
|
|
424
|
+
};
|
|
425
|
+
var MAX_DECODE_ITERATIONS = 5;
|
|
426
|
+
function normalize(value, options = {}) {
|
|
427
|
+
if (typeof value !== "string") {
|
|
428
|
+
return value;
|
|
429
|
+
}
|
|
430
|
+
const opts = {
|
|
431
|
+
urlDecode: true,
|
|
432
|
+
htmlDecode: true,
|
|
433
|
+
sqlComments: true,
|
|
434
|
+
nullBytes: true,
|
|
435
|
+
whitespace: true,
|
|
436
|
+
...options
|
|
437
|
+
};
|
|
438
|
+
let result = value;
|
|
439
|
+
if (opts.nullBytes) {
|
|
440
|
+
result = removeNullBytes(result);
|
|
441
|
+
}
|
|
442
|
+
if (opts.urlDecode) {
|
|
443
|
+
result = urlDecode(result);
|
|
444
|
+
}
|
|
445
|
+
if (opts.htmlDecode) {
|
|
446
|
+
result = htmlEntityDecode(result);
|
|
447
|
+
}
|
|
448
|
+
if (opts.sqlComments) {
|
|
449
|
+
result = removeSqlComments(result);
|
|
450
|
+
}
|
|
451
|
+
if (opts.whitespace) {
|
|
452
|
+
result = normalizeWhitespace(result);
|
|
453
|
+
}
|
|
454
|
+
return result;
|
|
455
|
+
}
|
|
456
|
+
function urlDecode(value) {
|
|
457
|
+
if (typeof value !== "string") {
|
|
458
|
+
return value;
|
|
459
|
+
}
|
|
460
|
+
let result = value;
|
|
461
|
+
let previous = "";
|
|
462
|
+
let iterations = 0;
|
|
463
|
+
while (result !== previous && iterations < MAX_DECODE_ITERATIONS) {
|
|
464
|
+
previous = result;
|
|
465
|
+
iterations++;
|
|
466
|
+
result = safeUrlDecode(result);
|
|
467
|
+
}
|
|
468
|
+
return result;
|
|
469
|
+
}
|
|
470
|
+
var utf8 = new TextDecoder();
|
|
471
|
+
function safeUrlDecode(value) {
|
|
472
|
+
return value.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
|
|
473
|
+
try {
|
|
474
|
+
return decodeURIComponent(run);
|
|
475
|
+
} catch {
|
|
476
|
+
const bytes = new Uint8Array(run.length / 3);
|
|
477
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(run.slice(i * 3 + 1, i * 3 + 3), 16);
|
|
478
|
+
return utf8.decode(bytes);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
function decodeQueryPlus(target) {
|
|
483
|
+
if (typeof target !== "string") return target;
|
|
484
|
+
const query = target.indexOf("?");
|
|
485
|
+
if (query === -1) return target;
|
|
486
|
+
return target.slice(0, query + 1) + target.slice(query + 1).replace(/\+/g, " ");
|
|
487
|
+
}
|
|
488
|
+
function htmlEntityDecode(value) {
|
|
489
|
+
if (typeof value !== "string") {
|
|
490
|
+
return value;
|
|
491
|
+
}
|
|
492
|
+
let result = value;
|
|
493
|
+
for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
|
|
494
|
+
result = result.split(entity).join(char);
|
|
495
|
+
}
|
|
496
|
+
return decodeHtmlEntities(result);
|
|
391
497
|
}
|
|
392
498
|
var NAMED_ENTITIES = {
|
|
393
499
|
lt: "<",
|
|
@@ -395,7 +501,6 @@ var NAMED_ENTITIES = {
|
|
|
395
501
|
amp: "&",
|
|
396
502
|
quot: '"',
|
|
397
503
|
apos: "'",
|
|
398
|
-
"#39": "'",
|
|
399
504
|
nbsp: "\xA0",
|
|
400
505
|
sol: "/",
|
|
401
506
|
bsol: "\\",
|
|
@@ -414,7 +519,7 @@ var NAMED_ENTITIES = {
|
|
|
414
519
|
ast: "*"
|
|
415
520
|
};
|
|
416
521
|
function decodeHtmlEntities(input) {
|
|
417
|
-
return input.replace(/&(#[xX]
|
|
522
|
+
return input.replace(/&(#[xX][0-9a-fA-F]+|#\d+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
|
|
418
523
|
if (body[0] === "#") {
|
|
419
524
|
const hex = body[1] === "x" || body[1] === "X";
|
|
420
525
|
const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
|
|
@@ -428,74 +533,359 @@ function decodeHtmlEntities(input) {
|
|
|
428
533
|
return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
|
|
429
534
|
});
|
|
430
535
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
constructor(req) {
|
|
435
|
-
this.#req = req;
|
|
436
|
-
this.#cookies = null;
|
|
536
|
+
function removeSqlComments(value) {
|
|
537
|
+
if (typeof value !== "string") {
|
|
538
|
+
return value;
|
|
437
539
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
540
|
+
let result = value;
|
|
541
|
+
result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
542
|
+
result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
|
|
543
|
+
return result;
|
|
544
|
+
}
|
|
545
|
+
function removeNullBytes(value) {
|
|
546
|
+
if (typeof value !== "string") {
|
|
547
|
+
return value;
|
|
548
|
+
}
|
|
549
|
+
let result = value.replace(/\x00/g, "");
|
|
550
|
+
result = result.replace(/[\x01-\x08\x0B\x0C\x0E-\x1F]/g, "");
|
|
551
|
+
return result;
|
|
552
|
+
}
|
|
553
|
+
function normalizeWhitespace(value) {
|
|
554
|
+
if (typeof value !== "string") {
|
|
555
|
+
return value;
|
|
556
|
+
}
|
|
557
|
+
let result = value.replace(/[\t\r\n\f\v]+/g, " ");
|
|
558
|
+
result = result.replace(/ {2,}/g, " ");
|
|
559
|
+
return result;
|
|
560
|
+
}
|
|
561
|
+
var MAX_RAW_CHARS = 1024 * 1024;
|
|
562
|
+
var MAX_RAW_NODES = 25e3;
|
|
563
|
+
var MAX_RAW_DEPTH = 200;
|
|
564
|
+
var MAX_RAW_ENTRIES = 2e3;
|
|
565
|
+
function serializeForRawDetection(body) {
|
|
566
|
+
if (body === null || body === void 0) return "";
|
|
567
|
+
if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
|
|
568
|
+
const visited = /* @__PURE__ */ new Set();
|
|
569
|
+
const output = [];
|
|
570
|
+
const stack = [{ kind: "value", value: body, depth: 0, root: true }];
|
|
571
|
+
let chars = 0;
|
|
572
|
+
let nodes = 0;
|
|
573
|
+
const append = (text) => {
|
|
574
|
+
if (chars >= MAX_RAW_CHARS) return;
|
|
575
|
+
const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
|
|
576
|
+
output.push(piece);
|
|
577
|
+
chars += piece.length;
|
|
578
|
+
};
|
|
579
|
+
while (stack.length > 0 && chars < MAX_RAW_CHARS) {
|
|
580
|
+
const task = stack.pop();
|
|
581
|
+
if (task.kind === "text") {
|
|
582
|
+
append(task.text);
|
|
583
|
+
continue;
|
|
441
584
|
}
|
|
442
|
-
if (
|
|
443
|
-
|
|
585
|
+
if (task.kind === "key") {
|
|
586
|
+
append(boundedJsonString(task.value));
|
|
587
|
+
append(":");
|
|
588
|
+
continue;
|
|
444
589
|
}
|
|
445
|
-
|
|
446
|
-
|
|
590
|
+
nodes++;
|
|
591
|
+
if (nodes > MAX_RAW_NODES) {
|
|
592
|
+
append('"[NodeLimit]"');
|
|
593
|
+
continue;
|
|
447
594
|
}
|
|
448
|
-
|
|
449
|
-
|
|
595
|
+
const value = task.value;
|
|
596
|
+
if (value === null || value === void 0) {
|
|
597
|
+
append(task.root ? "" : "null");
|
|
598
|
+
continue;
|
|
450
599
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
600
|
+
if (typeof value === "string") {
|
|
601
|
+
append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
|
|
602
|
+
continue;
|
|
454
603
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
case "get":
|
|
459
|
-
return this.#resolveGet(key);
|
|
460
|
-
case "post":
|
|
461
|
-
return this.#resolvePost(key);
|
|
462
|
-
case "request":
|
|
463
|
-
return this.#resolveRequest(key);
|
|
464
|
-
case "cookie":
|
|
465
|
-
return this.#resolveCookie(key);
|
|
466
|
-
case "server":
|
|
467
|
-
return this.#resolveServer(key);
|
|
468
|
-
case "files":
|
|
469
|
-
return this.#resolveFiles(key);
|
|
470
|
-
case "response":
|
|
471
|
-
return this.#resolveResponse(key);
|
|
472
|
-
case "egress":
|
|
473
|
-
return this.#resolveEgress(key);
|
|
474
|
-
default:
|
|
475
|
-
return [];
|
|
604
|
+
if (typeof value !== "object") {
|
|
605
|
+
append(String(value).slice(0, MAX_RAW_CHARS));
|
|
606
|
+
continue;
|
|
476
607
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
#resolveResponse(key) {
|
|
481
|
-
const resp = this.#req._response;
|
|
482
|
-
if (!resp) {
|
|
483
|
-
return [];
|
|
608
|
+
if (visited.has(value)) {
|
|
609
|
+
append("[Circular]");
|
|
610
|
+
continue;
|
|
484
611
|
}
|
|
485
|
-
if (
|
|
486
|
-
|
|
612
|
+
if (task.depth >= MAX_RAW_DEPTH) {
|
|
613
|
+
append('"[DepthLimit]"');
|
|
614
|
+
continue;
|
|
487
615
|
}
|
|
488
|
-
|
|
489
|
-
|
|
616
|
+
visited.add(value);
|
|
617
|
+
if (Array.isArray(value)) {
|
|
618
|
+
const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
|
|
619
|
+
const items = [];
|
|
620
|
+
for (let i = 0; i < length2; i++) {
|
|
621
|
+
if (i > 0) items.push({ kind: "text", text: "," });
|
|
622
|
+
items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
|
|
623
|
+
}
|
|
624
|
+
if (value.length > length2) {
|
|
625
|
+
if (length2 > 0) items.push({ kind: "text", text: "," });
|
|
626
|
+
items.push({ kind: "text", text: '"[EntryLimit]"' });
|
|
627
|
+
}
|
|
628
|
+
stack.push({ kind: "text", text: "]" });
|
|
629
|
+
for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
|
|
630
|
+
stack.push({ kind: "text", text: "[" });
|
|
631
|
+
continue;
|
|
490
632
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
633
|
+
let keys;
|
|
634
|
+
try {
|
|
635
|
+
keys = Object.getOwnPropertyNames(value);
|
|
636
|
+
} catch {
|
|
637
|
+
append('"[Unreadable]"');
|
|
638
|
+
continue;
|
|
494
639
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
640
|
+
const length = Math.min(keys.length, MAX_RAW_ENTRIES);
|
|
641
|
+
const entries = [];
|
|
642
|
+
for (let i = 0; i < length; i++) {
|
|
643
|
+
const key = keys[i];
|
|
644
|
+
if (i > 0) entries.push({ kind: "text", text: "," });
|
|
645
|
+
entries.push({ kind: "key", value: key });
|
|
646
|
+
entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
|
|
647
|
+
}
|
|
648
|
+
if (keys.length > length) {
|
|
649
|
+
if (length > 0) entries.push({ kind: "text", text: "," });
|
|
650
|
+
entries.push({ kind: "text", text: '"[EntryLimit]":null' });
|
|
651
|
+
}
|
|
652
|
+
stack.push({ kind: "text", text: "}" });
|
|
653
|
+
for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
|
|
654
|
+
stack.push({ kind: "text", text: "{" });
|
|
655
|
+
}
|
|
656
|
+
return output.join("");
|
|
657
|
+
}
|
|
658
|
+
function boundedJsonString(value) {
|
|
659
|
+
return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
|
|
660
|
+
}
|
|
661
|
+
function readOwnValue(value, key) {
|
|
662
|
+
try {
|
|
663
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
664
|
+
return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
|
|
665
|
+
} catch {
|
|
666
|
+
return "[Unreadable]";
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
|
|
670
|
+
function requestField(req, key) {
|
|
671
|
+
if (req === null || typeof req !== "object") return void 0;
|
|
672
|
+
if (Object.hasOwn(req, key)) return req[key];
|
|
673
|
+
if (!INHERITED_ACCESSORS.has(key)) return void 0;
|
|
674
|
+
let holder = Object.getPrototypeOf(req);
|
|
675
|
+
while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
|
|
676
|
+
if (holder === null || holder === Object.prototype) return void 0;
|
|
677
|
+
const descriptor = Object.getOwnPropertyDescriptor(holder, key);
|
|
678
|
+
if (typeof descriptor?.get !== "function") return void 0;
|
|
679
|
+
return req[key];
|
|
680
|
+
}
|
|
681
|
+
function normalizeRequest(req, options = {}) {
|
|
682
|
+
const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
|
|
683
|
+
const body = requestField(req, "body");
|
|
684
|
+
const url = requestField(req, "url");
|
|
685
|
+
const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
|
|
686
|
+
const headers = requestField(req, "headers") || {};
|
|
687
|
+
return {
|
|
688
|
+
query: normalizeObject(requestField(req, "query") || {}, options),
|
|
689
|
+
body: normalizeObject(body || {}, options),
|
|
690
|
+
headers: normalizeObject(headers, options),
|
|
691
|
+
// Cookies are split into name/value pairs BEFORE their values are normalised, and normalised the
|
|
692
|
+
// same way whether a framework parsed them or they come from the header — so a rule on a cookie
|
|
693
|
+
// sees one value, independent of which cookie parser (if any) ran first.
|
|
694
|
+
cookies: normalizeObject(cookieValues(requestField(req, "cookies"), headers), options),
|
|
695
|
+
url: normalize(decodeQueryPlus(url || ""), options),
|
|
696
|
+
originalUrl: normalize(decodeQueryPlus(requestField(req, "originalUrl") || url || ""), options),
|
|
697
|
+
_rawBody: rawBody
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
function cookieValues(parsed, headers) {
|
|
701
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
702
|
+
return parseCookieHeader(headers?.cookie);
|
|
703
|
+
}
|
|
704
|
+
var MAX_NORMALIZE_DEPTH = 1e3;
|
|
705
|
+
function normalizeObject(value, options = {}, depth = 0) {
|
|
706
|
+
if (typeof value === "string") {
|
|
707
|
+
return normalize(value, options);
|
|
708
|
+
}
|
|
709
|
+
if (value === null || typeof value !== "object") {
|
|
710
|
+
return value;
|
|
711
|
+
}
|
|
712
|
+
if (depth >= MAX_NORMALIZE_DEPTH) {
|
|
713
|
+
options.onLimit?.();
|
|
714
|
+
return value;
|
|
715
|
+
}
|
|
716
|
+
const copies = /* @__PURE__ */ new Map();
|
|
717
|
+
const copyOf = (node) => {
|
|
718
|
+
const copy = Array.isArray(node) ? new Array(node.length) : {};
|
|
719
|
+
copies.set(node, copy);
|
|
720
|
+
return copy;
|
|
721
|
+
};
|
|
722
|
+
const top = copyOf(value);
|
|
723
|
+
const stack = [[value, top, depth]];
|
|
724
|
+
while (stack.length > 0) {
|
|
725
|
+
const [node, copy, level] = stack.pop();
|
|
726
|
+
for (const key of Object.keys(node)) {
|
|
727
|
+
const child = node[key];
|
|
728
|
+
if (typeof child === "string") {
|
|
729
|
+
setOwn(copy, key, normalize(child, options));
|
|
730
|
+
} else if (child === null || typeof child !== "object") {
|
|
731
|
+
setOwn(copy, key, child);
|
|
732
|
+
} else if (copies.has(child)) {
|
|
733
|
+
setOwn(copy, key, copies.get(child));
|
|
734
|
+
} else if (level + 1 >= MAX_NORMALIZE_DEPTH) {
|
|
735
|
+
setOwn(copy, key, child);
|
|
736
|
+
options.onLimit?.();
|
|
737
|
+
} else {
|
|
738
|
+
const childCopy = copyOf(child);
|
|
739
|
+
setOwn(copy, key, childCopy);
|
|
740
|
+
stack.push([child, childCopy, level + 1]);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return top;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// src/protect/engine/request.js
|
|
748
|
+
var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
|
|
749
|
+
var ownValue = (obj, key) => obj !== null && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : void 0;
|
|
750
|
+
function fieldSpellings(key) {
|
|
751
|
+
const spellings = [{ key, nested: true }];
|
|
752
|
+
if (key.includes("[")) {
|
|
753
|
+
const dotted = key.replace(/\[\]$/, "").replace(/\[([^\][]*)\]/g, ".$1");
|
|
754
|
+
if (dotted !== key && dotted !== "" && !dotted.includes("[") && !dotted.includes("]")) {
|
|
755
|
+
spellings.push({ key: dotted, nested: true });
|
|
756
|
+
}
|
|
757
|
+
return spellings;
|
|
758
|
+
}
|
|
759
|
+
const [head, ...rest] = key.split(".");
|
|
760
|
+
const bracketed = head + rest.map((part) => `[${part}]`).join("");
|
|
761
|
+
if (bracketed !== key) spellings.push({ key: bracketed, nested: false });
|
|
762
|
+
spellings.push({ key: `${bracketed}[]`, nested: false });
|
|
763
|
+
return spellings;
|
|
764
|
+
}
|
|
765
|
+
var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
|
|
766
|
+
var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
|
|
767
|
+
function base64DecodeUtf8(value) {
|
|
768
|
+
const str = String(value);
|
|
769
|
+
if (typeof Buffer !== "undefined") {
|
|
770
|
+
return Buffer.from(str, "base64").toString("utf-8");
|
|
771
|
+
}
|
|
772
|
+
const binary = atob(str);
|
|
773
|
+
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
774
|
+
return new TextDecoder().decode(bytes);
|
|
775
|
+
}
|
|
776
|
+
var TEXT_MUTATIONS = /* @__PURE__ */ new Set(["base64_decode", "urldecode", "htmlentitydecode"]);
|
|
777
|
+
var MAX_MAPPED_NODES = 2e4;
|
|
778
|
+
var MAX_MAPPED_DEPTH = 1e3;
|
|
779
|
+
function mapStringLeaves(root, fn, onLimit) {
|
|
780
|
+
const copies = /* @__PURE__ */ new Map();
|
|
781
|
+
const copyOf = (node) => {
|
|
782
|
+
const copy = Array.isArray(node) ? [] : {};
|
|
783
|
+
copies.set(node, copy);
|
|
784
|
+
return copy;
|
|
785
|
+
};
|
|
786
|
+
const top = copyOf(root);
|
|
787
|
+
const stack = [[root, top, 0]];
|
|
788
|
+
let visited = 0;
|
|
789
|
+
while (stack.length) {
|
|
790
|
+
const [node, copy, depth] = stack.pop();
|
|
791
|
+
visited++;
|
|
792
|
+
for (const key of Object.keys(node)) {
|
|
793
|
+
const child = node[key];
|
|
794
|
+
if (typeof child === "string") {
|
|
795
|
+
setOwn(copy, key, fn(child));
|
|
796
|
+
} else if (child === null || typeof child !== "object") {
|
|
797
|
+
setOwn(copy, key, child);
|
|
798
|
+
} else if (copies.has(child)) {
|
|
799
|
+
setOwn(copy, key, copies.get(child));
|
|
800
|
+
} else if (depth + 1 >= MAX_MAPPED_DEPTH || visited + stack.length >= MAX_MAPPED_NODES) {
|
|
801
|
+
setOwn(copy, key, child);
|
|
802
|
+
onLimit();
|
|
803
|
+
} else {
|
|
804
|
+
const childCopy = copyOf(child);
|
|
805
|
+
setOwn(copy, key, childCopy);
|
|
806
|
+
stack.push([child, childCopy, depth + 1]);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return top;
|
|
811
|
+
}
|
|
812
|
+
var RequestResolver = class {
|
|
813
|
+
#req;
|
|
814
|
+
#cookies;
|
|
815
|
+
#skips = /* @__PURE__ */ new Set();
|
|
816
|
+
constructor(req) {
|
|
817
|
+
this.#req = req;
|
|
818
|
+
this.#cookies = null;
|
|
819
|
+
}
|
|
820
|
+
/** Record an inspection limit this evaluation reached (reported once per reason). */
|
|
821
|
+
noteSkip(reason) {
|
|
822
|
+
this.#skips.add(reason);
|
|
823
|
+
}
|
|
824
|
+
/** The inspection limits recorded so far, in the order first reached. */
|
|
825
|
+
get skips() {
|
|
826
|
+
return [...this.#skips];
|
|
827
|
+
}
|
|
828
|
+
resolve(parameter) {
|
|
829
|
+
if (!parameter || parameter === "false") {
|
|
830
|
+
return [null];
|
|
831
|
+
}
|
|
832
|
+
if (parameter === "rules") {
|
|
833
|
+
return [null];
|
|
834
|
+
}
|
|
835
|
+
if (parameter === "raw") {
|
|
836
|
+
return this.#resolveRaw();
|
|
837
|
+
}
|
|
838
|
+
if (parameter === "all") {
|
|
839
|
+
return this.#resolveAll();
|
|
840
|
+
}
|
|
841
|
+
const dotIndex = parameter.indexOf(".");
|
|
842
|
+
if (dotIndex === -1) {
|
|
843
|
+
return [];
|
|
844
|
+
}
|
|
845
|
+
const source = parameter.substring(0, dotIndex);
|
|
846
|
+
const key = parameter.substring(dotIndex + 1);
|
|
847
|
+
switch (source) {
|
|
848
|
+
case "get":
|
|
849
|
+
return this.#resolveGet(key);
|
|
850
|
+
case "post":
|
|
851
|
+
return this.#resolvePost(key);
|
|
852
|
+
case "request":
|
|
853
|
+
return this.#resolveRequest(key);
|
|
854
|
+
case "cookie":
|
|
855
|
+
return this.#resolveCookie(key);
|
|
856
|
+
case "server":
|
|
857
|
+
return this.#resolveServer(key);
|
|
858
|
+
case "files":
|
|
859
|
+
return this.#resolveFiles(key);
|
|
860
|
+
case "response":
|
|
861
|
+
return this.#resolveResponse(key);
|
|
862
|
+
case "egress":
|
|
863
|
+
return this.#resolveEgress(key);
|
|
864
|
+
default:
|
|
865
|
+
return [];
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
// Response-phase sources (req._response = { status, headers, body }). Lets rules inspect
|
|
869
|
+
// what the app is about to SEND — e.g. a leaked secret in the body — regardless of route.
|
|
870
|
+
#resolveResponse(key) {
|
|
871
|
+
const resp = this.#req._response;
|
|
872
|
+
if (!resp) {
|
|
873
|
+
return [];
|
|
874
|
+
}
|
|
875
|
+
if (key === "status") {
|
|
876
|
+
return resp.status !== void 0 ? [String(resp.status)] : [];
|
|
877
|
+
}
|
|
878
|
+
if (key === "body") {
|
|
879
|
+
return resp.body != null && resp.body !== "" ? [resp.body] : [];
|
|
880
|
+
}
|
|
881
|
+
if (key === "headers") {
|
|
882
|
+
const headers = resp.headers ?? {};
|
|
883
|
+
return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n")];
|
|
884
|
+
}
|
|
885
|
+
if (key.startsWith("header.")) {
|
|
886
|
+
const name = key.slice("header.".length).toLowerCase();
|
|
887
|
+
const headers = resp.headers ?? {};
|
|
888
|
+
return Object.hasOwn(headers, name) ? [headers[name]] : [];
|
|
499
889
|
}
|
|
500
890
|
return [];
|
|
501
891
|
}
|
|
@@ -525,6 +915,9 @@ var RequestResolver = class {
|
|
|
525
915
|
if (value === null || value === void 0) {
|
|
526
916
|
return value;
|
|
527
917
|
}
|
|
918
|
+
if (typeof value === "object" && TEXT_MUTATIONS.has(mutation)) {
|
|
919
|
+
return mapStringLeaves(value, (leaf) => this.#applyMutation(mutation, leaf), () => this.noteSkip("container-cap"));
|
|
920
|
+
}
|
|
528
921
|
switch (mutation) {
|
|
529
922
|
case "base64_decode":
|
|
530
923
|
try {
|
|
@@ -545,11 +938,7 @@ var RequestResolver = class {
|
|
|
545
938
|
return value;
|
|
546
939
|
}
|
|
547
940
|
case "urldecode":
|
|
548
|
-
|
|
549
|
-
return decodeURIComponent(String(value));
|
|
550
|
-
} catch {
|
|
551
|
-
return value;
|
|
552
|
-
}
|
|
941
|
+
return safeUrlDecode(String(value).replace(/\+/g, " "));
|
|
553
942
|
case "htmlentitydecode":
|
|
554
943
|
return decodeHtmlEntities(String(value));
|
|
555
944
|
case "intval":
|
|
@@ -568,16 +957,30 @@ var RequestResolver = class {
|
|
|
568
957
|
if (key.endsWith("*")) {
|
|
569
958
|
return this.#resolveWildcard(query, key);
|
|
570
959
|
}
|
|
571
|
-
|
|
572
|
-
return value !== void 0 ? [value] : [];
|
|
960
|
+
return this.#lookup(query, key);
|
|
573
961
|
}
|
|
574
962
|
#resolvePost(key) {
|
|
575
963
|
const body = this.#req.body ?? {};
|
|
576
964
|
if (key.endsWith("*")) {
|
|
577
965
|
return this.#resolveWildcard(body, key);
|
|
578
966
|
}
|
|
579
|
-
|
|
580
|
-
|
|
967
|
+
return this.#lookup(body, key);
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Every value a field path names in `obj`, in either spelling a form or query field can take.
|
|
971
|
+
*
|
|
972
|
+
* A parser that expands brackets (`qs`, as Express uses) turns `user[name]=x` into `{ user: { name } }`
|
|
973
|
+
* and `id[]=x` into `{ id: [x] }`; a flat parser (`URLSearchParams`, as the Fetch and Node adapters use)
|
|
974
|
+
* keeps `user[name]` and `id[]` as literal keys. The rule names the field once, so both shapes answer
|
|
975
|
+
* to both spellings: `user.name` and `user[name]`, `id` and `id[]`.
|
|
976
|
+
*/
|
|
977
|
+
#lookup(obj, key) {
|
|
978
|
+
const values = [];
|
|
979
|
+
for (const candidate of fieldSpellings(key)) {
|
|
980
|
+
const value = candidate.nested ? this.#getNestedValue(obj, candidate.key) : ownValue(obj, candidate.key);
|
|
981
|
+
if (value !== void 0) values.push(value);
|
|
982
|
+
}
|
|
983
|
+
return values;
|
|
581
984
|
}
|
|
582
985
|
#resolveRequest(key) {
|
|
583
986
|
const query = this.#req.query ?? {};
|
|
@@ -590,8 +993,11 @@ var RequestResolver = class {
|
|
|
590
993
|
...this.#resolveWildcard(cookies, key)
|
|
591
994
|
];
|
|
592
995
|
}
|
|
593
|
-
const
|
|
594
|
-
|
|
996
|
+
const fromQuery = this.#lookup(query, key);
|
|
997
|
+
if (fromQuery.length > 0) return fromQuery;
|
|
998
|
+
const fromBody = this.#lookup(body, key);
|
|
999
|
+
if (fromBody.length > 0) return fromBody;
|
|
1000
|
+
return Object.hasOwn(cookies, key) ? [cookies[key]] : [];
|
|
595
1001
|
}
|
|
596
1002
|
#resolveCookie(key) {
|
|
597
1003
|
const cookies = this.#parseCookies();
|
|
@@ -761,329 +1167,51 @@ var RequestResolver = class {
|
|
|
761
1167
|
this.#cookies = this.#req.cookies;
|
|
762
1168
|
return this.#cookies;
|
|
763
1169
|
}
|
|
764
|
-
|
|
765
|
-
if (!header) {
|
|
766
|
-
this.#cookies = {};
|
|
767
|
-
return this.#cookies;
|
|
768
|
-
}
|
|
769
|
-
const cookies = {};
|
|
770
|
-
for (const pair of header.split(";")) {
|
|
771
|
-
const eqIndex = pair.indexOf("=");
|
|
772
|
-
if (eqIndex === -1) {
|
|
773
|
-
continue;
|
|
774
|
-
}
|
|
775
|
-
const name = pair.substring(0, eqIndex).trim();
|
|
776
|
-
const value = pair.substring(eqIndex + 1).trim();
|
|
777
|
-
setOwn(cookies, name, value);
|
|
778
|
-
}
|
|
779
|
-
this.#cookies = cookies;
|
|
1170
|
+
this.#cookies = parseCookieHeader(this.#req.headers?.cookie);
|
|
780
1171
|
return this.#cookies;
|
|
781
1172
|
}
|
|
782
1173
|
};
|
|
783
1174
|
|
|
784
1175
|
// src/protect/notify.js
|
|
785
|
-
var reported = /* @__PURE__ */ new
|
|
1176
|
+
var reported = /* @__PURE__ */ new WeakMap();
|
|
1177
|
+
var warnings = 0;
|
|
1178
|
+
var MAX_WARNINGS = 20;
|
|
786
1179
|
function notify(fn, arg, label) {
|
|
787
1180
|
if (typeof fn !== "function") return false;
|
|
788
1181
|
try {
|
|
789
1182
|
const result = fn(arg);
|
|
790
1183
|
if (result !== null && typeof result === "object" && typeof result.then === "function") {
|
|
791
1184
|
try {
|
|
792
|
-
result.then(void 0, (err) => warnOnce(label, err));
|
|
1185
|
+
result.then(void 0, (err) => warnOnce(fn, label, err));
|
|
793
1186
|
} catch {
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
return true;
|
|
797
|
-
} catch (err) {
|
|
798
|
-
warnOnce(label, err);
|
|
799
|
-
return false;
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
function warnOnce(label, err) {
|
|
803
|
-
if (reported.has(label)) return;
|
|
804
|
-
reported.add(label);
|
|
805
|
-
try {
|
|
806
|
-
console.warn(
|
|
807
|
-
`Patchstack: the ${label} callback passed to createProtection failed and was ignored. Protection is unaffected; this is reported once per process. Cause: ${err && err.message ? err.message : String(err)}`
|
|
808
|
-
);
|
|
809
|
-
} catch {
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
// src/protect/engine/normalizer.js
|
|
814
|
-
var HTML_ENTITIES = {
|
|
815
|
-
"&": "&",
|
|
816
|
-
"<": "<",
|
|
817
|
-
">": ">",
|
|
818
|
-
""": '"',
|
|
819
|
-
"'": "'",
|
|
820
|
-
"'": "'",
|
|
821
|
-
" ": " ",
|
|
822
|
-
"'": "'",
|
|
823
|
-
""": '"',
|
|
824
|
-
"<": "<",
|
|
825
|
-
">": ">",
|
|
826
|
-
"&": "&",
|
|
827
|
-
""": '"',
|
|
828
|
-
"<": "<",
|
|
829
|
-
">": ">",
|
|
830
|
-
"&": "&"
|
|
831
|
-
};
|
|
832
|
-
var MAX_DECODE_ITERATIONS = 5;
|
|
833
|
-
function normalize(value, options = {}) {
|
|
834
|
-
if (typeof value !== "string") {
|
|
835
|
-
return value;
|
|
836
|
-
}
|
|
837
|
-
const opts = {
|
|
838
|
-
urlDecode: true,
|
|
839
|
-
htmlDecode: true,
|
|
840
|
-
sqlComments: true,
|
|
841
|
-
nullBytes: true,
|
|
842
|
-
whitespace: true,
|
|
843
|
-
...options
|
|
844
|
-
};
|
|
845
|
-
let result = value;
|
|
846
|
-
if (opts.nullBytes) {
|
|
847
|
-
result = removeNullBytes(result);
|
|
848
|
-
}
|
|
849
|
-
if (opts.urlDecode) {
|
|
850
|
-
result = urlDecode(result);
|
|
851
|
-
}
|
|
852
|
-
if (opts.htmlDecode) {
|
|
853
|
-
result = htmlEntityDecode(result);
|
|
854
|
-
}
|
|
855
|
-
if (opts.sqlComments) {
|
|
856
|
-
result = removeSqlComments(result);
|
|
857
|
-
}
|
|
858
|
-
if (opts.whitespace) {
|
|
859
|
-
result = normalizeWhitespace(result);
|
|
860
|
-
}
|
|
861
|
-
return result;
|
|
862
|
-
}
|
|
863
|
-
function urlDecode(value) {
|
|
864
|
-
if (typeof value !== "string") {
|
|
865
|
-
return value;
|
|
866
|
-
}
|
|
867
|
-
let result = value;
|
|
868
|
-
let previous = "";
|
|
869
|
-
let iterations = 0;
|
|
870
|
-
while (result !== previous && iterations < MAX_DECODE_ITERATIONS) {
|
|
871
|
-
previous = result;
|
|
872
|
-
iterations++;
|
|
873
|
-
try {
|
|
874
|
-
result = decodeURIComponent(result);
|
|
875
|
-
} catch {
|
|
876
|
-
result = safeUrlDecode(result);
|
|
877
|
-
break;
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
return result;
|
|
881
|
-
}
|
|
882
|
-
function safeUrlDecode(value) {
|
|
883
|
-
return value.replace(/%([0-9A-Fa-f]{2})/g, (match, hex) => {
|
|
884
|
-
try {
|
|
885
|
-
return String.fromCharCode(parseInt(hex, 16));
|
|
886
|
-
} catch {
|
|
887
|
-
return match;
|
|
888
|
-
}
|
|
889
|
-
});
|
|
890
|
-
}
|
|
891
|
-
function htmlEntityDecode(value) {
|
|
892
|
-
if (typeof value !== "string") {
|
|
893
|
-
return value;
|
|
894
|
-
}
|
|
895
|
-
let result = value;
|
|
896
|
-
for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
|
|
897
|
-
result = result.split(entity).join(char);
|
|
898
|
-
}
|
|
899
|
-
result = result.replace(/&#(\d+);/g, (match, code) => {
|
|
900
|
-
const num = parseInt(code, 10);
|
|
901
|
-
return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
|
|
902
|
-
});
|
|
903
|
-
result = result.replace(/&#x([0-9A-Fa-f]+);/g, (match, hex) => {
|
|
904
|
-
const num = parseInt(hex, 16);
|
|
905
|
-
return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
|
|
906
|
-
});
|
|
907
|
-
return result;
|
|
908
|
-
}
|
|
909
|
-
function removeSqlComments(value) {
|
|
910
|
-
if (typeof value !== "string") {
|
|
911
|
-
return value;
|
|
912
|
-
}
|
|
913
|
-
let result = value;
|
|
914
|
-
result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
915
|
-
result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
|
|
916
|
-
return result;
|
|
917
|
-
}
|
|
918
|
-
function removeNullBytes(value) {
|
|
919
|
-
if (typeof value !== "string") {
|
|
920
|
-
return value;
|
|
921
|
-
}
|
|
922
|
-
let result = value.replace(/\x00/g, "");
|
|
923
|
-
result = result.replace(/[\x01-\x08\x0B\x0C\x0E-\x1F]/g, "");
|
|
924
|
-
return result;
|
|
925
|
-
}
|
|
926
|
-
function normalizeWhitespace(value) {
|
|
927
|
-
if (typeof value !== "string") {
|
|
928
|
-
return value;
|
|
929
|
-
}
|
|
930
|
-
let result = value.replace(/[\t\r\n\f\v]+/g, " ");
|
|
931
|
-
result = result.replace(/ {2,}/g, " ");
|
|
932
|
-
return result;
|
|
933
|
-
}
|
|
934
|
-
var MAX_RAW_CHARS = 1024 * 1024;
|
|
935
|
-
var MAX_RAW_NODES = 25e3;
|
|
936
|
-
var MAX_RAW_DEPTH = 200;
|
|
937
|
-
var MAX_RAW_ENTRIES = 2e3;
|
|
938
|
-
function serializeForRawDetection(body) {
|
|
939
|
-
if (body === null || body === void 0) return "";
|
|
940
|
-
if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
|
|
941
|
-
const visited = /* @__PURE__ */ new Set();
|
|
942
|
-
const output = [];
|
|
943
|
-
const stack = [{ kind: "value", value: body, depth: 0, root: true }];
|
|
944
|
-
let chars = 0;
|
|
945
|
-
let nodes = 0;
|
|
946
|
-
const append = (text) => {
|
|
947
|
-
if (chars >= MAX_RAW_CHARS) return;
|
|
948
|
-
const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
|
|
949
|
-
output.push(piece);
|
|
950
|
-
chars += piece.length;
|
|
951
|
-
};
|
|
952
|
-
while (stack.length > 0 && chars < MAX_RAW_CHARS) {
|
|
953
|
-
const task = stack.pop();
|
|
954
|
-
if (task.kind === "text") {
|
|
955
|
-
append(task.text);
|
|
956
|
-
continue;
|
|
957
|
-
}
|
|
958
|
-
if (task.kind === "key") {
|
|
959
|
-
append(boundedJsonString(task.value));
|
|
960
|
-
append(":");
|
|
961
|
-
continue;
|
|
962
|
-
}
|
|
963
|
-
nodes++;
|
|
964
|
-
if (nodes > MAX_RAW_NODES) {
|
|
965
|
-
append('"[NodeLimit]"');
|
|
966
|
-
continue;
|
|
967
|
-
}
|
|
968
|
-
const value = task.value;
|
|
969
|
-
if (value === null || value === void 0) {
|
|
970
|
-
append(task.root ? "" : "null");
|
|
971
|
-
continue;
|
|
972
|
-
}
|
|
973
|
-
if (typeof value === "string") {
|
|
974
|
-
append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
|
|
975
|
-
continue;
|
|
976
|
-
}
|
|
977
|
-
if (typeof value !== "object") {
|
|
978
|
-
append(String(value).slice(0, MAX_RAW_CHARS));
|
|
979
|
-
continue;
|
|
980
|
-
}
|
|
981
|
-
if (visited.has(value)) {
|
|
982
|
-
append("[Circular]");
|
|
983
|
-
continue;
|
|
984
|
-
}
|
|
985
|
-
if (task.depth >= MAX_RAW_DEPTH) {
|
|
986
|
-
append('"[DepthLimit]"');
|
|
987
|
-
continue;
|
|
988
|
-
}
|
|
989
|
-
visited.add(value);
|
|
990
|
-
if (Array.isArray(value)) {
|
|
991
|
-
const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
|
|
992
|
-
const items = [];
|
|
993
|
-
for (let i = 0; i < length2; i++) {
|
|
994
|
-
if (i > 0) items.push({ kind: "text", text: "," });
|
|
995
|
-
items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
|
|
996
|
-
}
|
|
997
|
-
if (value.length > length2) {
|
|
998
|
-
if (length2 > 0) items.push({ kind: "text", text: "," });
|
|
999
|
-
items.push({ kind: "text", text: '"[EntryLimit]"' });
|
|
1000
|
-
}
|
|
1001
|
-
stack.push({ kind: "text", text: "]" });
|
|
1002
|
-
for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
|
|
1003
|
-
stack.push({ kind: "text", text: "[" });
|
|
1004
|
-
continue;
|
|
1005
|
-
}
|
|
1006
|
-
let keys;
|
|
1007
|
-
try {
|
|
1008
|
-
keys = Object.getOwnPropertyNames(value);
|
|
1009
|
-
} catch {
|
|
1010
|
-
append('"[Unreadable]"');
|
|
1011
|
-
continue;
|
|
1012
|
-
}
|
|
1013
|
-
const length = Math.min(keys.length, MAX_RAW_ENTRIES);
|
|
1014
|
-
const entries = [];
|
|
1015
|
-
for (let i = 0; i < length; i++) {
|
|
1016
|
-
const key = keys[i];
|
|
1017
|
-
if (i > 0) entries.push({ kind: "text", text: "," });
|
|
1018
|
-
entries.push({ kind: "key", value: key });
|
|
1019
|
-
entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
|
|
1020
|
-
}
|
|
1021
|
-
if (keys.length > length) {
|
|
1022
|
-
if (length > 0) entries.push({ kind: "text", text: "," });
|
|
1023
|
-
entries.push({ kind: "text", text: '"[EntryLimit]":null' });
|
|
1024
|
-
}
|
|
1025
|
-
stack.push({ kind: "text", text: "}" });
|
|
1026
|
-
for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
|
|
1027
|
-
stack.push({ kind: "text", text: "{" });
|
|
1028
|
-
}
|
|
1029
|
-
return output.join("");
|
|
1030
|
-
}
|
|
1031
|
-
function boundedJsonString(value) {
|
|
1032
|
-
return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
|
|
1033
|
-
}
|
|
1034
|
-
function readOwnValue(value, key) {
|
|
1035
|
-
try {
|
|
1036
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
1037
|
-
return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
|
|
1038
|
-
} catch {
|
|
1039
|
-
return "[Unreadable]";
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
|
|
1043
|
-
function requestField(req, key) {
|
|
1044
|
-
if (req === null || typeof req !== "object") return void 0;
|
|
1045
|
-
if (Object.hasOwn(req, key)) return req[key];
|
|
1046
|
-
if (!INHERITED_ACCESSORS.has(key)) return void 0;
|
|
1047
|
-
let holder = Object.getPrototypeOf(req);
|
|
1048
|
-
while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
|
|
1049
|
-
if (holder === null || holder === Object.prototype) return void 0;
|
|
1050
|
-
const descriptor = Object.getOwnPropertyDescriptor(holder, key);
|
|
1051
|
-
if (typeof descriptor?.get !== "function") return void 0;
|
|
1052
|
-
return req[key];
|
|
1053
|
-
}
|
|
1054
|
-
function normalizeRequest(req, options = {}) {
|
|
1055
|
-
const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
|
|
1056
|
-
const body = requestField(req, "body");
|
|
1057
|
-
const url = requestField(req, "url");
|
|
1058
|
-
const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
|
|
1059
|
-
return {
|
|
1060
|
-
query: normalizeObject(requestField(req, "query") || {}, options),
|
|
1061
|
-
body: normalizeObject(body || {}, options),
|
|
1062
|
-
headers: normalizeObject(requestField(req, "headers") || {}, options),
|
|
1063
|
-
url: normalize(url || "", options),
|
|
1064
|
-
originalUrl: normalize(requestField(req, "originalUrl") || url || "", options),
|
|
1065
|
-
_rawBody: rawBody
|
|
1066
|
-
};
|
|
1067
|
-
}
|
|
1068
|
-
var MAX_NORMALIZE_DEPTH = 200;
|
|
1069
|
-
function normalizeObject(value, options = {}, depth = 0) {
|
|
1070
|
-
if (typeof value === "string") {
|
|
1071
|
-
return normalize(value, options);
|
|
1072
|
-
}
|
|
1073
|
-
if (depth >= MAX_NORMALIZE_DEPTH) {
|
|
1074
|
-
return value;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return true;
|
|
1190
|
+
} catch (err) {
|
|
1191
|
+
warnOnce(fn, label, err);
|
|
1192
|
+
return false;
|
|
1075
1193
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1194
|
+
}
|
|
1195
|
+
function warnOnce(fn, label, err) {
|
|
1196
|
+
let labels = reported.get(fn);
|
|
1197
|
+
if (labels?.has(label)) return;
|
|
1198
|
+
if (!labels) {
|
|
1199
|
+
labels = /* @__PURE__ */ new Set();
|
|
1200
|
+
reported.set(fn, labels);
|
|
1078
1201
|
}
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1202
|
+
labels.add(label);
|
|
1203
|
+
if (warnings > MAX_WARNINGS) return;
|
|
1204
|
+
warnings++;
|
|
1205
|
+
try {
|
|
1206
|
+
if (warnings > MAX_WARNINGS) {
|
|
1207
|
+
console.warn("Patchstack: further failing callbacks passed to createProtection are not reported in this process.");
|
|
1208
|
+
return;
|
|
1083
1209
|
}
|
|
1084
|
-
|
|
1210
|
+
console.warn(
|
|
1211
|
+
`Patchstack: the ${label} callback passed to createProtection failed and was ignored. Protection is unaffected; this callback's failures are reported once. Cause: ${err && err.message ? err.message : String(err)}`
|
|
1212
|
+
);
|
|
1213
|
+
} catch {
|
|
1085
1214
|
}
|
|
1086
|
-
return value;
|
|
1087
1215
|
}
|
|
1088
1216
|
|
|
1089
1217
|
// src/protect/engine/engine.js
|
|
@@ -1471,17 +1599,21 @@ function arrayKeyValue(value, matchObj) {
|
|
|
1471
1599
|
}
|
|
1472
1600
|
var WHOLE_VALUE_MATCH_TYPES = /* @__PURE__ */ new Set(["isset", "array_in_array", "array_key_value"]);
|
|
1473
1601
|
function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
|
|
1474
|
-
const
|
|
1602
|
+
const leaves = [];
|
|
1475
1603
|
const stack = [[root, 0]];
|
|
1476
1604
|
let visited = 0;
|
|
1605
|
+
let truncated = false;
|
|
1477
1606
|
while (stack.length) {
|
|
1478
1607
|
const [node, depth] = stack.pop();
|
|
1479
1608
|
if (node === null || node === void 0) continue;
|
|
1480
1609
|
if (typeof node !== "object") {
|
|
1481
|
-
|
|
1610
|
+
leaves.push(node);
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
if (depth >= maxDepth || visited >= nodeCap) {
|
|
1614
|
+
truncated = true;
|
|
1482
1615
|
continue;
|
|
1483
1616
|
}
|
|
1484
|
-
if (depth >= maxDepth || visited >= nodeCap) continue;
|
|
1485
1617
|
visited++;
|
|
1486
1618
|
if (Array.isArray(node)) {
|
|
1487
1619
|
for (let i = node.length - 1; i >= 0; i--) stack.push([node[i], depth + 1]);
|
|
@@ -1489,7 +1621,18 @@ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
|
|
|
1489
1621
|
for (const k of Object.keys(node)) stack.push([node[k], depth + 1]);
|
|
1490
1622
|
}
|
|
1491
1623
|
}
|
|
1492
|
-
return
|
|
1624
|
+
return { leaves, truncated };
|
|
1625
|
+
}
|
|
1626
|
+
function skipsOf(resolver) {
|
|
1627
|
+
const skips = resolver?.skips;
|
|
1628
|
+
return skips && skips.length > 0 ? { skips } : {};
|
|
1629
|
+
}
|
|
1630
|
+
function serialisedValue(value) {
|
|
1631
|
+
try {
|
|
1632
|
+
return JSON.stringify(value);
|
|
1633
|
+
} catch {
|
|
1634
|
+
return void 0;
|
|
1635
|
+
}
|
|
1493
1636
|
}
|
|
1494
1637
|
var warnedKeys = /* @__PURE__ */ new Set();
|
|
1495
1638
|
function warnOnce2(key, message) {
|
|
@@ -1507,12 +1650,22 @@ function warnUnsupportedMatchType(type) {
|
|
|
1507
1650
|
`[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
|
|
1508
1651
|
);
|
|
1509
1652
|
}
|
|
1653
|
+
var SPECIAL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ws", "wss", "ftp", "file"]);
|
|
1654
|
+
function trimControls(text) {
|
|
1655
|
+
let start = 0;
|
|
1656
|
+
let end = text.length;
|
|
1657
|
+
while (start < end && text.charCodeAt(start) <= 32) start++;
|
|
1658
|
+
while (end > start && text.charCodeAt(end - 1) <= 32) end--;
|
|
1659
|
+
return text.slice(start, end);
|
|
1660
|
+
}
|
|
1510
1661
|
function hostFromValue(value) {
|
|
1511
|
-
const raw = String(value ?? "").trim();
|
|
1662
|
+
const raw = trimControls(String(value ?? "").replace(/[\t\n\r]/g, "")).trim();
|
|
1512
1663
|
if (raw === "") return "";
|
|
1513
|
-
|
|
1664
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(raw)?.[1]?.toLowerCase();
|
|
1665
|
+
const relative = /^[\\/]{2}/.test(raw);
|
|
1666
|
+
if (relative || /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || scheme !== void 0 && SPECIAL_SCHEMES.has(scheme)) {
|
|
1514
1667
|
try {
|
|
1515
|
-
return new URL(
|
|
1668
|
+
return new URL(relative ? `http:${raw}` : raw).hostname;
|
|
1516
1669
|
} catch {
|
|
1517
1670
|
return raw;
|
|
1518
1671
|
}
|
|
@@ -1540,10 +1693,18 @@ function isInternalHost(hostname) {
|
|
|
1540
1693
|
const allZeroHi = g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
|
|
1541
1694
|
if (allZeroHi && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
|
|
1542
1695
|
if ((g[0] & 65472) === 65152) return true;
|
|
1696
|
+
if ((g[0] & 65472) === 65216) return true;
|
|
1543
1697
|
if ((g[0] & 65024) === 64512) return true;
|
|
1698
|
+
if ((g[0] & 65280) === 65280) return true;
|
|
1699
|
+
const low32 = (g[6] << 16 >>> 0 | g[7]) >>> 0;
|
|
1544
1700
|
if (allZeroHi && (g[5] === 65535 || g[5] === 0)) {
|
|
1545
|
-
return isPrivateV4Int(
|
|
1701
|
+
return isPrivateV4Int(low32);
|
|
1702
|
+
}
|
|
1703
|
+
if (g[0] === 100 && g[1] === 65435 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) {
|
|
1704
|
+
return isPrivateV4Int(low32);
|
|
1546
1705
|
}
|
|
1706
|
+
if (g[0] === 100 && g[1] === 65435 && g[2] === 1) return true;
|
|
1707
|
+
if (g[0] === 8194) return isPrivateV4Int((g[1] << 16 >>> 0 | g[2]) >>> 0);
|
|
1547
1708
|
return false;
|
|
1548
1709
|
}
|
|
1549
1710
|
const v4 = parseIPv4ToInt(host);
|
|
@@ -1553,6 +1714,10 @@ function isInternalHost(hostname) {
|
|
|
1553
1714
|
function isPrivateV4Int(n) {
|
|
1554
1715
|
const a = n >>> 24 & 255;
|
|
1555
1716
|
const b = n >>> 16 & 255;
|
|
1717
|
+
const c = n >>> 8 & 255;
|
|
1718
|
+
if (a >= 224) return true;
|
|
1719
|
+
if (a === 192 && b === 0 && c === 0) return true;
|
|
1720
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
1556
1721
|
if (a === 127 || a === 10 || a === 0) return true;
|
|
1557
1722
|
if (a === 169 && b === 254) return true;
|
|
1558
1723
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
@@ -1901,8 +2066,12 @@ var RuleEngine = class {
|
|
|
1901
2066
|
let normalizedReq;
|
|
1902
2067
|
let resolver;
|
|
1903
2068
|
try {
|
|
1904
|
-
|
|
2069
|
+
let limited = false;
|
|
2070
|
+
normalizedReq = { ...req, ...normalizeRequest(req, { onLimit: () => {
|
|
2071
|
+
limited = true;
|
|
2072
|
+
} }) };
|
|
1905
2073
|
resolver = new RequestResolver(normalizedReq);
|
|
2074
|
+
if (limited) resolver.noteSkip("container-cap");
|
|
1906
2075
|
} catch (err) {
|
|
1907
2076
|
this.#reportError(err);
|
|
1908
2077
|
return { blocked: false, rule: null, message: null };
|
|
@@ -1928,14 +2097,15 @@ var RuleEngine = class {
|
|
|
1928
2097
|
// that re-read the request instead would be reading it a second time: a getter, a stream or
|
|
1929
2098
|
// anything else that answers once can give a different value, and evidence that disagrees
|
|
1930
2099
|
// with the match it belongs to is worse than none.
|
|
1931
|
-
resolver
|
|
2100
|
+
resolver,
|
|
2101
|
+
...skipsOf(resolver)
|
|
1932
2102
|
};
|
|
1933
2103
|
}
|
|
1934
2104
|
} catch (err) {
|
|
1935
2105
|
this.#reportError(err);
|
|
1936
2106
|
}
|
|
1937
2107
|
}
|
|
1938
|
-
return { blocked: false, rule: null, message: null };
|
|
2108
|
+
return { blocked: false, rule: null, message: null, ...skipsOf(resolver) };
|
|
1939
2109
|
}
|
|
1940
2110
|
#evaluateRule(conditions, resolver) {
|
|
1941
2111
|
const inclusiveConditions = conditions.filter((c) => c.inclusive);
|
|
@@ -1991,9 +2161,15 @@ var RuleEngine = class {
|
|
|
1991
2161
|
if (WHOLE_VALUE_MATCH_TYPES.has(match.type)) {
|
|
1992
2162
|
if (matchValue(match.type, value, match.value, match)) return true;
|
|
1993
2163
|
} else {
|
|
1994
|
-
|
|
2164
|
+
const { leaves, truncated } = collectLeafValues(value);
|
|
2165
|
+
for (const leaf of leaves) {
|
|
1995
2166
|
if (matchValue(match.type, leaf, match.value, match)) return true;
|
|
1996
2167
|
}
|
|
2168
|
+
if (truncated) {
|
|
2169
|
+
resolver.noteSkip("container-cap");
|
|
2170
|
+
const text = serialisedValue(value);
|
|
2171
|
+
if (text !== void 0 && matchValue(match.type, text, match.value, match)) return true;
|
|
2172
|
+
}
|
|
1997
2173
|
}
|
|
1998
2174
|
continue;
|
|
1999
2175
|
}
|
|
@@ -2380,6 +2556,9 @@ function conditionShapeProblem(condition) {
|
|
|
2380
2556
|
if (nested && !named) {
|
|
2381
2557
|
return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
|
|
2382
2558
|
}
|
|
2559
|
+
if (condition.inclusive !== void 0 && typeof condition.inclusive !== "boolean") {
|
|
2560
|
+
return "inclusive must be true or false";
|
|
2561
|
+
}
|
|
2383
2562
|
if (named && nested) {
|
|
2384
2563
|
if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
|
|
2385
2564
|
if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
|
|
@@ -3086,7 +3265,7 @@ async function fromFetchRequest(request, options = {}) {
|
|
|
3086
3265
|
// that guessed differently would attribute one request to two addresses.
|
|
3087
3266
|
ip: client.ip ?? "",
|
|
3088
3267
|
_clientIp: client,
|
|
3089
|
-
cookies:
|
|
3268
|
+
cookies: parseCookieHeader(headers.cookie),
|
|
3090
3269
|
// Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
|
|
3091
3270
|
// drops, so prototype-pollution rules on `raw` are robust.
|
|
3092
3271
|
_rawBody: rawBody,
|
|
@@ -3240,22 +3419,53 @@ function decodeExtendedValue(value) {
|
|
|
3240
3419
|
return match[2];
|
|
3241
3420
|
}
|
|
3242
3421
|
}
|
|
3243
|
-
function parseCookies(header) {
|
|
3244
|
-
const cookies = {};
|
|
3245
|
-
if (!header) {
|
|
3246
|
-
return cookies;
|
|
3247
|
-
}
|
|
3248
|
-
for (const pair of header.split(";")) {
|
|
3249
|
-
const idx = pair.indexOf("=");
|
|
3250
|
-
if (idx === -1) {
|
|
3251
|
-
continue;
|
|
3252
|
-
}
|
|
3253
|
-
setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
|
|
3254
|
-
}
|
|
3255
|
-
return cookies;
|
|
3256
|
-
}
|
|
3257
3422
|
|
|
3258
3423
|
// src/protect/engine/node.js
|
|
3424
|
+
function readBodyPrefix(req, maxBytes, done) {
|
|
3425
|
+
const chunks = [];
|
|
3426
|
+
let retained = 0;
|
|
3427
|
+
let size = 0;
|
|
3428
|
+
let failed = false;
|
|
3429
|
+
let finished = false;
|
|
3430
|
+
const finish = (error, read) => {
|
|
3431
|
+
if (finished) return;
|
|
3432
|
+
finished = true;
|
|
3433
|
+
done(error, read);
|
|
3434
|
+
};
|
|
3435
|
+
req.on("data", (chunk) => {
|
|
3436
|
+
if (failed) return;
|
|
3437
|
+
try {
|
|
3438
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk, req.readableEncoding || "utf8") : chunk;
|
|
3439
|
+
size += bytes.length;
|
|
3440
|
+
const take = Math.min(bytes.length, maxBytes - retained);
|
|
3441
|
+
if (take <= 0) return;
|
|
3442
|
+
chunks.push(take === bytes.length ? bytes : bytes.subarray(0, take));
|
|
3443
|
+
retained += take;
|
|
3444
|
+
} catch {
|
|
3445
|
+
failed = true;
|
|
3446
|
+
chunks.length = 0;
|
|
3447
|
+
}
|
|
3448
|
+
});
|
|
3449
|
+
req.on("error", (error) => finish(error));
|
|
3450
|
+
req.on("end", () => {
|
|
3451
|
+
if (failed) {
|
|
3452
|
+
finish(null, { text: "", overflow: false, size, failed: true });
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3455
|
+
const prefix = Buffer.concat(chunks);
|
|
3456
|
+
const overflow = size > maxBytes;
|
|
3457
|
+
const text = (overflow ? prefix.subarray(0, completeUtf8Length(prefix)) : prefix).toString("utf8");
|
|
3458
|
+
finish(null, { text, overflow, size, failed: false });
|
|
3459
|
+
});
|
|
3460
|
+
}
|
|
3461
|
+
function completeUtf8Length(bytes) {
|
|
3462
|
+
let start = bytes.length - 1;
|
|
3463
|
+
while (start >= 0 && bytes.length - start <= 3 && (bytes[start] & 192) === 128) start--;
|
|
3464
|
+
if (start < 0) return bytes.length;
|
|
3465
|
+
const lead = bytes[start];
|
|
3466
|
+
const needed = lead >= 240 ? 4 : lead >= 224 ? 3 : lead >= 192 ? 2 : 1;
|
|
3467
|
+
return bytes.length - start < needed ? start : bytes.length;
|
|
3468
|
+
}
|
|
3259
3469
|
function fromNodeRequest(req, rawBody = "", options = {}) {
|
|
3260
3470
|
const method = (req.method || "GET").toUpperCase();
|
|
3261
3471
|
const headers = {};
|
|
@@ -3301,29 +3511,41 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
|
|
|
3301
3511
|
headers,
|
|
3302
3512
|
ip: client.ip ?? "",
|
|
3303
3513
|
_clientIp: client,
|
|
3304
|
-
cookies:
|
|
3514
|
+
cookies: parseCookieHeader(headers.cookie),
|
|
3305
3515
|
// Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
|
|
3306
3516
|
_rawBody: rawBody
|
|
3307
3517
|
};
|
|
3308
3518
|
}
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3519
|
+
|
|
3520
|
+
// src/protect/egress.js
|
|
3521
|
+
var REGISTRY = /* @__PURE__ */ Symbol.for("patchstack.connect.egress-guard");
|
|
3522
|
+
function egressRegistry() {
|
|
3523
|
+
const existing = globalThis[REGISTRY];
|
|
3524
|
+
if (existing && existing.screens instanceof Set && existing.surfaces instanceof Map) return existing;
|
|
3525
|
+
const created = { screens: /* @__PURE__ */ new Set(), surfaces: /* @__PURE__ */ new Map() };
|
|
3526
|
+
Object.defineProperty(globalThis, REGISTRY, { value: created, configurable: true, writable: true });
|
|
3527
|
+
return created;
|
|
3528
|
+
}
|
|
3529
|
+
var refusal = (host) => new Error(`Patchstack blocked an outbound request to a disallowed address: ${host}`);
|
|
3530
|
+
function readableDestination(input, init) {
|
|
3531
|
+
try {
|
|
3532
|
+
const raw = typeof input === "string" || input instanceof URL ? String(input) : input?.url ?? input?.href;
|
|
3533
|
+
if (typeof raw !== "string" && !(raw instanceof URL)) return null;
|
|
3534
|
+
const url = new URL(String(raw)).href;
|
|
3535
|
+
const method = String(init?.method ?? input?.method ?? "GET").toUpperCase();
|
|
3536
|
+
return { url, method };
|
|
3537
|
+
} catch {
|
|
3538
|
+
return null;
|
|
3313
3539
|
}
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
|
|
3540
|
+
}
|
|
3541
|
+
function anyRefuses(screens, url, host, method) {
|
|
3542
|
+
let refused = false;
|
|
3543
|
+
for (const screen of screens) {
|
|
3544
|
+
if (screen.block(url, host, method)) refused = true;
|
|
3320
3545
|
}
|
|
3321
|
-
return
|
|
3546
|
+
return refused;
|
|
3322
3547
|
}
|
|
3323
|
-
|
|
3324
|
-
// src/protect/egress.js
|
|
3325
3548
|
async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
|
|
3326
|
-
const restores = [];
|
|
3327
3549
|
if (typeof shouldBlock !== "function") return () => {
|
|
3328
3550
|
};
|
|
3329
3551
|
const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
@@ -3368,6 +3590,9 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3368
3590
|
resolve(false);
|
|
3369
3591
|
}
|
|
3370
3592
|
});
|
|
3593
|
+
const registry = egressRegistry();
|
|
3594
|
+
const own = { block, prescreen: resolvesToDisallowed, dns: screen, skip };
|
|
3595
|
+
registry.screens.add(own);
|
|
3371
3596
|
const originalFetch = globalThis.fetch;
|
|
3372
3597
|
if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
|
|
3373
3598
|
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
@@ -3440,15 +3665,19 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3440
3665
|
} catch {
|
|
3441
3666
|
host = null;
|
|
3442
3667
|
}
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3668
|
+
const screens = [...registry.screens];
|
|
3669
|
+
if (anyRefuses(screens, u, host, method)) throw refusal(host ?? u);
|
|
3670
|
+
const resolved = await Promise.all(screens.map((each) => each.prescreen(u, host, method)));
|
|
3671
|
+
if (resolved.includes(true)) throw refusal(host ?? u);
|
|
3446
3672
|
};
|
|
3447
3673
|
const guarded = async (input, init) => {
|
|
3448
3674
|
let cur;
|
|
3449
3675
|
try {
|
|
3450
3676
|
cur = new Request(input, { ...init || {}, redirect: "manual" });
|
|
3451
3677
|
} catch {
|
|
3678
|
+
const destination = readableDestination(input, init);
|
|
3679
|
+
if (destination) await screenUrl(destination.url, destination.method);
|
|
3680
|
+
else for (const each of registry.screens) each.skip("unrecognised-request", {});
|
|
3452
3681
|
return originalFetch(input, init);
|
|
3453
3682
|
}
|
|
3454
3683
|
const callerRedirect = init && init.redirect || input && input.redirect || "follow";
|
|
@@ -3459,13 +3688,12 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3459
3688
|
const headers = new Headers(cur.headers);
|
|
3460
3689
|
const signal = cur.signal;
|
|
3461
3690
|
const replay = captureReplayBody(cur);
|
|
3691
|
+
const { body: _callerBody, ...callerOptions } = init || {};
|
|
3462
3692
|
let body;
|
|
3463
3693
|
for (let hop = 0; ; hop++) {
|
|
3464
3694
|
let resp;
|
|
3465
3695
|
try {
|
|
3466
|
-
resp = await originalFetch(
|
|
3467
|
-
hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
|
|
3468
|
-
);
|
|
3696
|
+
resp = hop === 0 ? await originalFetch(cur, { ...callerOptions, redirect: "manual" }) : await originalFetch(url, { ...callerOptions, method, headers, body, redirect: "manual", signal });
|
|
3469
3697
|
} catch (error) {
|
|
3470
3698
|
replay.cancel();
|
|
3471
3699
|
throw error;
|
|
@@ -3473,7 +3701,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3473
3701
|
const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
|
|
3474
3702
|
if (!location) {
|
|
3475
3703
|
replay.cancel();
|
|
3476
|
-
return resp;
|
|
3704
|
+
return hop === 0 ? resp : markRedirected(resp);
|
|
3477
3705
|
}
|
|
3478
3706
|
await discardResponseBody(resp);
|
|
3479
3707
|
if (hop >= MAX_REDIRECTS) {
|
|
@@ -3506,8 +3734,12 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3506
3734
|
};
|
|
3507
3735
|
guarded.__patchstackGuarded = true;
|
|
3508
3736
|
globalThis.fetch = guarded;
|
|
3509
|
-
|
|
3510
|
-
|
|
3737
|
+
registry.surfaces.set(guarded, {
|
|
3738
|
+
release() {
|
|
3739
|
+
if (globalThis.fetch !== guarded) return false;
|
|
3740
|
+
globalThis.fetch = originalFetch;
|
|
3741
|
+
return true;
|
|
3742
|
+
}
|
|
3511
3743
|
});
|
|
3512
3744
|
}
|
|
3513
3745
|
let syncBuiltins = () => {
|
|
@@ -3521,12 +3753,16 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3521
3753
|
for (const moduleName of ["node:http", "node:https"]) {
|
|
3522
3754
|
try {
|
|
3523
3755
|
const mod = await import(moduleName);
|
|
3524
|
-
|
|
3525
|
-
|
|
3756
|
+
if (registry.surfaces.has(moduleName)) continue;
|
|
3757
|
+
const release = patchHttpModule(mod.default ?? mod, registry);
|
|
3758
|
+
if (release) {
|
|
3526
3759
|
patchedAny = true;
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3760
|
+
registry.surfaces.set(moduleName, {
|
|
3761
|
+
release() {
|
|
3762
|
+
if (!release()) return false;
|
|
3763
|
+
syncBuiltins();
|
|
3764
|
+
return true;
|
|
3765
|
+
}
|
|
3530
3766
|
});
|
|
3531
3767
|
}
|
|
3532
3768
|
} catch {
|
|
@@ -3534,27 +3770,41 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3534
3770
|
}
|
|
3535
3771
|
if (patchedAny) syncBuiltins();
|
|
3536
3772
|
return () => {
|
|
3537
|
-
|
|
3773
|
+
registry.screens.delete(own);
|
|
3774
|
+
if (registry.screens.size > 0) return;
|
|
3775
|
+
for (const [name, surface] of registry.surfaces) {
|
|
3538
3776
|
try {
|
|
3539
|
-
|
|
3777
|
+
if (surface.release()) registry.surfaces.delete(name);
|
|
3540
3778
|
} catch {
|
|
3541
3779
|
}
|
|
3542
3780
|
}
|
|
3543
3781
|
};
|
|
3544
3782
|
}
|
|
3545
|
-
function
|
|
3783
|
+
function markRedirected(response) {
|
|
3784
|
+
try {
|
|
3785
|
+
Object.defineProperty(response, "redirected", { value: true, configurable: true });
|
|
3786
|
+
} catch {
|
|
3787
|
+
}
|
|
3788
|
+
return response;
|
|
3789
|
+
}
|
|
3790
|
+
function patchHttpModule(http, registry) {
|
|
3546
3791
|
if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
|
|
3547
3792
|
const originalRequest = http.request;
|
|
3548
3793
|
const originalGet = http.get;
|
|
3549
3794
|
const OriginalClientRequest = typeof http.ClientRequest === "function" ? http.ClientRequest : null;
|
|
3550
3795
|
const guardArgs = (args) => {
|
|
3551
3796
|
const target = extractHttpTarget(args);
|
|
3552
|
-
if (target
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3797
|
+
if (!target) return args;
|
|
3798
|
+
const screens = [...registry.screens];
|
|
3799
|
+
if (anyRefuses(screens, target.url, target.host, target.method)) throw refusal(target.host ?? target.url);
|
|
3800
|
+
const resolving = target.host ? screens.filter((each) => each.dns && each.dns.isIP(target.host) === 0 && !each.dns.isExempt(target.host)) : [];
|
|
3801
|
+
if (resolving.length > 0) {
|
|
3802
|
+
const block = (url, host, method) => anyRefuses(resolving, url, host, method);
|
|
3803
|
+
const skip = (reason, detail) => {
|
|
3804
|
+
for (const each of resolving) each.skip(reason, detail);
|
|
3805
|
+
};
|
|
3556
3806
|
try {
|
|
3557
|
-
return withScreeningLookup(args, target, block,
|
|
3807
|
+
return withScreeningLookup(args, target, block, resolving[0].dns.lookup, skip);
|
|
3558
3808
|
} catch {
|
|
3559
3809
|
}
|
|
3560
3810
|
}
|
|
@@ -3582,10 +3832,14 @@ function patchHttpModule(http, block, screen, skip) {
|
|
|
3582
3832
|
}
|
|
3583
3833
|
http.__patchstackGuarded = true;
|
|
3584
3834
|
return () => {
|
|
3585
|
-
if (http.request
|
|
3586
|
-
if (guardedGet && http.get
|
|
3587
|
-
if (GuardedClientRequest && http.ClientRequest
|
|
3835
|
+
if (http.request !== guardedRequest) return false;
|
|
3836
|
+
if (guardedGet && http.get !== guardedGet) return false;
|
|
3837
|
+
if (GuardedClientRequest && http.ClientRequest !== GuardedClientRequest) return false;
|
|
3838
|
+
http.request = originalRequest;
|
|
3839
|
+
if (guardedGet) http.get = originalGet;
|
|
3840
|
+
if (GuardedClientRequest) http.ClientRequest = OriginalClientRequest;
|
|
3588
3841
|
delete http.__patchstackGuarded;
|
|
3842
|
+
return true;
|
|
3589
3843
|
};
|
|
3590
3844
|
}
|
|
3591
3845
|
function extractHttpTarget(args) {
|
|
@@ -4484,7 +4738,7 @@ function makeRefreshHandler(tick, secret) {
|
|
|
4484
4738
|
return async (request) => {
|
|
4485
4739
|
if (!secret) return new Response("not found", { status: 404 });
|
|
4486
4740
|
const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
|
|
4487
|
-
if (provided
|
|
4741
|
+
if (!await sameSecret(provided, secret)) return new Response("forbidden", { status: 403 });
|
|
4488
4742
|
let refreshed = true;
|
|
4489
4743
|
try {
|
|
4490
4744
|
const status = await runOnce();
|
|
@@ -4495,6 +4749,33 @@ function makeRefreshHandler(tick, secret) {
|
|
|
4495
4749
|
return new Response(JSON.stringify({ refreshed }), { status: 200, headers: { "content-type": "application/json" } });
|
|
4496
4750
|
};
|
|
4497
4751
|
}
|
|
4752
|
+
async function sameSecret(provided, secret) {
|
|
4753
|
+
if (typeof provided !== "string" || typeof secret !== "string") return false;
|
|
4754
|
+
const subtle = globalThis.crypto?.subtle;
|
|
4755
|
+
if (subtle && typeof TextEncoder === "function") {
|
|
4756
|
+
try {
|
|
4757
|
+
const encoder2 = new TextEncoder();
|
|
4758
|
+
const [a, b] = await Promise.all([
|
|
4759
|
+
subtle.digest("SHA-256", encoder2.encode(provided)),
|
|
4760
|
+
subtle.digest("SHA-256", encoder2.encode(secret))
|
|
4761
|
+
]);
|
|
4762
|
+
return equalBytes(new Uint8Array(a), new Uint8Array(b));
|
|
4763
|
+
} catch {
|
|
4764
|
+
}
|
|
4765
|
+
}
|
|
4766
|
+
let diff = provided.length ^ secret.length;
|
|
4767
|
+
const length = Math.max(provided.length, secret.length);
|
|
4768
|
+
for (let i = 0; i < length; i++) {
|
|
4769
|
+
diff |= (provided.charCodeAt(i) || 0) ^ (secret.charCodeAt(i) || 0);
|
|
4770
|
+
}
|
|
4771
|
+
return diff === 0;
|
|
4772
|
+
}
|
|
4773
|
+
function equalBytes(a, b) {
|
|
4774
|
+
if (a.length !== b.length) return false;
|
|
4775
|
+
let diff = 0;
|
|
4776
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
4777
|
+
return diff === 0;
|
|
4778
|
+
}
|
|
4498
4779
|
|
|
4499
4780
|
// src/protect/detections.js
|
|
4500
4781
|
var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
|
|
@@ -5228,9 +5509,180 @@ function hardensWithoutBody(rule) {
|
|
|
5228
5509
|
if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
|
|
5229
5510
|
const { parameters, complete } = readRuleParameters(rule);
|
|
5230
5511
|
if (!complete) return false;
|
|
5231
|
-
return parameters.every(
|
|
5232
|
-
|
|
5512
|
+
return parameters.every(bodyIndependent);
|
|
5513
|
+
}
|
|
5514
|
+
function readsOnlyResponseHeaders(rule) {
|
|
5515
|
+
if (!rule) return false;
|
|
5516
|
+
const { parameters, complete } = readRuleParameters(rule);
|
|
5517
|
+
if (!complete || !parameters.some(isHeaderParameter)) return false;
|
|
5518
|
+
return parameters.every(bodyIndependent);
|
|
5519
|
+
}
|
|
5520
|
+
function isHeaderParameter(parameter) {
|
|
5521
|
+
return parameter === "response.headers" || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
|
|
5522
|
+
}
|
|
5523
|
+
function bodyIndependent(parameter) {
|
|
5524
|
+
return BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
|
|
5525
|
+
}
|
|
5526
|
+
|
|
5527
|
+
// src/protect/supabase-guard.js
|
|
5528
|
+
var GUARD_PATH = "/_patchstack/guard";
|
|
5529
|
+
var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
5530
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
5531
|
+
var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
|
|
5532
|
+
"connection",
|
|
5533
|
+
"cookie",
|
|
5534
|
+
"cookie2",
|
|
5535
|
+
"forwarded",
|
|
5536
|
+
"host",
|
|
5537
|
+
"keep-alive",
|
|
5538
|
+
"proxy-authenticate",
|
|
5539
|
+
"proxy-authorization",
|
|
5540
|
+
"te",
|
|
5541
|
+
"trailer",
|
|
5542
|
+
"transfer-encoding",
|
|
5543
|
+
"upgrade",
|
|
5544
|
+
"x-forwarded-for",
|
|
5545
|
+
"x-forwarded-host",
|
|
5546
|
+
"x-forwarded-port",
|
|
5547
|
+
"x-forwarded-proto",
|
|
5548
|
+
"x-ps-target",
|
|
5549
|
+
"x-real-ip"
|
|
5550
|
+
]);
|
|
5551
|
+
var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
|
|
5552
|
+
"connection",
|
|
5553
|
+
"content-encoding",
|
|
5554
|
+
"content-length",
|
|
5555
|
+
"keep-alive",
|
|
5556
|
+
"proxy-authenticate",
|
|
5557
|
+
"proxy-authorization",
|
|
5558
|
+
"set-cookie",
|
|
5559
|
+
"set-cookie2",
|
|
5560
|
+
"te",
|
|
5561
|
+
"trailer",
|
|
5562
|
+
"transfer-encoding",
|
|
5563
|
+
"upgrade"
|
|
5564
|
+
]);
|
|
5565
|
+
var BodyTooLargeError = class extends Error {
|
|
5566
|
+
};
|
|
5567
|
+
async function boundedBody(request, maxBytes) {
|
|
5568
|
+
const stated = Number(request.headers.get("content-length"));
|
|
5569
|
+
if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
|
|
5570
|
+
if (!request.body) return void 0;
|
|
5571
|
+
const reader = request.body.getReader();
|
|
5572
|
+
const chunks = [];
|
|
5573
|
+
let total = 0;
|
|
5574
|
+
try {
|
|
5575
|
+
for (; ; ) {
|
|
5576
|
+
const { done, value } = await reader.read();
|
|
5577
|
+
if (done) break;
|
|
5578
|
+
total += value.byteLength;
|
|
5579
|
+
if (total > maxBytes) {
|
|
5580
|
+
void reader.cancel().catch(() => {
|
|
5581
|
+
});
|
|
5582
|
+
throw new BodyTooLargeError();
|
|
5583
|
+
}
|
|
5584
|
+
chunks.push(value);
|
|
5585
|
+
}
|
|
5586
|
+
} finally {
|
|
5587
|
+
reader.releaseLock();
|
|
5588
|
+
}
|
|
5589
|
+
const body = new Uint8Array(total);
|
|
5590
|
+
let offset = 0;
|
|
5591
|
+
for (const chunk of chunks) {
|
|
5592
|
+
body.set(chunk, offset);
|
|
5593
|
+
offset += chunk.byteLength;
|
|
5594
|
+
}
|
|
5595
|
+
return body;
|
|
5596
|
+
}
|
|
5597
|
+
function requestHeaders(input) {
|
|
5598
|
+
const output = new Headers();
|
|
5599
|
+
const connectionTokens = new Set(
|
|
5600
|
+
(input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
|
|
5233
5601
|
);
|
|
5602
|
+
input.forEach((value, key) => {
|
|
5603
|
+
const lower = key.toLowerCase();
|
|
5604
|
+
if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
|
|
5605
|
+
});
|
|
5606
|
+
return output;
|
|
5607
|
+
}
|
|
5608
|
+
function responseHeaders(input) {
|
|
5609
|
+
const output = new Headers();
|
|
5610
|
+
const connectionTokens = new Set(
|
|
5611
|
+
(input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
|
|
5612
|
+
);
|
|
5613
|
+
input.forEach((value, key) => {
|
|
5614
|
+
const lower = key.toLowerCase();
|
|
5615
|
+
if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
|
|
5616
|
+
});
|
|
5617
|
+
return output;
|
|
5618
|
+
}
|
|
5619
|
+
var SOURCE_REQUEST = /* @__PURE__ */ Symbol.for("@patchstack/connect.sourceRequest");
|
|
5620
|
+
function createSupabaseGuard({
|
|
5621
|
+
protection,
|
|
5622
|
+
supabaseUrl,
|
|
5623
|
+
fetchImpl = fetch,
|
|
5624
|
+
maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
|
|
5625
|
+
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
5626
|
+
}) {
|
|
5627
|
+
const guard = protection.fetchGuard();
|
|
5628
|
+
const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
|
|
5629
|
+
const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
|
|
5630
|
+
const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
5631
|
+
return async function handleGuardRequest(request, ...hostArgs) {
|
|
5632
|
+
const target = request.headers.get("x-ps-target");
|
|
5633
|
+
if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
|
|
5634
|
+
let targetUrl;
|
|
5635
|
+
try {
|
|
5636
|
+
targetUrl = new URL(target);
|
|
5637
|
+
} catch {
|
|
5638
|
+
return new Response("patchstack: invalid target", { status: 400 });
|
|
5639
|
+
}
|
|
5640
|
+
if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
|
|
5641
|
+
return new Response("patchstack: target not allowed", { status: 403 });
|
|
5642
|
+
}
|
|
5643
|
+
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
5644
|
+
let body;
|
|
5645
|
+
try {
|
|
5646
|
+
body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
|
|
5647
|
+
} catch (error) {
|
|
5648
|
+
if (error instanceof BodyTooLargeError) {
|
|
5649
|
+
return new Response("patchstack: request body too large", { status: 413 });
|
|
5650
|
+
}
|
|
5651
|
+
throw error;
|
|
5652
|
+
}
|
|
5653
|
+
const forwardHeaders = requestHeaders(request.headers);
|
|
5654
|
+
const evalReq = new Request(targetUrl.toString(), {
|
|
5655
|
+
method: request.method,
|
|
5656
|
+
// Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
|
|
5657
|
+
// sanitized at the cross-origin boundary below.
|
|
5658
|
+
headers: request.headers,
|
|
5659
|
+
body
|
|
5660
|
+
});
|
|
5661
|
+
Object.defineProperty(evalReq, SOURCE_REQUEST, { value: request });
|
|
5662
|
+
const blocked = await guard(evalReq, ...hostArgs);
|
|
5663
|
+
if (blocked) return blocked;
|
|
5664
|
+
let upstream;
|
|
5665
|
+
try {
|
|
5666
|
+
upstream = await fetchImpl(targetUrl.toString(), {
|
|
5667
|
+
method: request.method,
|
|
5668
|
+
headers: forwardHeaders,
|
|
5669
|
+
body,
|
|
5670
|
+
redirect: "manual",
|
|
5671
|
+
signal: AbortSignal.timeout(upstreamTimeout)
|
|
5672
|
+
});
|
|
5673
|
+
} catch (error) {
|
|
5674
|
+
if (error?.name === "TimeoutError" || error?.name === "AbortError") {
|
|
5675
|
+
return new Response("patchstack: upstream timed out", { status: 504 });
|
|
5676
|
+
}
|
|
5677
|
+
throw error;
|
|
5678
|
+
}
|
|
5679
|
+
const forwarded = new Response(upstream.body, {
|
|
5680
|
+
status: upstream.status,
|
|
5681
|
+
statusText: upstream.statusText,
|
|
5682
|
+
headers: responseHeaders(upstream.headers)
|
|
5683
|
+
});
|
|
5684
|
+
return protection.screenResponse ? protection.screenResponse(forwarded, evalReq) : forwarded;
|
|
5685
|
+
};
|
|
5234
5686
|
}
|
|
5235
5687
|
|
|
5236
5688
|
// src/protect/firewall-log.js
|
|
@@ -5279,8 +5731,13 @@ function resolveApiBase(pulseOrManifestUrl) {
|
|
|
5279
5731
|
function createFirewallLogReporter(opts) {
|
|
5280
5732
|
const creds = parseApiKey(opts.apiKey);
|
|
5281
5733
|
if (!creds) {
|
|
5282
|
-
return {
|
|
5283
|
-
|
|
5734
|
+
return {
|
|
5735
|
+
record() {
|
|
5736
|
+
},
|
|
5737
|
+
flush: () => Promise.resolve(),
|
|
5738
|
+
stop: () => Promise.resolve(),
|
|
5739
|
+
health: () => ({ recorded: 0, delivered: 0, failed: 0, dropped: 0, queued: 0 })
|
|
5740
|
+
};
|
|
5284
5741
|
}
|
|
5285
5742
|
const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
|
|
5286
5743
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
@@ -5293,6 +5750,19 @@ function createFirewallLogReporter(opts) {
|
|
|
5293
5750
|
let drainPromise = null;
|
|
5294
5751
|
let activeController = null;
|
|
5295
5752
|
let ended = false;
|
|
5753
|
+
let recorded = 0;
|
|
5754
|
+
let delivered = 0;
|
|
5755
|
+
let failed = 0;
|
|
5756
|
+
let dropped = 0;
|
|
5757
|
+
let activeBatch = null;
|
|
5758
|
+
const settle = (sent, outcome) => {
|
|
5759
|
+
if (sent.counted) return;
|
|
5760
|
+
sent.counted = true;
|
|
5761
|
+
if (outcome === "delivered") delivered += sent.size;
|
|
5762
|
+
else if (outcome === "failed") failed += sent.size;
|
|
5763
|
+
else dropped += sent.size;
|
|
5764
|
+
if (activeBatch === sent) activeBatch = null;
|
|
5765
|
+
};
|
|
5296
5766
|
let cachedToken = null;
|
|
5297
5767
|
let tokenInflight = null;
|
|
5298
5768
|
const fetchAccessToken = async (signal) => {
|
|
@@ -5346,10 +5816,16 @@ function createFirewallLogReporter(opts) {
|
|
|
5346
5816
|
const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
|
|
5347
5817
|
attemptTimer?.unref?.();
|
|
5348
5818
|
let entry;
|
|
5819
|
+
const sent = { size: batch.length, counted: false };
|
|
5820
|
+
activeBatch = sent;
|
|
5349
5821
|
entry = (async () => {
|
|
5350
5822
|
try {
|
|
5351
5823
|
const token = await fetchAccessToken(controller?.signal);
|
|
5352
|
-
if (
|
|
5824
|
+
if (ended) return;
|
|
5825
|
+
if (!token) {
|
|
5826
|
+
settle(sent, "failed");
|
|
5827
|
+
return;
|
|
5828
|
+
}
|
|
5353
5829
|
const body = new URLSearchParams();
|
|
5354
5830
|
body.set("type", "firewall");
|
|
5355
5831
|
body.set("logs", JSON.stringify(batch));
|
|
@@ -5366,10 +5842,11 @@ function createFirewallLogReporter(opts) {
|
|
|
5366
5842
|
// Both phases carry the attempt controller, so slow transports cannot accumulate work.
|
|
5367
5843
|
...controller ? { signal: controller.signal } : {}
|
|
5368
5844
|
});
|
|
5369
|
-
|
|
5370
|
-
|
|
5845
|
+
const res = p && typeof p.then === "function" ? await p.catch(() => null) : p;
|
|
5846
|
+
settle(sent, res && res.ok ? "delivered" : "failed");
|
|
5371
5847
|
} catch {
|
|
5372
5848
|
} finally {
|
|
5849
|
+
settle(sent, "failed");
|
|
5373
5850
|
clearTimeout(attemptTimer);
|
|
5374
5851
|
if (activeController === controller) activeController = null;
|
|
5375
5852
|
if (inFlight === entry) inFlight = null;
|
|
@@ -5396,7 +5873,11 @@ function createFirewallLogReporter(opts) {
|
|
|
5396
5873
|
if (stopped) return;
|
|
5397
5874
|
const fid = event?.rule?.id;
|
|
5398
5875
|
if (fid === void 0 || fid === null || fid === "") return;
|
|
5399
|
-
if (queue.length >= MAX_QUEUE2)
|
|
5876
|
+
if (queue.length >= MAX_QUEUE2) {
|
|
5877
|
+
dropped++;
|
|
5878
|
+
return;
|
|
5879
|
+
}
|
|
5880
|
+
recorded++;
|
|
5400
5881
|
queue.push({
|
|
5401
5882
|
fid,
|
|
5402
5883
|
method: event.method ?? null,
|
|
@@ -5412,6 +5893,10 @@ function createFirewallLogReporter(opts) {
|
|
|
5412
5893
|
if (!timer) timer = setTimeout(flush, flushMs);
|
|
5413
5894
|
},
|
|
5414
5895
|
flush,
|
|
5896
|
+
/** Where the records went so far; see the counters above. `queued` is what is waiting now. */
|
|
5897
|
+
health() {
|
|
5898
|
+
return { recorded, delivered, failed, dropped, queued: queue.length };
|
|
5899
|
+
},
|
|
5415
5900
|
/**
|
|
5416
5901
|
* Stop, and hand back a wait for what was outstanding.
|
|
5417
5902
|
*
|
|
@@ -5430,6 +5915,8 @@ function createFirewallLogReporter(opts) {
|
|
|
5430
5915
|
ended = true;
|
|
5431
5916
|
activeController?.abort();
|
|
5432
5917
|
activeController = null;
|
|
5918
|
+
if (activeBatch) settle(activeBatch, "dropped");
|
|
5919
|
+
dropped += queue.length;
|
|
5433
5920
|
queue = [];
|
|
5434
5921
|
inFlight = null;
|
|
5435
5922
|
};
|
|
@@ -5466,165 +5953,6 @@ function telemetryEnabled() {
|
|
|
5466
5953
|
return !/^(0|false|off|no)$/i.test(String(v));
|
|
5467
5954
|
}
|
|
5468
5955
|
|
|
5469
|
-
// src/protect/supabase-guard.js
|
|
5470
|
-
var GUARD_PATH = "/_patchstack/guard";
|
|
5471
|
-
var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
5472
|
-
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
5473
|
-
var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
|
|
5474
|
-
"connection",
|
|
5475
|
-
"cookie",
|
|
5476
|
-
"cookie2",
|
|
5477
|
-
"forwarded",
|
|
5478
|
-
"host",
|
|
5479
|
-
"keep-alive",
|
|
5480
|
-
"proxy-authenticate",
|
|
5481
|
-
"proxy-authorization",
|
|
5482
|
-
"te",
|
|
5483
|
-
"trailer",
|
|
5484
|
-
"transfer-encoding",
|
|
5485
|
-
"upgrade",
|
|
5486
|
-
"x-forwarded-for",
|
|
5487
|
-
"x-forwarded-host",
|
|
5488
|
-
"x-forwarded-port",
|
|
5489
|
-
"x-forwarded-proto",
|
|
5490
|
-
"x-ps-target",
|
|
5491
|
-
"x-real-ip"
|
|
5492
|
-
]);
|
|
5493
|
-
var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
|
|
5494
|
-
"connection",
|
|
5495
|
-
"content-encoding",
|
|
5496
|
-
"content-length",
|
|
5497
|
-
"keep-alive",
|
|
5498
|
-
"proxy-authenticate",
|
|
5499
|
-
"proxy-authorization",
|
|
5500
|
-
"set-cookie",
|
|
5501
|
-
"set-cookie2",
|
|
5502
|
-
"te",
|
|
5503
|
-
"trailer",
|
|
5504
|
-
"transfer-encoding",
|
|
5505
|
-
"upgrade"
|
|
5506
|
-
]);
|
|
5507
|
-
var BodyTooLargeError = class extends Error {
|
|
5508
|
-
};
|
|
5509
|
-
async function boundedBody(request, maxBytes) {
|
|
5510
|
-
const stated = Number(request.headers.get("content-length"));
|
|
5511
|
-
if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
|
|
5512
|
-
if (!request.body) return void 0;
|
|
5513
|
-
const reader = request.body.getReader();
|
|
5514
|
-
const chunks = [];
|
|
5515
|
-
let total = 0;
|
|
5516
|
-
try {
|
|
5517
|
-
for (; ; ) {
|
|
5518
|
-
const { done, value } = await reader.read();
|
|
5519
|
-
if (done) break;
|
|
5520
|
-
total += value.byteLength;
|
|
5521
|
-
if (total > maxBytes) {
|
|
5522
|
-
void reader.cancel().catch(() => {
|
|
5523
|
-
});
|
|
5524
|
-
throw new BodyTooLargeError();
|
|
5525
|
-
}
|
|
5526
|
-
chunks.push(value);
|
|
5527
|
-
}
|
|
5528
|
-
} finally {
|
|
5529
|
-
reader.releaseLock();
|
|
5530
|
-
}
|
|
5531
|
-
const body = new Uint8Array(total);
|
|
5532
|
-
let offset = 0;
|
|
5533
|
-
for (const chunk of chunks) {
|
|
5534
|
-
body.set(chunk, offset);
|
|
5535
|
-
offset += chunk.byteLength;
|
|
5536
|
-
}
|
|
5537
|
-
return body;
|
|
5538
|
-
}
|
|
5539
|
-
function requestHeaders(input) {
|
|
5540
|
-
const output = new Headers();
|
|
5541
|
-
const connectionTokens = new Set(
|
|
5542
|
-
(input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
|
|
5543
|
-
);
|
|
5544
|
-
input.forEach((value, key) => {
|
|
5545
|
-
const lower = key.toLowerCase();
|
|
5546
|
-
if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
|
|
5547
|
-
});
|
|
5548
|
-
return output;
|
|
5549
|
-
}
|
|
5550
|
-
function responseHeaders(input) {
|
|
5551
|
-
const output = new Headers();
|
|
5552
|
-
const connectionTokens = new Set(
|
|
5553
|
-
(input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
|
|
5554
|
-
);
|
|
5555
|
-
input.forEach((value, key) => {
|
|
5556
|
-
const lower = key.toLowerCase();
|
|
5557
|
-
if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
|
|
5558
|
-
});
|
|
5559
|
-
return output;
|
|
5560
|
-
}
|
|
5561
|
-
function createSupabaseGuard({
|
|
5562
|
-
protection,
|
|
5563
|
-
supabaseUrl,
|
|
5564
|
-
fetchImpl = fetch,
|
|
5565
|
-
maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
|
|
5566
|
-
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
5567
|
-
}) {
|
|
5568
|
-
const guard = protection.fetchGuard();
|
|
5569
|
-
const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
|
|
5570
|
-
const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
|
|
5571
|
-
const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
5572
|
-
return async function handleGuardRequest(request) {
|
|
5573
|
-
const target = request.headers.get("x-ps-target");
|
|
5574
|
-
if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
|
|
5575
|
-
let targetUrl;
|
|
5576
|
-
try {
|
|
5577
|
-
targetUrl = new URL(target);
|
|
5578
|
-
} catch {
|
|
5579
|
-
return new Response("patchstack: invalid target", { status: 400 });
|
|
5580
|
-
}
|
|
5581
|
-
if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
|
|
5582
|
-
return new Response("patchstack: target not allowed", { status: 403 });
|
|
5583
|
-
}
|
|
5584
|
-
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
5585
|
-
let body;
|
|
5586
|
-
try {
|
|
5587
|
-
body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
|
|
5588
|
-
} catch (error) {
|
|
5589
|
-
if (error instanceof BodyTooLargeError) {
|
|
5590
|
-
return new Response("patchstack: request body too large", { status: 413 });
|
|
5591
|
-
}
|
|
5592
|
-
throw error;
|
|
5593
|
-
}
|
|
5594
|
-
const forwardHeaders = requestHeaders(request.headers);
|
|
5595
|
-
const evalReq = new Request(targetUrl.toString(), {
|
|
5596
|
-
method: request.method,
|
|
5597
|
-
// Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
|
|
5598
|
-
// sanitized at the cross-origin boundary below.
|
|
5599
|
-
headers: request.headers,
|
|
5600
|
-
body
|
|
5601
|
-
});
|
|
5602
|
-
const blocked = await guard(evalReq);
|
|
5603
|
-
if (blocked) return blocked;
|
|
5604
|
-
let upstream;
|
|
5605
|
-
try {
|
|
5606
|
-
upstream = await fetchImpl(targetUrl.toString(), {
|
|
5607
|
-
method: request.method,
|
|
5608
|
-
headers: forwardHeaders,
|
|
5609
|
-
body,
|
|
5610
|
-
redirect: "manual",
|
|
5611
|
-
signal: AbortSignal.timeout(upstreamTimeout)
|
|
5612
|
-
});
|
|
5613
|
-
} catch (error) {
|
|
5614
|
-
if (error?.name === "TimeoutError" || error?.name === "AbortError") {
|
|
5615
|
-
return new Response("patchstack: upstream timed out", { status: 504 });
|
|
5616
|
-
}
|
|
5617
|
-
throw error;
|
|
5618
|
-
}
|
|
5619
|
-
const forwarded = new Response(upstream.body, {
|
|
5620
|
-
status: upstream.status,
|
|
5621
|
-
statusText: upstream.statusText,
|
|
5622
|
-
headers: responseHeaders(upstream.headers)
|
|
5623
|
-
});
|
|
5624
|
-
return protection.screenResponse ? protection.screenResponse(forwarded, evalReq) : forwarded;
|
|
5625
|
-
};
|
|
5626
|
-
}
|
|
5627
|
-
|
|
5628
5956
|
// src/protect/verify-sentinel.js
|
|
5629
5957
|
var VERIFY_HEADER = "x-patchstack-verify";
|
|
5630
5958
|
var CHALLENGE = "PATCHSTACK_VERIFY_CHALLENGE";
|
|
@@ -5811,20 +6139,47 @@ async function createProtection(options = {}) {
|
|
|
5811
6139
|
whitelist_keys: delivered.whitelist_keys,
|
|
5812
6140
|
onError
|
|
5813
6141
|
});
|
|
5814
|
-
responseRuleSet = responseRules.map((rule) =>
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
6142
|
+
responseRuleSet = responseRules.map((rule) => {
|
|
6143
|
+
const unscoped = [];
|
|
6144
|
+
const rewrites = rule.action === "redact" || rule.action === "encode";
|
|
6145
|
+
let derived;
|
|
6146
|
+
try {
|
|
6147
|
+
const redactors = rewrites ? extractRedactors(rule, (parameter) => unscoped.push(parameter)) : null;
|
|
6148
|
+
const mutatedSpan = rewrites && hasSpanMutations(rule);
|
|
6149
|
+
derived = {
|
|
6150
|
+
redactors,
|
|
6151
|
+
mutatedSpan,
|
|
6152
|
+
// A redaction that reads response headers only, and masks spans in them, and an explicit block
|
|
6153
|
+
// that reads response headers only, are decided and carried out without the body — so they
|
|
6154
|
+
// still apply to a response whose body was not screened.
|
|
6155
|
+
redactsHeaders: rule.action === "redact" && !mutatedSpan && (redactors ?? []).some((r) => !r.jsonPath) && readsOnlyResponseHeaders(rule),
|
|
6156
|
+
blocksOnHeaders: rule.action === "block" && readsOnlyResponseHeaders(rule)
|
|
6157
|
+
};
|
|
6158
|
+
} catch (err) {
|
|
6159
|
+
notify(onError, err, "onError");
|
|
6160
|
+
derived = { redactors: null, mutatedSpan: false, redactsHeaders: false, blocksOnHeaders: false };
|
|
6161
|
+
}
|
|
6162
|
+
if (unscoped.length) {
|
|
6163
|
+
notify(
|
|
6164
|
+
onError,
|
|
6165
|
+
new Error(
|
|
6166
|
+
`Patchstack: response rule ${JSON.stringify(rule.id ?? null)} has a condition whose parameter names no place in the response to mask (${unscoped.map((p) => JSON.stringify(p ?? null)).join(", ")}); its matches are not masked`
|
|
6167
|
+
),
|
|
6168
|
+
"onError"
|
|
6169
|
+
);
|
|
6170
|
+
}
|
|
6171
|
+
return {
|
|
6172
|
+
rule,
|
|
6173
|
+
engine: new RuleEngine({ firewall: [rule], onError }),
|
|
6174
|
+
...derived,
|
|
6175
|
+
// Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
|
|
6176
|
+
// have any chance of matching. Lets screenText skip the full scan on responses with no candidate
|
|
6177
|
+
// — the common case — cutting CPU/latency and shrinking the regex/ReDoS surface.
|
|
6178
|
+
// Case-insensitive, and checked against the header values as well as the body, since a rule may
|
|
6179
|
+
// read either.
|
|
6180
|
+
prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
|
|
6181
|
+
};
|
|
6182
|
+
});
|
|
5828
6183
|
egressRuleSet = egressRules.map((rule) => ({
|
|
5829
6184
|
rule,
|
|
5830
6185
|
engine: new RuleEngine({ firewall: [rule], onError })
|
|
@@ -5838,6 +6193,9 @@ async function createProtection(options = {}) {
|
|
|
5838
6193
|
skipCounts[key] = (skipCounts[key] ?? 0) + 1;
|
|
5839
6194
|
notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
|
|
5840
6195
|
};
|
|
6196
|
+
const recordEvaluationSkips = (result) => {
|
|
6197
|
+
for (const reason of result?.skips ?? []) recordSkip("request", reason);
|
|
6198
|
+
};
|
|
5841
6199
|
const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
|
|
5842
6200
|
const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
|
|
5843
6201
|
const planCache = createPlanCache();
|
|
@@ -5880,15 +6238,19 @@ async function createProtection(options = {}) {
|
|
|
5880
6238
|
});
|
|
5881
6239
|
return effectiveMode === "block" ? block() : allow2();
|
|
5882
6240
|
};
|
|
6241
|
+
const decidedByHeaders = (_rule, entry) => Boolean(entry?.redactsHeaders || entry?.blocksOnHeaders);
|
|
6242
|
+
const decidedWithoutBody = (rule, entry) => hardensWithoutBody(rule) || decidedByHeaders(rule, entry);
|
|
5883
6243
|
const screenText = (text, meta, reqCtx, only) => {
|
|
5884
6244
|
let blockRule = null;
|
|
5885
6245
|
const redactions = [];
|
|
5886
6246
|
const headerMutations = [];
|
|
5887
6247
|
let lowerText = null;
|
|
5888
|
-
|
|
5889
|
-
|
|
6248
|
+
const responseSkips = /* @__PURE__ */ new Set();
|
|
6249
|
+
for (const entry of responseRuleSet) {
|
|
6250
|
+
const { rule, engine: re, redactors, prefilter, mutatedSpan } = entry;
|
|
6251
|
+
if (only && !only(rule, entry)) continue;
|
|
5890
6252
|
if (prefilter) {
|
|
5891
|
-
if (lowerText === null) lowerText = text.
|
|
6253
|
+
if (lowerText === null) lowerText = prefilterText(text, meta.headers);
|
|
5892
6254
|
if (!prefilter.some((p) => lowerText.includes(p))) continue;
|
|
5893
6255
|
}
|
|
5894
6256
|
let result;
|
|
@@ -5898,6 +6260,11 @@ async function createProtection(options = {}) {
|
|
|
5898
6260
|
notify(onError, err, "onError");
|
|
5899
6261
|
continue;
|
|
5900
6262
|
}
|
|
6263
|
+
for (const reason of result.skips ?? []) {
|
|
6264
|
+
if (responseSkips.has(reason)) continue;
|
|
6265
|
+
responseSkips.add(reason);
|
|
6266
|
+
recordSkip("response", reason);
|
|
6267
|
+
}
|
|
5901
6268
|
if (!result.blocked) continue;
|
|
5902
6269
|
const responseMode = ruleMode(rule);
|
|
5903
6270
|
onDetect({
|
|
@@ -5927,6 +6294,8 @@ async function createProtection(options = {}) {
|
|
|
5927
6294
|
if (blockRule) return { verdict: "block" };
|
|
5928
6295
|
let body = text;
|
|
5929
6296
|
const headers = { ...meta.headers || {} };
|
|
6297
|
+
let responseIsJson;
|
|
6298
|
+
let structure;
|
|
5930
6299
|
for (const { rule, redactors } of redactions) {
|
|
5931
6300
|
const mask = maskFn(rule.category);
|
|
5932
6301
|
const transform = rule.action === "encode" ? htmlEscape : null;
|
|
@@ -5935,23 +6304,32 @@ async function createProtection(options = {}) {
|
|
|
5935
6304
|
if (pathRedactors.length) body = applyPathRedactors(body, pathRedactors, mask, screenCap, transform);
|
|
5936
6305
|
if (!spanRedactors.length) continue;
|
|
5937
6306
|
const beforeSpan = body;
|
|
5938
|
-
|
|
5939
|
-
if (body
|
|
6307
|
+
const bodySpans = spanRedactors.filter((r) => r.scope.body === true);
|
|
6308
|
+
if (bodySpans.length) body = applyRedactors(body, bodySpans, mask, transform);
|
|
6309
|
+
if (body !== beforeSpan) {
|
|
6310
|
+
responseIsJson ??= isJson(text);
|
|
6311
|
+
if (responseIsJson) {
|
|
6312
|
+
if (structure?.text !== beforeSpan) structure = { text: beforeSpan, tokens: lexJson(beforeSpan) };
|
|
6313
|
+
const tokens = lexJson(body);
|
|
6314
|
+
if (!sameJsonStructure(structure.text, structure.tokens, body, tokens)) return { verdict: "block" };
|
|
6315
|
+
structure = { text: body, tokens };
|
|
6316
|
+
}
|
|
6317
|
+
}
|
|
5940
6318
|
if (transform) continue;
|
|
5941
6319
|
for (const name of Object.keys(headers)) {
|
|
5942
6320
|
const value = headers[name];
|
|
6321
|
+
const headerSpans = spanRedactors.filter((r) => masksHeader(r, name));
|
|
5943
6322
|
if (typeof value === "string") {
|
|
5944
|
-
setOwn(headers, name, applyRedactors(value,
|
|
6323
|
+
setOwn(headers, name, applyRedactors(value, headerSpans, mask));
|
|
5945
6324
|
} else if (Array.isArray(value)) {
|
|
5946
6325
|
setOwn(
|
|
5947
6326
|
headers,
|
|
5948
6327
|
name,
|
|
5949
|
-
value.map((item) => typeof item === "string" ? applyRedactors(item,
|
|
6328
|
+
value.map((item) => typeof item === "string" ? applyRedactors(item, headerSpans, mask) : item)
|
|
5950
6329
|
);
|
|
5951
6330
|
}
|
|
5952
6331
|
}
|
|
5953
6332
|
}
|
|
5954
|
-
if (body !== text && isJson(text) && !isJson(body)) return { verdict: "block" };
|
|
5955
6333
|
for (const rule of headerMutations) applyHeaderMutation(headers, rule);
|
|
5956
6334
|
return { verdict: "redact", body, headers };
|
|
5957
6335
|
};
|
|
@@ -5994,18 +6372,53 @@ async function createProtection(options = {}) {
|
|
|
5994
6372
|
}
|
|
5995
6373
|
};
|
|
5996
6374
|
};
|
|
5997
|
-
const
|
|
6375
|
+
const peerOf = (request, hostArgs) => {
|
|
6376
|
+
if (typeof options.peerAddress !== "function") return void 0;
|
|
6377
|
+
try {
|
|
6378
|
+
return options.peerAddress(request?.[SOURCE_REQUEST] ?? request, ...hostArgs);
|
|
6379
|
+
} catch (err) {
|
|
6380
|
+
notify(onError, err, "onError");
|
|
6381
|
+
return void 0;
|
|
6382
|
+
}
|
|
6383
|
+
};
|
|
6384
|
+
let warnedNoPeer = false;
|
|
6385
|
+
const warnNoPeer = () => {
|
|
6386
|
+
if (warnedNoPeer) return;
|
|
6387
|
+
warnedNoPeer = true;
|
|
6388
|
+
const message = "[patchstack] trustedProxy is set, but this Fetch runtime supplied no peer address, so forwarded headers are not used and client addresses read as unavailable. Pass { peerAddress } to supply one.";
|
|
6389
|
+
if (typeof onError === "function") notify(onError, new Error(message), "onError");
|
|
6390
|
+
else console.warn(message);
|
|
6391
|
+
};
|
|
6392
|
+
const clientByRequest = /* @__PURE__ */ new WeakMap();
|
|
6393
|
+
const clientForResponse = (request, hostArgs) => {
|
|
6394
|
+
const known = clientByRequest.get(request);
|
|
6395
|
+
if (known) return known;
|
|
6396
|
+
const client = resolveClientIp({
|
|
6397
|
+
peer: peerOf(request, hostArgs),
|
|
6398
|
+
headers: headerObject(request.headers),
|
|
6399
|
+
trustedProxy: options.trustedProxy
|
|
6400
|
+
});
|
|
6401
|
+
if (options.trustedProxy !== void 0 && client.source === "unavailable") warnNoPeer();
|
|
6402
|
+
return client;
|
|
6403
|
+
};
|
|
6404
|
+
const screenFetchRequest = async (request, hostArgs = []) => {
|
|
5998
6405
|
let result;
|
|
5999
6406
|
let shaped;
|
|
6000
6407
|
try {
|
|
6001
6408
|
shaped = await fromFetchRequest(request, {
|
|
6409
|
+
peer: peerOf(request, hostArgs),
|
|
6002
6410
|
trustedProxy: options.trustedProxy,
|
|
6003
6411
|
maxBodyBytes: options.maxBodyBytes
|
|
6004
6412
|
});
|
|
6413
|
+
if (shaped?._clientIp) {
|
|
6414
|
+
clientByRequest.set(request, shaped._clientIp);
|
|
6415
|
+
if (options.trustedProxy !== void 0 && shaped._clientIp.source === "unavailable") warnNoPeer();
|
|
6416
|
+
}
|
|
6005
6417
|
if (shaped?._bodyInspectionSkip) {
|
|
6006
6418
|
recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
|
|
6007
6419
|
}
|
|
6008
6420
|
result = engine.evaluate(shaped);
|
|
6421
|
+
recordEvaluationSkips(result);
|
|
6009
6422
|
} catch (err) {
|
|
6010
6423
|
notify(onError, err, "onError");
|
|
6011
6424
|
return { blocked: null, client: void 0 };
|
|
@@ -6055,15 +6468,19 @@ async function createProtection(options = {}) {
|
|
|
6055
6468
|
const read = await readTextResponse(response, screenCap);
|
|
6056
6469
|
if (read.skip) {
|
|
6057
6470
|
if (read.skip !== "not-a-response") {
|
|
6058
|
-
recordSkip("response", read.skip, {
|
|
6471
|
+
recordSkip("response", read.skip, {
|
|
6472
|
+
status: response?.status,
|
|
6473
|
+
...read.encoding ? { encoding: read.encoding } : {},
|
|
6474
|
+
...read.charset ? { charset: read.charset } : {}
|
|
6475
|
+
});
|
|
6059
6476
|
}
|
|
6060
6477
|
if (read.skip === "not-a-response") return response;
|
|
6061
6478
|
return hardenHeadersOnly(response, reqCtx);
|
|
6062
6479
|
}
|
|
6063
|
-
const text = read.text;
|
|
6480
|
+
const text = readJsonEscapes(read.text);
|
|
6064
6481
|
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
|
|
6065
|
-
if (r.verdict === "block") return leakResponse();
|
|
6066
|
-
if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
|
|
6482
|
+
if (r.verdict === "block" || r.verdict === "redact" && outgrowsCharset(read.ascii, r.body)) return leakResponse();
|
|
6483
|
+
if (r.verdict === "redact") return rebuildResponse(response, withBom(read.bom, r.body), r.headers);
|
|
6067
6484
|
return response;
|
|
6068
6485
|
};
|
|
6069
6486
|
const headerValueChanged = (was, value) => {
|
|
@@ -6125,7 +6542,8 @@ async function createProtection(options = {}) {
|
|
|
6125
6542
|
const hardenHeadersOnly = (response, reqCtx) => {
|
|
6126
6543
|
try {
|
|
6127
6544
|
const meta = { status: response.status, headers: headerObject(response.headers) };
|
|
6128
|
-
const r = screenText("", meta, reqCtx,
|
|
6545
|
+
const r = screenText("", meta, reqCtx, decidedWithoutBody);
|
|
6546
|
+
if (r.verdict === "block") return leakResponse();
|
|
6129
6547
|
if (r.verdict !== "redact" || !r.headers) return response;
|
|
6130
6548
|
if (!headersChanged(meta.headers, r.headers)) return response;
|
|
6131
6549
|
return rebuildResponse(response, response.body, r.headers);
|
|
@@ -6145,6 +6563,8 @@ async function createProtection(options = {}) {
|
|
|
6145
6563
|
const chunks = [];
|
|
6146
6564
|
let size = 0;
|
|
6147
6565
|
let overflow = false;
|
|
6566
|
+
let withheld = false;
|
|
6567
|
+
let headScreened = false;
|
|
6148
6568
|
const MAX = screenCap;
|
|
6149
6569
|
let hardened = false;
|
|
6150
6570
|
let answeredWithoutBody = false;
|
|
@@ -6176,6 +6596,7 @@ async function createProtection(options = {}) {
|
|
|
6176
6596
|
}
|
|
6177
6597
|
};
|
|
6178
6598
|
const stillToAnswer = (rule) => !hardensWithoutBody(rule);
|
|
6599
|
+
const notYetAnswered = (rule, entry) => (!answeredWithoutBody || stillToAnswer(rule)) && !(headScreened && decidedByHeaders(rule, entry));
|
|
6179
6600
|
let pendingHead = null;
|
|
6180
6601
|
let sending = false;
|
|
6181
6602
|
const origWriteHead = typeof res.writeHead === "function" ? res.writeHead.bind(res) : null;
|
|
@@ -6237,7 +6658,12 @@ async function createProtection(options = {}) {
|
|
|
6237
6658
|
if (typeof res.flushHeaders === "function") {
|
|
6238
6659
|
const origFlushHeaders = res.flushHeaders.bind(res);
|
|
6239
6660
|
res.flushHeaders = function() {
|
|
6240
|
-
|
|
6661
|
+
const unread = screenUnreadHead();
|
|
6662
|
+
if (unread?.withhold) {
|
|
6663
|
+
sendWithheld();
|
|
6664
|
+
return void 0;
|
|
6665
|
+
}
|
|
6666
|
+
sendHead(unread);
|
|
6241
6667
|
return origFlushHeaders();
|
|
6242
6668
|
};
|
|
6243
6669
|
}
|
|
@@ -6272,6 +6698,32 @@ async function createProtection(options = {}) {
|
|
|
6272
6698
|
headers: at === -1 ? set : { ...set, ...writeHeadHeaderObject(pendingHead[at]) }
|
|
6273
6699
|
};
|
|
6274
6700
|
};
|
|
6701
|
+
const screenUnreadHead = () => {
|
|
6702
|
+
if (headScreened) return void 0;
|
|
6703
|
+
headScreened = true;
|
|
6704
|
+
try {
|
|
6705
|
+
const head = effectiveHead();
|
|
6706
|
+
const r = screenText("", head, reqCtx, decidedByHeaders);
|
|
6707
|
+
if (r.verdict === "block") {
|
|
6708
|
+
if (!reallySent()) return { withhold: true };
|
|
6709
|
+
recordSkip("response", "headers-sent", { action: "block" });
|
|
6710
|
+
return void 0;
|
|
6711
|
+
}
|
|
6712
|
+
if (r.verdict !== "redact" || !r.headers) return void 0;
|
|
6713
|
+
const changed = /* @__PURE__ */ new Map();
|
|
6714
|
+
for (const [name, value] of Object.entries(r.headers)) {
|
|
6715
|
+
if (headerValueChanged(head.headers[name], value)) changed.set(name.toLowerCase(), value);
|
|
6716
|
+
}
|
|
6717
|
+
if (changed.size && reallySent()) {
|
|
6718
|
+
recordSkip("response", "headers-sent", { headers: [...changed.keys()] });
|
|
6719
|
+
return void 0;
|
|
6720
|
+
}
|
|
6721
|
+
return changed.size ? { headers: changed } : void 0;
|
|
6722
|
+
} catch (err) {
|
|
6723
|
+
notify(onError, err, "onError");
|
|
6724
|
+
return void 0;
|
|
6725
|
+
}
|
|
6726
|
+
};
|
|
6275
6727
|
const sendHead = (changes) => {
|
|
6276
6728
|
if (sending) return;
|
|
6277
6729
|
const hardened2 = hardenOnce();
|
|
@@ -6293,6 +6745,17 @@ async function createProtection(options = {}) {
|
|
|
6293
6745
|
applyToResponse({ headers: merged });
|
|
6294
6746
|
origWriteHead(...args);
|
|
6295
6747
|
};
|
|
6748
|
+
const sendWithheld = (cb) => {
|
|
6749
|
+
const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
|
|
6750
|
+
const headers = /* @__PURE__ */ new Map();
|
|
6751
|
+
for (const name of Object.keys(effectiveHead().headers)) headers.set(name.toLowerCase(), null);
|
|
6752
|
+
headers.set("content-type", "application/json");
|
|
6753
|
+
headers.set("content-length", String(Buffer.byteLength(body)));
|
|
6754
|
+
headers.set("transfer-encoding", null);
|
|
6755
|
+
withheld = true;
|
|
6756
|
+
sendHead({ status: 500, headers });
|
|
6757
|
+
return origEnd(body, cb);
|
|
6758
|
+
};
|
|
6296
6759
|
const applyToResponse = (changes) => {
|
|
6297
6760
|
if (changes.status !== void 0) res.statusCode = changes.status;
|
|
6298
6761
|
for (const [name, value] of changes.headers ?? []) {
|
|
@@ -6308,7 +6771,12 @@ async function createProtection(options = {}) {
|
|
|
6308
6771
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
|
|
6309
6772
|
size += buf.length;
|
|
6310
6773
|
if (size > MAX) {
|
|
6311
|
-
|
|
6774
|
+
const unread = screenUnreadHead();
|
|
6775
|
+
if (unread?.withhold) {
|
|
6776
|
+
sendWithheld();
|
|
6777
|
+
return;
|
|
6778
|
+
}
|
|
6779
|
+
sendHead(unread);
|
|
6312
6780
|
for (const c of chunks) origWrite(c);
|
|
6313
6781
|
chunks.length = 0;
|
|
6314
6782
|
origWrite(buf);
|
|
@@ -6319,6 +6787,11 @@ async function createProtection(options = {}) {
|
|
|
6319
6787
|
chunks.push(buf);
|
|
6320
6788
|
};
|
|
6321
6789
|
res.write = function(chunk, enc, cb) {
|
|
6790
|
+
if (withheld) {
|
|
6791
|
+
if (typeof enc === "function") enc();
|
|
6792
|
+
else if (typeof cb === "function") cb();
|
|
6793
|
+
return true;
|
|
6794
|
+
}
|
|
6322
6795
|
if (overflow) return origWrite(chunk, enc, cb);
|
|
6323
6796
|
collect(chunk, enc);
|
|
6324
6797
|
if (typeof enc === "function") enc();
|
|
@@ -6334,15 +6807,24 @@ async function createProtection(options = {}) {
|
|
|
6334
6807
|
cb = enc;
|
|
6335
6808
|
enc = void 0;
|
|
6336
6809
|
}
|
|
6810
|
+
if (withheld) {
|
|
6811
|
+
if (typeof cb === "function") cb();
|
|
6812
|
+
return res;
|
|
6813
|
+
}
|
|
6337
6814
|
if (overflow) {
|
|
6338
6815
|
if (chunk != null) origWrite(chunk, enc);
|
|
6339
6816
|
return origEnd(cb);
|
|
6340
6817
|
}
|
|
6341
6818
|
collect(chunk, enc);
|
|
6819
|
+
if (withheld) {
|
|
6820
|
+
if (typeof cb === "function") cb();
|
|
6821
|
+
return res;
|
|
6822
|
+
}
|
|
6342
6823
|
if (overflow) return origEnd(cb);
|
|
6343
6824
|
hardenOnce();
|
|
6344
|
-
const passThrough = () => {
|
|
6345
|
-
|
|
6825
|
+
const passThrough = (changes) => {
|
|
6826
|
+
if (changes?.withhold) return sendWithheld(cb);
|
|
6827
|
+
sendHead(changes);
|
|
6346
6828
|
for (const c of chunks) origWrite(c);
|
|
6347
6829
|
return origEnd(cb);
|
|
6348
6830
|
};
|
|
@@ -6353,21 +6835,28 @@ async function createProtection(options = {}) {
|
|
|
6353
6835
|
const kind = screenableContentType(ct);
|
|
6354
6836
|
if (kind === "skip" || kind === "sniff" && looksBinary(buffer)) {
|
|
6355
6837
|
recordSkip("response", kind === "skip" ? baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" : "binary-body");
|
|
6356
|
-
return passThrough();
|
|
6838
|
+
return passThrough(screenUnreadHead());
|
|
6357
6839
|
}
|
|
6358
6840
|
const codings = declaredCodings(head.headers["content-encoding"]);
|
|
6359
6841
|
if (codings.length > 0 && stillEncoded(buffer)) {
|
|
6360
6842
|
recordSkip("response", "encoded-body", { encoding: codings.join(", ") });
|
|
6361
|
-
return passThrough();
|
|
6843
|
+
return passThrough(screenUnreadHead());
|
|
6844
|
+
}
|
|
6845
|
+
const reading = charsetReading(ct, buffer);
|
|
6846
|
+
if (reading.skip) {
|
|
6847
|
+
recordSkip("response", "unsupported-charset", { charset: reading.skip });
|
|
6848
|
+
return passThrough(screenUnreadHead());
|
|
6362
6849
|
}
|
|
6363
|
-
const text = buffer.toString("utf8");
|
|
6850
|
+
const text = readJsonEscapes(buffer.toString("utf8", reading.bom ? 3 : 0));
|
|
6364
6851
|
let r;
|
|
6365
6852
|
try {
|
|
6366
|
-
r = screenText(text, head, reqCtx,
|
|
6853
|
+
r = screenText(text, head, reqCtx, notYetAnswered);
|
|
6367
6854
|
} catch (err) {
|
|
6368
6855
|
notify(onError, err, "onError");
|
|
6369
6856
|
return passThrough();
|
|
6370
6857
|
}
|
|
6858
|
+
if (r.verdict === "redact" && outgrowsCharset(reading.ascii, r.body)) r = { verdict: "block" };
|
|
6859
|
+
if (r.verdict === "redact") r = { ...r, body: withBom(reading.bom, r.body) };
|
|
6371
6860
|
if (r.verdict !== "block" && r.verdict !== "redact") return passThrough();
|
|
6372
6861
|
if (reallySent() && res.chunkedEncoding !== true) {
|
|
6373
6862
|
notify(onError, new Error("Patchstack: response withheld \u2014 its headers were sent before the body could be screened"), "onError");
|
|
@@ -6375,20 +6864,11 @@ async function createProtection(options = {}) {
|
|
|
6375
6864
|
return res;
|
|
6376
6865
|
}
|
|
6377
6866
|
if (r.verdict === "block") {
|
|
6378
|
-
const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
|
|
6379
6867
|
if (reallySent()) {
|
|
6380
6868
|
res.destroy?.();
|
|
6381
6869
|
return res;
|
|
6382
6870
|
}
|
|
6383
|
-
|
|
6384
|
-
status: 500,
|
|
6385
|
-
headers: /* @__PURE__ */ new Map([
|
|
6386
|
-
["content-type", "application/json"],
|
|
6387
|
-
["content-length", String(Buffer.byteLength(body))],
|
|
6388
|
-
["transfer-encoding", null]
|
|
6389
|
-
])
|
|
6390
|
-
});
|
|
6391
|
-
return origEnd(body, cb);
|
|
6871
|
+
return sendWithheld(cb);
|
|
6392
6872
|
}
|
|
6393
6873
|
const changed = /* @__PURE__ */ new Map();
|
|
6394
6874
|
if (r.headers) {
|
|
@@ -6484,21 +6964,15 @@ async function createProtection(options = {}) {
|
|
|
6484
6964
|
// .fetch(), and by the Supabase guard on its forwarded upstream response.
|
|
6485
6965
|
// A standalone response screen with no request phase of its own — the Supabase guard's forwarded
|
|
6486
6966
|
// upstream response. It resolves once here, which is the only resolution for this call.
|
|
6487
|
-
screenResponse: (response, request) => screenResp(
|
|
6488
|
-
response,
|
|
6489
|
-
request ? reqContextFromFetch(
|
|
6490
|
-
request,
|
|
6491
|
-
resolveClientIp({ headers: headerObject(request.headers), trustedProxy: options.trustedProxy })
|
|
6492
|
-
) : void 0
|
|
6493
|
-
),
|
|
6967
|
+
screenResponse: (response, request, ...hostArgs) => screenResp(response, request ? reqContextFromFetch(request, clientForResponse(request, hostArgs)) : void 0),
|
|
6494
6968
|
// (request) => Response | null (null = allow, caller proceeds). Request phase only.
|
|
6495
6969
|
fetchGuard() {
|
|
6496
|
-
return async (request) => (await screenFetchRequest(request)).blocked;
|
|
6970
|
+
return async (request, ...hostArgs) => (await screenFetchRequest(request, hostArgs)).blocked;
|
|
6497
6971
|
},
|
|
6498
6972
|
// Wrap a fetch handler: screens the request, then the response (redact/block).
|
|
6499
6973
|
fetch(handler) {
|
|
6500
6974
|
return async (request, ...rest) => {
|
|
6501
|
-
const { blocked, client } = await screenFetchRequest(request);
|
|
6975
|
+
const { blocked, client } = await screenFetchRequest(request, rest);
|
|
6502
6976
|
if (blocked) return blocked;
|
|
6503
6977
|
const response = await handler(request, ...rest);
|
|
6504
6978
|
return screenResp(response, reqContextFromFetch(request, client));
|
|
@@ -6512,6 +6986,7 @@ async function createProtection(options = {}) {
|
|
|
6512
6986
|
const { shaped, client } = shapeExpressRequest(req);
|
|
6513
6987
|
try {
|
|
6514
6988
|
result = engine.evaluate(shaped);
|
|
6989
|
+
recordEvaluationSkips(result);
|
|
6515
6990
|
} catch (err) {
|
|
6516
6991
|
notify(onError, err, "onError");
|
|
6517
6992
|
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
|
|
@@ -6546,33 +7021,25 @@ async function createProtection(options = {}) {
|
|
|
6546
7021
|
screenNodeRequest(req, res, next, "", req.body);
|
|
6547
7022
|
return;
|
|
6548
7023
|
}
|
|
6549
|
-
|
|
6550
|
-
|
|
6551
|
-
|
|
6552
|
-
|
|
6553
|
-
size += chunk.length;
|
|
6554
|
-
if (size > maxBytes) {
|
|
6555
|
-
overflow = true;
|
|
7024
|
+
readBodyPrefix(req, maxBytes, (err, read) => {
|
|
7025
|
+
if (err) {
|
|
7026
|
+
notify(onError, err, "onError");
|
|
7027
|
+
next();
|
|
6556
7028
|
return;
|
|
6557
7029
|
}
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
notify(onError, err, "onError");
|
|
6562
|
-
next();
|
|
6563
|
-
});
|
|
6564
|
-
req.on("end", () => {
|
|
6565
|
-
if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
|
|
6566
|
-
screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
|
|
7030
|
+
if (read.overflow) recordSkip("request", "body-cap", { bytes: read.size, limit: maxBytes });
|
|
7031
|
+
if (read.failed) recordSkip("request", "read-failed", { bytes: read.size });
|
|
7032
|
+
screenNodeRequest(req, res, next, read.text, void 0, read.overflow || read.failed);
|
|
6567
7033
|
});
|
|
6568
7034
|
};
|
|
6569
|
-
function screenNodeRequest(req, res, next, rawBody, parsedBody) {
|
|
7035
|
+
function screenNodeRequest(req, res, next, rawBody, parsedBody, truncated = false) {
|
|
6570
7036
|
let shaped;
|
|
6571
7037
|
let result;
|
|
6572
7038
|
try {
|
|
6573
7039
|
shaped = fromNodeRequest(req, rawBody, { trustedProxy: options.trustedProxy });
|
|
6574
7040
|
if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
|
|
6575
7041
|
result = engine.evaluate(shaped);
|
|
7042
|
+
recordEvaluationSkips(result);
|
|
6576
7043
|
} catch (err) {
|
|
6577
7044
|
notify(onError, err, "onError");
|
|
6578
7045
|
return next();
|
|
@@ -6591,7 +7058,7 @@ async function createProtection(options = {}) {
|
|
|
6591
7058
|
}
|
|
6592
7059
|
},
|
|
6593
7060
|
() => {
|
|
6594
|
-
if (req.body === void 0) req.body = shaped.body;
|
|
7061
|
+
if (req.body === void 0 && !truncated) req.body = shaped.body;
|
|
6595
7062
|
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
|
|
6596
7063
|
next();
|
|
6597
7064
|
},
|
|
@@ -6657,12 +7124,17 @@ async function createProtection(options = {}) {
|
|
|
6657
7124
|
protection.stop = () => {
|
|
6658
7125
|
loop?.stop();
|
|
6659
7126
|
recovery?.stop();
|
|
7127
|
+
protection.uninstallEgress?.();
|
|
6660
7128
|
const outstanding = [firewallLog?.stop(), detections?.stop()].filter(
|
|
6661
7129
|
(wait) => wait && typeof wait.then === "function"
|
|
6662
7130
|
);
|
|
6663
7131
|
return Promise.all(outstanding).then(() => void 0);
|
|
6664
7132
|
};
|
|
6665
|
-
protection.stopRefresh =
|
|
7133
|
+
protection.stopRefresh = () => {
|
|
7134
|
+
loop?.stop();
|
|
7135
|
+
recovery?.stop();
|
|
7136
|
+
return Promise.resolve();
|
|
7137
|
+
};
|
|
6666
7138
|
Object.defineProperty(protection, "ruleSource", {
|
|
6667
7139
|
get: () => ({ ...ruleSource }),
|
|
6668
7140
|
enumerable: true
|
|
@@ -6675,6 +7147,10 @@ async function createProtection(options = {}) {
|
|
|
6675
7147
|
get: () => detections ? () => detections.health() : void 0,
|
|
6676
7148
|
enumerable: true
|
|
6677
7149
|
});
|
|
7150
|
+
Object.defineProperty(protection, "blockLogHealth", {
|
|
7151
|
+
get: () => firewallLog ? () => firewallLog.health() : void 0,
|
|
7152
|
+
enumerable: true
|
|
7153
|
+
});
|
|
6678
7154
|
return protection;
|
|
6679
7155
|
}
|
|
6680
7156
|
function accessorOf(obj, name) {
|
|
@@ -6749,6 +7225,14 @@ function screenableContentType(ct) {
|
|
|
6749
7225
|
if (base === "application/octet-stream") return "sniff";
|
|
6750
7226
|
return "skip";
|
|
6751
7227
|
}
|
|
7228
|
+
function prefilterText(body, headers) {
|
|
7229
|
+
const values = [];
|
|
7230
|
+
for (const value of Object.values(headers ?? {})) {
|
|
7231
|
+
if (Array.isArray(value)) values.push(...value.map(String));
|
|
7232
|
+
else if (value !== void 0 && value !== null) values.push(String(value));
|
|
7233
|
+
}
|
|
7234
|
+
return [body, ...values].join("\n").toLowerCase();
|
|
7235
|
+
}
|
|
6752
7236
|
function looksBinary(bytes) {
|
|
6753
7237
|
const n = Math.min(bytes.length, 512);
|
|
6754
7238
|
let ctrl = 0;
|
|
@@ -6772,6 +7256,52 @@ function stillEncoded(bytes) {
|
|
|
6772
7256
|
return true;
|
|
6773
7257
|
}
|
|
6774
7258
|
}
|
|
7259
|
+
var UTF8_CHARSETS = /* @__PURE__ */ new Set(["utf-8", "utf8", "unicode-1-1-utf-8", "us-ascii", "ascii"]);
|
|
7260
|
+
var WIDE_CHARSET = /^(?:utf-?(?:16|32)|ucs-?[24]|unicode|iso-10646)/;
|
|
7261
|
+
function declaredCharset(ct) {
|
|
7262
|
+
const m = /;\s*charset\s*=\s*(?:"([^"]*)"|([^;\s]*))/i.exec(String(ct ?? ""));
|
|
7263
|
+
const label = m ? (m[1] ?? m[2] ?? "").trim().toLowerCase() : "";
|
|
7264
|
+
return label || null;
|
|
7265
|
+
}
|
|
7266
|
+
function charsetReading(ct, bytes) {
|
|
7267
|
+
if (bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) return { ascii: false, bom: true };
|
|
7268
|
+
if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 254) return { skip: "utf-16le" };
|
|
7269
|
+
if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) return { skip: "utf-16be" };
|
|
7270
|
+
const charset = declaredCharset(ct);
|
|
7271
|
+
if (!charset || UTF8_CHARSETS.has(charset)) return { ascii: false };
|
|
7272
|
+
if (WIDE_CHARSET.test(charset)) return { skip: charset };
|
|
7273
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
7274
|
+
const b = bytes[i];
|
|
7275
|
+
if (b >= 128 || b === 0 || b === 27) return { skip: charset };
|
|
7276
|
+
}
|
|
7277
|
+
return { ascii: true };
|
|
7278
|
+
}
|
|
7279
|
+
function decodeForScreening(ct, bytes) {
|
|
7280
|
+
const reading = charsetReading(ct, bytes);
|
|
7281
|
+
if (reading.skip) return { skip: "unsupported-charset", charset: reading.skip };
|
|
7282
|
+
return { text: new TextDecoder().decode(bytes), ascii: reading.ascii, bom: reading.bom === true };
|
|
7283
|
+
}
|
|
7284
|
+
function withBom(bom, body) {
|
|
7285
|
+
return bom ? "\uFEFF" + body : body;
|
|
7286
|
+
}
|
|
7287
|
+
function outgrowsCharset(ascii, body) {
|
|
7288
|
+
return ascii === true && /[^\x00-\x7f]/.test(body);
|
|
7289
|
+
}
|
|
7290
|
+
var KEPT_ESCAPES = /* @__PURE__ */ new Set([34, 92, 60, 62, 38, 39]);
|
|
7291
|
+
function readJsonEscapes(text) {
|
|
7292
|
+
if (!text.includes("\\u") && !text.includes("\\/")) return text;
|
|
7293
|
+
try {
|
|
7294
|
+
JSON.parse(text);
|
|
7295
|
+
} catch {
|
|
7296
|
+
return text;
|
|
7297
|
+
}
|
|
7298
|
+
return text.replace(/\\(?:u([0-9a-fA-F]{4})|([\s\S]))/g, (whole, hex, ch, offset) => {
|
|
7299
|
+
if (hex === void 0) return ch === "/" && text[offset - 1] !== "<" ? "/" : whole;
|
|
7300
|
+
const code = parseInt(hex, 16);
|
|
7301
|
+
if (code < 32 || code > 126 || KEPT_ESCAPES.has(code)) return whole;
|
|
7302
|
+
return String.fromCharCode(code);
|
|
7303
|
+
});
|
|
7304
|
+
}
|
|
6775
7305
|
async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
6776
7306
|
if (!response || typeof response.clone !== "function") return { skip: "not-a-response" };
|
|
6777
7307
|
const ct = response.headers?.get?.("content-type") || "";
|
|
@@ -6825,17 +7355,18 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
|
6825
7355
|
const bytes = concatBytes(chunks, size);
|
|
6826
7356
|
if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
|
|
6827
7357
|
try {
|
|
6828
|
-
return
|
|
7358
|
+
return decodeForScreening(ct, bytes);
|
|
6829
7359
|
} catch {
|
|
6830
7360
|
return { skip: "decode-failed" };
|
|
6831
7361
|
}
|
|
6832
7362
|
}
|
|
6833
7363
|
try {
|
|
6834
|
-
if (codings.length > 0) {
|
|
7364
|
+
if (codings.length > 0 || typeof clone.arrayBuffer === "function") {
|
|
6835
7365
|
const bytes = new Uint8Array(await clone.arrayBuffer());
|
|
6836
7366
|
if (bytes.byteLength > cap) return { skip: "body-cap" };
|
|
6837
|
-
if (stillEncoded(bytes)) return encodedSkip();
|
|
6838
|
-
|
|
7367
|
+
if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
|
|
7368
|
+
if (codings.length === 0 && sniff && looksBinary(bytes)) return { skip: "binary-body" };
|
|
7369
|
+
return decodeForScreening(ct, bytes);
|
|
6839
7370
|
}
|
|
6840
7371
|
const text = await clone.text();
|
|
6841
7372
|
if (text.length > cap) return { skip: "body-cap" };
|
|
@@ -6866,8 +7397,9 @@ function headerObject(headers) {
|
|
|
6866
7397
|
function hasSpanMutations(rule) {
|
|
6867
7398
|
let found = false;
|
|
6868
7399
|
const walk = (conds) => {
|
|
6869
|
-
for (const c of conds
|
|
7400
|
+
for (const c of Array.isArray(conds) ? conds : []) {
|
|
6870
7401
|
if (found) return;
|
|
7402
|
+
if (!c || typeof c !== "object") continue;
|
|
6871
7403
|
if (Array.isArray(c.rules)) walk(c.rules);
|
|
6872
7404
|
const isSpan = c.match && (c.match.type === "regex" || c.match.type === "contains" || c.match.type === "stripos");
|
|
6873
7405
|
if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
|
|
@@ -6876,10 +7408,34 @@ function hasSpanMutations(rule) {
|
|
|
6876
7408
|
walk(rule.rule_v2);
|
|
6877
7409
|
return found;
|
|
6878
7410
|
}
|
|
6879
|
-
function
|
|
7411
|
+
function redactorScope(parameter) {
|
|
7412
|
+
if (parameter === void 0 || parameter === null || parameterProblem(parameter) !== null) return null;
|
|
7413
|
+
const scope = { body: false, allHeaders: false, headers: /* @__PURE__ */ new Set() };
|
|
7414
|
+
for (const name of Array.isArray(parameter) ? parameter : [parameter]) {
|
|
7415
|
+
if (typeof name !== "string" || name === "") return null;
|
|
7416
|
+
if (name.startsWith("response.header.")) scope.headers.add(name.slice("response.header.".length).toLowerCase());
|
|
7417
|
+
else if (name === "response.headers") scope.allHeaders = true;
|
|
7418
|
+
else if (name === "response.body" || name === "response.status" || BROAD_SCOPE_SOURCES.has(name.split(".")[0])) {
|
|
7419
|
+
scope.body = true;
|
|
7420
|
+
scope.allHeaders = true;
|
|
7421
|
+
} else return null;
|
|
7422
|
+
}
|
|
7423
|
+
return scope;
|
|
7424
|
+
}
|
|
7425
|
+
var BROAD_SCOPE_SOURCES = /* @__PURE__ */ new Set(["get", "post", "request", "cookie", "files", "server", "raw", "all"]);
|
|
7426
|
+
function masksHeader(redactor, name) {
|
|
7427
|
+
return redactor.scope.allHeaders || redactor.scope.headers.has(name);
|
|
7428
|
+
}
|
|
7429
|
+
function extractRedactors(rule, onUnscoped) {
|
|
6880
7430
|
const out = [];
|
|
7431
|
+
const scoped = (c) => {
|
|
7432
|
+
const scope = redactorScope(c.parameter);
|
|
7433
|
+
if (scope === null) onUnscoped?.(c.parameter);
|
|
7434
|
+
return scope;
|
|
7435
|
+
};
|
|
6881
7436
|
const walk = (conds) => {
|
|
6882
|
-
for (const c of conds
|
|
7437
|
+
for (const c of Array.isArray(conds) ? conds : []) {
|
|
7438
|
+
if (!c || typeof c !== "object") continue;
|
|
6883
7439
|
if (Array.isArray(c.rules)) walk(c.rules);
|
|
6884
7440
|
const m = c.match;
|
|
6885
7441
|
if (!m) continue;
|
|
@@ -6887,16 +7443,21 @@ function extractRedactors(rule) {
|
|
|
6887
7443
|
const safe = safeRegExp(m.value);
|
|
6888
7444
|
if (safe) {
|
|
6889
7445
|
const flags = safe.flags.includes("g") ? safe.flags : safe.flags + "g";
|
|
7446
|
+
const scope = scoped(c);
|
|
6890
7447
|
try {
|
|
6891
|
-
out.push({ re: new RegExp(safe.source, flags) });
|
|
7448
|
+
if (scope) out.push({ re: new RegExp(safe.source, flags), scope });
|
|
6892
7449
|
} catch {
|
|
6893
7450
|
}
|
|
6894
7451
|
}
|
|
6895
7452
|
} else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
|
|
6896
|
-
|
|
7453
|
+
const scope = scoped(c);
|
|
7454
|
+
if (scope) out.push({ literal: String(m.value), scope });
|
|
6897
7455
|
} else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
|
|
6898
|
-
|
|
6899
|
-
|
|
7456
|
+
const scope = scoped(c);
|
|
7457
|
+
if (scope) out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") }, scope });
|
|
7458
|
+
} else if (m.type === "array_key_value" && m.match && Array.isArray(c.parameter) && !readsBody(c.parameter) && redactorScope(c.parameter) === null) {
|
|
7459
|
+
onUnscoped?.(c.parameter);
|
|
7460
|
+
} else if (m.type === "array_key_value" && m.match && readsBody(c.parameter)) {
|
|
6900
7461
|
const keys = Array.isArray(m.key) ? m.key : [m.key];
|
|
6901
7462
|
for (const key of keys) {
|
|
6902
7463
|
out.push({ jsonPath: String(key).split("."), condition: m.match });
|
|
@@ -6918,30 +7479,101 @@ function isJson(text) {
|
|
|
6918
7479
|
return false;
|
|
6919
7480
|
}
|
|
6920
7481
|
}
|
|
6921
|
-
|
|
6922
|
-
|
|
6923
|
-
|
|
6924
|
-
|
|
6925
|
-
|
|
6926
|
-
|
|
6927
|
-
const left = expected.next();
|
|
6928
|
-
const right = actual.next();
|
|
6929
|
-
if (left.done || right.done) return left.done === right.done;
|
|
6930
|
-
if (left.value !== right.value) return false;
|
|
6931
|
-
}
|
|
7482
|
+
var JSON_STRING = 1;
|
|
7483
|
+
var JSON_NUMBER = 2;
|
|
7484
|
+
var JSON_LITERAL = 3;
|
|
7485
|
+
var JSON_NUMBER_TOKEN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
7486
|
+
function isHexCode(c) {
|
|
7487
|
+
return c >= 48 && c <= 57 || c >= 65 && c <= 70 || c >= 97 && c <= 102;
|
|
6932
7488
|
}
|
|
6933
|
-
|
|
6934
|
-
|
|
6935
|
-
|
|
6936
|
-
|
|
6937
|
-
|
|
6938
|
-
|
|
7489
|
+
var PLAIN_STRING_SEGMENT = /^[^"\\\u0000-\u001f]*$/;
|
|
7490
|
+
function lexJson(text) {
|
|
7491
|
+
let kinds = new Int32Array(1024);
|
|
7492
|
+
let spans = new Int32Array(2048);
|
|
7493
|
+
let count = 0;
|
|
7494
|
+
const n = text.length;
|
|
7495
|
+
let i = 0;
|
|
7496
|
+
while (i < n) {
|
|
7497
|
+
const c = text.charCodeAt(i);
|
|
7498
|
+
if (c === 32 || c === 9 || c === 10 || c === 13) {
|
|
7499
|
+
i++;
|
|
6939
7500
|
continue;
|
|
6940
7501
|
}
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
|
|
7502
|
+
const start = i;
|
|
7503
|
+
let kind;
|
|
7504
|
+
if (c === 34) {
|
|
7505
|
+
i++;
|
|
7506
|
+
const close = text.indexOf('"', i);
|
|
7507
|
+
if (close === -1) return null;
|
|
7508
|
+
if (PLAIN_STRING_SEGMENT.test(text.slice(i, close))) {
|
|
7509
|
+
i = close;
|
|
7510
|
+
} else {
|
|
7511
|
+
for (; ; ) {
|
|
7512
|
+
if (i >= n) return null;
|
|
7513
|
+
const d = text.charCodeAt(i);
|
|
7514
|
+
if (d === 34) break;
|
|
7515
|
+
if (d < 32) return null;
|
|
7516
|
+
if (d !== 92) {
|
|
7517
|
+
i++;
|
|
7518
|
+
continue;
|
|
7519
|
+
}
|
|
7520
|
+
const e = text.charCodeAt(i + 1);
|
|
7521
|
+
if (e === 117) {
|
|
7522
|
+
for (let k = 2; k < 6; k++) if (!isHexCode(text.charCodeAt(i + k))) return null;
|
|
7523
|
+
i += 6;
|
|
7524
|
+
} else if (e === 34 || e === 92 || e === 47 || e === 98 || e === 102 || e === 110 || e === 114 || e === 116) {
|
|
7525
|
+
i += 2;
|
|
7526
|
+
} else {
|
|
7527
|
+
return null;
|
|
7528
|
+
}
|
|
7529
|
+
}
|
|
7530
|
+
}
|
|
7531
|
+
i++;
|
|
7532
|
+
kind = JSON_STRING;
|
|
7533
|
+
} else if (c === 123 || c === 125 || c === 91 || c === 93 || c === 58 || c === 44) {
|
|
7534
|
+
i++;
|
|
7535
|
+
kind = c;
|
|
7536
|
+
} else if (text.startsWith("true", i) || text.startsWith("null", i)) {
|
|
7537
|
+
i += 4;
|
|
7538
|
+
kind = JSON_LITERAL;
|
|
7539
|
+
} else if (text.startsWith("false", i)) {
|
|
7540
|
+
i += 5;
|
|
7541
|
+
kind = JSON_LITERAL;
|
|
7542
|
+
} else {
|
|
7543
|
+
JSON_NUMBER_TOKEN.lastIndex = i;
|
|
7544
|
+
if (!JSON_NUMBER_TOKEN.test(text)) return null;
|
|
7545
|
+
i = JSON_NUMBER_TOKEN.lastIndex;
|
|
7546
|
+
kind = JSON_NUMBER;
|
|
7547
|
+
}
|
|
7548
|
+
if (count === kinds.length) {
|
|
7549
|
+
const grownKinds = new Int32Array(count * 2);
|
|
7550
|
+
grownKinds.set(kinds);
|
|
7551
|
+
kinds = grownKinds;
|
|
7552
|
+
const grownSpans = new Int32Array(count * 4);
|
|
7553
|
+
grownSpans.set(spans);
|
|
7554
|
+
spans = grownSpans;
|
|
7555
|
+
}
|
|
7556
|
+
kinds[count] = kind;
|
|
7557
|
+
spans[2 * count] = start;
|
|
7558
|
+
spans[2 * count + 1] = i;
|
|
7559
|
+
count++;
|
|
7560
|
+
}
|
|
7561
|
+
return { kinds, spans, count };
|
|
7562
|
+
}
|
|
7563
|
+
function sameJsonStructure(beforeText, before, afterText, after) {
|
|
7564
|
+
if (!before || !after || before.count !== after.count) return false;
|
|
7565
|
+
const { kinds, spans, count } = before;
|
|
7566
|
+
for (let k = 0; k < count; k++) {
|
|
7567
|
+
const kind = kinds[k];
|
|
7568
|
+
if (after.kinds[k] !== kind) return false;
|
|
7569
|
+
if (kind > JSON_LITERAL) continue;
|
|
7570
|
+
if (kind === JSON_STRING && kinds[k + 1] !== 58) continue;
|
|
7571
|
+
const was = beforeText.slice(spans[2 * k], spans[2 * k + 1]);
|
|
7572
|
+
const now = afterText.slice(after.spans[2 * k], after.spans[2 * k + 1]);
|
|
7573
|
+
if (was === now) continue;
|
|
7574
|
+
if (kind !== JSON_STRING || JSON.parse(was) !== JSON.parse(now)) return false;
|
|
6944
7575
|
}
|
|
7576
|
+
return true;
|
|
6945
7577
|
}
|
|
6946
7578
|
function applyRedactors(body, redactors, mask, transform) {
|
|
6947
7579
|
let out = body;
|
|
@@ -6958,6 +7590,11 @@ function applyRedactors(body, redactors, mask, transform) {
|
|
|
6958
7590
|
}
|
|
6959
7591
|
return out;
|
|
6960
7592
|
}
|
|
7593
|
+
function readsBody(parameter) {
|
|
7594
|
+
if (!Array.isArray(parameter)) return isBodyParam(parameter);
|
|
7595
|
+
const names = parameter.every((member) => typeof member === "string" && member !== "");
|
|
7596
|
+
return names && parameterProblem(parameter) === null && parameter.some((member) => isBodyParam(member));
|
|
7597
|
+
}
|
|
6961
7598
|
function isBodyParam(parameter) {
|
|
6962
7599
|
return parameter == null || parameter === "response.body" || parameter === "raw" || parameter === "response.raw";
|
|
6963
7600
|
}
|
|
@@ -6989,7 +7626,7 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
|
|
|
6989
7626
|
let obj;
|
|
6990
7627
|
try {
|
|
6991
7628
|
JSON.parse(text);
|
|
6992
|
-
preserved =
|
|
7629
|
+
preserved = preserveNumbers(text, mask);
|
|
6993
7630
|
obj = JSON.parse(preserved.text);
|
|
6994
7631
|
} catch {
|
|
6995
7632
|
return text;
|
|
@@ -7008,22 +7645,22 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
|
|
|
7008
7645
|
}
|
|
7009
7646
|
});
|
|
7010
7647
|
}
|
|
7011
|
-
return changed ?
|
|
7648
|
+
return changed ? restoreNumbers(JSON.stringify(obj), preserved.numbers) : text;
|
|
7012
7649
|
}
|
|
7013
|
-
function
|
|
7650
|
+
function preserveNumbers(text, mask) {
|
|
7014
7651
|
const occupied = /* @__PURE__ */ new Set([mask]);
|
|
7015
|
-
const
|
|
7652
|
+
const found = [];
|
|
7016
7653
|
const tokens = /"(?:[^"\\]|\\[\s\S])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
|
|
7017
7654
|
for (const match of text.matchAll(tokens)) {
|
|
7018
7655
|
const token = match[0];
|
|
7019
7656
|
if (token[0] === '"') occupied.add(JSON.parse(token));
|
|
7020
|
-
else
|
|
7657
|
+
else found.push({ start: match.index, token });
|
|
7021
7658
|
}
|
|
7022
7659
|
const numbers = /* @__PURE__ */ new Map();
|
|
7023
7660
|
const chunks = [];
|
|
7024
7661
|
let offset = 0;
|
|
7025
7662
|
let index = 0;
|
|
7026
|
-
for (const { start, token } of
|
|
7663
|
+
for (const { start, token } of found) {
|
|
7027
7664
|
let marker;
|
|
7028
7665
|
do {
|
|
7029
7666
|
marker = "__PSNUMBER_" + index++ + "__";
|
|
@@ -7035,7 +7672,7 @@ function preserveBigInts(text, mask) {
|
|
|
7035
7672
|
chunks.push(text.slice(offset));
|
|
7036
7673
|
return { text: chunks.join(""), numbers };
|
|
7037
7674
|
}
|
|
7038
|
-
function
|
|
7675
|
+
function restoreNumbers(text, numbers) {
|
|
7039
7676
|
return text.replace(/"(__PSNUMBER_\d+__)"/g, (token, marker) => numbers.get(marker) ?? token);
|
|
7040
7677
|
}
|
|
7041
7678
|
function isHeaderMutation(action) {
|