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