@patchstack/connect 0.3.3 → 0.3.4

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,59 @@
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) and covers
3
+ // both ways a Lovable app talks to its database:
4
+ // - browser → Supabase directly: the generated Supabase client is patched to tunnel every call
5
+ // through handleGuardRequest (registered as request middleware in src/start.ts).
6
+ // - browser → TanStack server function → Supabase: inspectServerFn checks the server-fn args
7
+ // (registered as function middleware in src/start.ts) before anything is written.
8
+ // Either way Patchstack sees the traffic and runs the same policy.
9
+ //
10
+ // Always-on by default (blocks). Rules come from the Patchstack API per-site (cached); the
11
+ // bundled rules.json is only a fallback for before a token is configured. The engine ships
12
+ // inside @patchstack/connect — nothing else to install.
13
+ import {
14
+ createProtection,
15
+ createSupabaseGuard,
16
+ createServerFnGuard,
17
+ GUARD_PATH,
18
+ } from "@patchstack/connect/protect";
19
+ import fallbackRules from "./rules.json";
20
+
21
+ export { GUARD_PATH };
22
+
23
+ // One shared protection policy for both guards (rules load once).
24
+ let _protection: Awaited<ReturnType<typeof createProtection>> | undefined;
25
+ async function getProtection() {
26
+ if (!_protection) {
27
+ // Always-on: block by default. An explicit PATCHSTACK_MODE=dry-run downgrades to log-only.
28
+ const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
29
+ const token = process.env.PATCHSTACK_WAF_TOKEN;
30
+ _protection = await createProtection(
31
+ token
32
+ ? { token, mode, cacheDir: ".patchstack" } // live per-site rules from the Patchstack API (cached)
33
+ : { rules: fallbackRules as never, mode }, // demo fallback until a token is set
34
+ );
35
+ }
36
+ return _protection;
18
37
  }
19
38
 
39
+ // Request-middleware path: the browser tunnels its direct Supabase calls here.
40
+ let _handle: ((request: Request) => Promise<Response>) | undefined;
20
41
  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" },
42
+ if (!_handle) {
43
+ _handle = createSupabaseGuard({
44
+ protection: await getProtection(),
45
+ supabaseUrl: process.env.SUPABASE_URL,
82
46
  });
83
47
  }
48
+ return _handle(request);
49
+ }
84
50
 
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 });
51
+ // Function-middleware path: inspect a server function's decoded args before the handler runs.
52
+ // Returns a block receipt (throw on it to abort the call) or null to allow.
53
+ let _inspect: ((data: unknown) => Promise<{ rule?: string; message: string } | null>) | undefined;
54
+ export async function inspectServerFn(data: unknown): Promise<{ rule?: string; message: string } | null> {
55
+ if (!_inspect) {
56
+ _inspect = createServerFnGuard({ protection: await getProtection() });
57
+ }
58
+ return _inspect(data);
102
59
  }
@@ -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
  }