@patchstack/connect 0.3.28 → 0.3.30

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.
Files changed (40) hide show
  1. package/AGENT-INSTALL.md +37 -21
  2. package/README.md +24 -10
  3. package/dist/{chunk-MJOTFUDE.js → chunk-LLKP5EJS.js} +1 -1
  4. package/dist/chunk-LLKP5EJS.js.map +1 -0
  5. package/dist/cli.js +1267 -224
  6. package/dist/cli.js.map +1 -1
  7. package/dist/index.cjs +419 -104
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +28 -9
  10. package/dist/index.d.ts +28 -9
  11. package/dist/index.js +412 -97
  12. package/dist/index.js.map +1 -1
  13. package/dist/protect/templates/astro-middleware.ts +33 -13
  14. package/dist/protect/templates/demo-rules.json +2 -3
  15. package/dist/protect/templates/express-guard.cjs +26 -15
  16. package/dist/protect/templates/express-guard.js +26 -15
  17. package/dist/protect/templates/express-guard.ts +33 -16
  18. package/dist/protect/templates/fastify-plugin.cjs +33 -11
  19. package/dist/protect/templates/fastify-plugin.js +33 -11
  20. package/dist/protect/templates/fastify-plugin.ts +40 -12
  21. package/dist/protect/templates/generic-guard.cjs +28 -12
  22. package/dist/protect/templates/generic-guard.js +28 -13
  23. package/dist/protect/templates/generic-guard.ts +41 -20
  24. package/dist/protect/templates/guard.ts +47 -27
  25. package/dist/protect/templates/next-middleware.ts +29 -12
  26. package/dist/protect/templates/nuxt-middleware.ts +29 -12
  27. package/dist/protect/templates/rules.json +2 -2
  28. package/dist/protect/templates/sveltekit-hooks.ts +33 -13
  29. package/dist/protect.cjs +1039 -235
  30. package/dist/protect.cjs.map +1 -1
  31. package/dist/protect.d.ts +39 -11
  32. package/dist/protect.edge.js +663 -132
  33. package/dist/protect.edge.js.map +4 -4
  34. package/dist/protect.js +665 -134
  35. package/dist/protect.js.map +1 -1
  36. package/dist/{refresh-manifest-VRBE6RH6.js → refresh-manifest-ZSP76JWQ.js} +351 -94
  37. package/dist/refresh-manifest-ZSP76JWQ.js.map +1 -0
  38. package/package.json +5 -2
  39. package/dist/chunk-MJOTFUDE.js.map +0 -1
  40. package/dist/refresh-manifest-VRBE6RH6.js.map +0 -1
@@ -10,31 +10,48 @@ import fallbackRules from "./rules.json";
10
10
  // Baked by `patchstack-connect protect` from .patchstackrc.json when available.
11
11
  const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
12
12
 
13
- let _protection: Awaited<ReturnType<typeof createProtection>> | undefined;
13
+ let _protection: Promise<Awaited<ReturnType<typeof createProtection>>> | undefined;
14
14
 
15
- /** One memoized protection policy. Rules come from the Patchstack API per-site (cached); the
16
- * bundled rules.json is the fallback until a site UUID / token is configured. */
15
+ /**
16
+ * One protection policy, memoized on the IN-FLIGHT promise rather than the resolved value.
17
+ *
18
+ * A cold start takes several concurrent requests. Caching only the finished value lets each of them see an
19
+ * empty cache and start its own build — several rule fetches, several refresh loops, and several policies
20
+ * where the app is meant to have one. Holding the promise means the first request starts it and the rest
21
+ * await the same one.
22
+ *
23
+ * A failed build is not cached: the slot is cleared so the next request tries again rather than inheriting
24
+ * one bad boot for the life of the process.
25
+ */
17
26
  export async function getProtection() {
18
27
  if (!_protection) {
19
- const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
20
- const token = process.env.PATCHSTACK_WAF_TOKEN;
21
- const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
22
- // The sandbox dev server is long-lived and isn't restarted on change, so refresh the live
23
- // rules periodically — a dependency flagged after boot is then enforced without a restart.
24
- // Production relies on a redeploy (which re-fetches at boot), so refresh stays off there.
25
- const refreshMs = process.env.PATCHSTACK_ENVIRONMENT === "sandbox" ? 15000 : 0;
26
- const common = { mode, egress: true, refreshMs } as const;
27
- _protection = await createProtection(
28
- siteUuid
29
- ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" }
30
- : token
31
- ? { ...common, token, cacheDir: ".patchstack" }
32
- : { ...common, rules: fallbackRules as never },
33
- );
28
+ _protection = buildProtection().catch((err) => {
29
+ _protection = undefined; // don't cache a failed boot
30
+ throw err;
31
+ });
34
32
  }
33
+
35
34
  return _protection;
36
35
  }
