fix WAF silently dropping WebSocket upgrade requests

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>
This commit is contained in:
fuomag9
2026-03-08 23:14:12 +01:00
parent d6df70ab5f
commit 26fcf8ca90
3 changed files with 87 additions and 2 deletions

View File

@@ -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<string, unknown> {
export function buildWafHandler(waf: WafSettings, allowWebsocket = false): Record<string, unknown> {
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.

View File

@@ -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) {

View File

@@ -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"');
});
});