Skip to content

Commit 3cbcea5

Browse files
test(protect): port engine unit suite + restructure tests/protect/ (#58)
* feat(protect): rule-driven Tier-3 — response-leak redaction + egress SSRF; array params Brings the output-side protection into the vendored runtime, as RULES (nothing hardcoded), via rule phases: - new rule properties: `phase` (request|response|egress, default request) + `category` - engine: resolve `response.status|body|header.*` and `egress.url|host|method`; new `internal_host` match type (private/loopback/link-local/metadata ranges) - runtime (createProtection): response phase screens the outgoing body; egress phase (opt-in `egress:true`) wraps global fetch to screen outbound calls. Driven by default bundles in defaults.js (overridable via responseRules/egressRules). Response leak handling supports per-rule `action`: - `redact` (default for the secret rules) — mask only the offending span(s) and still serve the page; a legit response that leaks one key gets that key masked, not withheld - `block` — withhold the whole response Also fixes a real engine bug: array `parameter` (["get.a","post.b"]) — used pervasively in rule_v2 — silently never matched (`parameter.indexOf('.')` on an array returns -1). #evaluateCondition now resolves each element and ORs them. Enforcement only in block mode; dry-run observes + serves original. Fail-open throughout. createSupabaseGuard / createServerFnGuard unchanged. Full suite: 151 pass; build + typecheck green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(protect): port the engine unit suite + restructure tests/protect/ When the node-waf engine was vendored into src/protect/engine/, its ~180 unit tests did NOT come along — connect only had the runtime integration test, leaving matchValue / resolver / normalizer / mutations / adapters with no direct coverage. Ports the engine suite from the upstream engine (adapted to vitest) and groups all protect tests under tests/protect/: - engine.test.ts (54) — match types incl. internal_host, evaluate, inclusive/OR, whitelist, nested rules, ctype value-inversion, array parameters, fail-open, safeRegExp - request.test.ts (38) — resolver: get/post/request/cookie/server/files/raw/all + response.*/egress.* + wildcards + mutations - normalizer.test.ts (63) — normalize pipeline + _rawBody prototype-pollution serialization - fetch.test.ts (6) / node.test.ts (5) — the fetch + node request adapters - middleware.test.ts (16) — the Express middleware + rule client (mocked fetch) - runtime.test.ts (11) — createProtection integration (moved from tests/protect.test.ts): request/response-redact/response-block/egress, dry-run/block, Supabase + server-fn guards Full suite 333 pass; typecheck + build green. Engine source unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(protect): deep Tier-3 coverage (response, egress, options, invalid rules) The ported suite covered the engine well but the Tier-3 protect-layer features only had happy-path coverage. Adds tests/protect/tier3.test.ts (17): - response redaction: every default secret type; multiple spans in one body; maskWith (string + per-category fn); contains-literal redaction; redact-with-no-maskable-pattern falls back to withholding; response.header.* rules - response gating/robustness: non-text content-type skipped; oversized (content-length) skipped; malformed rule (no rule_v2 array) skipped; invalid regex never matches / no crash - egress: every internal range blocked via the guard (127/10/192.168/172.16/localhost/::1/ metadata) + external allowed; allowHosts exemption; onEgressBlock payload; uninstallEgress restores global fetch - options/phases: default dry-run; responseRules replaces defaults; rules split by phase (a phase:response rule screens responses, NOT requests) protect coverage 193 → 210; full suite 350 pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(protect): runtime node()/express() guards, token+cache path, egressRules override - runtime .node() (body buffering + block/allow) and .express() guards - rule source: token path caches the fetched bundle, then falls back to last-known-good when the API fails; no-token/no-cache → empty request ruleset (allows) - egressRules override replaces the default SSRF rule Notes a pre-existing gap: runtime.node() consumes the body to screen it but doesn't re-expose req.body downstream (follow-up on the feature branch). Full suite 355 pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b3e9bdf commit 3cbcea5

10 files changed

Lines changed: 2323 additions & 1 deletion

tests/protect/engine.test.ts

Lines changed: 594 additions & 0 deletions
Large diffs are not rendered by default.

tests/protect/fetch.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, it } from 'vitest';
2+
import assert from 'node:assert';
3+
import { createFetchMiddleware, wrapFetchHandler, fromFetchRequest } from '../../src/protect/engine/fetch.js';
4+
5+
const rules = {
6+
firewall: [
7+
{
8+
id: 1,
9+
title: 'block path traversal',
10+
rule_v2: [
11+
{
12+
parameter: 'get.file',
13+
mutations: ['urldecode'],
14+
match: { type: 'contains', value: '..' }
15+
}
16+
]
17+
}
18+
],
19+
whitelists: [],
20+
whitelist_keys: {}
21+
};
22+
23+
describe('fetch adapter', () => {
24+
it('fromFetchRequest parses query (repeats), headers, JSON body, ip, and verbatim _rawBody', async () => {
25+
const req = await fromFetchRequest(
26+
new Request('https://app.dev/api?q=1&q=2', {
27+
method: 'POST',
28+
headers: { 'content-type': 'application/json', 'x-forwarded-for': '1.2.3.4, 5.6.7.8' },
29+
body: JSON.stringify({ a: 1 })
30+
})
31+
);
32+
assert.deepStrictEqual(req.query.q, ['1', '2']);
33+
assert.strictEqual(req.body.a, 1);
34+
assert.strictEqual(req.ip, '1.2.3.4');
35+
assert.strictEqual(req.headers['content-type'], 'application/json');
36+
assert.strictEqual(req._rawBody, JSON.stringify({ a: 1 }));
37+
assert.strictEqual(req.originalUrl, '/api?q=1&q=2');
38+
});
39+
40+
it('guard blocks a malicious request with a 403 Response', async () => {
41+
const guard = createFetchMiddleware(rules);
42+
const res = await guard(new Request('https://app.dev/read?file=..%2f..%2fetc%2fpasswd'));
43+
assert.ok(res instanceof Response);
44+
assert.strictEqual(res.status, 403);
45+
const body = await res.json();
46+
assert.strictEqual(body.error, 'Blocked by Patchstack WAF');
47+
});
48+
49+
it('guard returns null (allow) for a benign request', async () => {
50+
const guard = createFetchMiddleware(rules);
51+
const res = await guard(new Request('https://app.dev/read?file=notes.txt'));
52+
assert.strictEqual(res, null);
53+
});
54+
55+
it('wrapFetchHandler calls through on allow and short-circuits on block', async () => {
56+
const handler = async () => new Response('ok', { status: 200 });
57+
const wrapped = wrapFetchHandler(handler, rules);
58+
59+
const allowed = await wrapped(new Request('https://app.dev/read?file=ok.txt'));
60+
assert.strictEqual(allowed.status, 200);
61+
assert.strictEqual(await allowed.text(), 'ok');
62+
63+
const blocked = await wrapped(new Request('https://app.dev/read?file=../secret'));
64+
assert.strictEqual(blocked.status, 403);
65+
});
66+
67+
it('matches a __proto__ prototype-pollution payload via verbatim `raw` body', async () => {
68+
const ppRules = {
69+
firewall: [
70+
{
71+
id: 2,
72+
title: 'prototype pollution',
73+
rule_v2: [{ parameter: 'raw', match: { type: 'contains', value: '__proto__' } }]
74+
}
75+
],
76+
whitelists: [],
77+
whitelist_keys: {}
78+
};
79+
const guard = createFetchMiddleware(ppRules);
80+
const res = await guard(
81+
new Request('https://app.dev/settings', {
82+
method: 'POST',
83+
headers: { 'content-type': 'application/json' },
84+
body: '{"__proto__":{"polluted":"yes"}}'
85+
})
86+
);
87+
assert.strictEqual(res?.status, 403, '__proto__ in the verbatim body should be caught');
88+
});
89+
90+
it('fails open when a rule throws (never blocks on engine error)', async () => {
91+
const badEngine = { evaluate() { throw new Error('boom'); } };
92+
let captured = null;
93+
const guard = createFetchMiddleware(badEngine, { onError: (e) => (captured = e) });
94+
const res = await guard(new Request('https://app.dev/x'));
95+
assert.strictEqual(res, null);
96+
assert.ok(captured instanceof Error);
97+
});
98+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
{
2+
"firewall": [
3+
{
4+
"id": 101,
5+
"title": "Block SQL Injection in search",
6+
"rule_v2": [
7+
{
8+
"parameter": "get.search",
9+
"match": { "type": "regex", "value": "/union\\s+select/i" },
10+
"inclusive": false
11+
}
12+
]
13+
},
14+
{
15+
"id": 102,
16+
"title": "Block XSS in comment field",
17+
"rule_v2": [
18+
{
19+
"parameter": "post.comment",
20+
"match": { "type": "regex", "value": "/<script[^>]*>/i" },
21+
"inclusive": false
22+
}
23+
]
24+
},
25+
{
26+
"id": 103,
27+
"title": "Block admin action exploitation",
28+
"rule_v2": [
29+
{
30+
"parameter": "post.action",
31+
"match": { "type": "equals", "value": "delete_all_users" },
32+
"inclusive": true
33+
},
34+
{
35+
"parameter": "server.REQUEST_METHOD",
36+
"match": { "type": "equals", "value": "POST" },
37+
"inclusive": true
38+
}
39+
]
40+
},
41+
{
42+
"id": 104,
43+
"title": "Block path traversal",
44+
"rule_v2": [
45+
{
46+
"parameter": "server.REQUEST_URI",
47+
"match": { "type": "regex", "value": "/\\.\\.[\\/\\\\]/i" },
48+
"inclusive": false
49+
}
50+
]
51+
},
52+
{
53+
"id": 105,
54+
"title": "Block prototype pollution",
55+
"rule_v2": [
56+
{
57+
"parameter": "raw",
58+
"match": { "type": "contains", "value": "__proto__" },
59+
"inclusive": false
60+
}
61+
]
62+
},
63+
{
64+
"id": 106,
65+
"title": "Block SSRF to internal IPs",
66+
"rule_v2": [
67+
{
68+
"parameter": "post.url",
69+
"match": { "type": "regex", "value": "/(localhost|127\\.0\\.0\\.1|0\\.0\\.0\\.0|::1|10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.)/i" },
70+
"inclusive": false
71+
}
72+
]
73+
},
74+
{
75+
"id": 107,
76+
"title": "Block base64-encoded payloads",
77+
"rule_v2": [
78+
{
79+
"parameter": "post.data",
80+
"match": { "type": "contains", "value": "<script>" },
81+
"mutations": ["base64_decode"],
82+
"inclusive": false
83+
}
84+
]
85+
},
86+
{
87+
"id": 108,
88+
"title": "Block user agent bot",
89+
"rule_v2": [
90+
{
91+
"parameter": "server.HTTP_USER_AGENT",
92+
"match": { "type": "contains", "value": "sqlmap" },
93+
"inclusive": false
94+
}
95+
]
96+
}
97+
],
98+
"whitelists": [
99+
{
100+
"rule_id": 101,
101+
"rule_v2": [
102+
{
103+
"parameter": "server.REMOTE_ADDR",
104+
"match": { "type": "equals", "value": "10.0.0.1" },
105+
"inclusive": false
106+
}
107+
]
108+
}
109+
],
110+
"whitelist_keys": {
111+
"POST": ["_token", "csrf"],
112+
"GET": ["page", "limit"]
113+
}
114+
}

0 commit comments

Comments
 (0)