mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* 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>
503 lines
20 KiB
Go
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"}`)
|
|
}
|