diff --git a/src/lib/caddy-waf.ts b/src/lib/caddy-waf.ts index 74725110..2a4ee3db 100644 --- a/src/lib/caddy-waf.ts +++ b/src/lib/caddy-waf.ts @@ -74,10 +74,30 @@ export function resolveEffectiveWaf( * the embedded filesystem is unavailable causes a Caddy config load error: * "failed to readfile: open @coraza.conf-recommended: no such file or directory" * Therefore all @-prefixed includes are gated behind load_owasp_crs. + * + * @param allowWebsocket - When true, a SecLang rule is prepended that bypasses + * WAF inspection for the initial HTTP upgrade request (Upgrade: websocket). + * After the protocol switch the connection becomes a WebSocket tunnel that the + * WAF cannot inspect anyway, but without this bypass the WAF may silently drop + * the upgrade handshake: the block happens before SecAuditEngine captures it, + * producing no log entry and an unexplained connection failure. */ -export function buildWafHandler(waf: WafSettings): Record { +export function buildWafHandler(waf: WafSettings, allowWebsocket = false): Record { const parts: string[] = []; + if (allowWebsocket) { + // WebSocket upgrade is an HTTP GET with Upgrade: websocket. The WAF sits + // first in the handler chain and would process this request. After the + // 101 Switching Protocols response the connection becomes a raw WebSocket + // tunnel — the WAF never sees subsequent frames. Turning the rule engine + // off for the upgrade request prevents silent drops while having zero + // impact on normal HTTP traffic through the same host. + parts.push( + 'SecRule REQUEST_HEADERS:Upgrade "@rx (?i)^websocket$" ' + + '"id:9900,phase:1,pass,nolog,noauditlog,ctl:ruleEngine=off"' + ); + } + if (waf.load_owasp_crs) { // @-prefixed paths resolve from the embedded coraza-coreruleset filesystem, // which is only mounted when load_owasp_crs is true. diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index a1f5b291..ad0ab67f 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -658,7 +658,7 @@ async function buildProxyRoutes( meta.waf ); if (effectiveWaf?.enabled && effectiveWaf.mode !== 'Off') { - handlers.unshift(buildWafHandler(effectiveWaf)); + handlers.unshift(buildWafHandler(effectiveWaf, Boolean(row.allow_websocket))); } if (row.hsts_enabled) { diff --git a/tests/unit/caddy-waf.test.ts b/tests/unit/caddy-waf.test.ts index 03d541b0..b87ca416 100644 --- a/tests/unit/caddy-waf.test.ts +++ b/tests/unit/caddy-waf.test.ts @@ -251,3 +251,68 @@ describe('resolveEffectiveWaf — override mode', () => { 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"'); + }); +});