37
36
 
37
+ async function buildProtection() {
38
+ const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
39
+ const token = process.env.PATCHSTACK_WAF_TOKEN;
40
+ const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
41
+ // The sandbox dev server is long-lived and isn't restarted on change, so refresh the live
42
+ // rules periodically — a dependency flagged after boot is then enforced without a restart.
43
+ // Production relies on a redeploy (which re-fetches at boot), so refresh stays off there.
44
+ const refreshMs = process.env.PATCHSTACK_ENVIRONMENT === "sandbox" ? 15000 : 0;
45
+ const common = { mode, egress: true, refreshMs } as const;
46
+ return createProtection(
47
+ siteUuid
48
+ ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" }
49
+ : token
50
+ ? { ...common, token, cacheDir: ".patchstack" }
51
+ : { ...common, rules: fallbackRules as never },
52
+ );
53
+ }
54
+
38
55
  // --- Web Fetch (Cloudflare Workers, Bun, Deno, Hono, Next edge, TanStack server.ts) ---------
39
56
  // Wrap your fetch handler: export default { fetch: protectFetch(originalFetch) }
40
57
  export function protectFetch<H extends (request: Request, ...rest: unknown[]) => unknown>(handler: H): H {
@@ -42,12 +59,16 @@ export function protectFetch<H extends (request: Request, ...rest: unknown[]) =>
42
59
  const protection = await getProtection();
43
60
  const blocked = await protection.fetchGuard()(request);
44
61
  if (blocked) return blocked;
45
- return protection.screenResponse(await handler(request, ...rest) as Response);
62
+ // Response rules can be scoped to a route or a method, and the engine can only apply that scope if it is
63
+ // given the request the response belongs to. Passed through here for that reason: without it a scoped
64
+ // response rule is delivered, counted as protection, and never matches anything.
65
+ return protection.screenResponse(await handler(request, ...rest) as Response, request);
46
66
  }) as H;
47
67
  }
48
68
 
49
69
  // --- Node / Express -------------------------------------------------------------------------
