Skip to content

Add WAF support to HTTP ingress routes#786

Merged
evanphx merged 11 commits into
mainfrom
mir-1101-provide-simple-waf-web-application-firewall
May 11, 2026
Merged

Add WAF support to HTTP ingress routes#786
evanphx merged 11 commits into
mainfrom
mir-1101-provide-simple-waf-web-application-firewall

Conversation

@evanphx
Copy link
Copy Markdown
Contributor

@evanphx evanphx commented May 6, 2026

Summary

  • Integrates Coraza WAF with OWASP Core Rule Set (CRS) into the HTTP ingress layer
  • Routes gain a waf_level field (0=disabled, 1-4=CRS paranoia level) enabling request-level security filtering (SQL injection, XSS, path traversal, etc.) before traffic reaches user apps
  • New CLI commands: miren route waf <host> --level N and miren route unwaf <host>
  • Refactors duplicated OIDC middleware composition into a unified WAF → OIDC → serve chain

Test plan

  • pkg/waf unit tests — SQL injection, XSS, POST injection blocked; clean requests pass; paranoia levels 1-4; engine caching
  • servers/httpingress WAF middleware tests — disabled when level 0, blocks attacks, allows clean, respects levels, handles invalid/negative levels
  • api/ingress/client tests — set/lookup WAF level, disable (zero-value via Patch), invalid levels, non-existent route
  • Blackbox tests — enable/disable WAF, malicious request blocked then allowed after disable, JSON output, default route, invalid level rejection
  • make lint passes

Integrate Coraza WAF with OWASP Core Rule Set into the HTTP ingress
layer. Routes gain a waf_level field (0=disabled, 1-4=CRS paranoia
level) that enables request-level security filtering before traffic
reaches user apps.

- Add waf_level field to http_route entity schema
- Create pkg/waf engine with per-paranoia-level caching
- Add WAF middleware to httpingress (WAF → OIDC → serve chain)
- Refactor duplicated OIDC middleware into unified composition
- Add CLI commands: route waf / route unwaf
- Show WAF level in route show and route list
- Use Patch for waf_level=0 to handle entity zero-value encoding
@evanphx evanphx requested a review from a team as a code owner May 6, 2026 18:01
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds per-route Web Application Firewall (WAF) support. The HTTP route schema gains a waf_profile reference and a WafProfile entity with paranoia level (1–4). New ingress client methods create/get WAF profiles and set/detach a route’s WAF level. A Coraza-based pkg/waf Engine caches per-level WAFs and is wired into httpingress middleware. The CLI gains miren route waf; route list/show include WAF fields. Unit, integration, blackbox tests, docs, and go.mod dependency updates are included.


Comment @coderabbitai help to get the list of available commands and usage tips.

evanphx added 3 commits May 6, 2026 11:40
Consolidate WAF enable/disable into a single command (route waf) with
a --disable flag instead of a separate route unwaf subcommand.

