When allowWebsocket=true and WAF is enabled, the WAF handler sits first in the handler chain and processes the initial HTTP upgrade request (GET + Upgrade: websocket). If any rule matches, Coraza can block the handshake before SecAuditEngine captures it — producing no log entry and an unexplained connection failure from the client's perspective. Fix: when allowWebsocket=true, prepend a phase:1 SecLang rule that matches Upgrade: websocket (case-insensitive) and turns the rule engine off for that transaction via ctl:ruleEngine=off. After the 101 Switching Protocols response the connection becomes a raw WebSocket tunnel that the WAF cannot inspect anyway, so this bypass has no impact on normal HTTP traffic through the same host. The rule is inserted before OWASP CRS includes so it always fires first regardless of which ruleset is loaded. Add 9 unit tests in caddy-waf.test.ts covering: bypass present/absent, phase:1 placement, case-insensitive regex, nolog/noauditlog flags, ordering before CRS, and compatibility with custom directives. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
319 lines
13 KiB
TypeScript
319 lines
13 KiB
TypeScript
/**
|
|
* 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"');
|
|
});
|
|
});
|