Files
J3vbandClaude Opus 5 39551de4a6 refactor(server): work off the complexity backlog — 62 findings to 0 (#1389)
* refactor(ws): split handleVoiceJoin into cohesive join-stage helpers

handleVoiceJoin was 130 statements / cyclomatic 59 / nestif 11, breaking all
three complexity budgets at once. Split along the stage boundaries the doc
comment already described: precheck, leave-current, persist, restore
moderator flags, grant token, complete. The publish-permission derivation
becomes its own helper because it is the one branch-heavy block inside the
token grant.

Pure move: every statement is preserved verbatim. The only edits are bare
`return`s becoming the typed returns of their new helper, `c.userID` becoming
the `userID` parameter inside voiceJoinPublishPerms, and voiceJoinComplete
re-reading `ch.VoiceMaxUsers` instead of receiving it — `ch` is never mutated,
so the value is identical.

Verified by normalising both revisions of the region to sorted, comment- and
whitespace-stripped statements and diffing: the only deltas are the ones
listed above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: collapse the three duplicated sibling pairs

dupl flagged three pairs of adjacent near-identical functions. Each pair is
now one parameterised implementation plus two thin, still-greppable wrappers.

- ws/voice_controls.go: handleVoiceMuteV2 / handleVoiceDeafenV2 share
  voiceSelfToggleV2; handleVoiceCameraV2 / handleVoiceScreenshareV2 share
  voiceStreamToggleV2. Camera and screenshare drawing from one
  voice_max_video budget (OC-0023) was a bug caused by exactly this
  duplication drifting, so one body is the point, not a side effect.
- db/mention_queries.go: ListMentionTargetsByRoles / ListMentionTargetsByUserIDs
  share listMentionTargets. The matched column is a closed named type
  (mentionTargetColumn) rather than a bare string, so the value interpolated
  into the SELECT cannot become caller-supplied.

Behaviour is unchanged: every rate-limit key, error code, error string, slog
message and slog key is preserved verbatim, including the two "failed to
update <kind> state" messages, which are now assembled the same way
enableVideoSlot already assembled them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(api): extract readEmojiUpload from handleCreateEmoji

handleCreateEmoji was 101 lines against a 100-line budget. The upload-bytes
stage — pull the file out of the parsed form, cap its size, sniff its MIME
type and sniff its dimensions — is the one self-contained block in it, and it
already wrote its own refusals, so it moves out whole as readEmojiUpload.

The permission-before-parse ordering the doc comment calls out is unchanged;
so is every error string. file.Close() now runs when the helper returns
rather than when the handler does, which is strictly earlier and unobservable:
the bytes are already copied into raw and nothing else touches the handle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: extract one cohesive block from three single-budget offenders

Each of these was over exactly one budget, so each gets exactly one extraction
rather than a restructure:

- api/totp_handler.go handleVerifyTOTP (102 lines / 100): the block that
  resolves the user behind the partial-auth challenge and decrypts their TOTP
  secret becomes totpChallengeSecret. The ban-inside-the-partial-window check
  moves with it.
- service/message_reactions.go handleReaction (cyclop 21 / 20): the whole
  authorisation chain — channel lookup, archived gate, DM participant and
  block checks, non-DM permission check — becomes reactionAudience, which
  also returns the DM fan-out audience it already resolved. Check order is
  unchanged and load-bearing.
- db/admin_queries.go BackupToSafe (cyclop 21 / 20): the character allowlist
  loop and the SQL-comment rejection become validateBackupPathChars. That
  loop alone was most of the branch count.

No error string, no check and no ordering changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(plugin): split InstallFromZip into staged install helpers

104 statements / cyclomatic 44 / nestif 12. Split along the stages the code
already had: installZipExtract (the per-entry write loop, with
installZipEntryDest holding the mode/symlink/zip-slip guard chain and
installZipWriteEntry the size-capped copy), installZipStagedManifest,
installZipPromote, and installZipReactivate for the :399 nested block.

Every zip-slip, symlink, entry-mode and uncompressed-size check is preserved
in the same order relative to the writes it guards. The 19 inline
`cleanup(); return` sites collapse to 4 in the orchestrator, one per stage,
because each helper now returns an error instead of unwinding itself — the
staging directory is still removed on exactly the same set of failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(api): split newWAFMiddleware into engine build and per-phase helpers

184 lines / cyclomatic 38, and the request-body block at :382 was the worst
nested site in the tree at nestif 17.

Engine construction moves out of the closure (wafInlineEngine, wafCRSEngine —
the Coraza directive string is lifted verbatim), and each request phase
becomes its own helper: wafInlineRequestHeaders, wafCRSRequestHeaders
(including the Host/Transfer-Encoding re-add for CRS 920280), wafFeedCRSBody
and wafInspectRequestBody, which is the old :382 block.

The three `handleWAFInterruption(w, it); return` sites inside the body block
become one: the helper now returns the interruption and the orchestrator
handles it. No statement runs between the two points on either side, so the
verdict is honoured identically — in particular a CRS body interruption still
returns without replacing r.Body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(service): split SendMessage and lift EditMessage's access check

SendMessage was 79 statements / cyclomatic 35 with an 11-deep nested
attachment block at :101; EditMessage was one point over cyclop.

SendMessage becomes sendMessagePrecheck (permission and DM-block gates,
content sanitisation), sendMessageLinkAttachments (the :101 block: attachment
ownership, claim and link) and sendMessageDMSideEffects. EditMessage gets
editMessageCheckAccess and nothing else — one budget over earns one
extraction.

The sanitizeContent fixpoint and the attachment ownership check are unchanged,
as is the order of every gate. The DM side effects run behind
`isDM && !s.sendMessageDMSideEffects(...)`, so a non-DM never enters them;
inside, only the GetDMParticipantIDs failure returns false, matching the one
error the original early-returned on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(admin): split handlePatchUser into per-field apply helpers

106 lines / cyclomatic 29, with the ban block at :154 nested 9 deep.

Each optional field of the partial edit becomes its own helper —
patchUserPrecheck, patchUserAuthorizeRole, patchUserApplyBan (the :154 block,
including the session disconnect and the broadcast) and patchUserApplyRole.
Each returns a bool meaning "keep going"; none of them writes a success
response, so the single response site in the orchestrator is unchanged.

Field application order, the permission-cache invalidation on a role change
and the disconnect-and-broadcast on a ban are all preserved, as are the three
fail-closed `mod == nil` guards, which now sit at the top of their own helper
and still fire on exactly the same conditions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(admin): split handleSetup into first-run setup stages

143 lines / cyclomatic 30, with the optional-wizard block at :219 sitting
exactly on the nestif threshold.

Split into the stages the endpoint already had: request gating (rate limit and
origin check, which run before any auth exists on a fresh server), owner
account creation, and the wizard application that was the :219 block.

Every gate in front of the handler is a security control on an unauthenticated
endpoint; none moved relative to the work it protects. setup_wizard.go is
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: split run() into named bootstrap and shutdown steps

131 statements / cyclomatic 57, with the executable-path fallback at :126
nested 9 deep.

The five anonymous `defer func(){...}()` blocks become named functions —
telemetryStop, runClosePlugins, runStopEventPersistence, runStopAuditWriter,
maintenanceStop — and the bootstrap stages move out likewise.

Every defer is still registered in run() itself, at the same point in the
sequence, so the LIFO teardown order is unchanged; that order is documented
in the surrounding comments and is load-bearing (the audit-writer stop must
follow database.Close's registration, the event-persistence stop must precede
it). runStopEventPersistence is now registered unconditionally with a nil
persister meaning "disabled", where the old code registered its defer inside
the enabled branch — a no-op occupying that slot cannot change the relative
order of the others.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(ws): split handleReconnect into resume stages

77 statements / cyclomatic 41, plus the replay block at :199 and, in
handleFreshConnect, the voice-state restore at :622.

handleReconnect becomes reconnectPrecheck, reconnectSelectReplay (with
reconnectVetColdTail for the cold-tier gap check), reconnectRegister and
reconnectWriteReplay. handleFreshConnect's stale-voice cleanup moves to its
own helper, where the `if h.livekit != nil` wrapper becomes a guard clause —
that block was the tail of its scope, so returning early and falling off the
end are the same.

The parts that carry the invariants are moved verbatim: reconnectRegister
still takes h.seqMu, still calls registerNow inside that same critical
section (BUG-123 / OC-0206), still unlocks on every exit, and still emits the
"full" tier counter and telemetry on each of its three re-check failures.
handleReconnect's two-boolean contract is unchanged — the collapsed
`return false, false` sites are all fall-through-to-full-ready, and the
single `return true, false` is still the handshake-write-failure path whose
teardown already ran (OC-0051).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(server): fold in the adversarial review of the complexity refactors

Eleven skeptic passes over the refactor commits on this branch found no
blocker and no major — behaviour is preserved throughout. They did find
comment and accuracy defects worth correcting:

- db/mention_queries.go: the mentionTargetColumn rationale claimed the named
  type made the interpolated column "only ever one of the two constants". A
  Go named type is not closed, so that is a convention the type makes visible,
  not one it enforces. Reworded, gosec justification included.
- ws/voice_controls.go: the dupl collapse generalised away three specifics —
  that a server deafen is the moderator's to lift (now on the serverDeafen
  field), the concrete voice_states.camera / voice_states.screenshare column
  names, and the half of the OC-0023 rationale about neither stream kind
  hiding from the other's count. All three restored.
