@patchstack/connect 0.3.22 → 0.3.24
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 +2868 -129
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +105 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +37 -2
- package/dist/index.d.ts +37 -2
- package/dist/index.js +103 -1
- package/dist/index.js.map +1 -1
- package/dist/protect.cjs +1182 -217
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +27 -1
- package/dist/protect.edge.js +3530 -0
- package/dist/protect.edge.js.map +7 -0
- package/dist/protect.js +1066 -193
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-OW7WT5A6.js → refresh-manifest-MUWVIPFR.js} +12 -2
- package/dist/refresh-manifest-MUWVIPFR.js.map +1 -0
- package/package.json +11 -3
- package/dist/refresh-manifest-OW7WT5A6.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
|
};
|
|
@@ -1857,6 +2308,169 @@ function makeRefreshHandler(tick, secret) {
|
|
|
1857
2308
|
};
|
|
1858
2309
|
}
|
|
1859
2310
|
|
|
2311
|
+
// src/protect/firewall-log.js
|
|
2312
|
+
var DEFAULT_API_BASE = "https://api.patchstack.com";
|
|
2313
|
+
var DEFAULT_FLUSH_MS = 1e3;
|
|
2314
|
+
var MAX_BATCH = 50;
|
|
2315
|
+
var TOKEN_SKEW_MS = 6e4;
|
|
2316
|
+
function parseApiKey(apiKey) {
|
|
2317
|
+
if (typeof apiKey !== "string" || apiKey.length === 0) return null;
|
|
2318
|
+
const idx = apiKey.lastIndexOf("-");
|
|
2319
|
+
if (idx <= 0 || idx === apiKey.length - 1) return null;
|
|
2320
|
+
const clientSecret = apiKey.slice(0, idx);
|
|
2321
|
+
const clientId = apiKey.slice(idx + 1);
|
|
2322
|
+
if (!/^\d+$/.test(clientId) || clientSecret.length === 0) return null;
|
|
2323
|
+
return { clientId, clientSecret };
|
|
2324
|
+
}
|
|
2325
|
+
function resolveApiBase(pulseOrManifestUrl) {
|
|
2326
|
+
const fromEnv = typeof process !== "undefined" ? process.env?.PATCHSTACK_API_BASE : void 0;
|
|
2327
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) {
|
|
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
|
+
);
|
|
2333
|
+
}
|
|
2334
|
+
if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
|
|
2335
|
+
try {
|
|
2336
|
+
return new URL(pulseOrManifestUrl).origin;
|
|
2337
|
+
} catch {
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
const endpoint = typeof process !== "undefined" ? process.env?.PATCHSTACK_ENDPOINT : void 0;
|
|
2341
|
+
if (typeof endpoint === "string" && endpoint.length > 0) {
|
|
2342
|
+
try {
|
|
2343
|
+
return new URL(endpoint).origin;
|
|
2344
|
+
} catch {
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
return DEFAULT_API_BASE;
|
|
2348
|
+
}
|
|
2349
|
+
function createFirewallLogReporter(opts) {
|
|
2350
|
+
const creds = parseApiKey(opts.apiKey);
|
|
2351
|
+
if (!creds) {
|
|
2352
|
+
return { record() {
|
|
2353
|
+
}, flush() {
|
|
2354
|
+
}, stop() {
|
|
2355
|
+
} };
|
|
2356
|
+
}
|
|
2357
|
+
const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
|
|
2358
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
2359
|
+
const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS;
|
|
2360
|
+
const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
|
|
2361
|
+
let queue = [];
|
|
2362
|
+
let timer = null;
|
|
2363
|
+
let stopped = false;
|
|
2364
|
+
let cachedToken = null;
|
|
2365
|
+
let tokenInflight = null;
|
|
2366
|
+
const fetchAccessToken = async () => {
|
|
2367
|
+
if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_SKEW_MS) {
|
|
2368
|
+
return cachedToken.token;
|
|
2369
|
+
}
|
|
2370
|
+
if (tokenInflight) return tokenInflight;
|
|
2371
|
+
tokenInflight = (async () => {
|
|
2372
|
+
try {
|
|
2373
|
+
const res = await fetchImpl(`${apiBase}/oauth/token`, {
|
|
2374
|
+
method: "POST",
|
|
2375
|
+
headers: {
|
|
2376
|
+
"Content-Type": "application/json",
|
|
2377
|
+
Accept: "application/json",
|
|
2378
|
+
"User-Agent": "@patchstack/connect"
|
|
2379
|
+
},
|
|
2380
|
+
body: JSON.stringify({
|
|
2381
|
+
grant_type: "client_credentials",
|
|
2382
|
+
client_id: creds.clientId,
|
|
2383
|
+
client_secret: creds.clientSecret
|
|
2384
|
+
})
|
|
2385
|
+
});
|
|
2386
|
+
if (!res || !res.ok) return null;
|
|
2387
|
+
const body = await res.json();
|
|
2388
|
+
const token = body?.access_token;
|
|
2389
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
2390
|
+
const expiresIn = Number(body?.expires_in);
|
|
2391
|
+
const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
|
|
2392
|
+
cachedToken = { token, expiresAt: Date.now() + ttlMs };
|
|
2393
|
+
return token;
|
|
2394
|
+
} catch {
|
|
2395
|
+
return null;
|
|
2396
|
+
} finally {
|
|
2397
|
+
tokenInflight = null;
|
|
2398
|
+
}
|
|
2399
|
+
})();
|
|
2400
|
+
return tokenInflight;
|
|
2401
|
+
};
|
|
2402
|
+
const flush = () => {
|
|
2403
|
+
if (timer) {
|
|
2404
|
+
clearTimeout(timer);
|
|
2405
|
+
timer = null;
|
|
2406
|
+
}
|
|
2407
|
+
if (queue.length === 0 || typeof fetchImpl !== "function") return;
|
|
2408
|
+
const batch = queue.splice(0, MAX_BATCH);
|
|
2409
|
+
void (async () => {
|
|
2410
|
+
const token = await fetchAccessToken();
|
|
2411
|
+
if (!token) return;
|
|
2412
|
+
const body = new URLSearchParams();
|
|
2413
|
+
body.set("type", "firewall");
|
|
2414
|
+
body.set("logs", JSON.stringify(batch));
|
|
2415
|
+
try {
|
|
2416
|
+
const p = fetchImpl(`${apiBase}/api/logs/log`, {
|
|
2417
|
+
method: "POST",
|
|
2418
|
+
headers: {
|
|
2419
|
+
Authorization: `Bearer ${token}`,
|
|
2420
|
+
Accept: "application/json",
|
|
2421
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
2422
|
+
"User-Agent": "@patchstack/connect",
|
|
2423
|
+
...sourceHost ? { "Source-Host": sourceHost } : {}
|
|
2424
|
+
},
|
|
2425
|
+
body
|
|
2426
|
+
});
|
|
2427
|
+
if (p && typeof p.then === "function") p.catch(() => {
|
|
2428
|
+
});
|
|
2429
|
+
} catch {
|
|
2430
|
+
}
|
|
2431
|
+
})();
|
|
2432
|
+
};
|
|
2433
|
+
return {
|
|
2434
|
+
/**
|
|
2435
|
+
* @param {{
|
|
2436
|
+
* rule?: { id?: string | number },
|
|
2437
|
+
* method?: string | null,
|
|
2438
|
+
* path?: string | null,
|
|
2439
|
+
* ip?: string | null,
|
|
2440
|
+
* userAgent?: string | null,
|
|
2441
|
+
* }} event
|
|
2442
|
+
*/
|
|
2443
|
+
record(event) {
|
|
2444
|
+
if (stopped) return;
|
|
2445
|
+
const fid = event?.rule?.id;
|
|
2446
|
+
if (fid === void 0 || fid === null || fid === "") return;
|
|
2447
|
+
queue.push({
|
|
2448
|
+
fid,
|
|
2449
|
+
method: event.method ?? null,
|
|
2450
|
+
request_uri: event.path ?? null,
|
|
2451
|
+
ip: event.ip ?? null,
|
|
2452
|
+
user_agent: event.userAgent ?? null,
|
|
2453
|
+
log_date: (/* @__PURE__ */ new Date()).toISOString()
|
|
2454
|
+
});
|
|
2455
|
+
if (queue.length >= MAX_BATCH) {
|
|
2456
|
+
flush();
|
|
2457
|
+
return;
|
|
2458
|
+
}
|
|
2459
|
+
if (!timer) timer = setTimeout(flush, flushMs);
|
|
2460
|
+
},
|
|
2461
|
+
flush,
|
|
2462
|
+
stop() {
|
|
2463
|
+
stopped = true;
|
|
2464
|
+
flush();
|
|
2465
|
+
}
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
function telemetryEnabled() {
|
|
2469
|
+
const v = typeof process !== "undefined" ? process.env?.PATCHSTACK_TELEMETRY : void 0;
|
|
2470
|
+
if (v === void 0 || v === "") return true;
|
|
2471
|
+
return !/^(0|false|off|no)$/i.test(String(v));
|
|
2472
|
+
}
|
|
2473
|
+
|
|
1860
2474
|
// src/protect/supabase-guard.js
|
|
1861
2475
|
var GUARD_PATH = "/_patchstack/guard";
|
|
1862
2476
|
var HOP_BY_HOP = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
|
|
@@ -1929,9 +2543,30 @@ function createServerFnGuard({ protection }) {
|
|
|
1929
2543
|
}
|
|
1930
2544
|
async function createProtection(options = {}) {
|
|
1931
2545
|
const onError = options.onError;
|
|
1932
|
-
const
|
|
2546
|
+
const userOnDetect = options.onDetect ?? defaultOnDetect;
|
|
2547
|
+
const apiKey = await resolveApiKey(options);
|
|
2548
|
+
const firewallLog = apiKey && telemetryEnabled() && options.reportFirewallLog !== false ? createFirewallLogReporter({
|
|
2549
|
+
apiKey,
|
|
2550
|
+
apiBase: resolveApiBase(options.pulseRulesUrl ?? options.baseUrl),
|
|
2551
|
+
sourceHost: options.sourceHost,
|
|
2552
|
+
fetchImpl: options.fetchImpl
|
|
2553
|
+
}) : null;
|
|
2554
|
+
const onDetect = (detection) => {
|
|
2555
|
+
userOnDetect(detection);
|
|
2556
|
+
if (firewallLog && detection?.mode === "block") {
|
|
2557
|
+
firewallLog.record({
|
|
2558
|
+
rule: detection.rule,
|
|
2559
|
+
method: detection.method,
|
|
2560
|
+
path: detection.path,
|
|
2561
|
+
ip: detection.ip,
|
|
2562
|
+
userAgent: detection.userAgent
|
|
2563
|
+
});
|
|
2564
|
+
}
|
|
2565
|
+
};
|
|
1933
2566
|
const store = makeStore(options);
|
|
1934
|
-
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 });
|
|
1935
2570
|
let mode = resolveMode(options, bundle);
|
|
1936
2571
|
let requestRules;
|
|
1937
2572
|
let responseRules;
|
|
@@ -1955,39 +2590,84 @@ async function createProtection(options = {}) {
|
|
|
1955
2590
|
responseRuleSet = responseRules.map((rule) => ({
|
|
1956
2591
|
rule,
|
|
1957
2592
|
engine: new RuleEngine({ firewall: [rule], onError }),
|
|
1958
|
-
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
|
|
1959
2603
|
}));
|
|
1960
2604
|
egressEngine = new RuleEngine({ firewall: egressRules, onError });
|
|
1961
2605
|
};
|
|
1962
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
|
+
};
|
|
1963
2619
|
const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
|
|
1964
|
-
const
|
|
2620
|
+
const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
|
|
2621
|
+
const decide = (phase, result, block, allow2, ctx = {}) => {
|
|
1965
2622
|
if (!result || !result.blocked) return allow2();
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
2623
|
+
const effectiveMode = ruleMode(result.rule);
|
|
2624
|
+
onDetect({
|
|
2625
|
+
phase,
|
|
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,
|
|
2629
|
+
category: result.rule?.category,
|
|
2630
|
+
rule: result.rule,
|
|
2631
|
+
message: result.message,
|
|
2632
|
+
method: ctx.method,
|
|
2633
|
+
path: ctx.path,
|
|
2634
|
+
ip: ctx.ip,
|
|
2635
|
+
userAgent: ctx.userAgent
|
|
2636
|
+
});
|
|
2637
|
+
return effectiveMode === "block" ? block() : allow2();
|
|
1972
2638
|
};
|
|
1973
|
-
const screenText = (text, meta) => {
|
|
2639
|
+
const screenText = (text, meta, reqCtx) => {
|
|
1974
2640
|
let blockRule = null;
|
|
1975
2641
|
const redactions = [];
|
|
1976
|
-
|
|
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
|
+
}
|
|
1977
2649
|
let result;
|
|
1978
2650
|
try {
|
|
1979
|
-
result = re.evaluate({ _response: { ...meta, body: text } });
|
|
2651
|
+
result = re.evaluate({ ...reqCtx || {}, _response: { ...meta, body: text } });
|
|
1980
2652
|
} catch (err) {
|
|
1981
2653
|
onError?.(err);
|
|
1982
2654
|
continue;
|
|
1983
2655
|
}
|
|
1984
2656
|
if (!result.blocked) continue;
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
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);
|
|
1988
2668
|
else if (!blockRule) blockRule = rule;
|
|
1989
2669
|
}
|
|
1990
|
-
if (mode !== "block" || !blockRule && !redactions.length) return { verdict: "pass" };
|
|
2670
|
+
if (mode !== "block" || !blockRule && !redactions.length && !headerMutations.length) return { verdict: "pass" };
|
|
1991
2671
|
if (blockRule) return { verdict: "block" };
|
|
1992
2672
|
let body = text;
|
|
1993
2673
|
const headers = { ...meta.headers || {} };
|
|
@@ -2009,17 +2689,33 @@ async function createProtection(options = {}) {
|
|
|
2009
2689
|
}
|
|
2010
2690
|
}
|
|
2011
2691
|
}
|
|
2692
|
+
for (const rule of headerMutations) applyHeaderMutation(headers, rule);
|
|
2012
2693
|
return { verdict: "redact", body, headers };
|
|
2013
2694
|
};
|
|
2014
|
-
const
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
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);
|
|
2018
2714
|
if (r.verdict === "block") return leakResponse();
|
|
2019
2715
|
if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
|
|
2020
2716
|
return response;
|
|
2021
2717
|
};
|
|
2022
|
-
const wrapNodeResponse = (res) => {
|
|
2718
|
+
const wrapNodeResponse = (res, reqCtx) => {
|
|
2023
2719
|
const origWrite = res.write.bind(res);
|
|
2024
2720
|
const origEnd = res.end.bind(res);
|
|
2025
2721
|
const chunks = [];
|
|
@@ -2035,6 +2731,7 @@ async function createProtection(options = {}) {
|
|
|
2035
2731
|
chunks.length = 0;
|
|
2036
2732
|
origWrite(buf);
|
|
2037
2733
|
overflow = true;
|
|
2734
|
+
recordSkip("response", "body-cap", { bytes: size });
|
|
2038
2735
|
return;
|
|
2039
2736
|
}
|
|
2040
2737
|
chunks.push(buf);
|
|
@@ -2061,16 +2758,19 @@ async function createProtection(options = {}) {
|
|
|
2061
2758
|
}
|
|
2062
2759
|
collect(chunk, enc);
|
|
2063
2760
|
if (overflow) return origEnd(cb);
|
|
2064
|
-
const
|
|
2761
|
+
const buffer = Buffer.concat(chunks);
|
|
2065
2762
|
let ct = res.getHeader ? res.getHeader("content-type") : void 0;
|
|
2066
2763
|
if (Array.isArray(ct)) ct = ct[0];
|
|
2067
|
-
|
|
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");
|
|
2068
2767
|
for (const c of chunks) origWrite(c);
|
|
2069
2768
|
return origEnd(cb);
|
|
2070
2769
|
}
|
|
2770
|
+
const text = buffer.toString("utf8");
|
|
2071
2771
|
let r;
|
|
2072
2772
|
try {
|
|
2073
|
-
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} });
|
|
2773
|
+
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
|
|
2074
2774
|
} catch (err) {
|
|
2075
2775
|
onError?.(err);
|
|
2076
2776
|
for (const c of chunks) origWrite(c);
|
|
@@ -2093,7 +2793,12 @@ async function createProtection(options = {}) {
|
|
|
2093
2793
|
const current = res.getHeaders ? res.getHeaders() : {};
|
|
2094
2794
|
for (const [name, value] of Object.entries(r.headers)) {
|
|
2095
2795
|
if (name.toLowerCase() === "content-length") continue;
|
|
2096
|
-
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)) {
|
|
2097
2802
|
try {
|
|
2098
2803
|
res.setHeader(name, value);
|
|
2099
2804
|
} catch {
|
|
@@ -2123,8 +2828,9 @@ async function createProtection(options = {}) {
|
|
|
2123
2828
|
return false;
|
|
2124
2829
|
}
|
|
2125
2830
|
if (!result.blocked) return false;
|
|
2126
|
-
|
|
2127
|
-
|
|
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";
|
|
2128
2834
|
};
|
|
2129
2835
|
const protection = {
|
|
2130
2836
|
get mode() {
|
|
@@ -2133,9 +2839,18 @@ async function createProtection(options = {}) {
|
|
|
2133
2839
|
get rules() {
|
|
2134
2840
|
return { request: requestRules, response: responseRules, egress: egressRules };
|
|
2135
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
|
+
},
|
|
2136
2851
|
// Screen a fetch Response through the response-phase rules (redact/block). Used by
|
|
2137
2852
|
// .fetch(), and by the Supabase guard on its forwarded upstream response.
|
|
2138
|
-
screenResponse: (response) => screenResp(response),
|
|
2853
|
+
screenResponse: (response, request) => screenResp(response, request ? reqContextFromFetch(request) : void 0),
|
|
2139
2854
|
// (request) => Response | null (null = allow, caller proceeds). Request phase only.
|
|
2140
2855
|
fetchGuard() {
|
|
2141
2856
|
return async (request) => {
|
|
@@ -2146,7 +2861,7 @@ async function createProtection(options = {}) {
|
|
|
2146
2861
|
onError?.(err);
|
|
2147
2862
|
return null;
|
|
2148
2863
|
}
|
|
2149
|
-
return decide("request", result, () => blockResponse(result, request), () => null);
|
|
2864
|
+
return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
|
|
2150
2865
|
};
|
|
2151
2866
|
},
|
|
2152
2867
|
// Wrap a fetch handler: screens the request, then the response (redact/block).
|
|
@@ -2156,7 +2871,7 @@ async function createProtection(options = {}) {
|
|
|
2156
2871
|
const blocked = await guard(request);
|
|
2157
2872
|
if (blocked) return blocked;
|
|
2158
2873
|
const response = await handler(request, ...rest);
|
|
2159
|
-
return screenResp(response);
|
|
2874
|
+
return screenResp(response, reqContextFromFetch(request));
|
|
2160
2875
|
};
|
|
2161
2876
|
},
|
|
2162
2877
|
// Express middleware (request phase; expects express-parsed req.query/req.body).
|
|
@@ -2168,7 +2883,7 @@ async function createProtection(options = {}) {
|
|
|
2168
2883
|
result = engine.evaluate(req);
|
|
2169
2884
|
} catch (err) {
|
|
2170
2885
|
onError?.(err);
|
|
2171
|
-
if (exprOptions.screenResponses) wrapNodeResponse(res);
|
|
2886
|
+
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
2172
2887
|
return next();
|
|
2173
2888
|
}
|
|
2174
2889
|
decide(
|
|
@@ -2182,9 +2897,10 @@ async function createProtection(options = {}) {
|
|
|
2182
2897
|
}
|
|
2183
2898
|
},
|
|
2184
2899
|
() => {
|
|
2185
|
-
if (exprOptions.screenResponses) wrapNodeResponse(res);
|
|
2900
|
+
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
2186
2901
|
next();
|
|
2187
|
-
}
|
|
2902
|
+
},
|
|
2903
|
+
nodeRequestMeta(req)
|
|
2188
2904
|
);
|
|
2189
2905
|
};
|
|
2190
2906
|
},
|
|
@@ -2209,6 +2925,7 @@ async function createProtection(options = {}) {
|
|
|
2209
2925
|
next();
|
|
2210
2926
|
});
|
|
2211
2927
|
req.on("end", () => {
|
|
2928
|
+
if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
|
|
2212
2929
|
const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
|
|
2213
2930
|
let shaped;
|
|
2214
2931
|
let result;
|
|
@@ -2234,9 +2951,10 @@ async function createProtection(options = {}) {
|
|
|
2234
2951
|
},
|
|
2235
2952
|
() => {
|
|
2236
2953
|
if (req.body === void 0) req.body = shaped.body;
|
|
2237
|
-
if (nodeOptions.screenResponses) wrapNodeResponse(res);
|
|
2954
|
+
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
2238
2955
|
next();
|
|
2239
|
-
}
|
|
2956
|
+
},
|
|
2957
|
+
nodeRequestMeta(req)
|
|
2240
2958
|
);
|
|
2241
2959
|
});
|
|
2242
2960
|
};
|
|
@@ -2246,6 +2964,9 @@ async function createProtection(options = {}) {
|
|
|
2246
2964
|
protection.uninstallEgress = await installEgressGuard({
|
|
2247
2965
|
shouldBlock: egressShouldBlock,
|
|
2248
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),
|
|
2249
2970
|
dnsScreen: options.screenDns !== false,
|
|
2250
2971
|
allowHosts: options.allowHosts
|
|
2251
2972
|
});
|
|
@@ -2257,7 +2978,7 @@ async function createProtection(options = {}) {
|
|
|
2257
2978
|
let reporter = null;
|
|
2258
2979
|
if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
|
|
2259
2980
|
try {
|
|
2260
|
-
({ reportManifest: reporter } = await import("./refresh-manifest-
|
|
2981
|
+
({ reportManifest: reporter } = await import("./refresh-manifest-MUWVIPFR.js"));
|
|
2261
2982
|
} catch (err) {
|
|
2262
2983
|
onError?.(err);
|
|
2263
2984
|
}
|
|
@@ -2270,7 +2991,7 @@ async function createProtection(options = {}) {
|
|
|
2270
2991
|
onError?.(err);
|
|
2271
2992
|
}
|
|
2272
2993
|
}
|
|
2273
|
-
const next = await resolveRules(options, store);
|
|
2994
|
+
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
|
|
2274
2995
|
mode = resolveMode(options, next);
|
|
2275
2996
|
applyBundle(next);
|
|
2276
2997
|
};
|
|
@@ -2280,7 +3001,12 @@ async function createProtection(options = {}) {
|
|
|
2280
3001
|
}
|
|
2281
3002
|
if (options.refreshMs > 0 && live) {
|
|
2282
3003
|
const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
|
|
2283
|
-
protection.stopRefresh =
|
|
3004
|
+
protection.stopRefresh = () => {
|
|
3005
|
+
loop.stop();
|
|
3006
|
+
firewallLog?.stop();
|
|
3007
|
+
};
|
|
3008
|
+
} else if (firewallLog) {
|
|
3009
|
+
protection.stopRefresh = () => firewallLog.stop();
|
|
2284
3010
|
}
|
|
2285
3011
|
return protection;
|
|
2286
3012
|
}
|
|
@@ -2292,21 +3018,77 @@ function resolveMode(options, bundle) {
|
|
|
2292
3018
|
if (options?.mode === "dry-run") return "dry-run";
|
|
2293
3019
|
return "dry-run";
|
|
2294
3020
|
}
|
|
3021
|
+
async function resolveApiKey(options) {
|
|
3022
|
+
if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
|
|
3023
|
+
if (typeof process !== "undefined") {
|
|
3024
|
+
const fromEnv = process.env?.PATCHSTACK_API_KEY;
|
|
3025
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
3026
|
+
}
|
|
3027
|
+
try {
|
|
3028
|
+
if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
|
|
3029
|
+
const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
|
|
3030
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
3031
|
+
const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
|
|
3032
|
+
const key = JSON.parse(raw)?.apiKey;
|
|
3033
|
+
if (typeof key === "string" && key.length > 0) return key;
|
|
3034
|
+
} catch {
|
|
3035
|
+
}
|
|
3036
|
+
return void 0;
|
|
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
|
+
}
|
|
2295
3055
|
function byPhase(rules, phase) {
|
|
2296
3056
|
return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
|
|
2297
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
|
+
}
|
|
2298
3079
|
async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
2299
|
-
if (!response || typeof response.clone !== "function") return
|
|
2300
|
-
const ct =
|
|
2301
|
-
const
|
|
2302
|
-
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";
|
|
2303
3085
|
const len = Number(response.headers?.get?.("content-length") || 0);
|
|
2304
|
-
if (len && len > cap) return
|
|
3086
|
+
if (len && len > cap) return { skip: "body-cap" };
|
|
2305
3087
|
let clone;
|
|
2306
3088
|
try {
|
|
2307
3089
|
clone = response.clone();
|
|
2308
3090
|
} catch {
|
|
2309
|
-
return
|
|
3091
|
+
return { skip: "clone-failed" };
|
|
2310
3092
|
}
|
|
2311
3093
|
const body = clone.body;
|
|
2312
3094
|
if (body && typeof body.getReader === "function") {
|
|
@@ -2314,11 +3096,16 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
|
2314
3096
|
const chunks = [];
|
|
2315
3097
|
let size = 0;
|
|
2316
3098
|
let over = false;
|
|
3099
|
+
let sniffed = !sniff;
|
|
2317
3100
|
try {
|
|
2318
3101
|
for (; ; ) {
|
|
2319
3102
|
const { done, value } = await reader.read();
|
|
2320
3103
|
if (done) break;
|
|
2321
3104
|
if (!value) continue;
|
|
3105
|
+
if (!sniffed) {
|
|
3106
|
+
sniffed = true;
|
|
3107
|
+
if (looksBinary(value)) return { skip: "binary-body" };
|
|
3108
|
+
}
|
|
2322
3109
|
size += value.byteLength;
|
|
2323
3110
|
if (over) continue;
|
|
2324
3111
|
if (size > cap) {
|
|
@@ -2328,20 +3115,22 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
|
2328
3115
|
chunks.push(value);
|
|
2329
3116
|
}
|
|
2330
3117
|
} catch {
|
|
2331
|
-
return
|
|
3118
|
+
return { skip: "read-failed" };
|
|
2332
3119
|
}
|
|
2333
|
-
if (over) return
|
|
3120
|
+
if (over) return { skip: "body-cap" };
|
|
2334
3121
|
try {
|
|
2335
|
-
return new TextDecoder().decode(concatBytes(chunks, size));
|
|
3122
|
+
return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
|
|
2336
3123
|
} catch {
|
|
2337
|
-
return
|
|
3124
|
+
return { skip: "decode-failed" };
|
|
2338
3125
|
}
|
|
2339
3126
|
}
|
|
2340
3127
|
try {
|
|
2341
3128
|
const text = await clone.text();
|
|
2342
|
-
|
|
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 };
|
|
2343
3132
|
} catch {
|
|
2344
|
-
return
|
|
3133
|
+
return { skip: "read-failed" };
|
|
2345
3134
|
}
|
|
2346
3135
|
}
|
|
2347
3136
|
function concatBytes(chunks, total) {
|
|
@@ -2362,6 +3151,19 @@ function headerObject(headers) {
|
|
|
2362
3151
|
if (setCookies && setCookies.length) out["set-cookie"] = setCookies;
|
|
2363
3152
|
return out;
|
|
2364
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
|
+
}
|
|
2365
3167
|
function extractRedactors(rule) {
|
|
2366
3168
|
const out = [];
|
|
2367
3169
|
const walk = (conds) => {
|
|
@@ -2398,7 +3200,10 @@ function applyRedactors(body, redactors, mask, transform) {
|
|
|
2398
3200
|
let out = body;
|
|
2399
3201
|
for (const r of redactors) {
|
|
2400
3202
|
if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
|
|
2401
|
-
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
|
+
}
|
|
2402
3207
|
}
|
|
2403
3208
|
return out;
|
|
2404
3209
|
}
|
|
@@ -2501,12 +3306,52 @@ function restoreBigInts(text) {
|
|
|
2501
3306
|
if (!text.includes(BIGINT_OPEN)) return text;
|
|
2502
3307
|
return text.replace(new RegExp(`"${BIGINT_OPEN}(-?\\d+)${BIGINT_CLOSE}"`, "g"), "$1");
|
|
2503
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
|
+
}
|
|
2504
3344
|
function rebuildResponse(response, body, redactedHeaders) {
|
|
2505
3345
|
const headers = new Headers(response.headers);
|
|
2506
3346
|
headers.delete("content-length");
|
|
2507
3347
|
if (redactedHeaders) {
|
|
2508
3348
|
for (const [name, value] of Object.entries(redactedHeaders)) {
|
|
2509
|
-
if (
|
|
3349
|
+
if (value === null || value === void 0) {
|
|
3350
|
+
try {
|
|
3351
|
+
headers.delete(name);
|
|
3352
|
+
} catch {
|
|
3353
|
+
}
|
|
3354
|
+
} else if (typeof value === "string") {
|
|
2510
3355
|
if (headers.get(name) !== value) {
|
|
2511
3356
|
try {
|
|
2512
3357
|
headers.set(name, value);
|
|
@@ -2522,7 +3367,8 @@ function rebuildResponse(response, body, redactedHeaders) {
|
|
|
2522
3367
|
}
|
|
2523
3368
|
}
|
|
2524
3369
|
}
|
|
2525
|
-
|
|
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 });
|
|
2526
3372
|
}
|
|
2527
3373
|
function leakResponse() {
|
|
2528
3374
|
return new Response(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), {
|
|
@@ -2555,6 +3401,33 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
|
|
|
2555
3401
|
const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
|
|
2556
3402
|
console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
|
|
2557
3403
|
}
|
|
3404
|
+
function fetchRequestMeta(request) {
|
|
3405
|
+
if (!request) return {};
|
|
3406
|
+
let path = null;
|
|
3407
|
+
try {
|
|
3408
|
+
path = new URL(request.url).pathname;
|
|
3409
|
+
} catch {
|
|
3410
|
+
path = typeof request.url === "string" ? request.url : null;
|
|
3411
|
+
}
|
|
3412
|
+
return {
|
|
3413
|
+
method: request.method ?? null,
|
|
3414
|
+
path,
|
|
3415
|
+
ip: request.headers?.get?.("x-forwarded-for") ?? null,
|
|
3416
|
+
userAgent: request.headers?.get?.("user-agent") ?? null
|
|
3417
|
+
};
|
|
3418
|
+
}
|
|
3419
|
+
function nodeRequestMeta(req) {
|
|
3420
|
+
if (!req) return {};
|
|
3421
|
+
const headers = req.headers ?? {};
|
|
3422
|
+
const ua = headers["user-agent"] ?? headers["User-Agent"];
|
|
3423
|
+
const fwd = headers["x-forwarded-for"] ?? headers["X-Forwarded-For"];
|
|
3424
|
+
return {
|
|
3425
|
+
method: req.method ?? null,
|
|
3426
|
+
path: req.originalUrl || req.url || null,
|
|
3427
|
+
ip: req.ip ?? (typeof fwd === "string" ? fwd : null),
|
|
3428
|
+
userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
|
|
3429
|
+
};
|
|
3430
|
+
}
|
|
2558
3431
|
export {
|
|
2559
3432
|
GUARD_PATH,
|
|
2560
3433
|
PulseRuleClient,
|