Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
594 changes: 594 additions & 0 deletions tests/protect/engine.test.ts

Large diffs are not rendered by default.

98 changes: 98 additions & 0 deletions tests/protect/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it } from 'vitest';
import assert from 'node:assert';
import { createFetchMiddleware, wrapFetchHandler, fromFetchRequest } from '../../src/protect/engine/fetch.js';

const rules = {
firewall: [
{
id: 1,
title: 'block path traversal',
rule_v2: [
{
parameter: 'get.file',
mutations: ['urldecode'],
match: { type: 'contains', value: '..' }
}
]
}
],
whitelists: [],
whitelist_keys: {}
};

describe('fetch adapter', () => {
it('fromFetchRequest parses query (repeats), headers, JSON body, ip, and verbatim _rawBody', async () => {
const req = await fromFetchRequest(
new Request('https://app.dev/api?q=1&q=2', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': '1.2.3.4, 5.6.7.8' },
body: JSON.stringify({ a: 1 })
})
);
assert.deepStrictEqual(req.query.q, ['1', '2']);
assert.strictEqual(req.body.a, 1);
assert.strictEqual(req.ip, '1.2.3.4');
assert.strictEqual(req.headers['content-type'], 'application/json');
assert.strictEqual(req._rawBody, JSON.stringify({ a: 1 }));
assert.strictEqual(req.originalUrl, '/api?q=1&q=2');
});

it('guard blocks a malicious request with a 403 Response', async () => {
const guard = createFetchMiddleware(rules);
const res = await guard(new Request('https://app.dev/read?file=..%2f..%2fetc%2fpasswd'));
assert.ok(res instanceof Response);
assert.strictEqual(res.status, 403);
const body = await res.json();
assert.strictEqual(body.error, 'Blocked by Patchstack WAF');
});

it('guard returns null (allow) for a benign request', async () => {
const guard = createFetchMiddleware(rules);
const res = await guard(new Request('https://app.dev/read?file=notes.txt'));
assert.strictEqual(res, null);
});

it('wrapFetchHandler calls through on allow and short-circuits on block', async () => {
const handler = async () => new Response('ok', { status: 200 });
const wrapped = wrapFetchHandler(handler, rules);

const allowed = await wrapped(new Request('https://app.dev/read?file=ok.txt'));
assert.strictEqual(allowed.status, 200);
assert.strictEqual(await allowed.text(), 'ok');

const blocked = await wrapped(new Request('https://app.dev/read?file=../secret'));
assert.strictEqual(blocked.status, 403);
});

it('matches a __proto__ prototype-pollution payload via verbatim `raw` body', async () => {
const ppRules = {
firewall: [
{
id: 2,
title: 'prototype pollution',
rule_v2: [{ parameter: 'raw', match: { type: 'contains', value: '__proto__' } }]
}
],
whitelists: [],
whitelist_keys: {}
};
const guard = createFetchMiddleware(ppRules);
const res = await guard(
new Request('https://app.dev/settings', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"__proto__":{"polluted":"yes"}}'
})
);
assert.strictEqual(res?.status, 403, '__proto__ in the verbatim body should be caught');
});

it('fails open when a rule throws (never blocks on engine error)', async () => {
const badEngine = { evaluate() { throw new Error('boom'); } };
let captured = null;
const guard = createFetchMiddleware(badEngine, { onError: (e) => (captured = e) });
const res = await guard(new Request('https://app.dev/x'));
assert.strictEqual(res, null);
assert.ok(captured instanceof Error);
});
});
114 changes: 114 additions & 0 deletions tests/protect/fixtures/rules-response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
{
"firewall": [
{
"id": 101,
"title": "Block SQL Injection in search",
"rule_v2": [
{
"parameter": "get.search",
"match": { "type": "regex", "value": "/union\\s+select/i" },
"inclusive": false
}
]
},
{
"id": 102,
"title": "Block XSS in comment field",
"rule_v2": [
{
"parameter": "post.comment",
"match": { "type": "regex", "value": "/<script[^>]*>/i" },
"inclusive": false
}
]
},
{
"id": 103,
"title": "Block admin action exploitation",
"rule_v2": [
{
"parameter": "post.action",
"match": { "type": "equals", "value": "delete_all_users" },
"inclusive": true
},
{
"parameter": "server.REQUEST_METHOD",
"match": { "type": "equals", "value": "POST" },
"inclusive": true
}
]
},
{
"id": 104,
"title": "Block path traversal",
"rule_v2": [
{
"parameter": "server.REQUEST_URI",
"match": { "type": "regex", "value": "/\\.\\.[\\/\\\\]/i" },
"inclusive": false
}
]
},
{
"id": 105,
"title": "Block prototype pollution",
"rule_v2": [
{
"parameter": "raw",
"match": { "type": "contains", "value": "__proto__" },
"inclusive": false
}
]
},
{
"id": 106,
"title": "Block SSRF to internal IPs",
"rule_v2": [
{
"parameter": "post.url",
"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" },
"inclusive": false
}
]
},
{
"id": 107,
"title": "Block base64-encoded payloads",
"rule_v2": [
{
"parameter": "post.data",
"match": { "type": "contains", "value": "<script>" },
"mutations": ["base64_decode"],
"inclusive": false
}
]
},
{
"id": 108,
"title": "Block user agent bot",
"rule_v2": [
{
"parameter": "server.HTTP_USER_AGENT",
"match": { "type": "contains", "value": "sqlmap" },
"inclusive": false
}
]
}
],
"whitelists": [
{
"rule_id": 101,
"rule_v2": [
{
"parameter": "server.REMOTE_ADDR",
"match": { "type": "equals", "value": "10.0.0.1" },
"inclusive": false
}
]
}
],
"whitelist_keys": {
"POST": ["_token", "csrf"],
"GET": ["page", "limit"]
}
}
Loading
Loading