- ws/voice_join.go: `maxUsers := ch.VoiceMaxUsers` had been hoisted to the top
  of voiceJoinComplete, moving a read across the tail supersession guard. The
  read is inert, but it was the one statement in that commit whose position
  relative to a security guard changed; it now sits at its use, as before.
- ws/*_test.go: three test comments cited voice_join.go line numbers that the
  split invalidated. They now cite the helper by name instead.
- service/message_reactions.go: reactionAudience's doc claimed to enforce
  "every gate on reacting"; it enforces the channel-scoped ones, and the doc
  now says which gates stay with the caller.
- api/emoji_handler.go: the readEmojiUpload call reused the outer `ok` from
  the auth check by assignment; it gets its own readOK.
- admin/setup_handler.go: a moved comment kept a "the response above" deictic
  that no longer had a response above it.

No behaviour change. Build, vet, full tests and -race on five packages green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(ws): clear the remaining complexity budgets across the hub

Eight files, thirteen findings. Each function is split at the stages it
already had; no branch is reordered, merged or inverted.

- handlers.go handleMessage (cyclop 28, 88 stmts): session re-check, frame
  decode and result application become handleMessageSessionRecheck,
  handleMessageDecode and handleMessageApply. The V2 constructor lookup ->
  DispatchV2 -> Result resolution order is untouched.
- serve_ready.go buildReady (cyclop 26, 61 stmts): the per-section fetches
  split out, readyChannelPayloads among them. Every visibility predicate is
  preserved verbatim — this is the payload that decides what a client may see.
- serve_pumps.go writePump (cyclop 31): writePumpWrite, writePumpDeliver,
  writePumpDrainChannel and writePumpDrainAndClose. Every channel receive
  stays in the same select statement, so scheduling is unchanged.
- hub_sweep.go sweepStaleVoiceStates (cyclop 22, 56 stmts): the staleness
  predicate, the hub-lock ordering and the position of the race hook are all
  as they were — handleVoiceJoin's BUG-088 ordering depends on them.
- hub_broadcast.go channelReadAudienceImpl and RefreshChannelVisibility
  (cyclop 22 each, 57 stmts): channelReadAudienceDM and
  refreshChannelVisibilityCanSend. The audience predicate is the OC-0090
  group-DM leak surface, so it is extracted, never simplified.
- livekit_webhook.go (nestif 13 and 14): webhookJoinedEnforceVoiceState,
  webhookLeftCleanupClient and webhookLeftFinishLeave. DB delete still
  precedes broadcast on every path.
- livekit_download.go EnsureLiveKitBinary (52 stmts): one extraction,
  ensureLiveKitStageBinary, keeping every archive path check intact.
- voice_moderation.go (nestif 8): voiceModDeafenRollback. The persisted
  server_muted flag remains the authority.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(api): clear the remaining complexity budgets across the HTTP layer

- router.go NewRouter (cyclop 28, 84 stmts): split by wiring concern into
  routerTOTPKey, routerHealthDeps, routerMiddleware, routerUploadRoutes,
  routerPluginWiring, routerVoiceRoutes and routerMetricsRoutes. Middleware
  ORDER is a security property (auth before handler, WAF before body parse,
  rate limit before work) and is unchanged; the returned cleanup func still
  closes over and releases everything it did before.
- auth_handler.go handleRegister (133 lines) and handleLogin (cyclop 21,
  152 lines): registerPolicyGate, registerReadRequest, loginReadRequest and
  loginAuthenticate. The always-compare posture, every rate-limit key, every
  counter reset and the ban-check-versus-password-compare order are all
  preserved — including loginUserFailureThreshold staying unscaled by
  scaledAuthLimit, which is deliberate and commented.
- upload_handler.go handleServeFile (cyclop 31, 128 lines): serveFileResolve
  and serveFileAuthorize. Every header this sets — Content-Disposition
  included, which is what stops a stored file being served as active content —
  is still set with the same value in the same circumstances.
- profile_handler.go handleUploadAvatar (120 lines): avatarUploadReadImage,
  mirroring readEmojiUpload in shape but with the avatar caps and MIME set.
  The two deliberately do not share a helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: clear the last complexity budgets in db and admin

- db/account.go DeleteAccount (cyclop 28, 55 stmts): grouped by subsystem into
  deleteAccountAdminGuard, deleteAccountDMChannels and
  deleteAccountCloseDMChannels, each taking the same transaction. The
  transaction boundary, the delete ORDER (which foreign keys depend on) and
  the rollback path are unchanged.
- admin/logstream.go handleLogStream (cyclop 24): logStreamAuthorize. Flush
  cadence, heartbeat and disconnect detection untouched.
- admin/setup_wizard.go validateWizard (cyclop 23): grouped by section into
  wizardValidateIdentity, wizardValidateNetwork and wizardValidateMedia. Every
  message and bound is unchanged — this is the first input-validation boundary
  on a fresh server, before any auth exists.

With this the tree is at zero: golangci-lint run reports 0 issues against the
budgets set in #1384 (funlen 100/50, cyclop 20, nestif 8, dupl 150), with no
//nolint and no exclusion added anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:39:45 +02:00

503 lines
20 KiB
Go

// Package api provides the HTTP router and handlers for the OwnCord server.
//
// waf.go implements Coraza WAF middleware for OWASP CRS protection.
// Toggle via config: server.waf_enabled (default: false).
// The OWASP Core Rule Set layer mode is server.waf_crs_mode (default: detect).
package api
import (
"fmt"
"io"
"log/slog"
"net/http"
coreruleset "github.com/corazawaf/coraza-coreruleset/v4"
"github.com/corazawaf/coraza/v3"
"github.com/corazawaf/coraza/v3/types"
)
// CRS layer modes (server.waf_crs_mode). The CRS engine runs alongside the
// long-standing inline rules, which keep their blocking behavior in every mode.
const (
// CRSModeOff disables the OWASP CRS layer entirely.
CRSModeOff = "off"
// CRSModeDetect evaluates the full OWASP CRS and logs matches without
// ever blocking a request (SecRuleEngine DetectionOnly).
CRSModeDetect = "detect"
// CRSModeBlock evaluates the full OWASP CRS in anomaly-scoring blocking
// mode. Only enable after reviewing detect-mode logs against real traffic.
CRSModeBlock = "block"
)
// normalizeCRSMode maps a config string to a known CRS mode. Empty and
// unknown values fall back to detect so a typo never silently disables the
// CRS layer (and never accidentally enables blocking either).
func normalizeCRSMode(mode string) string {
switch mode {
case CRSModeOff, CRSModeDetect, CRSModeBlock:
return mode
case "":
return CRSModeDetect
default:
slog.Warn("waf: unknown server.waf_crs_mode, falling back to detect",
"mode", mode)
return CRSModeDetect
}
}
// newCRSWAF builds a second Coraza engine loaded with the embedded OWASP Core
// Rule Set (github.com/corazawaf/coraza-coreruleset/v4). It is kept separate
// from the inline-rules engine so the inline rules keep their exact,
// test-pinned blocking behavior regardless of the CRS mode.
//
// Rationale for defaulting to detection-only: OwnCord is a chat server, and
// chat messages routinely contain SQL-ish and HTML-ish text that the CRS is
// prone to false-positive on. Blocking mode on a chat API needs tuning
// against real traffic first; detect mode gives the operator full CRS
// visibility (every match is logged) with zero user-facing risk.
func newCRSWAF(paranoiaLevel int, block bool, onMatch func(types.MatchedRule)) (coraza.WAF, error) {
engine := "DetectionOnly"
if block {
engine = "On"
}
return coraza.NewWAF(
coraza.NewWAFConfig().
WithRootFS(slashFS{coreruleset.FS}).
WithErrorCallback(onMatch).
WithDirectives(fmt.Sprintf(`
Include @coraza.conf-recommended
Include @crs-setup.conf.example
# Paranoia level (mirrors the inline engine; CRS rule 901120
# only defaults this if unset, so it must be set before the
# rule files are included).
SecAction "id:900000,phase:1,pass,t:none,nolog,setvar:tx.blocking_paranoia_level=%d"
# Allowed HTTP methods (CRS rule 911100). The CRS default is
# "GET HEAD POST OPTIONS", but this REST API also serves
# PUT/PATCH/DELETE routes (profile updates, blocks, pins,
# channel management), so those must be allowed or every such
# request scores anomaly 5 (= instant block at the default
# threshold). id 900200 is the canonical crs-setup id for
# this setting.
SecAction "id:900200,phase:1,pass,t:none,nolog,setvar:'tx.allowed_methods=GET HEAD POST OPTIONS PUT PATCH DELETE'"
# Exclude the file upload endpoint from CRS body inspection
# (binary multipart content up to upload.max_size_mb; the
# inline engine excludes it the same way). Rule 920420
# ("Request content type is not allowed by policy", anomaly
# score 5) is also removed for this route: uploads
# legitimately post binary content types (e.g.
# application/octet-stream) that the CRS default policy
# rejects. Local rule ids 1-99999 are reserved for us by the
# CRS numbering scheme.
SecRule REQUEST_URI "@beginsWith /api/v1/uploads" "id:1001,phase:1,pass,nolog,ctl:requestBodyAccess=Off,ctl:ruleRemoveById=920420"
# Same exclusion for the other routes with a larger-than-1-MiB
# app-level cap (see bodyCapExemptPrefixes in constants.go, and
# the mirrored inline-engine rules 900004/900005): keeps this
# engine's SecRequestBodyLimitAction ProcessPartial from
# evaluating rules against a truncated buffer, and 920420
# from rejecting their non-default content types (application/zip,
# raw image bytes) under CRS blocking mode.
SecRule REQUEST_URI "@beginsWith /api/v1/admin/plugins/install" "id:1002,phase:1,pass,nolog,ctl:requestBodyAccess=Off,ctl:ruleRemoveById=920420"
SecRule REQUEST_URI "@beginsWith /api/v1/users/me/avatar" "id:1003,phase:1,pass,nolog,ctl:requestBodyAccess=Off,ctl:ruleRemoveById=920420"
Include @owasp_crs/*.conf
# Engine mode: DetectionOnly logs matches without interrupting;
# On enforces CRS anomaly-scoring blocking.
SecRuleEngine %s
# We never feed response data into this engine (parity with the
# inline engine), so don't pay for response body buffering.
SecResponseBodyAccess Off
# Body limits: match the app's 1 MiB non-upload cap (see
# MaxBodySizeUnless / config.MaxMessageBytes) instead of the
# recommended-config 12.5 MiB, and never reject on size —
# request size enforcement belongs to the app middleware, not
# the CRS layer. Uploads are excluded from body access above.
SecRequestBodyLimit 1048576
SecRequestBodyLimitAction ProcessPartial
# Match logging goes through the error callback into slog;
# don't also emit native audit log records.
SecAuditEngine Off
`, paranoiaLevel, engine)),
)
}
// logCRSMatch is the per-rule CRS match logger. It is used for the block-mode
// default (blocked requests are rare and their per-rule detail is wanted) and
// whenever a caller supplies it explicitly (tests). The default detect-mode
// path does NOT use it — see logCRSMatchesAggregate.
func logCRSMatch(mr types.MatchedRule) {
slog.Warn("waf: CRS rule matched",
"rule_id", mr.Rule().ID(),
"severity", mr.Rule().Severity().String(),
"uri", mr.URI(),
"msg", mr.Message(),
"data", mr.Data(),
)
}
// logCRSMatchesAggregate emits at most ONE log line for a request that tripped
// CRS detection rules, instead of one Warn per matched rule. In the default
// detect mode ordinary chat prose routinely trips several CRS SQLi/XSS rules
// per request (see TestWAFMiddleware_CRSBlockMode_FalsePositivesOnSQLishChatProse)
// and anomaly scoring amplifies the count, so per-rule Warn logging on the
// request goroutine is pure hot-path overhead (allocation + serialized log
// I/O + log-volume amplification). This keeps the signal — how many rules
// matched and the highest-severity one — on a single Warn and moves the full
// rule-id list to Debug. It reads only per-request transaction state (no
// shared/global state, no locks).
func logCRSMatchesAggregate(tx types.Transaction) {
if tx == nil {
return
}
matched := tx.MatchedRules()
ids := make([]int, 0, len(matched))
var (
topRuleID int
topSev types.RuleSeverity
topURI string
topMsg string
)
for _, mr := range matched {
// Internal bookkeeping rules (the setvar/ctl SecActions this package
// installs, and CRS setup actions) carry no message and are not
// detections; the per-rule callback skips them too (it only fires for
// rules with logging enabled), so keep them out of the count.
if mr.Message() == "" {
continue
}
ids = append(ids, mr.Rule().ID())
// Severity is inverted: 0 (emergency) is the most severe, 7 (debug)
// the least. The first detection seeds the max; smaller wins after.
if sev := mr.Rule().Severity(); len(ids) == 1 || sev < topSev {
topSev = sev
topRuleID = mr.Rule().ID()
topURI = mr.URI()
topMsg = mr.Message()
}
}
if len(ids) == 0 {
return
}
slog.Warn("waf: CRS detect-mode matches",
"matches", len(ids),
"top_rule_id", topRuleID,
"top_severity", topSev.String(),
"uri", topURI,
"top_msg", topMsg,
)
slog.Debug("waf: CRS detect-mode matched rule ids", "rule_ids", ids)
}
// NewWAFMiddlewareCRS creates a Coraza WAF middleware with OWASP CRS rules.
// paranoiaLevel controls rule sensitivity (1=low, 2=default, 3=strict,
// 4=paranoid); crsMode selects the CRS layer mode ("off" | "detect" |
// "block", see the CRSMode* constants — unknown or empty modes fall back to
// detect). Returns nil middleware if WAF creation fails (logged as error,
// server continues).
func NewWAFMiddlewareCRS(paranoiaLevel int, crsMode string) func(http.Handler) http.Handler {
return newWAFMiddleware(paranoiaLevel, crsMode, nil)
}
// wafInlineEngine builds the long-standing inline-rules Coraza engine used by
// newWAFMiddleware. Its rules keep their exact, test-pinned blocking behavior
// regardless of the CRS mode.
func wafInlineEngine(paranoiaLevel int) (coraza.WAF, error) {
return coraza.NewWAF(
coraza.NewWAFConfig().
WithDirectives(fmt.Sprintf(`
SecRuleEngine On
SecRequestBodyAccess On
SecResponseBodyAccess Off
SecRequestBodyLimit 1048576
# Paranoia level
SecAction "id:900000,phase:1,pass,t:none,nolog,setvar:tx.blocking_paranoia_level=%d"
# Core rules — SQL injection
SecRule ARGS|ARGS_NAMES|REQUEST_BODY "@detectSQLi" \
"id:942100,phase:2,deny,status:403,log,msg:'SQL Injection detected',tag:'OWASP_CRS',tag:'attack-sqli'"
# Core rules — XSS
SecRule ARGS|ARGS_NAMES|REQUEST_BODY "@detectXSS" \
"id:941100,phase:2,deny,status:403,log,msg:'XSS detected',tag:'OWASP_CRS',tag:'attack-xss'"
# Path traversal
SecRule ARGS|REQUEST_URI "@contains ../" \
"id:930100,phase:2,deny,status:403,log,msg:'Path traversal detected',tag:'OWASP_CRS',tag:'attack-lfi'"
# Command injection patterns
SecRule ARGS|REQUEST_BODY "@rx (?:;|\||\x60|&&|\$\()" \
"id:932100,phase:2,deny,status:403,log,msg:'Command injection detected',tag:'OWASP_CRS',tag:'attack-rce'"
# Block common scanners
SecRule REQUEST_HEADERS:User-Agent "@rx (?:nikto|sqlmap|nmap|masscan|dirbuster)" \
"id:913100,phase:1,deny,status:403,log,msg:'Scanner blocked',tag:'OWASP_CRS',tag:'automation'"
# Exclude WebSocket upgrade and health endpoints from body inspection
SecRule REQUEST_URI "@streq /ws" "id:900001,phase:1,pass,nolog,ctl:ruleRemoveById=942100;941100;932100"
SecRule REQUEST_URI "@streq /api/v1/health" "id:900002,phase:1,pass,nolog,ctl:ruleRemoveById=942100;941100;932100"
# Exclude file upload endpoint from body inspection (binary content)
SecRule REQUEST_URI "@beginsWith /api/v1/uploads" "id:900003,phase:1,pass,nolog,ctl:requestBodyAccess=Off"
# Exclude the other routes with a larger-than-1-MiB app-level
# cap too (see bodyCapExemptPrefixes in constants.go: 16 MiB
# plugin installs, 2 MiB avatars). Without this, coraza's
# default SecRequestBodyLimitAction (Reject) 413s any body
# that reaches this engine's 1 MiB SecRequestBodyLimit before
# the app's own, larger limit is ever consulted.
SecRule REQUEST_URI "@beginsWith /api/v1/admin/plugins/install" "id:900004,phase:1,pass,nolog,ctl:requestBodyAccess=Off"
SecRule REQUEST_URI "@beginsWith /api/v1/users/me/avatar" "id:900005,phase:1,pass,nolog,ctl:requestBodyAccess=Off"
`, paranoiaLevel)),
)
}
// wafCRSEngine builds the OWASP CRS layer for newWAFMiddleware. It returns the
// CRS engine (nil when the layer is off or failed to load) and whether
// detect-mode match logging is aggregated per request.
func wafCRSEngine(paranoiaLevel int, crsMode string, onCRSMatch func(types.MatchedRule)) (coraza.WAF, bool) {
// OWASP CRS layer — a second engine so the inline rules above keep their
// exact blocking behavior in every CRS mode. If the CRS fails to load the
// server continues with the inline engine only (same failure philosophy
// as above).
//
// aggregateCRSLog collapses detect-mode match logging to one line per
// request (logCRSMatchesAggregate) instead of one Warn per matched rule.
// It applies ONLY to the default detect-mode path — the hot path for
// ordinary traffic. When a caller supplies its own onCRSMatch (tests) the
// per-rule callback is wired so every match stays observable; in block
// mode the per-rule logCRSMatch is kept (blocked requests are rare and the
// per-rule detail is wanted), leaving block-mode behavior exactly as-is.
aggregateCRSLog := false
var crsWAF coraza.WAF
if crsMode != CRSModeOff {
crsCallback := onCRSMatch
if crsCallback == nil {
if crsMode == CRSModeDetect {
// Default detect mode: leave the engine error callback nil so
// nothing logs per rule on the request goroutine, and instead
// aggregate the transaction's matches after processing.
aggregateCRSLog = true
} else {
crsCallback = logCRSMatch
}
}
cw, crsErr := newCRSWAF(paranoiaLevel, crsMode == CRSModeBlock, crsCallback)
if crsErr != nil {
slog.Error("waf: failed to load OWASP CRS, continuing with inline rules only", "error", crsErr)
} else {
crsWAF = cw
}
}
return crsWAF, aggregateCRSLog
}
// wafInlineRequestHeaders feeds the connection, URI and request headers into
// the inline engine and runs its phase 1. A non-nil interruption means the
// request must be blocked.
func wafInlineRequestHeaders(tx types.Transaction, r *http.Request) *types.Interruption {
tx.ProcessConnection(r.RemoteAddr, 0, "", 0)
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
for name, values := range r.Header {
for _, value := range values {
tx.AddRequestHeader(name, value)
}
}
return tx.ProcessRequestHeaders()
}
// wafCRSRequestHeaders feeds the connection, URI and request headers into the
// CRS engine and runs its phase 1. A non-nil interruption means the request
// must be blocked.
func wafCRSRequestHeaders(crsTx types.Transaction, r *http.Request) *types.Interruption {
crsTx.ProcessConnection(r.RemoteAddr, 0, "", 0)
crsTx.ProcessURI(r.URL.String(), r.Method, r.Proto)
for name, values := range r.Header {
for _, value := range values {
crsTx.AddRequestHeader(name, value)
}
}
// net/http promotes Host and Transfer-Encoding out of
// r.Header; re-add them like the official coraza http
// connector does, otherwise CRS rule 920280 ("Request
// Missing a Host Header", anomaly score 5) fires on every
// request. The inline engine is left as-is on purpose — its
// rules never look at these headers and its behavior is
// pinned by tests.
if r.Host != "" {
crsTx.AddRequestHeader("Host", r.Host)
crsTx.SetServerName(r.Host)
}
for _, te := range r.TransferEncoding {
crsTx.AddRequestHeader("Transfer-Encoding", te)
}
return crsTx.ProcessRequestHeaders()
}
// wafFeedCRSBody mirrors the inline engine's buffered request body into the
// CRS engine so the body is only read from the wire once. A non-nil
// interruption means the request must be blocked.
func wafFeedCRSBody(tx, crsTx types.Transaction) *types.Interruption {
if reader, err := tx.RequestBodyReader(); err == nil && reader != nil {
if it, _, err := crsTx.ReadRequestBodyFrom(reader); it != nil {
return it
} else if err != nil {
slog.Debug("waf: error reading CRS request body", "error", err)
}
}
return nil
}
// wafInspectRequestBody buffers the request body through the inline engine,
// runs its phase 2, mirrors the buffer into the CRS engine and hands the
// buffered body to the downstream handler. A non-nil interruption means the
// request must be blocked.
func wafInspectRequestBody(r *http.Request, tx, crsTx types.Transaction) *types.Interruption {
it, written, err := tx.ReadRequestBodyFrom(r.Body)
if it != nil {
return it
} else if err != nil {
slog.Debug("waf: error reading request body", "error", err)
}
if it, err := tx.ProcessRequestBody(); it != nil {
return it
} else if err != nil {
slog.Debug("waf: error processing request body", "error", err)
}
// Feed the CRS engine from the inline engine's buffer so the
// body is only read from the wire once. written == 0 means the
// inline engine skipped buffering (requestBodyAccess turned
// off for this route, e.g. uploads) — the CRS engine excludes
// those routes too, so skip it as well and leave r.Body alone.
if written > 0 {
if crsTx != nil {
if it := wafFeedCRSBody(tx, crsTx); it != nil {
return it
}
}
// Replace body with buffered version so downstream handlers
// can read it. Only done when the inline engine actually
// buffered the body — replacing unconditionally would hand
// routes with body inspection disabled (uploads) an empty
// reader instead of the original stream.
reader, err := tx.RequestBodyReader()
if err == nil && reader != nil {
r.Body = io.NopCloser(reader)
}
}
return nil
}
// newWAFMiddleware is the implementation behind NewWAFMiddlewareCRS.
// onCRSMatch overrides the CRS match logger (used by tests to observe
// detect-mode matches); nil means log via slog.
func newWAFMiddleware(paranoiaLevel int, crsMode string, onCRSMatch func(types.MatchedRule)) func(http.Handler) http.Handler {
if paranoiaLevel < 1 || paranoiaLevel > 4 {
paranoiaLevel = 2
}
crsMode = normalizeCRSMode(crsMode)
waf, err := wafInlineEngine(paranoiaLevel)
if err != nil {
slog.Error("waf: failed to create WAF engine, continuing without WAF", "error", err)
return func(next http.Handler) http.Handler { return next }
}
crsWAF, aggregateCRSLog := wafCRSEngine(paranoiaLevel, crsMode, onCRSMatch)
slog.Info("waf: Coraza WAF enabled", "paranoia_level", paranoiaLevel, "crs_mode", crsMode)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tx := waf.NewTransaction()
defer func() {
tx.ProcessLogging()
if err := tx.Close(); err != nil {
slog.Debug("waf: error closing transaction", "error", err)
}
}()
var crsTx types.Transaction
if crsWAF != nil {
crsTx = crsWAF.NewTransaction()
defer func() {
// One aggregated match log per request (detect-mode
// default only); reads per-request transaction state, so
// it must run before the transaction is closed.
if aggregateCRSLog {
logCRSMatchesAggregate(crsTx)
}
crsTx.ProcessLogging()
if err := crsTx.Close(); err != nil {
slog.Debug("waf: error closing CRS transaction", "error", err)
}
}()
}
// Process request headers
if it := wafInlineRequestHeaders(tx, r); it != nil {
handleWAFInterruption(w, it)
return
}
// CRS phase 1. In detect mode the engine never interrupts, so the
// returned interruption is only non-nil in block mode.
if crsTx != nil {
if it := wafCRSRequestHeaders(crsTx, r); it != nil {
handleWAFInterruption(w, it)
return
}
}
// Process request body (if applicable). Use ContentLength != 0 so
// chunked requests (Transfer-Encoding: chunked → ContentLength == -1)
// are inspected too; otherwise the SQLi/XSS/RCE body rules are silently
// skipped for them. The read is bounded by SecRequestBodyLimit inside
// Coraza. ContentLength == 0 (no body) still skips inspection.
if r.Body != nil && r.ContentLength != 0 {
if it := wafInspectRequestBody(r, tx, crsTx); it != nil {
handleWAFInterruption(w, it)
return
}
}
// CRS phase 2 always runs, even without a body: CRS request rules
// (including query-string XSS/SQLi and the anomaly-blocking
// evaluation) are phase 2 rules. The inline engine deliberately
// keeps its original behavior of only running phase 2 when a body
// is present.
if crsTx != nil {
if it, err := crsTx.ProcessRequestBody(); it != nil {
handleWAFInterruption(w, it)
return
} else if err != nil {
slog.Debug("waf: error processing CRS request body", "error", err)
}
}
next.ServeHTTP(w, r)
})
}
}
func handleWAFInterruption(w http.ResponseWriter, it *types.Interruption) {
slog.Warn("waf: request blocked",
"status", it.Status,
"action", it.Action,
"rule_id", it.RuleID,
)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(it.Status)
_, _ = fmt.Fprintf(w, `{"error":"request blocked by security rules"}`)
}