Files
caddy-proxy-manager/tests/unit/caddy-waf.test.ts
akanealw 99819b70ff
Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
added caddy-proxy-manager for testing
2026-04-21 22:49:08 +00:00

319 lines
13 KiB
TypeScript
Executable File

/**
* Unit tests for src/lib/caddy-waf.ts
*
* Key regression: when WAF is enabled but OWASP CRS is NOT loaded,
* the generated directives must NOT contain any @-prefixed Include paths
* (e.g. @coraza.conf-recommended). Those paths only resolve from the
* embedded coraza-coreruleset filesystem which is mounted by the Caddy
* plugin only when load_owasp_crs=true. Including them without the
* filesystem causes:
* "failed to readfile: open @coraza.conf-recommended: no such file or directory"
*/
import { describe, it, expect } from 'vitest';
import { buildWafHandler, resolveEffectiveWaf } from '../../src/lib/caddy-waf';
const baseWaf = {
enabled: true,
mode: 'On' as const,
load_owasp_crs: false,
custom_directives: '',
};
// ---------------------------------------------------------------------------
// Regression: @-prefixed paths must not appear without load_owasp_crs
// ---------------------------------------------------------------------------
describe('buildWafHandler — without OWASP CRS', () => {
it('does NOT include @coraza.conf-recommended when load_owasp_crs is false', () => {
const handler = buildWafHandler({ ...baseWaf, load_owasp_crs: false });
expect(handler.directives).not.toContain('@coraza.conf-recommended');
});
it('does NOT include any @-prefixed Include when load_owasp_crs is false', () => {
const handler = buildWafHandler({ ...baseWaf, load_owasp_crs: false });
// Guard against any future @-prefixed file references leaking in
expect(handler.directives).not.toMatch(/Include @/);
});
it('does NOT set load_owasp_crs field on handler when disabled', () => {
const handler = buildWafHandler({ ...baseWaf, load_owasp_crs: false });
expect(handler.load_owasp_crs).toBeUndefined();
});
it('still emits SecRuleEngine directive', () => {
const handler = buildWafHandler({ ...baseWaf, mode: 'On', load_owasp_crs: false });
expect(handler.directives).toContain('SecRuleEngine On');
});
it('still emits SecRuleEngine Off in DetectionOnly-like mode', () => {
const handler = buildWafHandler({ ...baseWaf, mode: 'Off', load_owasp_crs: false });
expect(handler.directives).toContain('SecRuleEngine Off');
});
it('includes custom directives when provided', () => {
const directive = 'SecRule REQUEST_HEADERS:User-Agent "@contains leakix.net" "id:9002,phase:1,deny,status:403,log"';
const handler = buildWafHandler({ ...baseWaf, load_owasp_crs: false, custom_directives: directive });
expect(handler.directives).toContain(directive);
});
it('does not append empty/whitespace-only custom_directives', () => {
const handler = buildWafHandler({ ...baseWaf, load_owasp_crs: false, custom_directives: ' ' });
// The directives string should end with the last standard directive
expect((handler.directives as string).trimEnd()).not.toMatch(/\s+$/);
});
});
// ---------------------------------------------------------------------------
// With OWASP CRS enabled
// ---------------------------------------------------------------------------
describe('buildWafHandler — with OWASP CRS', () => {
it('includes @coraza.conf-recommended when load_owasp_crs is true', () => {
const handler = buildWafHandler({ ...baseWaf, load_owasp_crs: true });
expect(handler.directives).toContain('Include @coraza.conf-recommended');
});
it('includes @crs-setup.conf.example when load_owasp_crs is true', () => {
const handler = buildWafHandler({ ...baseWaf, load_owasp_crs: true });
expect(handler.directives).toContain('Include @crs-setup.conf.example');
});
it('includes @owasp_crs/*.conf when load_owasp_crs is true', () => {
const handler = buildWafHandler({ ...baseWaf, load_owasp_crs: true });
expect(handler.directives).toContain('Include @owasp_crs/*.conf');
});
it('sets load_owasp_crs=true on the handler object', () => {
const handler = buildWafHandler({ ...baseWaf, load_owasp_crs: true });
expect(handler.load_owasp_crs).toBe(true);
});
it('@coraza.conf-recommended appears BEFORE CRS includes', () => {
const handler = buildWafHandler({ ...baseWaf, load_owasp_crs: true });
const directives = handler.directives as string;
const corazaPos = directives.indexOf('@coraza.conf-recommended');
const crsPos = directives.indexOf('@owasp_crs');
expect(corazaPos).toBeLessThan(crsPos);
});
});
// ---------------------------------------------------------------------------
// Excluded rule IDs
// ---------------------------------------------------------------------------
describe('buildWafHandler — excluded_rule_ids', () => {
it('emits SecRuleRemoveById with single ID', () => {
const handler = buildWafHandler({ ...baseWaf, excluded_rule_ids: [941100] });
expect(handler.directives).toContain('SecRuleRemoveById 941100');
});
it('emits SecRuleRemoveById with multiple IDs space-separated', () => {
const handler = buildWafHandler({ ...baseWaf, excluded_rule_ids: [941100, 942200, 943300] });
expect(handler.directives).toContain('SecRuleRemoveById 941100 942200 943300');
});
it('omits SecRuleRemoveById when excluded_rule_ids is empty', () => {
const handler = buildWafHandler({ ...baseWaf, excluded_rule_ids: [] });
expect(handler.directives).not.toContain('SecRuleRemoveById');
});
it('omits SecRuleRemoveById when excluded_rule_ids is undefined', () => {
const handler = buildWafHandler({ ...baseWaf });
expect(handler.directives).not.toContain('SecRuleRemoveById');
});
});
// ---------------------------------------------------------------------------
// Handler structure
// ---------------------------------------------------------------------------
describe('buildWafHandler — handler structure', () => {
it('always sets handler="waf"', () => {
expect(buildWafHandler(baseWaf).handler).toBe('waf');
});
it('directives is a non-empty string', () => {
const handler = buildWafHandler(baseWaf);
expect(typeof handler.directives).toBe('string');
expect((handler.directives as string).length).toBeGreaterThan(0);
});
it('always includes audit log directives', () => {
const handler = buildWafHandler(baseWaf);
expect(handler.directives).toContain('SecAuditEngine RelevantOnly');
expect(handler.directives).toContain('SecAuditLog /logs/waf-audit.log');
expect(handler.directives).toContain('SecAuditLogFormat JSON');
});
});
// ---------------------------------------------------------------------------
// resolveEffectiveWaf
// ---------------------------------------------------------------------------
const globalWaf = {
enabled: true,
mode: 'On' as const,
load_owasp_crs: false,
custom_directives: 'SecRule REQUEST_HEADERS:User-Agent "@contains leakix.net" "id:9002,phase:1,deny,status:403,log"',
};
describe('resolveEffectiveWaf — no per-host config', () => {
it('returns null when both global and host are null', () => {
expect(resolveEffectiveWaf(null, null)).toBeNull();
});
it('returns null when global is disabled and host is null', () => {
expect(resolveEffectiveWaf({ ...globalWaf, enabled: false }, null)).toBeNull();
});
it('applies global WAF when host has no per-host config (null)', () => {
const result = resolveEffectiveWaf(globalWaf, null);
expect(result).not.toBeNull();
expect(result!.enabled).toBe(true);
expect(result!.custom_directives).toContain('9002');
});
it('applies global WAF when host config is undefined', () => {
const result = resolveEffectiveWaf(globalWaf, undefined);
expect(result).not.toBeNull();
expect(result!.custom_directives).toContain('9002');
});
});
describe('resolveEffectiveWaf — merge mode (regression: host.enabled=false must opt out)', () => {
it('returns null when host explicitly disables WAF in merge mode (the bug fix)', () => {
// This was the bug: host.enabled=false in merge mode was ignored and global WAF applied anyway
const result = resolveEffectiveWaf(globalWaf, { enabled: false, waf_mode: 'merge' });
expect(result).toBeNull();
});
it('returns null when host.enabled=false with no waf_mode set (defaults to merge)', () => {
const result = resolveEffectiveWaf(globalWaf, { enabled: false });
expect(result).toBeNull();
});
it('merges host settings on top of global when host is enabled', () => {
const result = resolveEffectiveWaf(globalWaf, {
enabled: true,
waf_mode: 'merge',
mode: 'On',
load_owasp_crs: true,
custom_directives: 'SecRule ARGS "@contains evil" "id:9003,deny"',
});
expect(result).not.toBeNull();
expect(result!.load_owasp_crs).toBe(true);
// Both global and host custom directives are present
expect(result!.custom_directives).toContain('9002');
expect(result!.custom_directives).toContain('9003');
});
it('merge result has enabled=true', () => {
const result = resolveEffectiveWaf(globalWaf, { enabled: true, waf_mode: 'merge' });
expect(result!.enabled).toBe(true);
});
it('merged excluded_rule_ids combines global and host lists', () => {
const global = { ...globalWaf, excluded_rule_ids: [941100] };
const result = resolveEffectiveWaf(global, {
enabled: true,
waf_mode: 'merge',
excluded_rule_ids: [942200],
});
expect(result!.excluded_rule_ids).toContain(941100);
expect(result!.excluded_rule_ids).toContain(942200);
});
});
describe('resolveEffectiveWaf — override mode', () => {
it('returns null when host.enabled=false in override mode', () => {
const result = resolveEffectiveWaf(globalWaf, { enabled: false, waf_mode: 'override' });
expect(result).toBeNull();
});
it('uses only host config in override mode, ignores global custom_directives', () => {
const result = resolveEffectiveWaf(globalWaf, {
enabled: true,
waf_mode: 'override',
mode: 'On',
load_owasp_crs: true,
custom_directives: 'SecRule ARGS "@contains evil" "id:9003,deny"',
});
expect(result).not.toBeNull();
expect(result!.custom_directives).toBe('SecRule ARGS "@contains evil" "id:9003,deny"');
// Global directives are NOT included
expect(result!.custom_directives).not.toContain('9002');
expect(result!.load_owasp_crs).toBe(true);
});
it('host-only WAF with no global applies correctly', () => {
const result = resolveEffectiveWaf(null, { enabled: true, waf_mode: 'override', mode: 'On' });
expect(result).not.toBeNull();
expect(result!.enabled).toBe(true);
});
});
// ---------------------------------------------------------------------------
// buildWafHandler — WebSocket bypass
// ---------------------------------------------------------------------------
describe('buildWafHandler — WebSocket bypass (regression: silent WAF block on WS upgrade)', () => {
it('includes WebSocket bypass rule when allowWebsocket=true', () => {
const handler = buildWafHandler(baseWaf, true);
expect(handler.directives).toContain('Upgrade');
expect(handler.directives).toContain('websocket');
expect(handler.directives).toContain('ctl:ruleEngine=off');
});
it('bypass rule uses case-insensitive regex match for the Upgrade header value', () => {
const handler = buildWafHandler(baseWaf, true);
// The regex must catch both "websocket" and "WebSocket"
expect(handler.directives).toContain('(?i)');
});
it('bypass rule is phase:1 so it fires before any OWASP CRS rules', () => {
const handler = buildWafHandler(baseWaf, true);
expect(handler.directives).toContain('phase:1');
});
it('bypass rule uses nolog,noauditlog to avoid false-positive audit entries', () => {
const handler = buildWafHandler(baseWaf, true);
expect(handler.directives).toContain('nolog');
expect(handler.directives).toContain('noauditlog');
});
it('bypass rule appears BEFORE OWASP CRS includes when both are enabled', () => {
const handler = buildWafHandler({ ...baseWaf, load_owasp_crs: true }, true);
const directives = handler.directives as string;
const wsPos = directives.indexOf('ctl:ruleEngine=off');
const crsPos = directives.indexOf('@owasp_crs');
expect(wsPos).toBeGreaterThanOrEqual(0);
expect(crsPos).toBeGreaterThanOrEqual(0);
expect(wsPos).toBeLessThan(crsPos);
});
it('does NOT include WebSocket bypass rule when allowWebsocket=false', () => {
const handler = buildWafHandler(baseWaf, false);
expect(handler.directives).not.toContain('ctl:ruleEngine=off');
});
it('does NOT include WebSocket bypass rule when allowWebsocket not provided (default false)', () => {
const handler = buildWafHandler(baseWaf);
expect(handler.directives).not.toContain('ctl:ruleEngine=off');
});
it('still includes all standard directives alongside the bypass rule', () => {
const handler = buildWafHandler(baseWaf, true);
expect(handler.directives).toContain('SecRuleEngine On');
expect(handler.directives).toContain('SecAuditEngine RelevantOnly');
});
it('bypass rule is compatible with custom directives (both present)', () => {
const handler = buildWafHandler({
...baseWaf,
custom_directives: 'SecRule ARGS "@contains evil" "id:9001,deny"',
}, true);
expect(handler.directives).toContain('ctl:ruleEngine=off');
expect(handler.directives).toContain('SecRule ARGS "@contains evil"');
});
});