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:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user