@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.
- package/dist/cli.js +26 -83
- package/dist/cli.js.map +1 -1
- package/dist/protect/templates/guard.ts +29 -98
- package/dist/protect/templates/rules.json +4 -7
- package/dist/protect.cjs +1215 -0
- package/dist/protect.cjs.map +1 -0
- package/dist/protect.js +1186 -0
- package/dist/protect.js.map +1 -0
- package/package.json +5 -1
- package/dist/protect/templates/engine.d.ts +0 -57
- package/dist/protect/templates/engine.js +0 -137
- package/dist/protect/templates/manifest.d.ts +0 -1
|
@@ -1,102 +1,33 @@
|
|
|
1
|
-
// Patchstack
|
|
2
|
-
// touches this. It runs in the app's own server (the Cloudflare Worker). The
|
|
3
|
-
// client
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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": "
|
|
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":
|
|
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
|
}
|