Files
OwnCord/Server/api/waf.go
T
J3vbandClaude Fable 5 8579cb5d91 fix: batch of 25 correctness fixes across server and client (#1370)
* chore(workflows): raise subagent effort tiers (sonnet/haiku to xhigh, prove opus to high)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): 6 defect(s) (OC-0098, OC-0004, OC-0005, OC-0006, OC-0007, OC-0020)

* fix(db): 1 defect(s) (OC-0096)

* fix(admin): 1 defect(s) (OC-0097)

* fix(auth): 2 defect(s) (OC-0099, OC-0021)

* fix(voice): 1 defect(s) (OC-0018)

* fix(admin): 1 defect(s) (OC-0045)

* fix(api): 1 defect(s) (OC-0103)

* fix(client): 1 defect(s) (OC-0105)

* fix(client): 1 defect(s) (OC-0107)

* fix(api): 1 defect(s) (OC-0109)

* fix(api): 1 defect(s) (OC-0112)

* test(admin): compare restore bytes with bytes.Equal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(voice): 2 defect(s) (OC-0095, OC-0014)

OC-0095: createRoom never called setE2EEEnabled(true), so the full ECDH/HKDF/AES-GCM key exchange completed but frames still reached the SFU in plaintext.

OC-0014: token refresh timer was 23h while the server mints LiveKit tokens with a 5-minute TTL, so any reconnect after minute 5 presented an expired token.

* fix(profile): 2 defect(s) (OC-0100, OC-0102)

* fix(service): 1 defect(s) (OC-0022)

Archived channels were only read-only for SendMessage/DeleteMessage. Edit, reaction, pin and purge sinks bypassed the check. Route every write sink through a shared requireChannelWritable gate.

* fix(api): 1 defect(s) (OC-0048)

* chore(workflows): correct stale model labels in bughunt-fix phase details

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(client): 1 defect(s) (OC-0015)

* fix(voice): 1 defect(s) (OC-0002)

* test: fix two CI-only failures in the batch-4 test suite

The delete-account broadcast test now observes member_ban on a second
client's socket: the hub broadcasts and then force-disconnects the target,
so on a slow runner the close could beat the target's own copy of the
frame. The observer is also the party the event exists for.

The voice e2e mock now echoes the real joined channel id on voice_leave
(it hardcoded channel_id 0, which the dispatcher's channel-matched
self-leave teardown correctly ignores), and the rejoin test waits for the
mock's delayed echoes to settle before clicking the row again — clicking
inside the echo window toggled a leave instead of a join.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 14:49:27 +02:00

456 lines
18 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)
}
// 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 := 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)),
)
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 }
}
// 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
}
}
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
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)
}
}
if it := tx.ProcessRequestHeaders(); 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 {
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)
}
if it := crsTx.ProcessRequestHeaders(); 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 {
it, written, err := tx.ReadRequestBodyFrom(r.Body)
if it != nil {
handleWAFInterruption(w, it)
return
} else if err != nil {
slog.Debug("waf: error reading request body", "error", err)
}
if it, err := tx.ProcessRequestBody(); it != nil {
handleWAFInterruption(w, it)
return
} 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 reader, err := tx.RequestBodyReader(); err == nil && reader != nil {
if it, _, err := crsTx.ReadRequestBodyFrom(reader); it != nil {
handleWAFInterruption(w, it)
return
} else if err != nil {
slog.Debug("waf: error reading CRS request body", "error", err)
}
}
}
// 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)
}
}
}
// 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"}`)
}