Add WAF documentation page covering paranoia levels, what gets blocked,
and how the middleware fits into the request pipeline. Regenerate CLI
command docs to include the updated route waf command.
MustRun asserts success internally, so expected-failure cases for
invalid WAF levels need to use Run instead.
Replace deprecated reflect.Ptr with reflect.Pointer and convert
WriteString(fmt.Sprintf(...)) to fmt.Fprintf(...) across the codebase.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@api/ingress/client.go`:
- Around line 432-441: The exported function SetRouteWAFLevelOnRoute currently
dereferences route (route.WafLevel, route.ID) without checking for nil; add an
early nil guard at the start of SetRouteWAFLevelOnRoute (e.g., if route == nil {
return nil, fmt.Errorf("route is nil") }) to return a typed error instead of
panicking, then proceed with the existing validation, assignment to
route.WafLevel and the c.ec.Patch call using route.ID; ensure the error message
is descriptive and consistent with other API errors.

In `@docs/docs/waf.md`:
- Line 113: The docs incorrectly state oversized bodies "skip body inspection
but still have their headers and URL checked"; update the text to reflect the
actual behavior controlled by SecRequestBodyLimitAction in pkg/waf/waf.go: when
SecRequestBodyLimitAction is set to Reject, requests with bodies larger than 10
MB are rejected with HTTP 413 (Request Entity Too Large) rather than being
partially inspected or allowed through; change the sentence to explicitly
mention the 413 rejection and reference SecRequestBodyLimitAction Reject as the
governing configuration.

In `@pkg/waf/waf.go`:
- Around line 78-84: The WAF match callback currently logs the full request URI
via mr.URI(), which can contain sensitive query parameters; update the
WithErrorCallback handler to avoid logging full URIs by extracting and logging
only the path (or a redacted version) from mr.URI() before passing it to
log.Warn — locate the WithErrorCallback anonymous function handling
types.MatchedRule (the mr variable) and replace mr.URI() with a safe value
derived from parsing mr.URI() (e.g., use the path component or a redaction
function) so that log.Warn("WAF rule matched", "id", mr.Rule().ID(), "msg",
mr.Message(), "severity", mr.Rule().Severity().String(), "uri",
<redacted-or-path>) is used instead of the raw mr.URI().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ddae6a93-6c4f-43fc-9894-7357732982e4

📥 Commits

Reviewing files that changed from the base of the PR and between f05899e and a589541.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (21)
  • api/ingress/client.go
  • api/ingress/client_test.go
  • api/ingress/ingress_v1alpha/schema.gen.go
  • api/ingress/schema.yml
  • blackbox/route_waf_test.go
  • cli/commands/commands.go
  • cli/commands/route_list.go
  • cli/commands/route_show.go
  • cli/commands/route_waf.go
  • docs/command-sidebar.json
  • docs/docs/command/route-waf.md
  • docs/docs/command/route.md
  • docs/docs/commands.md
  • docs/docs/waf.md
  • docs/sidebars.ts
  • go.mod
  • pkg/waf/waf.go
  • pkg/waf/waf_test.go
  • servers/httpingress/httpingress.go
  • servers/httpingress/waf.go
  • servers/httpingress/waf_test.go

Comment thread api/ingress/client.go
Comment thread docs/docs/waf.md Outdated
Comment thread pkg/waf/waf.go
evanphx added 3 commits May 6, 2026 16:05
Add unreachable returns after t.Fatal to satisfy SA5011 nil-pointer
analysis, and remove always-true nil checks on runUndo which never
returns nil (SA4023).
Add unreachable returns after t.Fatal nil checks so staticcheck
recognizes the subsequent dereferences are safe.
Copy link
Copy Markdown
Contributor

@phinze phinze left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude and Paul here. The big-picture architectural conversation lives on the 787 review where we walked through both PRs together with notes from yesterday's product standup. Short version: the route schema is growing asymmetric across our three protections, and we're suggesting consolidating the auth side into a single auth_provider kind there.

Here's how we're thinking WAF should look: waf_profile is its own kind (initially with just paranoia_level, leaving headroom for custom rules, allowlists, mode toggles later). The route holds a waf_profile ref. The user-facing CLI m route waf <host> --level N works as it does today by auto-creating or looking up default profiles (default-l1, default-l2, etc.) under the covers. Power users get m waf profile add/list/show/remove to name and share profiles across routes.

The migration cost asymmetry is what makes doing this now cheap. waf_level int to waf_profile ref is a route-table backfill on every protected route. waf_profile ref to a future middleware ref is a much smaller move on far fewer rows. Doing the entity reshape now sets up the right shape and saves the bigger migration later.

Marking REQUEST_CHANGES on the waf_profile reshape specifically. Once that pivot lands we'll come back through for the rest of the review properly.

evanphx added 2 commits May 9, 2026 13:07
Replace the waf_level int field on http_route with a waf_profile ref
to a new waf_profile entity kind. Profiles are identified by entity ID
(e.g. waf-l1 through waf-l4) and contain paranoia_level. The middleware
resolves profiles via a 30s TTL cache to avoid per-request entity lookups.
- Add nil guards to SetRouteWAFLevelOnRoute and DetachWAFProfileFromRoute
- Fix docs: oversized request bodies are rejected (413), not passed through
- Log only path (not query string) in WAF match callbacks to avoid leaking
  sensitive query parameters
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
cli/commands/route_list.go (1)

29-38: ⚡ Quick win

Memoize WAF profile lookups inside route list.

This helper does an RPC per route even though the same few profile IDs repeat across the whole listing. On larger route sets that turns route list into an unnecessary N+1 lookup pattern.

Proposed fix
-	resolveWAFLevel := func(route *ingress_v1alpha.HttpRoute) int {
+	wafLevels := map[entity.Id]int{}
+	resolveWAFLevel := func(route *ingress_v1alpha.HttpRoute) int {
 		if entity.Empty(route.WafProfile) {
 			return 0
 		}
+		if level, ok := wafLevels[route.WafProfile]; ok {
+			return level
+		}
 		profile, err := ic.GetWAFProfileByID(ctx, route.WafProfile)
 		if err != nil || profile == nil {
+			wafLevels[route.WafProfile] = 0
 			return 0
 		}
-		return int(profile.ParanoiaLevel)
+		level := int(profile.ParanoiaLevel)
+		wafLevels[route.WafProfile] = level
+		return level
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/commands/route_list.go` around lines 29 - 38, The resolveWAFLevel helper
in route list performs an RPC per route via ic.GetWAFProfileByID causing N+1
calls; fix by adding a local memoization map (e.g., map[string]*WAFProfile or
map[string]*ingress_v1alpha.WafProfile and map[string]bool for missing) in the
enclosing scope of resolveWAFLevel, check the map before calling
GetWAFProfileByID, store fetched profiles (and negative results) in the map, and
have resolveWAFLevel return int(profile.ParanoiaLevel) using the cached entry to
avoid repeated RPCs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cli/commands/route_waf.go`:
- Around line 62-75: When opts.Disable is true the handler uses ctx.Printf to
print human-readable messages even when the user requested JSON; update the
disable branch (the block referencing opts.Disable,
entity.Empty(route.WafProfile), ic.DetachWAFProfileFromRoute and ctx.Printf) to
honor the CLI output format: detect the JSON format flag and emit a proper JSON
response (e.g., an object indicating route label and waf_enabled: false or an
error) instead of plain text both when the route is already unwafed and after a
successful detach, and fall back to the existing human-readable ctx.Printf only
for non-JSON output.

In `@servers/httpingress/waf.go`:
- Around line 50-69: The current lookup in GetWAFProfileByID trusts
profile.ParanoiaLevel and only caches successful profiles; update the logic
around s.ingressClient.GetWAFProfileByID, the profile variable, and the
s.wafProfileCache/wafProfileEntry so that missing or malformed profiles are
normalized and cached for the TTL: if profile is nil or profile.ParanoiaLevel is
out of range, set a fallback paranoia level clamped to the range 1..4, store
that value in s.wafProfileCache[profileID] (using the existing wafProfileEntry
with fetchedAt = time.Now()) under s.wafProfileMu lock, and return the
normalized level; if a real profile exists, clamp profile.ParanoiaLevel to 1..4
before caching and returning it so Handler/next won’t silently disable
inspection.

---

Nitpick comments:
In `@cli/commands/route_list.go`:
- Around line 29-38: The resolveWAFLevel helper in route list performs an RPC
per route via ic.GetWAFProfileByID causing N+1 calls; fix by adding a local
memoization map (e.g., map[string]*WAFProfile or
map[string]*ingress_v1alpha.WafProfile and map[string]bool for missing) in the
enclosing scope of resolveWAFLevel, check the map before calling
GetWAFProfileByID, store fetched profiles (and negative results) in the map, and
have resolveWAFLevel return int(profile.ParanoiaLevel) using the cached entry to
avoid repeated RPCs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f3afe4aa-3ab3-4b72-ac8f-dd227925ff21

📥 Commits

Reviewing files that changed from the base of the PR and between f628104 and 2a5aaf0.

📒 Files selected for processing (12)
  • api/ingress/client.go
  • api/ingress/client_test.go
  • api/ingress/ingress_v1alpha/schema.gen.go
  • api/ingress/schema.yml
  • cli/commands/route_list.go
  • cli/commands/route_show.go
  • cli/commands/route_waf.go
  • docs/docs/waf.md
  • pkg/waf/waf.go
  • servers/httpingress/httpingress.go
  • servers/httpingress/waf.go
  • servers/httpingress/waf_test.go
✅ Files skipped from review due to trivial changes (1)
  • docs/docs/waf.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • cli/commands/route_show.go
  • pkg/waf/waf.go
  • servers/httpingress/httpingress.go

Comment thread cli/commands/route_waf.go
Comment thread servers/httpingress/waf.go
Skip entity existence check for empty ref values during validation.
An empty ref means "no reference" and should not trigger a lookup
against the entity store. This fixes WAF profile detach which uses
Patch to set the waf_profile ref to empty.
@evanphx evanphx requested a review from phinze May 11, 2026 00:19
Copy link
Copy Markdown
Contributor

@phinze phinze left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude and Paul here. Round two — the waf_profile reshape we asked for landed cleanly, and the inline CR findings from last time (nil guards, URI redaction, docs accuracy) are addressed too. Took it for a spin in dev: enabled, blocked SQL injection and XSS, switched levels, disabled, and watched the shared waf-l1/waf-l2/waf-l3 profiles get lazily created as singletons in the entity store. The whole shape feels right and leaves room for the future m waf profile ... power-user CLI we floated.

One thing the spin surfaced beyond the inlines: we tried this against the real-world "my logs are full of WordPress scans" complaint and the WAF doesn't help with that — CRS catches payloads (SQL injection, XSS, command injection), not URL fingerprinting. Worth saying so explicitly in waf.md since users will reach for this expecting it to quiet down bot noise and be surprised when it doesn't. Inline comment with details.

Comment thread cli/commands/route_waf.go
Comment thread docs/docs/waf.md

import CliCommand from '@site/src/components/CliCommand';

# Web Application Firewall (WAF)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a "what this doesn't do" callout near the top? CRS catches attack payloads (SQL injection, XSS, command injection, path traversal) — not URL pattern scanning. Users hitting this from a "my Rails app logs are full of /wp-admin/ and /xmlrpc.php" angle will turn it on, see the same noise, and be confused.

A line like "the WAF inspects request content for attack patterns; it doesn't fingerprint suspicious URLs, rate-limit, or block bot reconnaissance — for that, consider an upstream proxy like Cloudflare" sets expectations and saves a support thread.

Comment thread pkg/entity/validation.go
- Honor --format json in route waf --disable and already-disabled paths
- Add callout to WAF docs clarifying CRS scope (no rate-limiting or
  bot fingerprinting)
- Fix second empty-ref validation path in validateToType to match
  the ValidateAttribute fix
@evanphx evanphx merged commit 97f9517 into main May 11, 2026
19 checks passed
@evanphx evanphx deleted the mir-1101-provide-simple-waf-web-application-firewall branch May 11, 2026 22:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants