@patchstack/connect 0.2.10 → 0.3.0

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.
@@ -1,102 +1,33 @@
1
- // Patchstack auto-guard — installed automatically by "add Patchstack"; the vibe coder never
2
- // touches this. It runs in the app's own server (the Cloudflare Worker). The patched Supabase
3
- // client tunnels every browser data call here; we inspect the payload against the rules, then
4
- // either block it (virtual patch) or forward it to real Supabase.
5
- import { evaluate, type Rule } from "./engine.js";
6
- import rulesData from "./rules.json";
7
- import { manifest } from "./manifest.js";
8
-
9
- export const GUARD_PATH = "/_patchstack/guard";
10
-
11
- // Headers that must not be copied verbatim when we re-emit the upstream response.
12
- const HOP_BY_HOP = new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
13
-
14
- function mode(): "dry-run" | "block" {
15
- // Always-on: protection blocks by default. Only an explicit PATCHSTACK_MODE=dry-run
16
- // downgrades to log-only (for testing) — there is no silent dry-run.
17
- return process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
1
+ // Patchstack runtime protection — installed by `patchstack-connect protect`; the vibe coder
2
+ // never touches this. It runs in the app's own server (e.g. the Cloudflare Worker). The
3
+ // generated Supabase client is patched to tunnel every browser data call here, so Patchstack
4
+ // sees the traffic; this guard runs the protection policy and forwards clean requests on.
5
+ //
6
+ // Always-on by default (blocks). Rules come from the Patchstack API per-site (cached); the
7
+ // bundled rules.json is only a fallback for before a token is configured. The engine ships
8
+ // inside @patchstack/connect — nothing else to install.
9
+ import { createProtection, createSupabaseGuard, GUARD_PATH } from "@patchstack/connect/protect";
10
+ import fallbackRules from "./rules.json";
11
+
12
+ export { GUARD_PATH };
13
+
14
+ let _handle: ((request: Request) => Promise<Response>) | undefined;
15
+
16
+ async function getHandler() {
17
+ if (!_handle) {
18
+ // Always-on: block by default. An explicit PATCHSTACK_MODE=dry-run downgrades to log-only.
19
+ const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
20
+ const token = process.env.PATCHSTACK_WAF_TOKEN;
21
+ const protection = await createProtection(
22
+ token
23
+ ? { token, mode, cacheDir: ".patchstack" } // live per-site rules from the Patchstack API (cached)
24
+ : { rules: fallbackRules as never, mode }, // demo fallback until a token is set
25
+ );
26
+ _handle = createSupabaseGuard({ protection, supabaseUrl: process.env.SUPABASE_URL });
27
+ }
28
+ return _handle;
18
29
  }
19
30
 
20
31
  export async function handleGuardRequest(request: Request): Promise<Response> {
21
- const target = request.headers.get("x-ps-target");
22
- if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
23
-
24
- // SSRF guard: the client names the target, but we only ever forward to the app's own
25
- // Supabase project (origin known server-side). Anything else — internal hosts, cloud
26
- // metadata, a different scheme — is rejected before we make any outbound request.
27
- const supabaseUrl = process.env.SUPABASE_URL;
28
- let targetUrl: URL;
29
- try {
30
- targetUrl = new URL(target);
31
- } catch {
32
- return new Response("patchstack: invalid target", { status: 400 });
33
- }
34
- if (!supabaseUrl || targetUrl.protocol !== "https:" || targetUrl.origin !== new URL(supabaseUrl).origin) {
35
- return new Response("patchstack: target not allowed", { status: 403 });
36
- }
37
-
38
- const hasBody = request.method !== "GET" && request.method !== "HEAD";
39
- const bodyText = hasBody ? await request.text() : "";
40
-
41
- let record: Record<string, unknown> = {};
42
- if (bodyText) {
43
- try {
44
- const parsed = JSON.parse(bodyText);
45
- record = Array.isArray(parsed) ? (parsed[0] ?? {}) : parsed;
46
- } catch {
47
- // not JSON — leave record empty; the rule simply won't match
48
- }
49
- }
50
-
51
- const ctx = {
52
- method: request.method,
53
- url: target,
54
- headers: Object.fromEntries(request.headers),
55
- // expose the record under a few shapes so rule parameters like "insert.title" resolve
56
- body: { ...record, insert: record, update: record },
57
- ip: request.headers.get("x-forwarded-for") ?? "",
58
- };
59
-
60
- const t0 = performance.now();
61
- const verdict = evaluate(ctx, (rulesData as { firewall: Rule[] }).firewall, manifest);
62
- const latencyMs = Math.round((performance.now() - t0) * 100) / 100;
63
-
64
- // Always log the flag (this is the "dry-run sees it too" behavior).
65
- const decision = verdict.matched ? `MATCH ${verdict.rule_id}` : "clean";
66
- console.log(`[patchstack] ${request.method} ${new URL(target).pathname} -> ${decision} · mode=${mode()}`);
67
-
68
- if (verdict.matched && mode() === "block") {
69
- const receipt = {
70
- blocked: true,
71
- rule_id: verdict.rule_id,
72
- vulnerability_id: verdict.vulnerability_id,
73
- package: verdict.package,
74
- version: verdict.version,
75
- matched_conditions: verdict.explain,
76
- latency_ms: latencyMs,
77
- };
78
- console.log("[patchstack] BLOCKED", JSON.stringify(receipt));
79
- return new Response(JSON.stringify(receipt), {
80
- status: 403,
81
- headers: { "content-type": "application/json", "x-patchstack": "blocked" },
82
- });
83
- }
84
-
85
- // Forward to real Supabase, server-side.
86
- const forwardHeaders = new Headers(request.headers);
87
- forwardHeaders.delete("x-ps-target");
88
- forwardHeaders.delete("host");
89
- const upstream = await fetch(target, {
90
- method: request.method,
91
- headers: forwardHeaders,
92
- body: hasBody ? bodyText : undefined,
93
- redirect: "manual",
94
- });
95
-
96
- const outHeaders = new Headers();
97
- upstream.headers.forEach((value, key) => {
98
- if (!HOP_BY_HOP.has(key.toLowerCase())) outHeaders.set(key, value);
99
- });
100
- const buf = await upstream.arrayBuffer();
101
- return new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });
32
+ return (await getHandler())(request);
102
33
  }
@@ -1,22 +1,19 @@
1
1
  {
2
- "_comment": "DEMO SCAFFOLD — static stub; the real per-site rules endpoint does not exist in saas yet. CVE-2022-21681 is a real marked advisory affecting <4.0.10 (demo installs 4.0.0). The demo uses an XSS-shaped payload for visual clarity; exact payload<->CVE alignment is pinned with Group 1's corpus.",
2
+ "_comment": "Demo fallback rules — used only when PATCHSTACK_WAF_TOKEN is unset. In production the guard fetches per-site rules from the Patchstack API (api.patchstack.com/api/get-rules/3, cached to disk). CVE id is representative until aligned with the real corpus.",
3
3
  "firewall": [
4
4
  {
5
5
  "id": "rm-npm-0001",
6
6
  "title": "Block stored XSS via vulnerable markdown renderer (marked)",
7
7
  "vulnerability_id": "CVE-2022-21681",
8
- "package_cond": {
9
- "package": "marked",
10
- "vulnerable_versions": ["4.0.0"]
11
- },
12
8
  "rule_v2": [
13
9
  {
14
- "parameter": ["insert.title", "body.title", "update.title"],
10
+ "parameter": "post.title",
15
11
  "mutations": ["urldecode"],
16
12
  "match": { "type": "inline_xss" }
17
13
  }
18
14
  ]
19
15
  }
20
16
  ],
21
- "whitelists": []
17
+ "whitelists": [],
18
+ "whitelist_keys": {}
22
19
  }