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
319 lines
13 KiB
TypeScript
Executable File
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"');
|
|
});
|
|
});
|