@patchstack/connect 0.3.20 → 0.3.21
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/dist/protect.cjs +232 -155
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +12 -1
- package/dist/protect.js +207 -130
- package/dist/protect.js.map +1 -1
- package/package.json +1 -1
package/dist/protect.d.ts
CHANGED
|
@@ -23,7 +23,12 @@ export interface Protection {
|
|
|
23
23
|
node(options?: { maxBodyBytes?: number; screenResponses?: boolean }): (req: unknown, res: unknown, next: () => void) => void;
|
|
24
24
|
/** Present when `egress: true` — restores the original global fetch. */
|
|
25
25
|
uninstallEgress?: () => void;
|
|
26
|
-
/** Present
|
|
26
|
+
/** Present with a live source — re-fetch + hot-swap the rules once (used by the loop + push). */
|
|
27
|
+
refresh?: () => Promise<void>;
|
|
28
|
+
/** Present with a live source — a fetch handler that runs `refresh()` when the request carries
|
|
29
|
+
* the configured refresh secret (a push/zero-day trigger). No secret set → the handler 404s. */
|
|
30
|
+
refreshHandler?: () => (request: Request) => Promise<Response>;
|
|
31
|
+
/** Present when `refreshMs > 0` — stops the live rule-refresh loop. */
|
|
27
32
|
stopRefresh?: () => void;
|
|
28
33
|
}
|
|
29
34
|
|
|
@@ -46,6 +51,12 @@ export interface CreateProtectionOptions {
|
|
|
46
51
|
* meaningful with a live source (`siteUuid`/`token`).
|
|
47
52
|
*/
|
|
48
53
|
refreshMs?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Shared secret gating the push refresh endpoint (`refreshHandler()`): the platform/SaaS hits the
|
|
56
|
+
* endpoint with this secret to trigger an immediate refresh. Falls back to `PATCHSTACK_REFRESH_SECRET`.
|
|
57
|
+
* Unset → the endpoint 404s (never an open refresh trigger).
|
|
58
|
+
*/
|
|
59
|
+
refreshSecret?: string;
|
|
49
60
|
/**
|
|
50
61
|
* During a refresh, also re-post the dependency manifest (the runtime counterpart to `scan`) so a
|
|
51
62
|
* dependency added after boot — e.g. via `npm install <pkg>`, which fires no npm lifecycle hook —
|
package/dist/protect.js
CHANGED
|
@@ -1665,9 +1665,189 @@ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
|
|
|
1665
1665
|
</html>`;
|
|
1666
1666
|
}
|
|
1667
1667
|
|
|
1668
|
-
// src/protect/
|
|
1668
|
+
// src/protect/rules/store.js
|
|
1669
1669
|
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
1670
1670
|
import { join } from "path";
|
|
1671
|
+
function makeStore(options = {}) {
|
|
1672
|
+
let mem = null;
|
|
1673
|
+
const durable = durableTier(options);
|
|
1674
|
+
return {
|
|
1675
|
+
async read() {
|
|
1676
|
+
if (mem) return mem;
|
|
1677
|
+
const env = await durable.read();
|
|
1678
|
+
if (env) mem = env;
|
|
1679
|
+
return env;
|
|
1680
|
+
},
|
|
1681
|
+
async write(env) {
|
|
1682
|
+
if (env) mem = env;
|
|
1683
|
+
await durable.write(env);
|
|
1684
|
+
}
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
function durableTier(options) {
|
|
1688
|
+
const adapter = options.ruleCache;
|
|
1689
|
+
if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
|
|
1690
|
+
return {
|
|
1691
|
+
read: async () => {
|
|
1692
|
+
try {
|
|
1693
|
+
return toEnvelope(await adapter.read());
|
|
1694
|
+
} catch {
|
|
1695
|
+
return null;
|
|
1696
|
+
}
|
|
1697
|
+
},
|
|
1698
|
+
write: async (env) => {
|
|
1699
|
+
try {
|
|
1700
|
+
await adapter.write(env);
|
|
1701
|
+
} catch {
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
const dir = options.cacheDir;
|
|
1707
|
+
return {
|
|
1708
|
+
read: async () => cacheRead(dir),
|
|
1709
|
+
write: async (env) => cacheWrite(dir, env)
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
function cachePath(dir) {
|
|
1713
|
+
return join(dir, "patchstack-rules.json");
|
|
1714
|
+
}
|
|
1715
|
+
function cacheWrite(dir, env) {
|
|
1716
|
+
if (!dir) return;
|
|
1717
|
+
try {
|
|
1718
|
+
mkdirSync(dir, { recursive: true });
|
|
1719
|
+
writeFileSync(cachePath(dir), JSON.stringify(env));
|
|
1720
|
+
} catch {
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
function cacheRead(dir) {
|
|
1724
|
+
if (!dir) return null;
|
|
1725
|
+
try {
|
|
1726
|
+
return toEnvelope(JSON.parse(readFileSync(cachePath(dir), "utf8")));
|
|
1727
|
+
} catch {
|
|
1728
|
+
return null;
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
function toEnvelope(value) {
|
|
1732
|
+
if (!value || typeof value !== "object") return null;
|
|
1733
|
+
if (value.bundle && typeof value.bundle === "object") {
|
|
1734
|
+
return { bundle: value.bundle, etag: value.etag ?? null };
|
|
1735
|
+
}
|
|
1736
|
+
if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
|
|
1737
|
+
return { bundle: value, etag: null };
|
|
1738
|
+
}
|
|
1739
|
+
return null;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// src/protect/rules/source.js
|
|
1743
|
+
async function resolveRules(options, store) {
|
|
1744
|
+
if (options.siteUuid) {
|
|
1745
|
+
const prior = await store.read();
|
|
1746
|
+
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag });
|
|
1747
|
+
const res = await client.getRules();
|
|
1748
|
+
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
|
|
1749
|
+
if (res.success && !res.notModified) {
|
|
1750
|
+
const bundle = normalizeBundle(res);
|
|
1751
|
+
await store.write({ bundle, etag: res.etag ?? null });
|
|
1752
|
+
return bundle;
|
|
1753
|
+
}
|
|
1754
|
+
if (prior?.bundle) {
|
|
1755
|
+
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
1756
|
+
return normalizeBundle(prior.bundle);
|
|
1757
|
+
}
|
|
1758
|
+
if (options.rules) {
|
|
1759
|
+
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
|
|
1760
|
+
return normalizeBundle(options.rules);
|
|
1761
|
+
}
|
|
1762
|
+
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
1763
|
+
return emptyBundle();
|
|
1764
|
+
}
|
|
1765
|
+
if (options.token) {
|
|
1766
|
+
const prior = await store.read();
|
|
1767
|
+
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag });
|
|
1768
|
+
const res = await client.getRules();
|
|
1769
|
+
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
|
|
1770
|
+
if (res.success && !res.notModified) {
|
|
1771
|
+
const bundle = normalizeBundle(res);
|
|
1772
|
+
await store.write({ bundle, etag: res.etag ?? null });
|
|
1773
|
+
return bundle;
|
|
1774
|
+
}
|
|
1775
|
+
if (prior?.bundle) {
|
|
1776
|
+
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
1777
|
+
return normalizeBundle(prior.bundle);
|
|
1778
|
+
}
|
|
1779
|
+
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
1780
|
+
return emptyBundle();
|
|
1781
|
+
}
|
|
1782
|
+
if (options.rules) {
|
|
1783
|
+
return normalizeBundle(options.rules);
|
|
1784
|
+
}
|
|
1785
|
+
return emptyBundle();
|
|
1786
|
+
}
|
|
1787
|
+
function normalizeBundle(b) {
|
|
1788
|
+
return {
|
|
1789
|
+
firewall: Array.isArray(b.firewall) ? b.firewall : [],
|
|
1790
|
+
whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
|
|
1791
|
+
whitelist_keys: b.whitelist_keys ?? {}
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
function emptyBundle() {
|
|
1795
|
+
return { firewall: [], whitelists: [], whitelist_keys: {} };
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// src/protect/rules/refresh.js
|
|
1799
|
+
var JITTER_FRACTION3 = 0.1;
|
|
1800
|
+
var MAX_BACKOFF_MULTIPLIER = 8;
|
|
1801
|
+
function startRefresh(tick, { refreshMs, onError } = {}) {
|
|
1802
|
+
let stopped = false;
|
|
1803
|
+
let failures = 0;
|
|
1804
|
+
let timer = null;
|
|
1805
|
+
const schedule = () => {
|
|
1806
|
+
if (stopped) return;
|
|
1807
|
+
const backoff = Math.min(2 ** failures, MAX_BACKOFF_MULTIPLIER);
|
|
1808
|
+
const jitter = 1 - Math.random() * JITTER_FRACTION3;
|
|
1809
|
+
const delay = refreshMs * backoff * jitter;
|
|
1810
|
+
timer = setTimeout(run, delay);
|
|
1811
|
+
timer?.unref?.();
|
|
1812
|
+
};
|
|
1813
|
+
const run = async () => {
|
|
1814
|
+
if (stopped) return;
|
|
1815
|
+
try {
|
|
1816
|
+
await tick();
|
|
1817
|
+
failures = 0;
|
|
1818
|
+
} catch (err) {
|
|
1819
|
+
failures++;
|
|
1820
|
+
onError?.(err);
|
|
1821
|
+
}
|
|
1822
|
+
schedule();
|
|
1823
|
+
};
|
|
1824
|
+
schedule();
|
|
1825
|
+
return {
|
|
1826
|
+
stop() {
|
|
1827
|
+
stopped = true;
|
|
1828
|
+
if (timer) clearTimeout(timer);
|
|
1829
|
+
}
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
function makeRefreshHandler(tick, secret) {
|
|
1833
|
+
return async (request) => {
|
|
1834
|
+
if (!secret) return new Response("not found", { status: 404 });
|
|
1835
|
+
let provided = null;
|
|
1836
|
+
try {
|
|
1837
|
+
provided = request?.headers?.get?.("x-patchstack-refresh") ?? new URL(request.url).searchParams.get("token");
|
|
1838
|
+
} catch {
|
|
1839
|
+
provided = null;
|
|
1840
|
+
}
|
|
1841
|
+
if (provided !== secret) return new Response("forbidden", { status: 403 });
|
|
1842
|
+
let refreshed = true;
|
|
1843
|
+
try {
|
|
1844
|
+
await tick();
|
|
1845
|
+
} catch {
|
|
1846
|
+
refreshed = false;
|
|
1847
|
+
}
|
|
1848
|
+
return new Response(JSON.stringify({ refreshed }), { status: 200, headers: { "content-type": "application/json" } });
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1671
1851
|
|
|
1672
1852
|
// src/protect/supabase-guard.js
|
|
1673
1853
|
var GUARD_PATH = "/_patchstack/guard";
|
|
@@ -1743,7 +1923,8 @@ async function createProtection(options = {}) {
|
|
|
1743
1923
|
const mode = options.mode === "block" ? "block" : "dry-run";
|
|
1744
1924
|
const onError = options.onError;
|
|
1745
1925
|
const onDetect = options.onDetect ?? defaultOnDetect;
|
|
1746
|
-
const
|
|
1926
|
+
const store = makeStore(options);
|
|
1927
|
+
const bundle = await resolveRules(options, store);
|
|
1747
1928
|
let requestRules;
|
|
1748
1929
|
let responseRules;
|
|
1749
1930
|
let egressRules;
|
|
@@ -2059,30 +2240,35 @@ async function createProtection(options = {}) {
|
|
|
2059
2240
|
allowHosts: options.allowHosts
|
|
2060
2241
|
});
|
|
2061
2242
|
}
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2243
|
+
const cwd = options.cwd ?? (typeof process !== "undefined" ? process.cwd() : void 0);
|
|
2244
|
+
const live = Boolean(options.siteUuid || options.token);
|
|
2245
|
+
const refreshSecret = options.refreshSecret ?? (typeof process !== "undefined" ? process.env.PATCHSTACK_REFRESH_SECRET : void 0);
|
|
2246
|
+
const refreshable = live && (options.refreshMs > 0 || Boolean(refreshSecret));
|
|
2247
|
+
let reporter = null;
|
|
2248
|
+
if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
|
|
2249
|
+
try {
|
|
2250
|
+
({ reportManifest: reporter } = await import("./refresh-manifest-XBOGN446.js"));
|
|
2251
|
+
} catch (err) {
|
|
2252
|
+
onError?.(err);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
const runRefreshTick = async () => {
|
|
2256
|
+
if (reporter) {
|
|
2066
2257
|
try {
|
|
2067
|
-
|
|
2258
|
+
await reporter(cwd);
|
|
2068
2259
|
} catch (err) {
|
|
2069
2260
|
onError?.(err);
|
|
2070
2261
|
}
|
|
2071
2262
|
}
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
applyBundle(await resolveRules(options));
|
|
2082
|
-
})().catch((err) => onError?.(err));
|
|
2083
|
-
}, options.refreshMs);
|
|
2084
|
-
timer.unref?.();
|
|
2085
|
-
protection.stopRefresh = () => clearInterval(timer);
|
|
2263
|
+
applyBundle(await resolveRules(options, store));
|
|
2264
|
+
};
|
|
2265
|
+
if (live) {
|
|
2266
|
+
protection.refresh = () => runRefreshTick();
|
|
2267
|
+
protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
|
|
2268
|
+
}
|
|
2269
|
+
if (options.refreshMs > 0 && live) {
|
|
2270
|
+
const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
|
|
2271
|
+
protection.stopRefresh = loop.stop;
|
|
2086
2272
|
}
|
|
2087
2273
|
return protection;
|
|
2088
2274
|
}
|
|
@@ -2324,115 +2510,6 @@ function leakResponse() {
|
|
|
2324
2510
|
headers: { "content-type": "application/json" }
|
|
2325
2511
|
});
|
|
2326
2512
|
}
|
|
2327
|
-
async function resolveRules(options) {
|
|
2328
|
-
const cache = makeCache(options);
|
|
2329
|
-
if (options.siteUuid) {
|
|
2330
|
-
const prior = await cache.read();
|
|
2331
|
-
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag });
|
|
2332
|
-
const res = await client.getRules();
|
|
2333
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
|
|
2334
|
-
if (res.success && !res.notModified) {
|
|
2335
|
-
const bundle = normalizeBundle(res);
|
|
2336
|
-
await cache.write({ bundle, etag: res.etag ?? null });
|
|
2337
|
-
return bundle;
|
|
2338
|
-
}
|
|
2339
|
-
if (prior?.bundle) {
|
|
2340
|
-
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
2341
|
-
return normalizeBundle(prior.bundle);
|
|
2342
|
-
}
|
|
2343
|
-
if (options.rules) {
|
|
2344
|
-
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
|
|
2345
|
-
return normalizeBundle(options.rules);
|
|
2346
|
-
}
|
|
2347
|
-
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
2348
|
-
return emptyBundle();
|
|
2349
|
-
}
|
|
2350
|
-
if (options.token) {
|
|
2351
|
-
const prior = await cache.read();
|
|
2352
|
-
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag });
|
|
2353
|
-
const res = await client.getRules();
|
|
2354
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
|
|
2355
|
-
if (res.success && !res.notModified) {
|
|
2356
|
-
const bundle = normalizeBundle(res);
|
|
2357
|
-
await cache.write({ bundle, etag: res.etag ?? null });
|
|
2358
|
-
return bundle;
|
|
2359
|
-
}
|
|
2360
|
-
if (prior?.bundle) {
|
|
2361
|
-
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
2362
|
-
return normalizeBundle(prior.bundle);
|
|
2363
|
-
}
|
|
2364
|
-
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
2365
|
-
return emptyBundle();
|
|
2366
|
-
}
|
|
2367
|
-
if (options.rules) {
|
|
2368
|
-
return normalizeBundle(options.rules);
|
|
2369
|
-
}
|
|
2370
|
-
return emptyBundle();
|
|
2371
|
-
}
|
|
2372
|
-
function normalizeBundle(b) {
|
|
2373
|
-
return {
|
|
2374
|
-
firewall: Array.isArray(b.firewall) ? b.firewall : [],
|
|
2375
|
-
whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
|
|
2376
|
-
whitelist_keys: b.whitelist_keys ?? {}
|
|
2377
|
-
};
|
|
2378
|
-
}
|
|
2379
|
-
function emptyBundle() {
|
|
2380
|
-
return { firewall: [], whitelists: [], whitelist_keys: {} };
|
|
2381
|
-
}
|
|
2382
|
-
function makeCache(options) {
|
|
2383
|
-
const adapter = options.ruleCache;
|
|
2384
|
-
if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
|
|
2385
|
-
return {
|
|
2386
|
-
read: async () => {
|
|
2387
|
-
try {
|
|
2388
|
-
return toEnvelope(await adapter.read());
|
|
2389
|
-
} catch {
|
|
2390
|
-
return null;
|
|
2391
|
-
}
|
|
2392
|
-
},
|
|
2393
|
-
write: async (env) => {
|
|
2394
|
-
try {
|
|
2395
|
-
await adapter.write(env);
|
|
2396
|
-
} catch {
|
|
2397
|
-
}
|
|
2398
|
-
}
|
|
2399
|
-
};
|
|
2400
|
-
}
|
|
2401
|
-
const dir = options.cacheDir;
|
|
2402
|
-
return {
|
|
2403
|
-
read: async () => cacheRead(dir),
|
|
2404
|
-
write: async (env) => cacheWrite(dir, env)
|
|
2405
|
-
};
|
|
2406
|
-
}
|
|
2407
|
-
function cachePath(dir) {
|
|
2408
|
-
return join(dir, "patchstack-rules.json");
|
|
2409
|
-
}
|
|
2410
|
-
function cacheWrite(dir, env) {
|
|
2411
|
-
if (!dir) return;
|
|
2412
|
-
try {
|
|
2413
|
-
mkdirSync(dir, { recursive: true });
|
|
2414
|
-
writeFileSync(cachePath(dir), JSON.stringify(env));
|
|
2415
|
-
} catch {
|
|
2416
|
-
}
|
|
2417
|
-
}
|
|
2418
|
-
function cacheRead(dir) {
|
|
2419
|
-
if (!dir) return null;
|
|
2420
|
-
try {
|
|
2421
|
-
return toEnvelope(JSON.parse(readFileSync(cachePath(dir), "utf8")));
|
|
2422
|
-
} catch {
|
|
2423
|
-
return null;
|
|
2424
|
-
}
|
|
2425
|
-
}
|
|
2426
|
-
function toEnvelope(value) {
|
|
2427
|
-
if (!value || typeof value !== "object") return null;
|
|
2428
|
-
if (value.bundle && typeof value.bundle === "object") {
|
|
2429
|
-
return { bundle: value.bundle, etag: value.etag ?? null };
|
|
2430
|
-
}
|
|
2431
|
-
if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
|
|
2432
|
-
return { bundle: value, etag: null };
|
|
2433
|
-
}
|
|
2434
|
-
return null;
|
|
2435
|
-
}
|
|
2436
2513
|
var BLOCK_MESSAGE = "This request has been blocked by Patchstack.";
|
|
2437
2514
|
function blockBody(result) {
|
|
2438
2515
|
return { error: BLOCK_MESSAGE, message: BLOCK_MESSAGE, rule: result?.rule?.id };
|