diff --git a/tests/protect/engine.test.ts b/tests/protect/engine.test.ts
new file mode 100644
index 0000000..c416804
--- /dev/null
+++ b/tests/protect/engine.test.ts
@@ -0,0 +1,594 @@
+import { describe, it } from 'vitest';
+import assert from 'node:assert';
+import { readFile } from 'node:fs/promises';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { RuleEngine, _testExports } from '../../src/protect/engine/engine.js';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const fixtureRules = JSON.parse(
+ await readFile(join(__dirname, 'fixtures', 'rules-response.json'), 'utf-8')
+);
+
+const { matchValue, safeRegExp } = _testExports;
+
+function createReq(overrides = {}) {
+ return {
+ method: 'GET',
+ url: '/test',
+ originalUrl: '/test',
+ query: {},
+ body: {},
+ headers: {},
+ ...overrides
+ };
+}
+
+describe('RuleEngine', () => {
+
+ describe('matchValue', () => {
+
+ it('should match equals (loose)', () => {
+ assert.strictEqual(matchValue('equals', '1', 1), true);
+ assert.strictEqual(matchValue('equals', 'hello', 'hello'), true);
+ assert.strictEqual(matchValue('equals', 'hello', 'world'), false);
+ });
+
+ it('should match equals_strict', () => {
+ assert.strictEqual(matchValue('equals_strict', '1', '1'), true);
+ assert.strictEqual(matchValue('equals_strict', '1', 1), false);
+ });
+
+ it('should match contains (case-insensitive)', () => {
+ assert.strictEqual(matchValue('contains', 'Hello World', 'hello'), true);
+ assert.strictEqual(matchValue('contains', 'test', 'xyz'), false);
+ });
+
+ it('should match not_contains', () => {
+ assert.strictEqual(matchValue('not_contains', 'hello', 'xyz'), true);
+ assert.strictEqual(matchValue('not_contains', 'hello', 'ell'), false);
+ });
+
+ it('should match regex', () => {
+ assert.strictEqual(matchValue('regex', 'UNION SELECT', '/union\\s+select/i'), true);
+ assert.strictEqual(matchValue('regex', 'normal query', '/union\\s+select/i'), false);
+ });
+
+ it('should match more_than', () => {
+ assert.strictEqual(matchValue('more_than', '10', 5), true);
+ assert.strictEqual(matchValue('more_than', '3', 5), false);
+ });
+
+ it('should match less_than', () => {
+ assert.strictEqual(matchValue('less_than', '3', 5), true);
+ assert.strictEqual(matchValue('less_than', '10', 5), false);
+ });
+
+ it('should match ctype_digit', () => {
+ assert.strictEqual(matchValue('ctype_digit', '12345', null), true);
+ assert.strictEqual(matchValue('ctype_digit', '123abc', null), false);
+ });
+
+ it('should honor the value inversion for ctype_* (value:false = flag when NOT of class)', () => {
+ // The canonical vPatch shape: `{ type: 'ctype_digit', value: false }` must FLAG a
+ // non-numeric value and PASS a numeric one — previously the matchVal was ignored,
+ // which inverted the rule (blocking legit numeric IDs, passing injection).
+ assert.strictEqual(matchValue('ctype_digit', '123abc', false), true, 'non-numeric should match value:false');
+ assert.strictEqual(matchValue('ctype_digit', '12345', false), false, 'numeric should not match value:false');
+ assert.strictEqual(matchValue('ctype_alnum', 'a b;drop', false), true, 'special chars match value:false');
+ assert.strictEqual(matchValue('ctype_alnum', 'abc123', false), false, 'alnum should not match value:false');
+ });
+
+ it('should not match ctype_*/is_numeric on empty or absent values', () => {
+ // engine-php skips empty (`$value != ''`); otherwise every missing param would
+ // false-positive a `value:false` rule.
+ assert.strictEqual(matchValue('ctype_digit', '', false), false);
+ assert.strictEqual(matchValue('ctype_alnum', '', false), false);
+ assert.strictEqual(matchValue('is_numeric', '', false), false);
+ assert.strictEqual(matchValue('ctype_digit', null, false), false);
+ });
+
+ it('should match is_numeric', () => {
+ assert.strictEqual(matchValue('is_numeric', '3.14', null), true);
+ assert.strictEqual(matchValue('is_numeric', 'abc', null), false);
+ assert.strictEqual(matchValue('is_numeric', '', null), false);
+ });
+
+ it('should match isset', () => {
+ assert.strictEqual(matchValue('isset', 'anything', null), true);
+ assert.strictEqual(matchValue('isset', null, null), false);
+ });
+
+ it('should match in_array', () => {
+ assert.strictEqual(matchValue('in_array', 'b', ['a', 'b', 'c']), true);
+ assert.strictEqual(matchValue('in_array', 'x', ['a', 'b', 'c']), false);
+ });
+
+ it('should match not_in_array', () => {
+ assert.strictEqual(matchValue('not_in_array', 'x', ['a', 'b']), true);
+ assert.strictEqual(matchValue('not_in_array', 'a', ['a', 'b']), false);
+ });
+
+ it('should match hostname', () => {
+ assert.strictEqual(matchValue('hostname', 'https://example.com/path', 'example.com'), true);
+ assert.strictEqual(matchValue('hostname', 'https://other.com/path', 'example.com'), false);
+ });
+
+ it('should match internal_host (SSRF egress ranges)', () => {
+ for (const host of ['127.0.0.1', '169.254.169.254', '10.0.0.5', '192.168.1.1', '172.16.0.1', 'localhost', 'metadata.google.internal', '::1']) {
+ assert.strictEqual(matchValue('internal_host', host, null), true, `${host} should be internal`);
+ }
+ for (const host of ['example.com', '8.8.8.8', '1.1.1.1', '172.32.0.1']) {
+ assert.strictEqual(matchValue('internal_host', host, null), false, `${host} should be external`);
+ }
+ });
+
+ it('should match quotes (and the inline_js_xss alias)', () => {
+ assert.strictEqual(matchValue('quotes', `x' OR 1=1`, null), true);
+ assert.strictEqual(matchValue('quotes', 'no quotes here', null), false);
+ assert.strictEqual(matchValue('inline_js_xss', 'say "hi"', null), true);
+ });
+
+ it('should match inline_xss (quote AND > or =)', () => {
+ assert.strictEqual(matchValue('inline_xss', `" onmouseover=alert(1)`, null), true);
+ assert.strictEqual(matchValue('inline_xss', `">' }
+ });
+ const result = engine.evaluate(req);
+
+ assert.strictEqual(result.blocked, true);
+ assert.strictEqual(result.rule.id, 102);
+ });
+
+ it('should block path traversal in URI', () => {
+ const engine = new RuleEngine(fixtureRules);
+ const req = createReq({
+ url: '/files/../../../etc/passwd',
+ originalUrl: '/files/../../../etc/passwd'
+ });
+ const result = engine.evaluate(req);
+
+ assert.strictEqual(result.blocked, true);
+ assert.strictEqual(result.rule.id, 104);
+ });
+
+ it('should block prototype pollution in raw body', () => {
+ const engine = new RuleEngine(fixtureRules);
+ const req = createReq({
+ body: '{"__proto__":{"admin":true}}'
+ });
+ const result = engine.evaluate(req);
+
+ assert.strictEqual(result.blocked, true);
+ assert.strictEqual(result.rule.id, 105);
+ });
+
+ it('should block SSRF to internal IPs', () => {
+ const engine = new RuleEngine(fixtureRules);
+ const req = createReq({
+ method: 'POST',
+ body: { url: 'http://127.0.0.1/admin' }
+ });
+ const result = engine.evaluate(req);
+
+ assert.strictEqual(result.blocked, true);
+ assert.strictEqual(result.rule.id, 106);
+ });
+
+ it('should block base64-encoded payloads with mutations', () => {
+ const engine = new RuleEngine(fixtureRules);
+ const encoded = Buffer.from('').toString('base64');
+ const req = createReq({
+ method: 'POST',
+ body: { data: encoded }
+ });
+ const result = engine.evaluate(req);
+
+ assert.strictEqual(result.blocked, true);
+ assert.strictEqual(result.rule.id, 107);
+ });
+
+ it('should block malicious user-agent', () => {
+ const engine = new RuleEngine(fixtureRules);
+ const req = createReq({
+ headers: { 'user-agent': 'sqlmap/1.5' }
+ });
+ const result = engine.evaluate(req);
+
+ assert.strictEqual(result.blocked, true);
+ assert.strictEqual(result.rule.id, 108);
+ });
+
+ it('should allow clean requests', () => {
+ const engine = new RuleEngine(fixtureRules);
+ const req = createReq({ query: { search: 'normal search term' } });
+ const result = engine.evaluate(req);
+
+ assert.strictEqual(result.blocked, false);
+ assert.strictEqual(result.rule, null);
+ });
+
+ it('should handle empty rules', () => {
+ const engine = new RuleEngine({ firewall: [] });
+ const result = engine.evaluate(createReq());
+
+ assert.strictEqual(result.blocked, false);
+ });
+
+ it('resolves array parameters (["get.x","post.x"]) as OR across sources', () => {
+ const engine = new RuleEngine({
+ firewall: [{ id: 'a', rule_v2: [{ parameter: ['get.x', 'post.x'], match: { type: 'contains', value: 'bad' } }] }]
+ });
+ assert.strictEqual(engine.evaluate(createReq({ query: { x: 'bad' } })).blocked, true, 'matches via get');
+ assert.strictEqual(engine.evaluate(createReq({ query: {}, body: { x: 'bad' } })).blocked, true, 'matches via post');
+ assert.strictEqual(engine.evaluate(createReq({ query: { x: 'ok' } })).blocked, false, 'no match');
+ });
+
+ });
+
+ describe('fail open', () => {
+ // A rule whose evaluation throws (here: a getter that blows up).
+ const throwingRule = {
+ id: 'bad',
+ get rule_v2() {
+ throw new Error('boom');
+ }
+ };
+
+ it('skips a throwing rule, allows the request, and reports the error', () => {
+ const errors = [];
+ const engine = new RuleEngine({ firewall: [throwingRule], onError: (e) => errors.push(e) });
+
+ const result = engine.evaluate(createReq({ query: { x: '1' } }));
+
+ assert.strictEqual(result.blocked, false); // fail open — never throws, never blocks
+ assert.strictEqual(errors.length, 1);
+ assert.match(errors[0].message, /boom/);
+ });
+
+ it('a throwing rule does not shadow a later rule that matches', () => {
+ const good = {
+ id: 'good',
+ title: 'x',
+ rule_v2: [{ parameter: 'get.x', match: { type: 'isset' } }]
+ };
+ const engine = new RuleEngine({ firewall: [throwingRule, good], onError: () => {} });
+
+ const result = engine.evaluate(createReq({ query: { x: '1' } }));
+
+ assert.strictEqual(result.blocked, true);
+ assert.strictEqual(result.rule.id, 'good');
+ });
+
+ it('evaluate() never throws even without an onError handler', () => {
+ const engine = new RuleEngine({ firewall: [throwingRule] });
+ // Suppress the default console.error for this assertion.
+ const originalError = console.error;
+ console.error = () => {};
+ try {
+ assert.doesNotThrow(() => engine.evaluate(createReq()));
+ } finally {
+ console.error = originalError;
+ }
+ });
+ });
+
+ describe('inclusive (AND) logic', () => {
+
+ it('should require ALL inclusive conditions to match', () => {
+ const engine = new RuleEngine(fixtureRules);
+
+ // Rule 103 requires BOTH: post.action=delete_all_users AND REQUEST_METHOD=POST
+ const req = createReq({
+ method: 'POST',
+ body: { action: 'delete_all_users' }
+ });
+ const result = engine.evaluate(req);
+
+ assert.strictEqual(result.blocked, true);
+ assert.strictEqual(result.rule.id, 103);
+ });
+
+ it('should not block when only one inclusive condition matches', () => {
+ const engine = new RuleEngine({
+ firewall: [{
+ id: 200,
+ title: 'Test AND rule',
+ rule_v2: [
+ { parameter: 'post.action', match: { type: 'equals', value: 'delete_all' }, inclusive: true },
+ { parameter: 'post.confirm', match: { type: 'equals', value: 'yes' }, inclusive: true }
+ ]
+ }]
+ });
+
+ // Only action matches, confirm is missing
+ const req = createReq({
+ method: 'POST',
+ body: { action: 'delete_all' }
+ });
+ const result = engine.evaluate(req);
+
+ assert.strictEqual(result.blocked, false);
+ });
+
+ it('should block when all inclusive conditions match', () => {
+ const engine = new RuleEngine({
+ firewall: [{
+ id: 200,
+ title: 'Test AND rule',
+ rule_v2: [
+ { parameter: 'post.action', match: { type: 'equals', value: 'delete_all' }, inclusive: true },
+ { parameter: 'post.confirm', match: { type: 'equals', value: 'yes' }, inclusive: true }
+ ]
+ }]
+ });
+
+ const req = createReq({
+ method: 'POST',
+ body: { action: 'delete_all', confirm: 'yes' }
+ });
+ const result = engine.evaluate(req);
+
+ assert.strictEqual(result.blocked, true);
+ });
+
+ });
+
+ describe('whitelists', () => {
+
+ it('should not block whitelisted requests', () => {
+ const engine = new RuleEngine(fixtureRules);
+
+ // Rule 101 blocks SQL injection in search, but whitelist allows IP 10.0.0.1
+ const req = createReq({
+ query: { search: '1 UNION SELECT * FROM users' },
+ ip: '10.0.0.1'
+ });
+ const result = engine.evaluate(req);
+
+ assert.strictEqual(result.blocked, false);
+ });
+
+ it('should block non-whitelisted requests', () => {
+ const engine = new RuleEngine(fixtureRules);
+
+ const req = createReq({
+ query: { search: '1 UNION SELECT * FROM users' },
+ ip: '192.168.1.1'
+ });
+ const result = engine.evaluate(req);
+
+ assert.strictEqual(result.blocked, true);
+ });
+
+ });
+
+ describe('nested rules', () => {
+
+ it('should evaluate nested rule conditions', () => {
+ const engine = new RuleEngine({
+ firewall: [{
+ id: 300,
+ title: 'Nested rule test',
+ rule_v2: [
+ {
+ parameter: 'rules',
+ match: { type: 'equals', value: '' },
+ inclusive: false,
+ rules: [
+ {
+ parameter: 'post.action',
+ match: { type: 'equals', value: 'exploit' },
+ inclusive: true
+ },
+ {
+ parameter: 'post.target',
+ match: { type: 'contains', value: 'admin' },
+ inclusive: true
+ }
+ ]
+ }
+ ]
+ }]
+ });
+
+ const req = createReq({
+ method: 'POST',
+ body: { action: 'exploit', target: 'admin_panel' }
+ });
+ const result = engine.evaluate(req);
+
+ assert.strictEqual(result.blocked, true);
+ });
+
+ });
+
+ describe('URL-encoded bypass regression tests', () => {
+
+ it('should block URL-encoded SQL injection (normalization enforced)', () => {
+ const engine = new RuleEngine(fixtureRules);
+ // Without normalization, %20UNION%20SELECT bypasses the /union\s+select/i regex
+ const req = createReq({ query: { search: '1%20UNION%20SELECT%20*%20FROM%20users' } });
+ const result = engine.evaluate(req);
+
+ assert.strictEqual(result.blocked, true, 'URL-encoded SQLi should be caught after normalization');
+ assert.strictEqual(result.rule.id, 101);
+ });
+
+ it('should block double-encoded SQL injection', () => {
+ const engine = new RuleEngine(fixtureRules);
+ const req = createReq({ query: { search: '1%2520UNION%2520SELECT' } });
+ const result = engine.evaluate(req);
+
+ assert.strictEqual(result.blocked, true, 'Double-encoded SQLi should be caught');
+ assert.strictEqual(result.rule.id, 101);
+ });
+
+ it('should block URL-encoded XSS in comment field', () => {
+ const engine = new RuleEngine(fixtureRules);
+ // %3Cscript%3E decodes to