50
- // Add before your routes: app.use(patchstackMiddleware)
70
+ // app.use(patchstackMiddleware) — register it before any body parser and before your routes. This guard
71
+ // reads the request stream itself and exposes what it read as req.body, so a parser is not also needed.
51
72
  export function patchstackMiddleware(req: unknown, res: unknown, next: (err?: unknown) => void) {
52
73
  getProtection()
53
74
  .then((protection) => (protection.node() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, next))
@@ -26,37 +26,54 @@ const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
26
26
  export { GUARD_PATH };
27
27
 
28
28
  // One shared protection policy for both guards (rules load once).
29
- let _protection: Awaited<ReturnType<typeof createProtection>> | undefined;
29
+ /**
30
+ * One protection policy, memoized on the IN-FLIGHT promise rather than the resolved value.
31
+ *
32
+ * A cold start takes several concurrent requests. Caching only the finished value lets each of them see an
33
+ * empty cache and start its own build — several rule fetches, several refresh loops, and several policies
34
+ * where the app is meant to have one. Holding the promise means the first request starts it and the rest
35
+ * await the same one. A failed build is not cached, so the next request retries rather than inheriting one
36
+ * bad boot for the life of the process.
37
+ */
38
+ let _protection: Promise<Awaited<ReturnType<typeof createProtection>>> | undefined;
30
39
  async function getProtection() {
31
40
  if (!_protection) {
32
- // Always-on: block by default. An explicit PATCHSTACK_MODE=dry-run downgrades to log-only.
33
- const mode: "block" | "dry-run" = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
34
- const token = process.env.PATCHSTACK_WAF_TOKEN;
35
- const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
36
- // Egress SSRF screening: block the app's outbound calls to internal / metadata addresses,
37
- // but never its own Supabase project.
38
- let allowHosts: string[] = [];
39
- try {
40
- if (process.env.SUPABASE_URL) allowHosts = [new URL(process.env.SUPABASE_URL).host];
41
- } catch {
42
- /* ignore a malformed SUPABASE_URL — just don't add an allow entry */
43
- }
44
- // The sandbox dev server is long-lived and isn't restarted on change, so refresh the live
45
- // rules periodically — a dependency flagged after boot is then enforced without a restart.
46
- // Production relies on a redeploy (which re-fetches at boot), so refresh stays off there.
47
- const refreshMs = process.env.PATCHSTACK_ENVIRONMENT === "sandbox" ? 15000 : 0;
48
- const common = { mode, egress: true, allowHosts, refreshMs };
49
- _protection = await createProtection(
50
- siteUuid
51
- ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" } // live per-site rules; bundled = offline fallback
52
- : token
53
- ? { ...common, token, cacheDir: ".patchstack" } // live per-site WAF rules from the Patchstack API (cached)
54
- : { ...common, rules: fallbackRules as never }, // demo fallback until a site UUID / token is set
55
- );
41
+ _protection = buildProtection().catch((err) => {
42
+ _protection = undefined; // don't cache a failed boot
43
+ throw err;
44
+ });
56
45
  }
46
+
57
47
  return _protection;
58
48
  }
59
49
 
50
+ async function buildProtection() {
51
+ // Always-on: block by default. An explicit PATCHSTACK_MODE=dry-run downgrades to log-only.
52
+ const mode: "block" | "dry-run" = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
53
+ const token = process.env.PATCHSTACK_WAF_TOKEN;
54
+ const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
55
+ // Egress SSRF screening: block the app's outbound calls to internal / metadata addresses,
56
+ // but never its own Supabase project.
57
+ let allowHosts: string[] = [];
58
+ try {
59
+ if (process.env.SUPABASE_URL) allowHosts = [new URL(process.env.SUPABASE_URL).host];
60
+ } catch {
61
+ /* ignore a malformed SUPABASE_URL — just don't add an allow entry */
62
+ }
63
+ // The sandbox dev server is long-lived and isn't restarted on change, so refresh the live
64
+ // rules periodically — a dependency flagged after boot is then enforced without a restart.
65
+ // Production relies on a redeploy (which re-fetches at boot), so refresh stays off there.
66
+ const refreshMs = process.env.PATCHSTACK_ENVIRONMENT === "sandbox" ? 15000 : 0;
67
+ const common = { mode, egress: true, allowHosts, refreshMs };
68
+ return createProtection(
69
+ siteUuid
70
+ ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" } // live per-site rules; bundled = offline fallback
71
+ : token
72
+ ? { ...common, token, cacheDir: ".patchstack" } // live per-site WAF rules from the Patchstack API (cached)
73
+ : { ...common, rules: fallbackRules as never }, // demo fallback until a site UUID / token is set
74
+ );
75
+ }
76
+
60
77
  // Request-middleware path: the browser tunnels its direct Supabase calls here.
61
78
  let _handle: ((request: Request) => Promise<Response>) | undefined;
62
79
  export async function handleGuardRequest(request: Request): Promise<Response> {
@@ -84,11 +101,14 @@ export async function inspectServerFn(data: unknown): Promise<{ rule?: string; m
84
101
  // src/start.ts (the browser→Supabase tunnel screens its own forwarded response). Only acts on a
85
102
  // web Response (text/JSON/HTML) — anything else, or any error, passes through untouched (fail-open,
86
103
  // never breaks a response).
87
- export async function screenResponse<T>(response: T): Promise<T> {
104
+ // `request` is optional but should be passed wherever it is available: response rules can be scoped to a
105
+ // route or a method, and the engine can only apply that scope if it is given the request the response
106
+ // belongs to. Without it a scoped response rule is delivered, counted as protection, and matches nothing.
107
+ export async function screenResponse<T>(response: T, request?: Request): Promise<T> {
88
108
  try {
89
109
  if (!(response instanceof Response)) return response;
90
110
  const protection = await getProtection();
91
- return (protection.screenResponse ? await protection.screenResponse(response) : response) as T;
111
+ return (protection.screenResponse ? await protection.screenResponse(response, request) : response) as T;
92
112
  } catch {
93
113
  return response; // fail open
94
114
  }
@@ -7,24 +7,41 @@ import fallbackRules from "./patchstack.rules.json";
7
7
 
8
8
  const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
9
9
 
10
- let _protection: Awaited<ReturnType<typeof createProtection>> | undefined;
10
+ /**
11
+ * One protection policy, memoized on the IN-FLIGHT promise rather than the resolved value.
12
+ *
13
+ * A cold start takes several concurrent requests. Caching only the finished value lets each of them see an
14
+ * empty cache and start its own build — several rule fetches, several refresh loops, and several policies
15
+ * where the app is meant to have one. Holding the promise means the first request starts it and the rest
16
+ * await the same one. A failed build is not cached, so the next request retries rather than inheriting one
17
+ * bad boot for the life of the process.
18
+ */
19
+ let _protection: Promise<Awaited<ReturnType<typeof createProtection>>> | undefined;
11
20
  async function getProtection() {
12
21
  if (!_protection) {
13
- const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
14
- const token = process.env.PATCHSTACK_WAF_TOKEN;
15
- const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
16
- const common = { mode, egress: true } as const;
17
- _protection = await createProtection(
18
- siteUuid
19
- ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" }
20
- : token
21
- ? { ...common, token, cacheDir: ".patchstack" }
22
- : { ...common, rules: fallbackRules as never },
23
- );
22
+ _protection = buildProtection().catch((err) => {
23
+ _protection = undefined; // don't cache a failed boot
24
+ throw err;
25
+ });
24
26
  }
27
+
25
28
  return _protection;
26
29
  }
27
30
 
31
+ async function buildProtection() {
32
+ const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
33
+ const token = process.env.PATCHSTACK_WAF_TOKEN;
34
+ const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
35
+ const common = { mode, egress: true } as const;
36
+ return createProtection(
37
+ siteUuid
38
+ ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" }
39
+ : token
40
+ ? { ...common, token, cacheDir: ".patchstack" }
41
+ : { ...common, rules: fallbackRules as never },
42
+ );
43
+ }
44
+
28
45
  // #region patchstack-next (managed by patchstack-connect protect — do not edit)
29
46
  export async function middleware(request: Request) {
30
47
  const protection = await getProtection();
@@ -7,24 +7,41 @@ import fallbackRules from "./patchstack.rules.json";
7
7
 
8
8
  const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
9
9
 
10
- let _protection: Awaited<ReturnType<typeof createProtection>> | undefined;
10
+ /**
11
+ * One protection policy, memoized on the IN-FLIGHT promise rather than the resolved value.
12
+ *
13
+ * A cold start takes several concurrent requests. Caching only the finished value lets each of them see an
14
+ * empty cache and start its own build — several rule fetches, several refresh loops, and several policies
15
+ * where the app is meant to have one. Holding the promise means the first request starts it and the rest
16
+ * await the same one. A failed build is not cached, so the next request retries rather than inheriting one
17
+ * bad boot for the life of the process.
18
+ */
19
+ let _protection: Promise<Awaited<ReturnType<typeof createProtection>>> | undefined;
11
20
  async function getProtection() {
12
21
  if (!_protection) {
13
- const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
14
- const token = process.env.PATCHSTACK_WAF_TOKEN;
15
- const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
16
- const common = { mode, egress: true } as const;
17
- _protection = await createProtection(
18
- siteUuid
19
- ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" }
20
- : token
21
- ? { ...common, token, cacheDir: ".patchstack" }
22
- : { ...common, rules: fallbackRules as never },
23
- );
22
+ _protection = buildProtection().catch((err) => {
23
+ _protection = undefined; // don't cache a failed boot
24
+ throw err;
25
+ });
24
26
  }
27
+
25
28
  return _protection;
26
29
  }
27
30
 
31
+ async function buildProtection() {
32
+ const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
33
+ const token = process.env.PATCHSTACK_WAF_TOKEN;
34
+ const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
35
+ const common = { mode, egress: true } as const;
36
+ return createProtection(
37
+ siteUuid
38
+ ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" }
39
+ : token
40
+ ? { ...common, token, cacheDir: ".patchstack" }
41
+ : { ...common, rules: fallbackRules as never },
42
+ );
43
+ }
44
+
28
45
  // #region patchstack-nuxt (managed by patchstack-connect protect — do not edit)
29
46
  export default defineEventHandler(async (event) => {
30
47
  const protection = await getProtection();
@@ -21,7 +21,7 @@
21
21
  "title": "Path traversal in a file/path parameter",
22
22
  "category": "lfi",
23
23
  "rule_v2": [
24
- { "parameter": ["get.file", "post.file", "raw.file", "get.path", "post.path"], "mutations": ["urldecode"], "match": { "type": "contains", "value": ".." } }
24
+ { "parameter": ["get.file", "post.file", "get.path", "post.path"], "mutations": ["urldecode"], "match": { "type": "contains", "value": ".." } }
25
25
  ]
26
26
  },
27
27
  {
@@ -29,7 +29,7 @@
29
29
  "title": "SSRF via a url parameter pointing at an internal/metadata address",
30
30
  "category": "ssrf",
31
31
  "rule_v2": [
32
- { "parameter": ["get.url", "post.url", "raw.url"], "mutations": ["urldecode"], "match": { "type": "regex", "value": "/localhost|127\\.0\\.0\\.1|169\\.254\\.169\\.254|::1|metadata\\.google/i" } }
32
+ { "parameter": ["get.url", "post.url"], "mutations": ["urldecode"], "match": { "type": "regex", "value": "/localhost|127\\.0\\.0\\.1|169\\.254\\.169\\.254|::1|metadata\\.google/i" } }
33
33
  ]
34
34
  },
35
35
  {
@@ -7,29 +7,49 @@ import fallbackRules from "./patchstack.rules.json";
7
7
 
8
8
  const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
9
9
 
10
- let _protection: Awaited<ReturnType<typeof createProtection>> | undefined;
10
+ /**
11
+ * One protection policy, memoized on the IN-FLIGHT promise rather than the resolved value.
12
+ *
13
+ * A cold start takes several concurrent requests. Caching only the finished value lets each of them see an
14
+ * empty cache and start its own build — several rule fetches, several refresh loops, and several policies
15
+ * where the app is meant to have one. Holding the promise means the first request starts it and the rest
16
+ * await the same one. A failed build is not cached, so the next request retries rather than inheriting one
17
+ * bad boot for the life of the process.
18
+ */
19
+ let _protection: Promise<Awaited<ReturnType<typeof createProtection>>> | undefined;
11
20
  async function getProtection() {
12
21
  if (!_protection) {
13
- const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
14
- const token = process.env.PATCHSTACK_WAF_TOKEN;
15
- const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
16
- const common = { mode, egress: true } as const;
17
- _protection = await createProtection(
18
- siteUuid
19
- ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" }
20
- : token
21
- ? { ...common, token, cacheDir: ".patchstack" }
22
- : { ...common, rules: fallbackRules as never },
23
- );
22
+ _protection = buildProtection().catch((err) => {
23
+ _protection = undefined; // don't cache a failed boot
24
+ throw err;
25
+ });
24
26
  }
27
+
25
28
  return _protection;
26
29
  }
27
30
 
31
+ async function buildProtection() {
32
+ const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
33
+ const token = process.env.PATCHSTACK_WAF_TOKEN;
34
+ const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
35
+ const common = { mode, egress: true } as const;
36
+ return createProtection(
37
+ siteUuid
38
+ ? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" }
39
+ : token
40
+ ? { ...common, token, cacheDir: ".patchstack" }
41
+ : { ...common, rules: fallbackRules as never },
42
+ );
43
+ }
44
+
28
45
  // #region patchstack-sveltekit (managed by patchstack-connect protect — do not edit)
29
46
  export const handle: Handle = async ({ event, resolve }) => {
30
47
  const protection = await getProtection();
31
48
  const blocked = await protection.fetchGuard()(event.request);
32
49
  if (blocked) return blocked; // 403 — blocked before it reaches your route
33
- return protection.screenResponse(await resolve(event));
50
+ // Response rules can be scoped to a route or a method, and the engine can only apply that scope if it is
51
+ // given the request the response belongs to. Passed through here for that reason: without it a scoped
52
+ // response rule is delivered, counted as protection, and never matches anything.
53
+ return protection.screenResponse(await resolve(event), event.request);
34
54
  };
35
55
  // #endregion patchstack-sveltekit