@patchstack/connect 0.3.23 → 0.3.25
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 +16 -7
- package/README.md +22 -2
- package/dist/chunk-MJOTFUDE.js +87 -0
- package/dist/chunk-MJOTFUDE.js.map +1 -0
- package/dist/cli.js +2857 -132
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +93 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.js +92 -1
- package/dist/index.js.map +1 -1
- package/dist/protect.cjs +941 -221
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +7 -0
- package/dist/protect.edge.js +3530 -0
- package/dist/protect.edge.js.map +7 -0
- package/dist/protect.js +826 -195
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-ZIFRZF25.js → refresh-manifest-MUWVIPFR.js} +9 -2
- package/dist/refresh-manifest-MUWVIPFR.js.map +1 -0
- package/package.json +11 -3
- package/dist/refresh-manifest-ZIFRZF25.js.map +0 -1
package/dist/protect.js
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pulseAuthHeader
|
|
3
|
+
} from "./chunk-MJOTFUDE.js";
|
|
4
|
+
|
|
5
|
+
// src/protect/safe-origin.js
|
|
6
|
+
function isSafeOrigin(value) {
|
|
7
|
+
try {
|
|
8
|
+
const u = new URL(value);
|
|
9
|
+
if (u.protocol === "https:") return true;
|
|
10
|
+
return u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "::1");
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function safeBaseUrl(candidate, fallback, label) {
|
|
16
|
+
if (typeof candidate !== "string" || candidate === "") return fallback;
|
|
17
|
+
if (isSafeOrigin(candidate)) return candidate;
|
|
18
|
+
warnOnce(label, `[patchstack] ignoring unsafe ${label} override (${candidate}): must be https (or localhost). Using the default.`);
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
var warned = /* @__PURE__ */ new Set();
|
|
22
|
+
function warnOnce(key, message) {
|
|
23
|
+
if (warned.has(key)) return;
|
|
24
|
+
warned.add(key);
|
|
25
|
+
console.warn(message);
|
|
26
|
+
}
|
|
27
|
+
|
|
1
28
|
// src/protect/engine/client.js
|
|
2
29
|
var DEFAULT_BASE_URL = "https://api.patchstack.com";
|
|
3
30
|
var DEFAULT_CACHE_TTL = 3e5;
|
|
@@ -10,9 +37,11 @@ var PatchstackRuleClient = class {
|
|
|
10
37
|
#cacheTime = null;
|
|
11
38
|
#ttlEffective = 0;
|
|
12
39
|
#etag;
|
|
13
|
-
|
|
40
|
+
#timeoutMs;
|
|
41
|
+
constructor({ token, baseUrl, cacheTtl, etag, timeoutMs } = {}) {
|
|
42
|
+
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
14
43
|
this.#token = token ?? process.env.PATCHSTACK_WAF_TOKEN;
|
|
15
|
-
this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_WAF_API_URL
|
|
44
|
+
this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_WAF_API_URL, DEFAULT_BASE_URL, "rule endpoint");
|
|
16
45
|
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL;
|
|
17
46
|
this.#etag = etag ?? null;
|
|
18
47
|
if (!this.#token) {
|
|
@@ -37,7 +66,7 @@ var PatchstackRuleClient = class {
|
|
|
37
66
|
method: "POST",
|
|
38
67
|
headers,
|
|
39
68
|
body: JSON.stringify({}),
|
|
40
|
-
signal: AbortSignal.timeout(
|
|
69
|
+
signal: AbortSignal.timeout(this.#timeoutMs)
|
|
41
70
|
});
|
|
42
71
|
if (response.status === 304) {
|
|
43
72
|
this.#touch(now);
|
|
@@ -90,6 +119,9 @@ var PatchstackRuleClient = class {
|
|
|
90
119
|
};
|
|
91
120
|
|
|
92
121
|
// src/protect/engine/request.js
|
|
122
|
+
var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
|
|
123
|
+
var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
|
|
124
|
+
var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
|
|
93
125
|
function base64DecodeUtf8(value) {
|
|
94
126
|
const str = String(value);
|
|
95
127
|
if (typeof Buffer !== "undefined") {
|
|
@@ -293,7 +325,9 @@ var RequestResolver = class {
|
|
|
293
325
|
default: {
|
|
294
326
|
if (key.startsWith("HTTP_")) {
|
|
295
327
|
const headerName = key.substring(5).toLowerCase().replace(/_/g, "-");
|
|
296
|
-
|
|
328
|
+
const headers = req.headers;
|
|
329
|
+
if (headers === null || typeof headers !== "object") return [];
|
|
330
|
+
return Object.prototype.hasOwnProperty.call(headers, headerName) ? [headers[headerName]] : [];
|
|
297
331
|
}
|
|
298
332
|
return [];
|
|
299
333
|
}
|
|
@@ -301,14 +335,32 @@ var RequestResolver = class {
|
|
|
301
335
|
}
|
|
302
336
|
#resolveFiles(key) {
|
|
303
337
|
const files = this.#req.files;
|
|
304
|
-
if (!files) {
|
|
338
|
+
if (!files || typeof files !== "object") {
|
|
305
339
|
return [];
|
|
306
340
|
}
|
|
341
|
+
const dot = key.lastIndexOf(".");
|
|
342
|
+
if (dot !== -1 && FILE_ATTRS.has(key.slice(dot + 1)) && Object.prototype.hasOwnProperty.call(files, key.slice(0, dot))) {
|
|
343
|
+
const attr = key.slice(dot + 1);
|
|
344
|
+
const entry = files[key.slice(0, dot)];
|
|
345
|
+
const list = Array.isArray(entry) ? entry : [entry];
|
|
346
|
+
const out = [];
|
|
347
|
+
for (const f of list) {
|
|
348
|
+
const v = fileAttribute(f, attr);
|
|
349
|
+
if (v !== void 0 && v !== "") out.push(v);
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
const filenamesOf = (entry) => Array.isArray(entry) ? entry.map(fileFilename) : [fileFilename(entry)];
|
|
307
354
|
if (key.endsWith("*")) {
|
|
308
|
-
|
|
355
|
+
const prefix = key.slice(0, -1);
|
|
356
|
+
const out = [];
|
|
357
|
+
for (const [k, entry] of Object.entries(files)) {
|
|
358
|
+
if (k.startsWith(prefix)) out.push(...filenamesOf(entry));
|
|
359
|
+
}
|
|
360
|
+
return out.filter((v) => v !== void 0);
|
|
309
361
|
}
|
|
310
|
-
|
|
311
|
-
return
|
|
362
|
+
if (!Object.prototype.hasOwnProperty.call(files, key)) return [];
|
|
363
|
+
return filenamesOf(files[key]).filter((v) => v !== void 0);
|
|
312
364
|
}
|
|
313
365
|
#resolveRaw() {
|
|
314
366
|
if (typeof this.#req._rawBody === "string") {
|
|
@@ -339,6 +391,9 @@ var RequestResolver = class {
|
|
|
339
391
|
if (body) {
|
|
340
392
|
parts.push(typeof body === "string" ? body : JSON.stringify(body));
|
|
341
393
|
}
|
|
394
|
+
if (typeof this.#req._rawBody === "string" && this.#req._rawBody) {
|
|
395
|
+
parts.push(this.#req._rawBody);
|
|
396
|
+
}
|
|
342
397
|
const headers = this.#req.headers ?? {};
|
|
343
398
|
const excludedHeaders = /* @__PURE__ */ new Set([
|
|
344
399
|
"host",
|
|
@@ -531,8 +586,6 @@ function removeSqlComments(value) {
|
|
|
531
586
|
let result = value;
|
|
532
587
|
result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
533
588
|
result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
|
|
534
|
-
result = result.replace(/--[^\r\n]*/g, "");
|
|
535
|
-
result = result.replace(/#[^\r\n]*/g, "");
|
|
536
589
|
return result;
|
|
537
590
|
}
|
|
538
591
|
function removeNullBytes(value) {
|
|
@@ -587,17 +640,21 @@ function normalizeRequest(req, options = {}) {
|
|
|
587
640
|
_rawBody: rawBody
|
|
588
641
|
};
|
|
589
642
|
}
|
|
590
|
-
|
|
643
|
+
var MAX_NORMALIZE_DEPTH = 200;
|
|
644
|
+
function normalizeObject(value, options = {}, depth = 0) {
|
|
591
645
|
if (typeof value === "string") {
|
|
592
646
|
return normalize(value, options);
|
|
593
647
|
}
|
|
648
|
+
if (depth >= MAX_NORMALIZE_DEPTH) {
|
|
649
|
+
return value;
|
|
650
|
+
}
|
|
594
651
|
if (Array.isArray(value)) {
|
|
595
|
-
return value.map((item) => normalizeObject(item, options));
|
|
652
|
+
return value.map((item) => normalizeObject(item, options, depth + 1));
|
|
596
653
|
}
|
|
597
654
|
if (typeof value === "object" && value !== null) {
|
|
598
655
|
const result = {};
|
|
599
656
|
for (const [key, val] of Object.entries(value)) {
|
|
600
|
-
result[key] = normalizeObject(val, options);
|
|
657
|
+
result[key] = normalizeObject(val, options, depth + 1);
|
|
601
658
|
}
|
|
602
659
|
return result;
|
|
603
660
|
}
|
|
@@ -605,14 +662,32 @@ function normalizeObject(value, options = {}) {
|
|
|
605
662
|
}
|
|
606
663
|
|
|
607
664
|
// src/protect/engine/engine.js
|
|
665
|
+
var GRP = "(?:[^()]|\\([^()]*\\))*";
|
|
608
666
|
var REDOS_PATTERNS = [
|
|
609
667
|
/\([^)]*[+*}][^)]*\)\s*[+*]/,
|
|
610
|
-
/\([^)]*\|[^)]*\)\s*[+*]
|
|
668
|
+
/\([^)]*\|[^)]*\)\s*[+*]/,
|
|
669
|
+
new RegExp("\\(" + GRP + "[+*}]" + GRP + "\\)\\s*[+*]"),
|
|
670
|
+
// nested quantified subgroup
|
|
671
|
+
new RegExp("\\(" + GRP + "\\|" + GRP + "\\)\\s*[+*]")
|
|
672
|
+
// nested alternation under a quantifier
|
|
611
673
|
];
|
|
674
|
+
var warnedRejectedPatterns = /* @__PURE__ */ new Set();
|
|
675
|
+
function warnRejectedPatternOnce(pattern) {
|
|
676
|
+
const key = String(pattern);
|
|
677
|
+
if (warnedRejectedPatterns.has(key)) return;
|
|
678
|
+
warnedRejectedPatterns.add(key);
|
|
679
|
+
console.warn(
|
|
680
|
+
`[patchstack] rule_v2 regex pattern rejected (unsafe or invalid) \u2014 condition treated as no-match. The rule relying on it is NOT enforced: ${key}`
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
var MAX_PATTERN_LENGTH = 1e3;
|
|
612
684
|
function safeRegExp(pattern) {
|
|
613
685
|
if (!pattern) {
|
|
614
686
|
return null;
|
|
615
687
|
}
|
|
688
|
+
if (typeof pattern !== "string" || pattern.length > MAX_PATTERN_LENGTH) {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
616
691
|
for (const dangerous of REDOS_PATTERNS) {
|
|
617
692
|
if (dangerous.test(pattern)) {
|
|
618
693
|
return null;
|
|
@@ -669,6 +744,34 @@ function arrayKeyValue(value, matchObj) {
|
|
|
669
744
|
}
|
|
670
745
|
return false;
|
|
671
746
|
}
|
|
747
|
+
var WHOLE_VALUE_MATCH_TYPES = /* @__PURE__ */ new Set(["isset", "array_in_array", "array_key_value"]);
|
|
748
|
+
function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
|
|
749
|
+
const out = [];
|
|
750
|
+
const stack = [[root, 0]];
|
|
751
|
+
let visited = 0;
|
|
752
|
+
while (stack.length) {
|
|
753
|
+
const [node, depth] = stack.pop();
|
|
754
|
+
if (node === null || node === void 0) continue;
|
|
755
|
+
if (typeof node !== "object") {
|
|
756
|
+
out.push(node);
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (depth >= maxDepth || visited >= nodeCap) continue;
|
|
760
|
+
visited++;
|
|
761
|
+
if (Array.isArray(node)) {
|
|
762
|
+
for (let i = node.length - 1; i >= 0; i--) stack.push([node[i], depth + 1]);
|
|
763
|
+
} else {
|
|
764
|
+
for (const k of Object.keys(node)) stack.push([node[k], depth + 1]);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return out;
|
|
768
|
+
}
|
|
769
|
+
var warnedKeys = /* @__PURE__ */ new Set();
|
|
770
|
+
function warnOnce2(key, message) {
|
|
771
|
+
if (warnedKeys.has(key)) return;
|
|
772
|
+
warnedKeys.add(key);
|
|
773
|
+
console.warn(message);
|
|
774
|
+
}
|
|
672
775
|
var warnedMatchTypes = /* @__PURE__ */ new Set();
|
|
673
776
|
function warnUnsupportedMatchType(type) {
|
|
674
777
|
if (type == null || warnedMatchTypes.has(type)) {
|
|
@@ -681,28 +784,93 @@ function warnUnsupportedMatchType(type) {
|
|
|
681
784
|
}
|
|
682
785
|
function isInternalHost(hostname) {
|
|
683
786
|
if (!hostname) return false;
|
|
684
|
-
|
|
787
|
+
let host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
|
|
788
|
+
host = host.replace(/%[^\]]*$/, "");
|
|
789
|
+
host = host.replace(/\.$/, "");
|
|
685
790
|
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
|
|
686
791
|
if (host === "metadata.google.internal") return true;
|
|
687
|
-
if (host
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
792
|
+
if (host.includes(":")) {
|
|
793
|
+
const g = expandIPv6(host);
|
|
794
|
+
if (!g) return false;
|
|
795
|
+
const allZeroHi = g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
|
|
796
|
+
if (allZeroHi && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
|
|
797
|
+
if ((g[0] & 65472) === 65152) return true;
|
|
798
|
+
if ((g[0] & 65024) === 64512) return true;
|
|
799
|
+
if (allZeroHi && (g[5] === 65535 || g[5] === 0)) {
|
|
800
|
+
return isPrivateV4Int((g[6] << 16 >>> 0 | g[7]) >>> 0);
|
|
801
|
+
}
|
|
802
|
+
return false;
|
|
696
803
|
}
|
|
804
|
+
const v4 = parseIPv4ToInt(host);
|
|
805
|
+
if (v4 !== null) return isPrivateV4Int(v4);
|
|
697
806
|
return false;
|
|
698
807
|
}
|
|
699
|
-
function
|
|
808
|
+
function isPrivateV4Int(n) {
|
|
809
|
+
const a = n >>> 24 & 255;
|
|
810
|
+
const b = n >>> 16 & 255;
|
|
700
811
|
if (a === 127 || a === 10 || a === 0) return true;
|
|
701
812
|
if (a === 169 && b === 254) return true;
|
|
702
813
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
703
814
|
if (a === 192 && b === 168) return true;
|
|
815
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
704
816
|
return false;
|
|
705
817
|
}
|
|
818
|
+
function parseIPv4ToInt(host) {
|
|
819
|
+
if (!/^[0-9a-fx]+(\.[0-9a-fx]+)*$/i.test(host)) return null;
|
|
820
|
+
const parts = host.split(".");
|
|
821
|
+
if (parts.length > 4) return null;
|
|
822
|
+
const nums = [];
|
|
823
|
+
for (const p of parts) {
|
|
824
|
+
let n;
|
|
825
|
+
if (/^0x[0-9a-f]+$/i.test(p)) n = parseInt(p, 16);
|
|
826
|
+
else if (/^0[0-7]+$/.test(p)) n = parseInt(p, 8);
|
|
827
|
+
else if (/^[0-9]+$/.test(p)) n = parseInt(p, 10);
|
|
828
|
+
else return null;
|
|
829
|
+
if (!Number.isInteger(n) || n < 0) return null;
|
|
830
|
+
nums.push(n);
|
|
831
|
+
}
|
|
832
|
+
const last = nums.length - 1;
|
|
833
|
+
let value = 0;
|
|
834
|
+
for (let i = 0; i < last; i++) {
|
|
835
|
+
if (nums[i] > 255) return null;
|
|
836
|
+
value += nums[i] * 2 ** (8 * (3 - i));
|
|
837
|
+
}
|
|
838
|
+
const maxLast = 2 ** (8 * (5 - nums.length)) - 1;
|
|
839
|
+
if (nums[last] > maxLast) return null;
|
|
840
|
+
value += nums[last];
|
|
841
|
+
if (value < 0 || value > 4294967295) return null;
|
|
842
|
+
return value >>> 0;
|
|
843
|
+
}
|
|
844
|
+
function expandIPv6(host) {
|
|
845
|
+
if (!host.includes(":")) return null;
|
|
846
|
+
let s = host;
|
|
847
|
+
const lastColon = s.lastIndexOf(":");
|
|
848
|
+
const tail = s.slice(lastColon + 1);
|
|
849
|
+
if (tail.includes(".")) {
|
|
850
|
+
const v4 = parseIPv4ToInt(tail);
|
|
851
|
+
if (v4 === null) return null;
|
|
852
|
+
s = s.slice(0, lastColon + 1) + (v4 >>> 16 & 65535).toString(16) + ":" + (v4 & 65535).toString(16);
|
|
853
|
+
}
|
|
854
|
+
const halves = s.split("::");
|
|
855
|
+
if (halves.length > 2) return null;
|
|
856
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
857
|
+
const back = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : null;
|
|
858
|
+
let groups;
|
|
859
|
+
if (back === null) {
|
|
860
|
+
groups = head;
|
|
861
|
+
} else {
|
|
862
|
+
const fill = 8 - head.length - back.length;
|
|
863
|
+
if (fill < 0) return null;
|
|
864
|
+
groups = [...head, ...Array(fill).fill("0"), ...back];
|
|
865
|
+
}
|
|
866
|
+
if (groups.length !== 8) return null;
|
|
867
|
+
const out = [];
|
|
868
|
+
for (const g of groups) {
|
|
869
|
+
if (!/^[0-9a-f]{1,4}$/i.test(g)) return null;
|
|
870
|
+
out.push(parseInt(g, 16));
|
|
871
|
+
}
|
|
872
|
+
return out;
|
|
873
|
+
}
|
|
706
874
|
function ruleAppliesTo(when, resolver) {
|
|
707
875
|
try {
|
|
708
876
|
if (when.method) {
|
|
@@ -730,14 +898,22 @@ function pathMatches(pattern, path) {
|
|
|
730
898
|
}
|
|
731
899
|
return path === pattern;
|
|
732
900
|
}
|
|
901
|
+
function normalizeDefaultPort(host) {
|
|
902
|
+
return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
|
|
903
|
+
}
|
|
733
904
|
function isCrossOrigin(resolver) {
|
|
734
905
|
try {
|
|
735
|
-
const host = String(resolver.resolve("server.HTTP_HOST")[0] ?? "")
|
|
906
|
+
const host = normalizeDefaultPort(String(resolver.resolve("server.HTTP_HOST")[0] ?? ""));
|
|
736
907
|
if (!host) return false;
|
|
737
|
-
const
|
|
738
|
-
|
|
739
|
-
const
|
|
740
|
-
|
|
908
|
+
const originRaw = resolver.resolve("server.HTTP_ORIGIN")[0];
|
|
909
|
+
const hasOrigin = originRaw !== void 0 && originRaw !== null;
|
|
910
|
+
const src = hasOrigin ? originRaw : resolver.resolve("server.HTTP_REFERER")[0];
|
|
911
|
+
if (src === void 0 || src === null) return false;
|
|
912
|
+
const s = String(src).trim();
|
|
913
|
+
if (hasOrigin && (s === "" || s.toLowerCase() === "null")) return true;
|
|
914
|
+
const srcHost = hostFromUrl(s);
|
|
915
|
+
if (srcHost === null) return hasOrigin;
|
|
916
|
+
return normalizeDefaultPort(srcHost) !== host;
|
|
741
917
|
} catch {
|
|
742
918
|
return false;
|
|
743
919
|
}
|
|
@@ -749,6 +925,41 @@ function hostFromUrl(value) {
|
|
|
749
925
|
return null;
|
|
750
926
|
}
|
|
751
927
|
}
|
|
928
|
+
function isOffOriginRedirect(resolver) {
|
|
929
|
+
try {
|
|
930
|
+
const status = Number(resolver.resolve("response.status")[0] ?? 0);
|
|
931
|
+
if (status < 300 || status >= 400) return false;
|
|
932
|
+
const location = resolver.resolve("response.header.location")[0];
|
|
933
|
+
if (!location) return false;
|
|
934
|
+
const host = String(resolver.resolve("server.HTTP_HOST")[0] ?? "").toLowerCase();
|
|
935
|
+
if (!host) return false;
|
|
936
|
+
const loc = String(location).replace(/[\t\r\n]/g, "").replace(/\\/g, "/");
|
|
937
|
+
let target;
|
|
938
|
+
try {
|
|
939
|
+
target = new URL(loc, "http://" + host).host.toLowerCase();
|
|
940
|
+
} catch {
|
|
941
|
+
return false;
|
|
942
|
+
}
|
|
943
|
+
return normalizeDefaultPort(target) !== normalizeDefaultPort(host);
|
|
944
|
+
} catch {
|
|
945
|
+
return false;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
function isReflectedCorsWithCredentials(resolver) {
|
|
949
|
+
try {
|
|
950
|
+
const acac = String(resolver.resolve("response.header.access-control-allow-credentials")[0] ?? "").toLowerCase();
|
|
951
|
+
if (acac !== "true") return false;
|
|
952
|
+
const acao = String(resolver.resolve("response.header.access-control-allow-origin")[0] ?? "");
|
|
953
|
+
if (!acao) return false;
|
|
954
|
+
if (acao === "*") return true;
|
|
955
|
+
if (acao.toLowerCase() === "null") return true;
|
|
956
|
+
const origin = String(resolver.resolve("server.HTTP_ORIGIN")[0] ?? "");
|
|
957
|
+
if (!origin) return false;
|
|
958
|
+
return acao === origin;
|
|
959
|
+
} catch {
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
752
963
|
function matchValue(type, value, matchVal, matchObj) {
|
|
753
964
|
if (value === null || value === void 0) {
|
|
754
965
|
if (type === "isset") {
|
|
@@ -756,7 +967,16 @@ function matchValue(type, value, matchVal, matchObj) {
|
|
|
756
967
|
}
|
|
757
968
|
return false;
|
|
758
969
|
}
|
|
759
|
-
|
|
970
|
+
let strValue;
|
|
971
|
+
if (typeof value === "string") {
|
|
972
|
+
strValue = value;
|
|
973
|
+
} else {
|
|
974
|
+
try {
|
|
975
|
+
strValue = String(value);
|
|
976
|
+
} catch {
|
|
977
|
+
strValue = "";
|
|
978
|
+
}
|
|
979
|
+
}
|
|
760
980
|
switch (type) {
|
|
761
981
|
case "equals":
|
|
762
982
|
return strValue == matchVal;
|
|
@@ -777,6 +997,7 @@ function matchValue(type, value, matchVal, matchObj) {
|
|
|
777
997
|
case "regex": {
|
|
778
998
|
const regex = safeRegExp(matchVal);
|
|
779
999
|
if (!regex) {
|
|
1000
|
+
warnRejectedPatternOnce(matchVal);
|
|
780
1001
|
return false;
|
|
781
1002
|
}
|
|
782
1003
|
return regex.test(strValue);
|
|
@@ -855,6 +1076,15 @@ var RuleEngine = class {
|
|
|
855
1076
|
this.#whitelists = whitelists;
|
|
856
1077
|
this.#whitelistKeys = whitelist_keys;
|
|
857
1078
|
this.#onError = onError;
|
|
1079
|
+
if (Array.isArray(whitelists) && whitelists.some((w) => w && Array.isArray(w.rule_v2) && !w.rule_id)) {
|
|
1080
|
+
warnOnce2(
|
|
1081
|
+
"whitelist-no-rule-id",
|
|
1082
|
+
"[patchstack] a whitelist has no `rule_id` \u2014 it suppresses ALL rules when it matches. Scope each whitelist to a specific rule_id, and key it only on values an attacker cannot set."
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
if (whitelist_keys && typeof whitelist_keys === "object" && Object.keys(whitelist_keys).length > 0) {
|
|
1086
|
+
warnOnce2("whitelist-keys-unimplemented", "[patchstack] `whitelist_keys` is not implemented and has no effect.");
|
|
1087
|
+
}
|
|
858
1088
|
}
|
|
859
1089
|
// A mitigation engine must never take down the app it protects: any error while
|
|
860
1090
|
// evaluating a rule is reported and the request is allowed through (fail open). A
|
|
@@ -935,6 +1165,12 @@ var RuleEngine = class {
|
|
|
935
1165
|
if (match && match.type === "cross_origin") {
|
|
936
1166
|
return isCrossOrigin(resolver);
|
|
937
1167
|
}
|
|
1168
|
+
if (match && match.type === "off_origin") {
|
|
1169
|
+
return isOffOriginRedirect(resolver);
|
|
1170
|
+
}
|
|
1171
|
+
if (match && match.type === "cors_reflected") {
|
|
1172
|
+
return isReflectedCorsWithCredentials(resolver);
|
|
1173
|
+
}
|
|
938
1174
|
const params = Array.isArray(parameter) ? parameter : [parameter];
|
|
939
1175
|
const values = params.flatMap((p) => resolver.resolve(p));
|
|
940
1176
|
if (values.length === 0) {
|
|
@@ -950,10 +1186,12 @@ var RuleEngine = class {
|
|
|
950
1186
|
}
|
|
951
1187
|
continue;
|
|
952
1188
|
}
|
|
953
|
-
if (typeof value === "object" && value !== null
|
|
954
|
-
|
|
955
|
-
if (matchValue(match.type,
|
|
956
|
-
|
|
1189
|
+
if (typeof value === "object" && value !== null) {
|
|
1190
|
+
if (WHOLE_VALUE_MATCH_TYPES.has(match.type)) {
|
|
1191
|
+
if (matchValue(match.type, value, match.value, match)) return true;
|
|
1192
|
+
} else {
|
|
1193
|
+
for (const leaf of collectLeafValues(value)) {
|
|
1194
|
+
if (matchValue(match.type, leaf, match.value, match)) return true;
|
|
957
1195
|
}
|
|
958
1196
|
}
|
|
959
1197
|
continue;
|
|
@@ -990,17 +1228,21 @@ var DEFAULT_CACHE_TTL2 = 3e5;
|
|
|
990
1228
|
var JITTER_FRACTION2 = 0.1;
|
|
991
1229
|
var PulseRuleClient = class {
|
|
992
1230
|
#siteUuid;
|
|
1231
|
+
#timeoutMs;
|
|
993
1232
|
#baseUrl;
|
|
994
1233
|
#cacheTtl;
|
|
995
1234
|
#cache = null;
|
|
996
1235
|
#cacheTime = null;
|
|
997
1236
|
#ttlEffective = 0;
|
|
998
1237
|
#etag;
|
|
999
|
-
|
|
1238
|
+
#pulseAuth;
|
|
1239
|
+
constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth } = {}) {
|
|
1240
|
+
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
1000
1241
|
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
|
|
1001
|
-
this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL
|
|
1242
|
+
this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
|
|
1002
1243
|
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
|
|
1003
1244
|
this.#etag = etag ?? null;
|
|
1245
|
+
this.#pulseAuth = pulseAuth ?? null;
|
|
1004
1246
|
if (!this.#siteUuid) {
|
|
1005
1247
|
throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
|
|
1006
1248
|
}
|
|
@@ -1012,9 +1254,15 @@ var PulseRuleClient = class {
|
|
|
1012
1254
|
}
|
|
1013
1255
|
const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
|
|
1014
1256
|
try {
|
|
1015
|
-
const headers = {
|
|
1257
|
+
const headers = {
|
|
1258
|
+
Accept: "application/json",
|
|
1259
|
+
...await pulseAuthHeader(
|
|
1260
|
+
{ pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
|
|
1261
|
+
fetch
|
|
1262
|
+
)
|
|
1263
|
+
};
|
|
1016
1264
|
if (this.#etag) headers["If-None-Match"] = this.#etag;
|
|
1017
|
-
const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(
|
|
1265
|
+
const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
|
|
1018
1266
|
if (response.status === 304) {
|
|
1019
1267
|
this.#touch(now);
|
|
1020
1268
|
return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
|
|
@@ -1091,25 +1339,9 @@ async function fromFetchRequest(request, options = {}) {
|
|
|
1091
1339
|
let body = {};
|
|
1092
1340
|
let files;
|
|
1093
1341
|
if (rawBody) {
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
} catch {
|
|
1098
|
-
body = {};
|
|
1099
|
-
}
|
|
1100
|
-
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
1101
|
-
body = {};
|
|
1102
|
-
for (const [k, v] of new URLSearchParams(rawBody)) {
|
|
1103
|
-
body[k] = k in body ? [].concat(body[k], v) : v;
|
|
1104
|
-
}
|
|
1105
|
-
} else if (contentType.includes("multipart/form-data")) {
|
|
1106
|
-
const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
|
|
1107
|
-
if (boundary) {
|
|
1108
|
-
const parsed = parseMultipart(rawBody, boundary);
|
|
1109
|
-
body = parsed.body;
|
|
1110
|
-
files = parsed.files;
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1342
|
+
const parsed = parseBody(rawBody, contentType);
|
|
1343
|
+
body = parsed.body;
|
|
1344
|
+
files = parsed.files;
|
|
1113
1345
|
}
|
|
1114
1346
|
const uri = url.pathname + url.search;
|
|
1115
1347
|
const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
|
|
@@ -1129,9 +1361,6 @@ async function fromFetchRequest(request, options = {}) {
|
|
|
1129
1361
|
};
|
|
1130
1362
|
}
|
|
1131
1363
|
async function readCappedText(request, max) {
|
|
1132
|
-
const ceiling = Math.max(max, max * 4);
|
|
1133
|
-
const declared = Number(request.headers?.get?.("content-length") || 0);
|
|
1134
|
-
if (declared && declared > ceiling) return "";
|
|
1135
1364
|
let clone;
|
|
1136
1365
|
try {
|
|
1137
1366
|
clone = request.clone();
|
|
@@ -1177,6 +1406,31 @@ function concatChunks(chunks, total) {
|
|
|
1177
1406
|
}
|
|
1178
1407
|
return out;
|
|
1179
1408
|
}
|
|
1409
|
+
function parseBody(rawBody, contentType) {
|
|
1410
|
+
const ct = String(contentType || "").toLowerCase();
|
|
1411
|
+
const isJson = ct.includes("application/json") || /\+json\b/.test(ct);
|
|
1412
|
+
const isForm = ct.includes("application/x-www-form-urlencoded");
|
|
1413
|
+
const isMultipart = ct.includes("multipart/form-data");
|
|
1414
|
+
const isAmbiguous = ct === "" || ct.startsWith("text/plain") || ct.includes("csp-report") || ct.includes("/json");
|
|
1415
|
+
if (isMultipart) {
|
|
1416
|
+
const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
|
|
1417
|
+
if (boundary) return parseMultipart(rawBody, boundary);
|
|
1418
|
+
return { body: {}, files: void 0 };
|
|
1419
|
+
}
|
|
1420
|
+
if (isForm) {
|
|
1421
|
+
const body = {};
|
|
1422
|
+
for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
|
|
1423
|
+
return { body, files: void 0 };
|
|
1424
|
+
}
|
|
1425
|
+
if (isJson || isAmbiguous) {
|
|
1426
|
+
try {
|
|
1427
|
+
const parsed = JSON.parse(rawBody);
|
|
1428
|
+
if (parsed && typeof parsed === "object") return { body: parsed, files: void 0 };
|
|
1429
|
+
} catch {
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
return { body: {}, files: void 0 };
|
|
1433
|
+
}
|
|
1180
1434
|
function parseMultipart(rawBody, boundary) {
|
|
1181
1435
|
const body = {};
|
|
1182
1436
|
const files = {};
|
|
@@ -1191,7 +1445,9 @@ function parseMultipart(rawBody, boundary) {
|
|
|
1191
1445
|
const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
|
|
1192
1446
|
const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
|
|
1193
1447
|
if (filename !== void 0) {
|
|
1194
|
-
|
|
1448
|
+
const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
|
|
1449
|
+
const file = { filename, type: partType, content };
|
|
1450
|
+
files[name] = name in files ? [].concat(files[name], file) : file;
|
|
1195
1451
|
} else {
|
|
1196
1452
|
body[name] = name in body ? [].concat(body[name], content) : content;
|
|
1197
1453
|
}
|
|
@@ -1243,25 +1499,9 @@ function fromNodeRequest(req, rawBody = "") {
|
|
|
1243
1499
|
let body = {};
|
|
1244
1500
|
let files;
|
|
1245
1501
|
if (rawBody) {
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
} catch {
|
|
1250
|
-
body = {};
|
|
1251
|
-
}
|
|
1252
|
-
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
1253
|
-
body = {};
|
|
1254
|
-
for (const [k, v] of new URLSearchParams(rawBody)) {
|
|
1255
|
-
body[k] = k in body ? [].concat(body[k], v) : v;
|
|
1256
|
-
}
|
|
1257
|
-
} else if (contentType.includes("multipart/form-data")) {
|
|
1258
|
-
const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
|
|
1259
|
-
if (boundary) {
|
|
1260
|
-
const parsed = parseMultipart(rawBody, boundary);
|
|
1261
|
-
body = parsed.body;
|
|
1262
|
-
files = parsed.files;
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1502
|
+
const parsed = parseBody(rawBody, contentType);
|
|
1503
|
+
body = parsed.body;
|
|
1504
|
+
files = parsed.files;
|
|
1265
1505
|
}
|
|
1266
1506
|
const uri = url.pathname + url.search;
|
|
1267
1507
|
const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
|
|
@@ -1295,7 +1535,7 @@ function parseCookies2(header) {
|
|
|
1295
1535
|
}
|
|
1296
1536
|
|
|
1297
1537
|
// src/protect/egress.js
|
|
1298
|
-
async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, lookup, allowHosts } = {}) {
|
|
1538
|
+
async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
|
|
1299
1539
|
const restores = [];
|
|
1300
1540
|
if (typeof shouldBlock !== "function") return () => {
|
|
1301
1541
|
};
|
|
@@ -1317,11 +1557,24 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
|
|
|
1317
1557
|
screen = null;
|
|
1318
1558
|
}
|
|
1319
1559
|
}
|
|
1560
|
+
const skip = (reason, detail) => {
|
|
1561
|
+
try {
|
|
1562
|
+
onSkip?.({ phase: "egress", reason, detail });
|
|
1563
|
+
} catch {
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1320
1566
|
const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
|
|
1321
|
-
if (!screen
|
|
1567
|
+
if (!screen) {
|
|
1568
|
+
if (host && dnsScreen) skip("resolver-unavailable", { host });
|
|
1569
|
+
return resolve(false);
|
|
1570
|
+
}
|
|
1571
|
+
if (!host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
|
|
1322
1572
|
try {
|
|
1323
1573
|
screen.lookup(host, { all: true }, (err, addresses) => {
|
|
1324
|
-
if (err || !Array.isArray(addresses))
|
|
1574
|
+
if (err || !Array.isArray(addresses)) {
|
|
1575
|
+
skip("resolver-failed", { host });
|
|
1576
|
+
return resolve(false);
|
|
1577
|
+
}
|
|
1325
1578
|
for (const a of addresses) {
|
|
1326
1579
|
const ip = a && typeof a === "object" ? a.address : a;
|
|
1327
1580
|
if (ip && block(url, ip, method)) return resolve(true);
|
|
@@ -1329,24 +1582,61 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
|
|
|
1329
1582
|
resolve(false);
|
|
1330
1583
|
});
|
|
1331
1584
|
} catch {
|
|
1585
|
+
skip("resolver-failed", { host });
|
|
1332
1586
|
resolve(false);
|
|
1333
1587
|
}
|
|
1334
1588
|
});
|
|
1335
1589
|
const originalFetch = globalThis.fetch;
|
|
1336
1590
|
if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
|
|
1337
|
-
const
|
|
1338
|
-
|
|
1591
|
+
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
1592
|
+
const MAX_REDIRECTS = 20;
|
|
1593
|
+
const screenUrl = async (u, method) => {
|
|
1339
1594
|
let host = null;
|
|
1340
1595
|
try {
|
|
1341
|
-
host = new URL(
|
|
1596
|
+
host = new URL(u).hostname;
|
|
1342
1597
|
} catch {
|
|
1343
1598
|
host = null;
|
|
1344
1599
|
}
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1600
|
+
if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
|
|
1601
|
+
throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
|
|
1602
|
+
}
|
|
1603
|
+
};
|
|
1604
|
+
const guarded = async (input, init) => {
|
|
1605
|
+
let cur;
|
|
1606
|
+
try {
|
|
1607
|
+
cur = new Request(input, { ...init || {}, redirect: "manual" });
|
|
1608
|
+
} catch {
|
|
1609
|
+
return originalFetch(input, init);
|
|
1610
|
+
}
|
|
1611
|
+
const callerRedirect = init && init.redirect || input && input.redirect || "follow";
|
|
1612
|
+
let url = cur.url;
|
|
1613
|
+
let method = cur.method;
|
|
1614
|
+
await screenUrl(url, method);
|
|
1615
|
+
if (callerRedirect !== "follow") return originalFetch(input, init);
|
|
1616
|
+
const headers = new Headers(cur.headers);
|
|
1617
|
+
const signal = cur.signal;
|
|
1618
|
+
let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
|
|
1619
|
+
for (let hop = 0; ; hop++) {
|
|
1620
|
+
const resp = await originalFetch(
|
|
1621
|
+
hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
|
|
1622
|
+
);
|
|
1623
|
+
const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
|
|
1624
|
+
if (!location) return resp;
|
|
1625
|
+
if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
|
|
1626
|
+
const next = new URL(location, url).href;
|
|
1627
|
+
if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
|
|
1628
|
+
method = "GET";
|
|
1629
|
+
body = void 0;
|
|
1630
|
+
headers.delete("content-type");
|
|
1631
|
+
headers.delete("content-length");
|
|
1632
|
+
}
|
|
1633
|
+
if (new URL(next).origin !== new URL(url).origin) {
|
|
1634
|
+
headers.delete("authorization");
|
|
1635
|
+
headers.delete("cookie");
|
|
1636
|
+
}
|
|
1637
|
+
await screenUrl(next, method);
|
|
1638
|
+
url = next;
|
|
1348
1639
|
}
|
|
1349
|
-
return originalFetch(input, init);
|
|
1350
1640
|
};
|
|
1351
1641
|
guarded.__patchstackGuarded = true;
|
|
1352
1642
|
globalThis.fetch = guarded;
|
|
@@ -1362,30 +1652,6 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
|
|
|
1362
1652
|
} catch {
|
|
1363
1653
|
}
|
|
1364
1654
|
}
|
|
1365
|
-
const OriginalWS = globalThis.WebSocket;
|
|
1366
|
-
if (typeof OriginalWS === "function" && !OriginalWS.__patchstackGuarded) {
|
|
1367
|
-
const GuardedWS = new Proxy(OriginalWS, {
|
|
1368
|
-
construct(target, args, newTarget) {
|
|
1369
|
-
const url = String(args?.[0] ?? "");
|
|
1370
|
-
let host = null;
|
|
1371
|
-
try {
|
|
1372
|
-
host = new URL(url).hostname;
|
|
1373
|
-
} catch {
|
|
1374
|
-
host = null;
|
|
1375
|
-
}
|
|
1376
|
-
if (block(url, host, "WEBSOCKET")) {
|
|
1377
|
-
throw new Error(`Patchstack blocked an outbound WebSocket to a disallowed address: ${host ?? url}`);
|
|
1378
|
-
}
|
|
1379
|
-
return Reflect.construct(target, args, newTarget);
|
|
1380
|
-
}
|
|
1381
|
-
});
|
|
1382
|
-
OriginalWS.__patchstackGuarded = true;
|
|
1383
|
-
globalThis.WebSocket = GuardedWS;
|
|
1384
|
-
restores.push(() => {
|
|
1385
|
-
if (globalThis.WebSocket === GuardedWS) globalThis.WebSocket = OriginalWS;
|
|
1386
|
-
delete OriginalWS.__patchstackGuarded;
|
|
1387
|
-
});
|
|
1388
|
-
}
|
|
1389
1655
|
return () => {
|
|
1390
1656
|
for (const restore of restores) {
|
|
1391
1657
|
try {
|
|
@@ -1516,6 +1782,7 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
1516
1782
|
phase: "response",
|
|
1517
1783
|
category: "secret-exposure",
|
|
1518
1784
|
action: "redact",
|
|
1785
|
+
prefilter: ["PRIVATE KEY"],
|
|
1519
1786
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
|
|
1520
1787
|
},
|
|
1521
1788
|
{
|
|
@@ -1524,6 +1791,7 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
1524
1791
|
phase: "response",
|
|
1525
1792
|
category: "secret-exposure",
|
|
1526
1793
|
action: "redact",
|
|
1794
|
+
prefilter: ["AKIA", "ASIA"],
|
|
1527
1795
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
|
|
1528
1796
|
},
|
|
1529
1797
|
{
|
|
@@ -1532,14 +1800,29 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
1532
1800
|
phase: "response",
|
|
1533
1801
|
category: "secret-exposure",
|
|
1534
1802
|
action: "redact",
|
|
1803
|
+
prefilter: ["AIza"],
|
|
1535
1804
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
|
|
1536
1805
|
},
|
|
1806
|
+
{
|
|
1807
|
+
id: "resp-vendor-api-key",
|
|
1808
|
+
title: "Vendor API key / token in response body",
|
|
1809
|
+
phase: "response",
|
|
1810
|
+
category: "secret-exposure",
|
|
1811
|
+
action: "redact",
|
|
1812
|
+
// High-signal, prefix-anchored provider tokens that never legitimately appear in a response
|
|
1813
|
+
// body: Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-),
|
|
1814
|
+
// Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), npm (npm_). Trailing
|
|
1815
|
+
// (?![0-9A-Za-z]) instead of \\b since some tokens end in - / _ .
|
|
1816
|
+
prefilter: ["sk_live_", "rk_live_", "ghp_", "gho_", "ghs_", "ghu_", "github_pat_", "glpat-", "xox", "sk-ant-", "ya29.", "npm_"],
|
|
1817
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:sk_live_[0-9A-Za-z]{16,}|rk_live_[0-9A-Za-z]{16,}|gh[opsu]_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{60,}|glpat-[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|sk-ant-[0-9A-Za-z_-]{20,}|ya29\\.[0-9A-Za-z_-]{20,}|npm_[0-9A-Za-z]{36})(?![0-9A-Za-z])/" } }]
|
|
1818
|
+
},
|
|
1537
1819
|
{
|
|
1538
1820
|
id: "resp-jwt",
|
|
1539
1821
|
title: "JWT in response body",
|
|
1540
1822
|
phase: "response",
|
|
1541
1823
|
category: "secret-exposure",
|
|
1542
1824
|
action: "redact",
|
|
1825
|
+
prefilter: ["eyJ"],
|
|
1543
1826
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\b/" } }]
|
|
1544
1827
|
},
|
|
1545
1828
|
{
|
|
@@ -1548,6 +1831,7 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
1548
1831
|
phase: "response",
|
|
1549
1832
|
category: "secret-exposure",
|
|
1550
1833
|
action: "redact",
|
|
1834
|
+
prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
|
|
1551
1835
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
|
|
1552
1836
|
},
|
|
1553
1837
|
{
|
|
@@ -1556,6 +1840,8 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
1556
1840
|
phase: "response",
|
|
1557
1841
|
category: "info-exposure",
|
|
1558
1842
|
action: "redact",
|
|
1843
|
+
// No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
|
|
1844
|
+
// gate on). The pattern is linearly bounded per line, so it runs on every screened body.
|
|
1559
1845
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
|
|
1560
1846
|
},
|
|
1561
1847
|
{
|
|
@@ -1564,6 +1850,7 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
1564
1850
|
phase: "response",
|
|
1565
1851
|
category: "info-exposure",
|
|
1566
1852
|
action: "redact",
|
|
1853
|
+
prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
|
|
1567
1854
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|ER_[A-Z_]+|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/i" } }]
|
|
1568
1855
|
},
|
|
1569
1856
|
{
|
|
@@ -1575,6 +1862,7 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
1575
1862
|
// Multi-language exception/traceback signatures a normal API response never carries:
|
|
1576
1863
|
// Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
|
|
1577
1864
|
// Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
|
|
1865
|
+
prefilter: ["Traceback", "Exception in thread", "System.", "goroutine ", ".java:", ".kt:", ".scala:", ".rb:", ".py:", ".cs:"],
|
|
1578
1866
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: '/(Traceback \\(most recent call last\\)|Exception in thread "|System\\.[A-Za-z.]+Exception|\\bat [\\w.$]+\\([\\w]+\\.(?:java|kt|scala|rb|py|cs):\\d+\\)|goroutine \\d+ \\[)/' } }]
|
|
1579
1867
|
}
|
|
1580
1868
|
];
|
|
@@ -1672,8 +1960,22 @@ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
|
|
|
1672
1960
|
}
|
|
1673
1961
|
|
|
1674
1962
|
// src/protect/rules/store.js
|
|
1675
|
-
|
|
1676
|
-
|
|
1963
|
+
var fsMod;
|
|
1964
|
+
async function loadFs() {
|
|
1965
|
+
if (fsMod !== void 0) return fsMod;
|
|
1966
|
+
try {
|
|
1967
|
+
const [fs, path] = await Promise.all([import("fs"), import("path")]);
|
|
1968
|
+
fsMod = {
|
|
1969
|
+
readFileSync: fs.readFileSync,
|
|
1970
|
+
writeFileSync: fs.writeFileSync,
|
|
1971
|
+
mkdirSync: fs.mkdirSync,
|
|
1972
|
+
join: path.join
|
|
1973
|
+
};
|
|
1974
|
+
} catch {
|
|
1975
|
+
fsMod = null;
|
|
1976
|
+
}
|
|
1977
|
+
return fsMod;
|
|
1978
|
+
}
|
|
1677
1979
|
function makeStore(options = {}) {
|
|
1678
1980
|
let mem = null;
|
|
1679
1981
|
const durable = durableTier(options);
|
|
@@ -1715,21 +2017,22 @@ function durableTier(options) {
|
|
|
1715
2017
|
write: async (env) => cacheWrite(dir, env)
|
|
1716
2018
|
};
|
|
1717
2019
|
}
|
|
1718
|
-
function
|
|
1719
|
-
return join(dir, "patchstack-rules.json");
|
|
1720
|
-
}
|
|
1721
|
-
function cacheWrite(dir, env) {
|
|
2020
|
+
async function cacheWrite(dir, env) {
|
|
1722
2021
|
if (!dir) return;
|
|
2022
|
+
const fs = await loadFs();
|
|
2023
|
+
if (!fs) return;
|
|
1723
2024
|
try {
|
|
1724
|
-
mkdirSync(dir, { recursive: true });
|
|
1725
|
-
writeFileSync(
|
|
2025
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
2026
|
+
fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
|
|
1726
2027
|
} catch {
|
|
1727
2028
|
}
|
|
1728
2029
|
}
|
|
1729
|
-
function cacheRead(dir) {
|
|
2030
|
+
async function cacheRead(dir) {
|
|
1730
2031
|
if (!dir) return null;
|
|
2032
|
+
const fs = await loadFs();
|
|
2033
|
+
if (!fs) return null;
|
|
1731
2034
|
try {
|
|
1732
|
-
return toEnvelope(JSON.parse(readFileSync(
|
|
2035
|
+
return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
|
|
1733
2036
|
} catch {
|
|
1734
2037
|
return null;
|
|
1735
2038
|
}
|
|
@@ -1745,56 +2048,204 @@ function toEnvelope(value) {
|
|
|
1745
2048
|
return null;
|
|
1746
2049
|
}
|
|
1747
2050
|
|
|
2051
|
+
// src/protect/rules/validate.js
|
|
2052
|
+
var LIMITS = {
|
|
2053
|
+
maxRules: 5e3,
|
|
2054
|
+
maxWhitelists: 2e3,
|
|
2055
|
+
maxConditionsPerRule: 250,
|
|
2056
|
+
maxNestingDepth: 12,
|
|
2057
|
+
maxRegexLength: 1e3,
|
|
2058
|
+
maxValueLength: 8192
|
|
2059
|
+
};
|
|
2060
|
+
var PHASES = /* @__PURE__ */ new Set(["request", "response", "egress"]);
|
|
2061
|
+
var ACTIONS = /* @__PURE__ */ new Set(["block", "redact", "encode", "set-header", "remove-header", "harden-cookie"]);
|
|
2062
|
+
function validateBundle(bundle, opts = {}) {
|
|
2063
|
+
const rejected = [];
|
|
2064
|
+
const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
|
|
2065
|
+
const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
|
|
2066
|
+
const firewall = [];
|
|
2067
|
+
for (const rule of inFirewall) {
|
|
2068
|
+
if (firewall.length >= LIMITS.maxRules) {
|
|
2069
|
+
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS.maxRules})` });
|
|
2070
|
+
continue;
|
|
2071
|
+
}
|
|
2072
|
+
const reason = ruleProblem(rule);
|
|
2073
|
+
if (reason) rejected.push({ id: idOf(rule), reason });
|
|
2074
|
+
else firewall.push(rule);
|
|
2075
|
+
}
|
|
2076
|
+
const whitelists = [];
|
|
2077
|
+
for (const wl of inWhitelists) {
|
|
2078
|
+
if (whitelists.length >= LIMITS.maxWhitelists) {
|
|
2079
|
+
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS.maxWhitelists})` });
|
|
2080
|
+
continue;
|
|
2081
|
+
}
|
|
2082
|
+
if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
|
|
2083
|
+
rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
|
|
2084
|
+
continue;
|
|
2085
|
+
}
|
|
2086
|
+
const reason = conditionsProblem(wl?.rule_v2);
|
|
2087
|
+
if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
|
|
2088
|
+
else whitelists.push(wl);
|
|
2089
|
+
}
|
|
2090
|
+
return {
|
|
2091
|
+
bundle: { ...bundle, firewall, whitelists },
|
|
2092
|
+
rejected
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
function idOf(rule) {
|
|
2096
|
+
const id = rule?.id ?? rule?.rule_id;
|
|
2097
|
+
return id === void 0 || id === null ? "(unidentified)" : String(id);
|
|
2098
|
+
}
|
|
2099
|
+
function ruleProblem(rule) {
|
|
2100
|
+
if (!rule || typeof rule !== "object") return "not an object";
|
|
2101
|
+
if (rule.phase !== void 0 && !PHASES.has(rule.phase)) return `unknown phase "${rule.phase}"`;
|
|
2102
|
+
if (rule.action !== void 0 && !ACTIONS.has(rule.action)) return `unknown action "${rule.action}"`;
|
|
2103
|
+
const capOverride = rule.max_bytes;
|
|
2104
|
+
if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
|
|
2105
|
+
return conditionsProblem(rule.rule_v2);
|
|
2106
|
+
}
|
|
2107
|
+
function conditionsProblem(conditions, depth = 0) {
|
|
2108
|
+
if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
|
|
2109
|
+
if (conditions.length === 0) return "rule_v2 is empty (would never match)";
|
|
2110
|
+
if (depth > LIMITS.maxNestingDepth) return `nesting deeper than ${LIMITS.maxNestingDepth}`;
|
|
2111
|
+
if (conditions.length > LIMITS.maxConditionsPerRule) {
|
|
2112
|
+
return `more than ${LIMITS.maxConditionsPerRule} conditions`;
|
|
2113
|
+
}
|
|
2114
|
+
for (const c of conditions) {
|
|
2115
|
+
if (!c || typeof c !== "object") return "condition is not an object";
|
|
2116
|
+
if (Array.isArray(c.rules)) {
|
|
2117
|
+
const nested = conditionsProblem(c.rules, depth + 1);
|
|
2118
|
+
if (nested) return nested;
|
|
2119
|
+
continue;
|
|
2120
|
+
}
|
|
2121
|
+
const m = c.match;
|
|
2122
|
+
if (!m || typeof m !== "object") return "condition has no match object";
|
|
2123
|
+
if (typeof m.type !== "string" || m.type === "") return "match.type must be a non-empty string";
|
|
2124
|
+
if (m.type === "regex") {
|
|
2125
|
+
if (typeof m.value !== "string") return "regex match.value must be a string";
|
|
2126
|
+
if (m.value.length > LIMITS.maxRegexLength) return `regex longer than ${LIMITS.maxRegexLength} chars`;
|
|
2127
|
+
} else if (typeof m.value === "string" && m.value.length > LIMITS.maxValueLength) {
|
|
2128
|
+
return `match.value longer than ${LIMITS.maxValueLength} chars`;
|
|
2129
|
+
}
|
|
2130
|
+
if (m.match) {
|
|
2131
|
+
const nested = conditionsProblem([{ match: m.match }], depth + 1);
|
|
2132
|
+
if (nested) return nested;
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
return null;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
1748
2138
|
// src/protect/rules/source.js
|
|
1749
|
-
|
|
2139
|
+
function liveUpdateRejections(res, options) {
|
|
2140
|
+
if (options.acceptPartialBundle) return [];
|
|
2141
|
+
const { rejected } = validateBundle(
|
|
2142
|
+
{ firewall: Array.isArray(res.firewall) ? res.firewall : [], whitelists: Array.isArray(res.whitelists) ? res.whitelists : [] },
|
|
2143
|
+
{ allowGlobalWhitelists: options.allowGlobalWhitelists }
|
|
2144
|
+
);
|
|
2145
|
+
return rejected;
|
|
2146
|
+
}
|
|
2147
|
+
function reportRejections(rejected, options, label) {
|
|
2148
|
+
const report = options.onRuleRejected;
|
|
2149
|
+
for (const r of rejected) {
|
|
2150
|
+
if (typeof report === "function") {
|
|
2151
|
+
try {
|
|
2152
|
+
report({ ...r, accepted: false });
|
|
2153
|
+
} catch {
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
|
|
2158
|
+
options.onError?.(new Error(
|
|
2159
|
+
`${label}: rejected the entire update because ${rejected.length} rule(s) failed validation \u2014 keeping the previous ruleset and NOT caching this response: ${sample}${rejected.length > 3 ? ", \u2026" : ""}`
|
|
2160
|
+
));
|
|
2161
|
+
}
|
|
2162
|
+
async function resolveRules(options, store, ctx = {}) {
|
|
2163
|
+
const timeoutMs = ctx.timeoutMs;
|
|
1750
2164
|
if (options.siteUuid) {
|
|
1751
2165
|
const prior = await store.read();
|
|
1752
|
-
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag });
|
|
2166
|
+
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth });
|
|
1753
2167
|
const res = await client.getRules();
|
|
1754
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
|
|
2168
|
+
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
1755
2169
|
if (res.success && !res.notModified) {
|
|
1756
|
-
const
|
|
2170
|
+
const rejected = liveUpdateRejections(res, options);
|
|
2171
|
+
if (rejected.length > 0) {
|
|
2172
|
+
reportRejections(rejected, options, "rule update rejected");
|
|
2173
|
+
if (prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2174
|
+
if (options.rules) return normalizeBundle(options.rules, options);
|
|
2175
|
+
return emptyBundle();
|
|
2176
|
+
}
|
|
2177
|
+
const bundle = normalizeBundle(res, options);
|
|
1757
2178
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
1758
2179
|
return bundle;
|
|
1759
2180
|
}
|
|
1760
2181
|
if (prior?.bundle) {
|
|
1761
2182
|
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
1762
|
-
return normalizeBundle(prior.bundle);
|
|
2183
|
+
return normalizeBundle(prior.bundle, options);
|
|
1763
2184
|
}
|
|
1764
2185
|
if (options.rules) {
|
|
1765
2186
|
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
|
|
1766
|
-
return normalizeBundle(options.rules);
|
|
2187
|
+
return normalizeBundle(options.rules, options);
|
|
1767
2188
|
}
|
|
1768
2189
|
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
1769
2190
|
return emptyBundle();
|
|
1770
2191
|
}
|
|
1771
2192
|
if (options.token) {
|
|
1772
2193
|
const prior = await store.read();
|
|
1773
|
-
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag });
|
|
2194
|
+
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
|
|
1774
2195
|
const res = await client.getRules();
|
|
1775
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
|
|
2196
|
+
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
1776
2197
|
if (res.success && !res.notModified) {
|
|
1777
|
-
const
|
|
2198
|
+
const rejected = liveUpdateRejections(res, options);
|
|
2199
|
+
if (rejected.length > 0) {
|
|
2200
|
+
reportRejections(rejected, options, "rule update rejected");
|
|
2201
|
+
if (prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2202
|
+
if (options.rules) return normalizeBundle(options.rules, options);
|
|
2203
|
+
return emptyBundle();
|
|
2204
|
+
}
|
|
2205
|
+
const bundle = normalizeBundle(res, options);
|
|
1778
2206
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
1779
2207
|
return bundle;
|
|
1780
2208
|
}
|
|
1781
2209
|
if (prior?.bundle) {
|
|
1782
2210
|
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
1783
|
-
return normalizeBundle(prior.bundle);
|
|
2211
|
+
return normalizeBundle(prior.bundle, options);
|
|
1784
2212
|
}
|
|
1785
2213
|
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
1786
2214
|
return emptyBundle();
|
|
1787
2215
|
}
|
|
1788
2216
|
if (options.rules) {
|
|
1789
|
-
return normalizeBundle(options.rules);
|
|
2217
|
+
return normalizeBundle(options.rules, options);
|
|
1790
2218
|
}
|
|
1791
2219
|
return emptyBundle();
|
|
1792
2220
|
}
|
|
1793
|
-
function normalizeBundle(b) {
|
|
2221
|
+
function normalizeBundle(b, options = {}) {
|
|
1794
2222
|
const enforcement = b?.enforcement ?? b?.mode;
|
|
2223
|
+
const { bundle: checked, rejected } = validateBundle(
|
|
2224
|
+
{
|
|
2225
|
+
firewall: Array.isArray(b.firewall) ? b.firewall : [],
|
|
2226
|
+
whitelists: Array.isArray(b.whitelists) ? b.whitelists : []
|
|
2227
|
+
},
|
|
2228
|
+
{ allowGlobalWhitelists: options.allowGlobalWhitelists }
|
|
2229
|
+
);
|
|
2230
|
+
if (rejected.length > 0) {
|
|
2231
|
+
const report = options.onRuleRejected;
|
|
2232
|
+
if (typeof report === "function") {
|
|
2233
|
+
for (const r of rejected) {
|
|
2234
|
+
try {
|
|
2235
|
+
report(r);
|
|
2236
|
+
} catch {
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
} else {
|
|
2240
|
+
const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
|
|
2241
|
+
console.warn(
|
|
2242
|
+
`[patchstack] ${rejected.length} delivered rule(s) rejected as invalid/oversized and are NOT enforced: ${sample}` + (rejected.length > 3 ? ", \u2026" : "")
|
|
2243
|
+
);
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
1795
2246
|
return {
|
|
1796
|
-
firewall:
|
|
1797
|
-
whitelists:
|
|
2247
|
+
firewall: checked.firewall,
|
|
2248
|
+
whitelists: checked.whitelists,
|
|
1798
2249
|
whitelist_keys: b.whitelist_keys ?? {},
|
|
1799
2250
|
...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
|
|
1800
2251
|
};
|
|
@@ -1874,7 +2325,11 @@ function parseApiKey(apiKey) {
|
|
|
1874
2325
|
function resolveApiBase(pulseOrManifestUrl) {
|
|
1875
2326
|
const fromEnv = typeof process !== "undefined" ? process.env?.PATCHSTACK_API_BASE : void 0;
|
|
1876
2327
|
if (typeof fromEnv === "string" && fromEnv.length > 0) {
|
|
1877
|
-
|
|
2328
|
+
const candidate = fromEnv.replace(/\/$/, "");
|
|
2329
|
+
if (isSafeOrigin(candidate)) return candidate;
|
|
2330
|
+
console.warn(
|
|
2331
|
+
"[patchstack] ignoring PATCHSTACK_API_BASE: block-log reporting requires an https origin (or localhost). Falling back to the default API origin."
|
|
2332
|
+
);
|
|
1878
2333
|
}
|
|
1879
2334
|
if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
|
|
1880
2335
|
try {
|
|
@@ -2016,10 +2471,6 @@ function telemetryEnabled() {
|
|
|
2016
2471
|
return !/^(0|false|off|no)$/i.test(String(v));
|
|
2017
2472
|
}
|
|
2018
2473
|
|
|
2019
|
-
// src/protect/runtime.js
|
|
2020
|
-
import { readFileSync as readFileSync2 } from "fs";
|
|
2021
|
-
import { join as join2 } from "path";
|
|
2022
|
-
|
|
2023
2474
|
// src/protect/supabase-guard.js
|
|
2024
2475
|
var GUARD_PATH = "/_patchstack/guard";
|
|
2025
2476
|
var HOP_BY_HOP = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
|
|
@@ -2093,7 +2544,7 @@ function createServerFnGuard({ protection }) {
|
|
|
2093
2544
|
async function createProtection(options = {}) {
|
|
2094
2545
|
const onError = options.onError;
|
|
2095
2546
|
const userOnDetect = options.onDetect ?? defaultOnDetect;
|
|
2096
|
-
const apiKey = resolveApiKey(options);
|
|
2547
|
+
const apiKey = await resolveApiKey(options);
|
|
2097
2548
|
const firewallLog = apiKey && telemetryEnabled() && options.reportFirewallLog !== false ? createFirewallLogReporter({
|
|
2098
2549
|
apiKey,
|
|
2099
2550
|
apiBase: resolveApiBase(options.pulseRulesUrl ?? options.baseUrl),
|
|
@@ -2113,7 +2564,9 @@ async function createProtection(options = {}) {
|
|
|
2113
2564
|
}
|
|
2114
2565
|
};
|
|
2115
2566
|
const store = makeStore(options);
|
|
2116
|
-
const
|
|
2567
|
+
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
|
|
2568
|
+
const pulseAuth = await resolvePulseAuth(options);
|
|
2569
|
+
const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
|
|
2117
2570
|
let mode = resolveMode(options, bundle);
|
|
2118
2571
|
let requestRules;
|
|
2119
2572
|
let responseRules;
|
|
@@ -2137,17 +2590,42 @@ async function createProtection(options = {}) {
|
|
|
2137
2590
|
responseRuleSet = responseRules.map((rule) => ({
|
|
2138
2591
|
rule,
|
|
2139
2592
|
engine: new RuleEngine({ firewall: [rule], onError }),
|
|
2140
|
-
redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null
|
|
2593
|
+
redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null,
|
|
2594
|
+
// A redact/encode condition that carries body-transforming mutations (base64_decode, urldecode,
|
|
2595
|
+
// json_decode, …) detects on the DECODED body but the span redactors run on the RAW body — so
|
|
2596
|
+
// they mask nothing and the secret is served while the log says "redacted". Flag it so screenText
|
|
2597
|
+
// fails such a rule CLOSED (block) instead of serving a no-op redaction.
|
|
2598
|
+
mutatedSpan: (rule.action === "redact" || rule.action === "encode") && hasSpanMutations(rule),
|
|
2599
|
+
// Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
|
|
2600
|
+
// have any chance of matching. Lets screenText skip the full scan on bodies with no candidate —
|
|
2601
|
+
// the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
|
|
2602
|
+
prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
|
|
2141
2603
|
}));
|
|
2142
2604
|
egressEngine = new RuleEngine({ firewall: egressRules, onError });
|
|
2143
2605
|
};
|
|
2144
2606
|
applyBundle(bundle);
|
|
2607
|
+
const skipCounts = /* @__PURE__ */ Object.create(null);
|
|
2608
|
+
const onSkip = typeof options.onSkip === "function" ? options.onSkip : null;
|
|
2609
|
+
const recordSkip = (phase, reason, detail) => {
|
|
2610
|
+
const key = `${phase}:${reason}`;
|
|
2611
|
+
skipCounts[key] = (skipCounts[key] ?? 0) + 1;
|
|
2612
|
+
if (onSkip) {
|
|
2613
|
+
try {
|
|
2614
|
+
onSkip({ phase, reason, detail, count: skipCounts[key] });
|
|
2615
|
+
} catch {
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
};
|
|
2145
2619
|
const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
|
|
2620
|
+
const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
|
|
2146
2621
|
const decide = (phase, result, block, allow2, ctx = {}) => {
|
|
2147
2622
|
if (!result || !result.blocked) return allow2();
|
|
2623
|
+
const effectiveMode = ruleMode(result.rule);
|
|
2148
2624
|
onDetect({
|
|
2149
2625
|
phase,
|
|
2150
|
-
mode,
|
|
2626
|
+
// The mode this detection was actually handled under, not the site's: a consumer counting blocks
|
|
2627
|
+
// would otherwise over-report, and the whole point of a dry-run rule is that it did not block.
|
|
2628
|
+
mode: effectiveMode,
|
|
2151
2629
|
category: result.rule?.category,
|
|
2152
2630
|
rule: result.rule,
|
|
2153
2631
|
message: result.message,
|
|
@@ -2156,30 +2634,40 @@ async function createProtection(options = {}) {
|
|
|
2156
2634
|
ip: ctx.ip,
|
|
2157
2635
|
userAgent: ctx.userAgent
|
|
2158
2636
|
});
|
|
2159
|
-
return
|
|
2637
|
+
return effectiveMode === "block" ? block() : allow2();
|
|
2160
2638
|
};
|
|
2161
|
-
const
|
|
2162
|
-
ct = (ct || "").toLowerCase();
|
|
2163
|
-
return ct === "" || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
|
|
2164
|
-
};
|
|
2165
|
-
const screenText = (text, meta) => {
|
|
2639
|
+
const screenText = (text, meta, reqCtx) => {
|
|
2166
2640
|
let blockRule = null;
|
|
2167
2641
|
const redactions = [];
|
|
2168
|
-
|
|
2642
|
+
const headerMutations = [];
|
|
2643
|
+
let lowerText = null;
|
|
2644
|
+
for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
|
|
2645
|
+
if (prefilter) {
|
|
2646
|
+
if (lowerText === null) lowerText = text.toLowerCase();
|
|
2647
|
+
if (!prefilter.some((p) => lowerText.includes(p))) continue;
|
|
2648
|
+
}
|
|
2169
2649
|
let result;
|
|
2170
2650
|
try {
|
|
2171
|
-
result = re.evaluate({ _response: { ...meta, body: text } });
|
|
2651
|
+
result = re.evaluate({ ...reqCtx || {}, _response: { ...meta, body: text } });
|
|
2172
2652
|
} catch (err) {
|
|
2173
2653
|
onError?.(err);
|
|
2174
2654
|
continue;
|
|
2175
2655
|
}
|
|
2176
2656
|
if (!result.blocked) continue;
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
if (
|
|
2657
|
+
const responseMode = ruleMode(rule);
|
|
2658
|
+
onDetect({ phase: "response", mode: responseMode, category: rule.category, rule, message: result.message });
|
|
2659
|
+
if (responseMode !== "block") continue;
|
|
2660
|
+
if (redactors && redactors.length) {
|
|
2661
|
+
const spanRedactors = redactors.filter((r) => !r.jsonPath);
|
|
2662
|
+
if (mutatedSpan && spanRedactors.length) {
|
|
2663
|
+
if (!blockRule) blockRule = rule;
|
|
2664
|
+
} else {
|
|
2665
|
+
redactions.push({ rule, redactors });
|
|
2666
|
+
}
|
|
2667
|
+
} else if (isHeaderMutation(rule.action)) headerMutations.push(rule);
|
|
2180
2668
|
else if (!blockRule) blockRule = rule;
|
|
2181
2669
|
}
|
|
2182
|
-
if (mode !== "block" || !blockRule && !redactions.length) return { verdict: "pass" };
|
|
2670
|
+
if (mode !== "block" || !blockRule && !redactions.length && !headerMutations.length) return { verdict: "pass" };
|
|
2183
2671
|
if (blockRule) return { verdict: "block" };
|
|
2184
2672
|
let body = text;
|
|
2185
2673
|
const headers = { ...meta.headers || {} };
|
|
@@ -2201,17 +2689,33 @@ async function createProtection(options = {}) {
|
|
|
2201
2689
|
}
|
|
2202
2690
|
}
|
|
2203
2691
|
}
|
|
2692
|
+
for (const rule of headerMutations) applyHeaderMutation(headers, rule);
|
|
2204
2693
|
return { verdict: "redact", body, headers };
|
|
2205
2694
|
};
|
|
2206
|
-
const
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2695
|
+
const reqContextFromFetch = (request) => {
|
|
2696
|
+
try {
|
|
2697
|
+
const u = new URL(request.url);
|
|
2698
|
+
const headers = headerObject(request.headers);
|
|
2699
|
+
if (!headers.host) headers.host = u.host;
|
|
2700
|
+
return { method: request.method, originalUrl: u.pathname + u.search, headers };
|
|
2701
|
+
} catch {
|
|
2702
|
+
return void 0;
|
|
2703
|
+
}
|
|
2704
|
+
};
|
|
2705
|
+
const reqContextFromNode = (req) => req ? { method: req.method, originalUrl: req.url, headers: req.headers || {} } : void 0;
|
|
2706
|
+
const screenResp = async (response, reqCtx) => {
|
|
2707
|
+
const read = await readTextResponse(response, screenCap);
|
|
2708
|
+
if (read.skip) {
|
|
2709
|
+
if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
|
|
2710
|
+
return response;
|
|
2711
|
+
}
|
|
2712
|
+
const text = read.text;
|
|
2713
|
+
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
|
|
2210
2714
|
if (r.verdict === "block") return leakResponse();
|
|
2211
2715
|
if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
|
|
2212
2716
|
return response;
|
|
2213
2717
|
};
|
|
2214
|
-
const wrapNodeResponse = (res) => {
|
|
2718
|
+
const wrapNodeResponse = (res, reqCtx) => {
|
|
2215
2719
|
const origWrite = res.write.bind(res);
|
|
2216
2720
|
const origEnd = res.end.bind(res);
|
|
2217
2721
|
const chunks = [];
|
|
@@ -2227,6 +2731,7 @@ async function createProtection(options = {}) {
|
|
|
2227
2731
|
chunks.length = 0;
|
|
2228
2732
|
origWrite(buf);
|
|
2229
2733
|
overflow = true;
|
|
2734
|
+
recordSkip("response", "body-cap", { bytes: size });
|
|
2230
2735
|
return;
|
|
2231
2736
|
}
|
|
2232
2737
|
chunks.push(buf);
|
|
@@ -2253,16 +2758,19 @@ async function createProtection(options = {}) {
|
|
|
2253
2758
|
}
|
|
2254
2759
|
collect(chunk, enc);
|
|
2255
2760
|
if (overflow) return origEnd(cb);
|
|
2256
|
-
const
|
|
2761
|
+
const buffer = Buffer.concat(chunks);
|
|
2257
2762
|
let ct = res.getHeader ? res.getHeader("content-type") : void 0;
|
|
2258
2763
|
if (Array.isArray(ct)) ct = ct[0];
|
|
2259
|
-
|
|
2764
|
+
const kind = screenableContentType(ct);
|
|
2765
|
+
if (kind === "skip" || kind === "sniff" && looksBinary(buffer)) {
|
|
2766
|
+
recordSkip("response", kind === "skip" ? baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" : "binary-body");
|
|
2260
2767
|
for (const c of chunks) origWrite(c);
|
|
2261
2768
|
return origEnd(cb);
|
|
2262
2769
|
}
|
|
2770
|
+
const text = buffer.toString("utf8");
|
|
2263
2771
|
let r;
|
|
2264
2772
|
try {
|
|
2265
|
-
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} });
|
|
2773
|
+
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
|
|
2266
2774
|
} catch (err) {
|
|
2267
2775
|
onError?.(err);
|
|
2268
2776
|
for (const c of chunks) origWrite(c);
|
|
@@ -2285,7 +2793,12 @@ async function createProtection(options = {}) {
|
|
|
2285
2793
|
const current = res.getHeaders ? res.getHeaders() : {};
|
|
2286
2794
|
for (const [name, value] of Object.entries(r.headers)) {
|
|
2287
2795
|
if (name.toLowerCase() === "content-length") continue;
|
|
2288
|
-
if (
|
|
2796
|
+
if (value === null || value === void 0) {
|
|
2797
|
+
try {
|
|
2798
|
+
res.removeHeader && res.removeHeader(name);
|
|
2799
|
+
} catch {
|
|
2800
|
+
}
|
|
2801
|
+
} else if (Array.isArray(value)) {
|
|
2289
2802
|
try {
|
|
2290
2803
|
res.setHeader(name, value);
|
|
2291
2804
|
} catch {
|
|
@@ -2315,8 +2828,9 @@ async function createProtection(options = {}) {
|
|
|
2315
2828
|
return false;
|
|
2316
2829
|
}
|
|
2317
2830
|
if (!result.blocked) return false;
|
|
2318
|
-
|
|
2319
|
-
|
|
2831
|
+
const egressMode = ruleMode(result.rule);
|
|
2832
|
+
onDetect({ phase: "egress", mode: egressMode, category: result.rule?.category, rule: result.rule, message: result.message });
|
|
2833
|
+
return egressMode === "block";
|
|
2320
2834
|
};
|
|
2321
2835
|
const protection = {
|
|
2322
2836
|
get mode() {
|
|
@@ -2325,9 +2839,18 @@ async function createProtection(options = {}) {
|
|
|
2325
2839
|
get rules() {
|
|
2326
2840
|
return { request: requestRules, response: responseRules, egress: egressRules };
|
|
2327
2841
|
},
|
|
2842
|
+
/**
|
|
2843
|
+
* Enforcement coverage: how often the guard FAILED OPEN rather than inspecting, keyed
|
|
2844
|
+
* `<phase>:<reason>` (e.g. `response:body-cap`, `request:body-cap`, `response:live-stream`,
|
|
2845
|
+
* `egress:resolver-failed`). "Always-on" is not "always inspected" — surface this (or pass
|
|
2846
|
+
* `onSkip`) so an unscreened path is visible and alertable rather than silent.
|
|
2847
|
+
*/
|
|
2848
|
+
coverage() {
|
|
2849
|
+
return { skipped: { ...skipCounts } };
|
|
2850
|
+
},
|
|
2328
2851
|
// Screen a fetch Response through the response-phase rules (redact/block). Used by
|
|
2329
2852
|
// .fetch(), and by the Supabase guard on its forwarded upstream response.
|
|
2330
|
-
screenResponse: (response) => screenResp(response),
|
|
2853
|
+
screenResponse: (response, request) => screenResp(response, request ? reqContextFromFetch(request) : void 0),
|
|
2331
2854
|
// (request) => Response | null (null = allow, caller proceeds). Request phase only.
|
|
2332
2855
|
fetchGuard() {
|
|
2333
2856
|
return async (request) => {
|
|
@@ -2348,7 +2871,7 @@ async function createProtection(options = {}) {
|
|
|
2348
2871
|
const blocked = await guard(request);
|
|
2349
2872
|
if (blocked) return blocked;
|
|
2350
2873
|
const response = await handler(request, ...rest);
|
|
2351
|
-
return screenResp(response);
|
|
2874
|
+
return screenResp(response, reqContextFromFetch(request));
|
|
2352
2875
|
};
|
|
2353
2876
|
},
|
|
2354
2877
|
// Express middleware (request phase; expects express-parsed req.query/req.body).
|
|
@@ -2360,7 +2883,7 @@ async function createProtection(options = {}) {
|
|
|
2360
2883
|
result = engine.evaluate(req);
|
|
2361
2884
|
} catch (err) {
|
|
2362
2885
|
onError?.(err);
|
|
2363
|
-
if (exprOptions.screenResponses) wrapNodeResponse(res);
|
|
2886
|
+
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
2364
2887
|
return next();
|
|
2365
2888
|
}
|
|
2366
2889
|
decide(
|
|
@@ -2374,7 +2897,7 @@ async function createProtection(options = {}) {
|
|
|
2374
2897
|
}
|
|
2375
2898
|
},
|
|
2376
2899
|
() => {
|
|
2377
|
-
if (exprOptions.screenResponses) wrapNodeResponse(res);
|
|
2900
|
+
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
2378
2901
|
next();
|
|
2379
2902
|
},
|
|
2380
2903
|
nodeRequestMeta(req)
|
|
@@ -2402,6 +2925,7 @@ async function createProtection(options = {}) {
|
|
|
2402
2925
|
next();
|
|
2403
2926
|
});
|
|
2404
2927
|
req.on("end", () => {
|
|
2928
|
+
if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
|
|
2405
2929
|
const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
|
|
2406
2930
|
let shaped;
|
|
2407
2931
|
let result;
|
|
@@ -2427,7 +2951,7 @@ async function createProtection(options = {}) {
|
|
|
2427
2951
|
},
|
|
2428
2952
|
() => {
|
|
2429
2953
|
if (req.body === void 0) req.body = shaped.body;
|
|
2430
|
-
if (nodeOptions.screenResponses) wrapNodeResponse(res);
|
|
2954
|
+
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
2431
2955
|
next();
|
|
2432
2956
|
},
|
|
2433
2957
|
nodeRequestMeta(req)
|
|
@@ -2440,6 +2964,9 @@ async function createProtection(options = {}) {
|
|
|
2440
2964
|
protection.uninstallEgress = await installEgressGuard({
|
|
2441
2965
|
shouldBlock: egressShouldBlock,
|
|
2442
2966
|
onBlock: options.onEgressBlock,
|
|
2967
|
+
// Route egress coverage gaps (a DNS resolver failure / no resolver on this runtime) into the
|
|
2968
|
+
// same skip accounting as the request/response phases.
|
|
2969
|
+
onSkip: ({ reason, detail }) => recordSkip("egress", reason, detail),
|
|
2443
2970
|
dnsScreen: options.screenDns !== false,
|
|
2444
2971
|
allowHosts: options.allowHosts
|
|
2445
2972
|
});
|
|
@@ -2451,7 +2978,7 @@ async function createProtection(options = {}) {
|
|
|
2451
2978
|
let reporter = null;
|
|
2452
2979
|
if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
|
|
2453
2980
|
try {
|
|
2454
|
-
({ reportManifest: reporter } = await import("./refresh-manifest-
|
|
2981
|
+
({ reportManifest: reporter } = await import("./refresh-manifest-MUWVIPFR.js"));
|
|
2455
2982
|
} catch (err) {
|
|
2456
2983
|
onError?.(err);
|
|
2457
2984
|
}
|
|
@@ -2464,7 +2991,7 @@ async function createProtection(options = {}) {
|
|
|
2464
2991
|
onError?.(err);
|
|
2465
2992
|
}
|
|
2466
2993
|
}
|
|
2467
|
-
const next = await resolveRules(options, store);
|
|
2994
|
+
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
|
|
2468
2995
|
mode = resolveMode(options, next);
|
|
2469
2996
|
applyBundle(next);
|
|
2470
2997
|
};
|
|
@@ -2491,7 +3018,7 @@ function resolveMode(options, bundle) {
|
|
|
2491
3018
|
if (options?.mode === "dry-run") return "dry-run";
|
|
2492
3019
|
return "dry-run";
|
|
2493
3020
|
}
|
|
2494
|
-
function resolveApiKey(options) {
|
|
3021
|
+
async function resolveApiKey(options) {
|
|
2495
3022
|
if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
|
|
2496
3023
|
if (typeof process !== "undefined") {
|
|
2497
3024
|
const fromEnv = process.env?.PATCHSTACK_API_KEY;
|
|
@@ -2499,29 +3026,69 @@ function resolveApiKey(options) {
|
|
|
2499
3026
|
}
|
|
2500
3027
|
try {
|
|
2501
3028
|
if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
|
|
3029
|
+
const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
|
|
2502
3030
|
const cwd = options?.cwd ?? process.cwd();
|
|
2503
|
-
const raw =
|
|
3031
|
+
const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
|
|
2504
3032
|
const key = JSON.parse(raw)?.apiKey;
|
|
2505
3033
|
if (typeof key === "string" && key.length > 0) return key;
|
|
2506
3034
|
} catch {
|
|
2507
3035
|
}
|
|
2508
3036
|
return void 0;
|
|
2509
3037
|
}
|
|
3038
|
+
async function resolvePulseAuth(options) {
|
|
3039
|
+
if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
|
|
3040
|
+
if (typeof process !== "undefined") {
|
|
3041
|
+
const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
|
|
3042
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
3043
|
+
}
|
|
3044
|
+
try {
|
|
3045
|
+
if (typeof process === "undefined" || typeof process.cwd !== "function") return resolveApiKey(options);
|
|
3046
|
+
const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
|
|
3047
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
3048
|
+
const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
|
|
3049
|
+
const key = JSON.parse(raw)?.pulseAuth;
|
|
3050
|
+
if (typeof key === "string" && key.length > 0) return key;
|
|
3051
|
+
} catch {
|
|
3052
|
+
}
|
|
3053
|
+
return resolveApiKey(options);
|
|
3054
|
+
}
|
|
2510
3055
|
function byPhase(rules, phase) {
|
|
2511
3056
|
return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
|
|
2512
3057
|
}
|
|
3058
|
+
function baseContentType(ct) {
|
|
3059
|
+
return String(ct || "").toLowerCase().split(";")[0].trim();
|
|
3060
|
+
}
|
|
3061
|
+
function screenableContentType(ct) {
|
|
3062
|
+
const base = baseContentType(ct);
|
|
3063
|
+
if (base === "text/event-stream") return "skip";
|
|
3064
|
+
if (base === "") return "text";
|
|
3065
|
+
if (/(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(base)) return "text";
|
|
3066
|
+
if (base === "application/octet-stream") return "sniff";
|
|
3067
|
+
return "skip";
|
|
3068
|
+
}
|
|
3069
|
+
function looksBinary(bytes) {
|
|
3070
|
+
const n = Math.min(bytes.length, 512);
|
|
3071
|
+
let ctrl = 0;
|
|
3072
|
+
for (let i = 0; i < n; i++) {
|
|
3073
|
+
const b = bytes[i];
|
|
3074
|
+
if (b === 0) return true;
|
|
3075
|
+
if (b < 9 || b > 13 && b < 32) ctrl++;
|
|
3076
|
+
}
|
|
3077
|
+
return n > 0 && ctrl / n > 0.1;
|
|
3078
|
+
}
|
|
2513
3079
|
async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
2514
|
-
if (!response || typeof response.clone !== "function") return
|
|
2515
|
-
const ct =
|
|
2516
|
-
const
|
|
2517
|
-
if (
|
|
3080
|
+
if (!response || typeof response.clone !== "function") return { skip: "not-a-response" };
|
|
3081
|
+
const ct = response.headers?.get?.("content-type") || "";
|
|
3082
|
+
const kind = screenableContentType(ct);
|
|
3083
|
+
if (kind === "skip") return { skip: baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" };
|
|
3084
|
+
const sniff = kind === "sniff";
|
|
2518
3085
|
const len = Number(response.headers?.get?.("content-length") || 0);
|
|
2519
|
-
if (len && len > cap) return
|
|
3086
|
+
if (len && len > cap) return { skip: "body-cap" };
|
|
2520
3087
|
let clone;
|
|
2521
3088
|
try {
|
|
2522
3089
|
clone = response.clone();
|
|
2523
3090
|
} catch {
|
|
2524
|
-
return
|
|
3091
|
+
return { skip: "clone-failed" };
|
|
2525
3092
|
}
|
|
2526
3093
|
const body = clone.body;
|
|
2527
3094
|
if (body && typeof body.getReader === "function") {
|
|
@@ -2529,11 +3096,16 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
|
2529
3096
|
const chunks = [];
|
|
2530
3097
|
let size = 0;
|
|
2531
3098
|
let over = false;
|
|
3099
|
+
let sniffed = !sniff;
|
|
2532
3100
|
try {
|
|
2533
3101
|
for (; ; ) {
|
|
2534
3102
|
const { done, value } = await reader.read();
|
|
2535
3103
|
if (done) break;
|
|
2536
3104
|
if (!value) continue;
|
|
3105
|
+
if (!sniffed) {
|
|
3106
|
+
sniffed = true;
|
|
3107
|
+
if (looksBinary(value)) return { skip: "binary-body" };
|
|
3108
|
+
}
|
|
2537
3109
|
size += value.byteLength;
|
|
2538
3110
|
if (over) continue;
|
|
2539
3111
|
if (size > cap) {
|
|
@@ -2543,20 +3115,22 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
|
2543
3115
|
chunks.push(value);
|
|
2544
3116
|
}
|
|
2545
3117
|
} catch {
|
|
2546
|
-
return
|
|
3118
|
+
return { skip: "read-failed" };
|
|
2547
3119
|
}
|
|
2548
|
-
if (over) return
|
|
3120
|
+
if (over) return { skip: "body-cap" };
|
|
2549
3121
|
try {
|
|
2550
|
-
return new TextDecoder().decode(concatBytes(chunks, size));
|
|
3122
|
+
return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
|
|
2551
3123
|
} catch {
|
|
2552
|
-
return
|
|
3124
|
+
return { skip: "decode-failed" };
|
|
2553
3125
|
}
|
|
2554
3126
|
}
|
|
2555
3127
|
try {
|
|
2556
3128
|
const text = await clone.text();
|
|
2557
|
-
|
|
3129
|
+
if (text.length > cap) return { skip: "body-cap" };
|
|
3130
|
+
if (sniff && looksBinary(new TextEncoder().encode(text.slice(0, 512)))) return { skip: "binary-body" };
|
|
3131
|
+
return { text };
|
|
2558
3132
|
} catch {
|
|
2559
|
-
return
|
|
3133
|
+
return { skip: "read-failed" };
|
|
2560
3134
|
}
|
|
2561
3135
|
}
|
|
2562
3136
|
function concatBytes(chunks, total) {
|
|
@@ -2577,6 +3151,19 @@ function headerObject(headers) {
|
|
|
2577
3151
|
if (setCookies && setCookies.length) out["set-cookie"] = setCookies;
|
|
2578
3152
|
return out;
|
|
2579
3153
|
}
|
|
3154
|
+
function hasSpanMutations(rule) {
|
|
3155
|
+
let found = false;
|
|
3156
|
+
const walk = (conds) => {
|
|
3157
|
+
for (const c of conds ?? []) {
|
|
3158
|
+
if (found) return;
|
|
3159
|
+
if (Array.isArray(c.rules)) walk(c.rules);
|
|
3160
|
+
const isSpan = c.match && (c.match.type === "regex" || c.match.type === "contains" || c.match.type === "stripos");
|
|
3161
|
+
if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
|
|
3162
|
+
}
|
|
3163
|
+
};
|
|
3164
|
+
walk(rule.rule_v2);
|
|
3165
|
+
return found;
|
|
3166
|
+
}
|
|
2580
3167
|
function extractRedactors(rule) {
|
|
2581
3168
|
const out = [];
|
|
2582
3169
|
const walk = (conds) => {
|
|
@@ -2613,7 +3200,10 @@ function applyRedactors(body, redactors, mask, transform) {
|
|
|
2613
3200
|
let out = body;
|
|
2614
3201
|
for (const r of redactors) {
|
|
2615
3202
|
if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
|
|
2616
|
-
else if (r.literal)
|
|
3203
|
+
else if (r.literal) {
|
|
3204
|
+
const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
|
3205
|
+
out = out.replace(re, (m) => transform ? transform(m) : mask);
|
|
3206
|
+
}
|
|
2617
3207
|
}
|
|
2618
3208
|
return out;
|
|
2619
3209
|
}
|
|
@@ -2716,12 +3306,52 @@ function restoreBigInts(text) {
|
|
|
2716
3306
|
if (!text.includes(BIGINT_OPEN)) return text;
|
|
2717
3307
|
return text.replace(new RegExp(`"${BIGINT_OPEN}(-?\\d+)${BIGINT_CLOSE}"`, "g"), "$1");
|
|
2718
3308
|
}
|
|
3309
|
+
function isHeaderMutation(action) {
|
|
3310
|
+
return action === "set-header" || action === "remove-header" || action === "harden-cookie";
|
|
3311
|
+
}
|
|
3312
|
+
function applyHeaderMutation(headers, rule) {
|
|
3313
|
+
if (rule.action === "remove-header") {
|
|
3314
|
+
for (const name of rule.remove_headers ?? []) headers[String(name).toLowerCase()] = null;
|
|
3315
|
+
return;
|
|
3316
|
+
}
|
|
3317
|
+
if (rule.action === "set-header") {
|
|
3318
|
+
const ensure = rule.ensure === true;
|
|
3319
|
+
for (const [name, value] of Object.entries(rule.set_headers ?? {})) {
|
|
3320
|
+
const key = String(name).toLowerCase();
|
|
3321
|
+
const present = headers[key] != null && headers[key] !== "";
|
|
3322
|
+
if (ensure && present) continue;
|
|
3323
|
+
headers[key] = String(value);
|
|
3324
|
+
}
|
|
3325
|
+
return;
|
|
3326
|
+
}
|
|
3327
|
+
if (rule.action === "harden-cookie") {
|
|
3328
|
+
const cookie = headers["set-cookie"];
|
|
3329
|
+
const flags = rule.cookie_flags ?? {};
|
|
3330
|
+
if (Array.isArray(cookie)) {
|
|
3331
|
+
headers["set-cookie"] = cookie.map((c) => typeof c === "string" ? hardenCookie(c, flags) : c);
|
|
3332
|
+
} else if (typeof cookie === "string") {
|
|
3333
|
+
headers["set-cookie"] = hardenCookie(cookie, flags);
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
|
|
3338
|
+
let out = String(cookie);
|
|
3339
|
+
if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
|
|
3340
|
+
if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
|
|
3341
|
+
if (sameSite && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${sameSite}`;
|
|
3342
|
+
return out;
|
|
3343
|
+
}
|
|
2719
3344
|
function rebuildResponse(response, body, redactedHeaders) {
|
|
2720
3345
|
const headers = new Headers(response.headers);
|
|
2721
3346
|
headers.delete("content-length");
|
|
2722
3347
|
if (redactedHeaders) {
|
|
2723
3348
|
for (const [name, value] of Object.entries(redactedHeaders)) {
|
|
2724
|
-
if (
|
|
3349
|
+
if (value === null || value === void 0) {
|
|
3350
|
+
try {
|
|
3351
|
+
headers.delete(name);
|
|
3352
|
+
} catch {
|
|
3353
|
+
}
|
|
3354
|
+
} else if (typeof value === "string") {
|
|
2725
3355
|
if (headers.get(name) !== value) {
|
|
2726
3356
|
try {
|
|
2727
3357
|
headers.set(name, value);
|
|
@@ -2737,7 +3367,8 @@ function rebuildResponse(response, body, redactedHeaders) {
|
|
|
2737
3367
|
}
|
|
2738
3368
|
}
|
|
2739
3369
|
}
|
|
2740
|
-
|
|
3370
|
+
const nullBody = response.status === 101 || response.status === 204 || response.status === 205 || response.status === 304;
|
|
3371
|
+
return new Response(nullBody ? null : body, { status: response.status, statusText: response.statusText, headers });
|
|
2741
3372
|
}
|
|
2742
3373
|
function leakResponse() {
|
|
2743
3374
|
return new Response(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), {
|