# Audit: `pkg/middleware` | | | |---|---| | **Package** | `github.com/bitechdev/ResolveSpec/pkg/middleware` | | **Files** | `panic.go` (33), `ratelimit.go` (233), `blacklist.go` (212), `sanitize.go` (251), `sizelimit.go` (70), `README.md` (18 KB) | | **Tests** | `panic_test.go` (86), `ratelimit_test.go` (388), `blacklist_test.go` (254), `sanitize_test.go` (273), `sizelimit_test.go` (126) — 1 127 lines | | **Audit date** | 2026-09-29 | | **Axes** | thread locking/waiting, slowness, security, panic handling & logging | | **Threat model** | hostile internet client; request bodies, headers, query params, schema/table/column names all attacker-controlled | | **Depth** | deep | ## Summary This package is the project's perimeter: rate limiting, IP blacklisting, request size limiting, input sanitization and panic recovery. It is also the package where the threat model bites hardest, and it has two structural problems. **First, almost none of it is mounted.** `NewRateLimiter`, `NewIPBlacklist`, `NewRequestSizeLimiter`, `DefaultSanitizer` and `StrictSanitizer` have **zero non-test callers** anywhere in the repository. Only `pkg/server/manager.go` imports the package, and only to apply `PanicRecovery` (`manager.go:466`). The `MiddlewareConfig` fields that would configure the rest (`pkg/config/config.go:123-125`) are declared, defaulted (`pkg/config/manager.go:209-211`) and **read by nothing**. So against the stated threat model there is currently **no rate limiting, no request-size limit, no IP blocking and no input sanitization in the serving path** — finding 1. **Second, the protections themselves would not hold if mounted.** Both IP-based controls derive the client address from `getClientIP` (`ratelimit.go:210-233`), which trusts `X-Forwarded-For` unconditionally and with no trusted-proxy configuration. One attacker-chosen header therefore bypasses the rate limiter entirely *and* makes its limiter map grow without bound (finding 2), and the same header defeats the IP blacklist outright when `UseProxy` is set (finding 3). The sanitizer is a denylist that I verified is bypassable four different ways — and in one case **manufactures a `javascript:` URI from input that contained none** — while simultaneously corrupting ordinary filter values like `price>100` and any JSON parameter (findings 6 and 7). The one live middleware, `PanicRecovery`, writes the panic value into the HTTP 500 body (`panic.go:28`), and `panic_test.go:57` asserts that it does — the leak is test-locked as the intended contract (finding 4). On the positive side the locking is genuinely careful: `getLimiter` (`ratelimit.go:42-62`) implements correct double-checked locking, the blacklist guards every field with an `RWMutex`, and the package carries 1 127 lines of tests — more than the 799 lines of code. There are **no data races** in the package. The defects are in trust boundaries and in what is wired up, not in concurrency. ## Findings | # | Severity | Axis | Finding | |---|---|---|---| | 1 | **High** | security | No protective middleware is mounted: rate limit, size limit, blacklist and sanitizer all have zero non-test callers, and `MiddlewareConfig` is read by nothing | | 2 | **High** | security / slowness | `getClientIP` trusts `X-Forwarded-For` unconditionally — the rate limiter is bypassable per-request and its limiter map grows without bound | | 3 | **High** | security | With `UseProxy: true` the IP blacklist is defeated by one client-supplied header; with it false, behind a proxy, it blocks everyone or no one | | 4 | **High** | security / panic handling | `PanicRecovery` writes the panic value into the 500 body, and a test asserts it | | 5 | **High** | security | `Sanitize` pattern-stripping **creates** a `javascript:` URI from input that had none (verified) | | 6 | **High** | correctness | `EscapeHTML` on query params corrupts every value containing `< > & " '` — `price>100`, `A&B Corp` and all JSON params (verified) | | 7 | **Medium** | security | Three further verified denylist bypasses: newline in `` | | 8 | **Medium** | correctness | `cleanupRoutine` flushes every limiter every 5 minutes, handing each client a fresh full burst | | 9 | **Medium** | security | `MiddlewareWithKeyFunc` falls back to `r.RemoteAddr` **with port**, giving each TCP connection its own bucket | | 10 | **Medium** | security | Both `StatsHandler`s expose client IPs, remaining budgets and the whole blacklist with no authentication | | 11 | **Medium** | logging | No log line or metric is emitted when a request is rate-limited, blocked or sanitized — the perimeter has no audit trail | | 12 | **Medium** | correctness | `UnblockCIDR` silently fails to unblock a non-canonical CIDR while deleting its reason | | 13 | **Medium** | correctness | `IsBlocked` fails open on an unparseable IP, and stores/compares IPs as raw strings, so IPv6 forms evade blocking | | 14 | **Medium** | correctness | Byte-slicing `MaxStringLength` and `SanitizeFilename` produces invalid UTF-8 (verified) | | 15 | **Medium** | correctness | Sanitizing one query param rewrites `RawQuery` via `q.Encode()`, dropping malformed params and reordering the rest | | 16 | **Medium** | slowness / correctness | `cleanupRoutine` goroutine has no stop, no `recover()`, and leaks per `RateLimiter` | | 17 | **Low** | correctness | `removeControlCharacters` keeps DEL and the C1 range despite its contract (verified) | | 18 | **Low** | correctness | 429 responses send JSON with `Content-Type: text/plain` and no `Retry-After` | | 19 | **Low** | slowness | `IsBlocked` is O(n) in CIDRs per request, with a redundant O(m) reason scan nested inside | | 20 | **Low** | correctness | `GetAllRateLimitInfo` takes n+1 lock acquisitions | | 21 | **Low** | security | `SanitizeURL` blocks only two schemes by prefix; `SanitizeFilename` misses encoded traversal and Windows drive prefixes | | 22 | **Low** | correctness | `getClientIP` mishandles a port-less IPv6 `RemoteAddr` and returns bracketed forms inconsistently | | 23 | **Low** | maintainability | `//nolint:all` at `ratelimit.go:4` blanket-suppresses linting | --- ### 1. High — the perimeter is not connected Searching the module for every constructor in this package: | Constructor | Non-test callers | |---|---| | `NewRateLimiter` | **0** | | `NewIPBlacklist` | **0** | | `NewRequestSizeLimiter` | **0** | | `DefaultSanitizer` | **0** outside the package (one internal, `sanitize.go:57`) | | `StrictSanitizer` | **0** | | `PanicRecovery` | 1 (`pkg/server/manager.go:466`) | Only one file outside the package imports it at all — `pkg/server/manager.go` — and only for panic recovery (`:466`). The configuration that exists to drive the rest is inert: ```go // pkg/config/config.go:121-126 // MiddlewareConfig holds middleware configuration type MiddlewareConfig struct { RateLimitRPS float64 `mapstructure:"rate_limit_rps"` RateLimitBurst int `mapstructure:"rate_limit_burst"` MaxRequestSize int64 `mapstructure:"max_request_size"` } ``` All three have defaults (`pkg/config/manager.go:209-211`) and **no reader anywhere** — grep for `RateLimitRPS`, `RateLimitBurst` and `MaxRequestSize` outside tests returns only these declarations and `pkg/middleware`'s own unrelated `DefaultMaxRequestSize` constant. **Failure scenario.** Under the stated threat model — a hostile internet client — the service as assembled has: - **No rate limit.** A single client can issue unlimited requests. Every one is a database round trip through `pkg/restheadspec`, so a trivial loop exhausts the connection pool (`dbmanager.max_open_conns` defaults to 25, `pkg/config/manager.go:224`) and the service stops answering for everyone. There is no other limiter in the stack. - **No request-size limit.** `max_request_size: 10485760` is configured and unenforced, so `r.Body` is unbounded. A single `POST` with a multi-gigabyte body is read into memory by the JSON decoder and OOM-kills the process. This is the cheapest possible denial of service and the configuration says it is prevented. - **No IP blacklist**, so an operator has no way to shed a known-bad source. - **No input sanitization** — less serious, given findings 5–7 argue the sanitizer should not be mounted in its current form. The gap is invisible from the config file, which advertises all three knobs, and invisible from `pkg/middleware/README.md` (18 KB of documentation for middleware that is never installed). An operator tuning `rate_limit_rps` downward during an incident would see no change and reasonably conclude the attack was overwhelming the limit rather than that no limit exists. **Recommendation.** Wire the chain in `pkg/server` from `MiddlewareConfig`, in an order that matters (outermost first): ```go // outermost → innermost handler = sizeLimiter.Middleware(handler) // cheapest rejection first handler = rateLimiter.Middleware(handler) handler = blacklist.Middleware(handler) handler = middleware.PanicRecovery(handler) // innermost: must see handler panics ``` Rationale for the order: the size limit and blacklist are O(1)-ish and should reject before any expensive work; `PanicRecovery` must be **innermost** of these so that a panic in the handler is converted to a 500 before the outer layers observe the response — and note `trackRequestsMiddleware` is applied later still (`manager.go:540`), so it correctly ends up outside `PanicRecovery`. Fix findings 2 and 3 *before* mounting the IP-based layers, and findings 5–7 before mounting the sanitizer; mounting them as they stand adds attack surface rather than removing it. Add a startup log line naming which middleware is active, so the inert state cannot recur silently. --- ### 2. High — `getClientIP` trusts `X-Forwarded-For` unconditionally `ratelimit.go:210-233`: ```go func getClientIP(r *http.Request) string { // Check X-Forwarded-For header (most common in production) // Format: X-Forwarded-For: client, proxy1, proxy2 if xff := r.Header.Get("X-Forwarded-For"); xff != "" { // Take the first IP (the original client) if idx := strings.Index(xff, ","); idx != -1 { return strings.TrimSpace(xff[:idx]) } return strings.TrimSpace(xff) } // Check X-Real-IP header (used by some proxies like nginx) if xri := r.Header.Get("X-Real-IP"); xri != "" { return strings.TrimSpace(xri) } ... } ``` There is no trusted-proxy list, no hop counting, and no validation that the returned string is even an IP address. The value is whatever the client sent, and it becomes the rate-limiter key at `ratelimit.go:83`. **Failure scenario — bypass.** The attacker sends a distinct `X-Forwarded-For` on every request: ``` GET /api/public/orders HTTP/1.1 X-Forwarded-For: 1.2.3.4 ... X-Forwarded-For: 1.2.3.5 ``` Each value is a new key, so `getLimiter` mints a **fresh** `rate.Limiter` with a full burst allowance for every request. `limiter.Allow()` on a brand-new limiter always succeeds, so **the rate limiter never denies anything**. Its entire purpose is defeated by a header an attacker types once. Note the first-value choice makes this worse than the usual mistake: when a real proxy *is* in front, the left-most XFF entry is precisely the one field the client controls end to end, so the header cannot be trusted even in the deployment it was written for. **Failure scenario — amplification.** The same requests grow `rl.limiters` without bound (`ratelimit.go:59-60`): ```go limiter = rate.NewLimiter(rl.rate, rl.burst) rl.limiters[key] = limiter ``` Each entry is a `*rate.Limiter` (~64 bytes) plus the attacker-supplied key string plus map overhead — call it 150–200 bytes, and the key length is attacker-chosen up to the header size limit, so it can be far larger. There is no cap on entries and no per-key validation. A few million requests — minutes of traffic — is hundreds of megabytes of live heap, and the key is unvalidated so it need not resemble an IP at all. **The component intended to prevent resource exhaustion becomes the most efficient way to cause it**, because a single cheap request with no authentication allocates permanent server memory. Finding 8's five-minute flush bounds the growth to one interval's worth, which is the only thing standing between this and a certain OOM. **Recommendation.** Only consult proxy headers when the immediate peer is a trusted proxy, and validate the result: ```go type ClientIPConfig struct { TrustedProxies []*net.IPNet // empty ⇒ never trust XFF/X-Real-IP } func (c *ClientIPConfig) ClientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr } peer := net.ParseIP(host) if peer == nil { return host } if !c.isTrusted(peer) { return peer.String() // normalized; ignore all proxy headers } // Walk XFF right-to-left, returning the first address that is NOT a // trusted proxy — that is the furthest hop we can actually vouch for. parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",") for i := len(parts) - 1; i >= 0; i-- { ip := net.ParseIP(strings.TrimSpace(parts[i])) if ip != nil && !c.isTrusted(ip) { return ip.String() } } return peer.String() } ``` Three properties matter and all three are missing today: **trust is opt-in** (default deny), the scan is **right-to-left** so the client cannot inject hops, and the result is **normalized through `net.ParseIP`** so it is a canonical address and nothing else. Independently, cap `rl.limiters` (evict LRU past N) so a key-space bug can never again be a memory leak, and add the same trusted-proxy plumbing to `blacklist.go` (finding 3). Surface `TrustedProxies` in `MiddlewareConfig`. --- ### 3. High — the IP blacklist is defeated by a client header `blacklist.go:154-169`: ```go func (bl *IPBlacklist) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var clientIP string if bl.useProxy { clientIP = getClientIP(r) // Clean up IPv6 brackets if present clientIP = strings.Trim(clientIP, "[]") } else { // Extract IP from RemoteAddr if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 { clientIP = r.RemoteAddr[:idx] } else { clientIP = r.RemoteAddr } clientIP = strings.Trim(clientIP, "[]") } ... ``` **Failure scenario.** `UseProxy` is a single boolean (`blacklist.go:23-26`) and both settings are wrong without a trusted-proxy list: - **`UseProxy: true`** — the address comes from `getClientIP`, so a blocked attacker sends `X-Forwarded-For: 203.0.113.9` and is no longer blocked. The blacklist is a **security control that any client can switch off by naming a header**, and because `IsBlocked` also fails open on an unparseable address (finding 13), even `X-Forwarded-For: x` suffices. Nothing is logged when a block is evaded, or at all (finding 11), so an operator watching the blacklist "work" sees blocked counts of zero and no indication why. - **`UseProxy: false` behind a proxy** — every request carries the proxy's address, so the blacklist either blocks nothing (the proxy is not listed) or blocks **all traffic at once** the moment the proxy's IP is added. Blocking one abusive client is impossible. Since a deployment behind a load balancer or CDN is the normal case for an internet-facing API, the correct configuration does not exist. **Recommendation.** Replace `UseProxy bool` with the `TrustedProxies` plumbing from finding 2 and share one `ClientIP` implementation between both middlewares — the two files currently disagree about bracket handling and port stripping, which is itself a source of mismatch between "the IP we rate-limit" and "the IP we block". Normalize through `net.ParseIP(...).String()` before both storing and comparing (finding 13), and log every block at `Info` with the matched rule (finding 11). --- ### 4. High — the panic value is returned to the client, and a test locks it in `panic.go:14-33` is the only middleware actually mounted: ```go func PanicRecovery(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rcv := recover(); rcv != nil { // Record the panic metric metrics.GetProvider().RecordPanic(panicMiddlewareMethodName) ... ctx := r.Context() err := logger.HandlePanic(panicMiddlewareMethodName, rcv, ctx) // Respond with a 500 error http.Error(w, err.Error(), http.StatusInternalServerError) } }() next.ServeHTTP(w, r) }) } ``` `logger.HandlePanic` returns `fmt.Errorf("panic in %s: %v", methodName, r)` (`pkg/logger/logger.go:210`), so the **raw panic value is written into the response body**. **Failure scenario.** An attacker probes endpoints until something panics, then reads the internals out of the 500 body: - `panic in PanicMiddleware: runtime error: index out of range [5] with length 3` — confirms an exploitable parsing path and gives exact bounds. - A panicking database driver leaks the failing SQL, and `pq`/`pgx` errors can carry the connection string — including credentials, since `dbmanager.connections.default.password` is part of the DSN. - `runtime error: invalid memory address or nil pointer dereference` maps attacker input to specific unguarded code paths, turning blind probing into a guided search. Because panics are reachable from request parsing, this is a repeatable oracle rather than a one-off. `pkg/restheadspec` and the adapters recover in several places, so the panics that reach here are the unanticipated ones — exactly those whose messages are most revealing. The leak is **deliberate and test-locked** — `panic_test.go:57`: ```go assert.Contains(t, rr.Body.String(), "panic in PanicMiddleware: something went terribly wrong", "expected error message in response body") ``` so any fix must change this assertion. That matters: a reviewer changing `panic.go` would see a test failure and assume they had broken something. **Mitigation that already exists.** `pkg/server` lets a caller supply `PanicHandler` (`pkg/server/interfaces.go:47`), used in preference to this middleware (`manager.go:456-466`). An embedder can therefore avoid the leak — but it is opt-out, not opt-in, so the insecure path is the default. **Recommendation.** Log in full, respond with nothing: ```go if rcv := recover(); rcv != nil { metrics.GetProvider().RecordPanic(panicMiddlewareMethodName) _ = logger.HandlePanic(panicMiddlewareMethodName, rcv, r.Context()) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } ``` Update `panic_test.go:57` to assert the body does **not** contain the panic text, which converts the test from locking in the bug to guarding the fix. Consider emitting a correlation ID in both the log line and the response body so support can still tie a report to a stack trace without disclosing it. Two further defects in the same nine lines: - **`http.Error` after a partial response.** If the handler wrote a 200 and streamed bytes before panicking, `WriteHeader(500)` is ignored ("superfluous WriteHeader" is logged by `net/http`) and the error text is appended **into the middle of the response body**. The client receives a 200 with corrupt trailing content — worse than a clean failure, because caches and clients treat it as valid. Track whether anything was written (see `metrics.audit.md` finding 11) and, when it was, abandon the connection with `panic(http.ErrAbortHandler)` instead. - **`RecordPanic` goes nowhere.** `metrics.GetProvider()` returns a `NoOpProvider` because nothing ever installs a provider (`metrics.audit.md` finding 1), so `panics_total` never increments. This is the *only* caller of `RecordPanic` in the repository, so panic counting is entirely non-functional. --- ### 5. High — the sanitizer manufactures a `javascript:` URI `sanitize.go:80-85` applies each block pattern exactly once: ```go // Check block patterns for _, pattern := range s.BlockPatterns { if pattern.MatchString(value) { // Replace matched pattern with empty string value = pattern.ReplaceAllString(value, "") } } ``` Removing a substring can **join its neighbours into a new match**, and because each pattern runs once there is no second pass to catch the result. I verified this against a faithful reimplementation of `DefaultSanitizer` (`sanitize.go:35-53`): | Input | Output | |---|---| | `javajavascript:script:alert(1)` | **`javascript:alert(1)`** | Removing the inner `javascript:` leaves `java` + `script:alert(1)` — a working `javascript:` URI. **The sanitizer produced the exact payload it exists to block, from input that did not contain it.** **Failure scenario.** An attacker submits `javajavascript:script:alert(1)` as a profile URL, comment or any stored string. Every layer that inspects the input sees a harmless string with no `javascript:` substring — a WAF, a manual review, a downstream denylist. The sanitizer then converts it into a live XSS payload, which is stored and later rendered into an `href`. The control inverts: input that would have been rejected as suspicious is laundered into something dangerous, and the transformation happens inside the component whose job is to prevent it. Note the HTML-escaping step (`sanitize.go:93-94`) does not help here, because the resulting string contains no HTML metacharacters to escape. **Recommendation.** Do not sanitize by deletion. Two changes, in order of importance: 1. **Reject rather than repair.** If a pattern matches, fail the request with 400 — never edit the value and continue. Editing guarantees this class of bug and silently changes user data (finding 11 means nobody finds out). 2. **Stop relying on a denylist for XSS.** Escape at the point of *output*, per context (HTML body, attribute, JS, URL), which is the only place the correct encoding is known. For rich text use a real allowlist sanitizer with a parser — `bluemonday` — instead of regexes over a string. Regex-based HTML filtering cannot be made correct; findings 7's bypasses are symptoms of that, not fixable individually. If deletion must be kept as a stopgap, loop each pattern to a fixed point (`for pattern.MatchString(v) { v = pattern.ReplaceAllString(v, "") }`) with an iteration cap — but note this makes the function O(n·k) on attacker-controlled input and still does not make the denylist complete. --- ### 6. High — HTML-escaping query parameters corrupts ordinary data `DefaultSanitizer` sets `EscapeHTML: true` (`sanitize.go:38`), applied at `sanitize.go:93-94`: ```go // Escape HTML entities if s.EscapeHTML && !s.StripHTML { value = html.EscapeString(value) } ``` and `Middleware` runs `Sanitize` over **every query parameter value** (`sanitize.go:145-159`) and four headers (`:163-177`), writing the results back into the request. Verified outputs: | Input | Output | |---|---| | `price>100` | `price>100` | | `A&B Corp` | `A&B Corp` | | `{"filter":"name = 'O'Brien'"}` | `{"filter":"name = 'O'Brien'"}` | **Failure scenario.** This project's API is query- and header-driven: filters, sort specifications and expand options arrive as parameters, and `pkg/restheadspec` carries structured options in headers. With this middleware mounted: - A filter `price>100` reaches the handler as `price>100` and either fails to parse or is treated as a literal — the query silently returns the wrong rows. - A customer named `A&B Corp` is **written to the database** as `A&B Corp`. The corruption is persistent, silent, and compounds on each edit (`&amp;`…). There is no log line (finding 11), so the first report is a user asking why their company name looks wrong. - Any JSON-valued parameter has its quotes turned into `"` and becomes **unparseable**, so requests fail with a decoding error that names the JSON, not the middleware. This is output encoding applied at input time — the canonical mistake. It does not prevent XSS (the escape is applied again, or undone, at render time, producing double-escaping or none) and it destroys data integrity for every non-HTML consumer, which here is all of them. **Recommendation.** Set `EscapeHTML: false` in `DefaultSanitizer` and escape in the template or serializer that renders the value, where the output context is known. If a stored-XSS defense is wanted at ingress, validate and reject (finding 5) rather than transform. Note `StrictSanitizer` (`sanitize.go:56-61`) is worse still: it sets `StripHTML: true`, so `<` in a legitimate value silently deletes everything up to the next `>`. --- ### 7. Medium — three further verified denylist bypasses Also verified against `DefaultSanitizer`'s patterns (`sanitize.go:44-51`) — all three reach the handler with the script intact (HTML-escaped only, which finding 6 shows is not a defense and is undone elsewhere): | Input | Result | Cause | |---|---|---| | `` | **not matched** | `.` does not match `\n` without the `(?s)` flag, so `]*>.*?` cannot span lines | | ``; an external-source tag has none | | `` | **not matched** | `` is matched literally, so a space before `>` evades it | The newline case is the most serious: a multi-line `` produces `<scr`, which is safe here but demonstrates the same removal-creates-new-text mechanism as finding 5. **Failure scenario.** Any of the three is stored and rendered, giving stored XSS in an application whose operators believe input is sanitized — the false assurance is the harm, because it displaces real output encoding. **Recommendation.** As finding 5: reject instead of strip, and replace the regex set with an allowlist parser. If the patterns are kept for defense in depth, add `(?s)` and make the closing-tag matching tolerant (``), while treating the list as best-effort and never as the control. --- ### 8. Medium — the cleanup routine hands every client a fresh burst `ratelimit.go:65-76`: ```go func (rl *RateLimiter) cleanupRoutine() { ticker := time.NewTicker(rl.cleanup) defer ticker.Stop() for range ticker.C { rl.mu.Lock() // Simple cleanup: remove all limiters // In production, you might want to track last access time rl.limiters = make(map[string]*rate.Limiter) rl.mu.Unlock() } } ``` The interval is hard-coded to 5 minutes (`:32`) with no way to configure it. **Failure scenario.** This does not evict *stale* limiters — it discards **all** of them, including those belonging to clients currently being throttled. A client that has exhausted its bucket gets a brand-new limiter with a full `burst` allowance (default 200, `pkg/config/manager.go:210`) at the next tick. An attacker who simply waits out the interval receives 200 free requests every 5 minutes on top of the sustained rate, indefinitely, and the penalty for abuse resets on a predictable schedule. Because the flush is unconditional, **the harder a client is being throttled, the more it gains** from each tick. The comment acknowledges the design is provisional; the consequence is that the limiter's long-run behaviour is not the configured rate. **Recommendation.** Evict by last use, not wholesale: ```go type entry struct { limiter *rate.Limiter lastSeen atomic.Int64 // unix nanos, updated in getLimiter } for range ticker.C { cutoff := time.Now().Add(-idleTTL).UnixNano() rl.mu.Lock() for k, e := range rl.limiters { if e.lastSeen.Load() < cutoff { delete(rl.limiters, k) } } rl.mu.Unlock() } ``` An idle limiter at full tokens is indistinguishable from no limiter, so evicting only idle entries is both safe and sufficient. Deleting in place also avoids handing the whole old map to the GC at once. Make the interval and TTL configurable, and pair this with the hard entry cap from finding 2 — eviction by age alone does not bound a burst of new keys within one interval. --- ### 9. Medium — `MiddlewareWithKeyFunc` falls back to a per-connection key `ratelimit.go:97-115`: ```go key := keyFunc(r) if key == "" { key = r.RemoteAddr } ``` `r.RemoteAddr` is `"ip:port"` — **including the ephemeral source port** — unlike `getClientIP`, which strips it (`ratelimit.go:228-230`). **Failure scenario.** Whenever the supplied `keyFunc` returns `""` — for an unauthenticated request if it keys on user ID, for a request missing the chosen header, or for every request if the function has a bug — the key becomes unique **per TCP connection**. A client that opens a new connection per request (trivial: `Connection: close`, or any non-pooling HTTP client) therefore gets a fresh limiter every time and is never limited. The failure is silent and input-dependent: the limiter appears to work for authenticated traffic and disappears for exactly the anonymous traffic that most needs limiting. **Recommendation.** Use the same normalized client IP as the default path, and make the fallback explicit: ```go key := keyFunc(r) if key == "" { key = "ip:" + clientIP(r) // shared, port-stripped, trusted-proxy aware } ``` Prefixing by key type (`ip:`, `user:`) also prevents a user ID from colliding with an IP string in the shared map — worth doing regardless. --- ### 10. Medium — both stats handlers are unauthenticated `ratelimit.go:175-206` and `blacklist.go:195-212` return JSON with no authentication, no authorization and no way to require any: ```go // ratelimit.go:191-198 stats := map[string]interface{}{ "total_tracked_ips": len(allInfo), "rate_limit_config": map[string]interface{}{ "requests_per_second": float64(rl.rate), "burst": rl.burst, }, "tracked_ips": allInfo, } ``` **Failure scenario.** If either is routed — and the doc comment invites it, "Example: GET /rate-limit-stats" (`ratelimit.go:174`) — any client learns: - **Every client IP currently using the service**, with remaining token counts (`RateLimitInfo`, `:118-123`). That is personal data about other users disclosed to an anonymous third party, and in most jurisdictions an IP address tied to activity is regulated. - **The exact limiter configuration** — `requests_per_second` and `burst` — plus, via `?ip=` (`:178`), the attacker's **own remaining budget in real time**. That converts rate-limit evasion from guesswork into a control loop: poll the endpoint, stay one token below the threshold, never get a 429. - From the blacklist handler, **the complete set of blocked IPs and CIDRs** with their `reason` strings (`blacklist.go:199-204`). An attacker learns which ranges to avoid and which proxies remain usable, and the reasons are free-text operator notes that may name incidents, customers or internal tooling. **Recommendation.** Do not ship unauthenticated introspection of a security control. Require authentication and authorization at the route, bind these to an internal-only listener (`servers.instances.*`, `pkg/config/manager.go:182-186`) as recommended for `/metrics` in `metrics.audit.md` finding 4, and drop `tracked_ips` from the default payload — the aggregate count is enough for a dashboard. If per-IP detail is needed, gate it behind an explicit admin scope and log each access. --- ### 11. Medium — the perimeter emits no audit trail Neither rate limiting nor blacklisting nor sanitization logs anything on the path that matters. `ratelimit.go:87-90`: ```go if !limiter.Allow() { http.Error(w, `{"error":"rate_limit_exceeded","message":"Too many requests"}`, http.StatusTooManyRequests) return } ``` `blacklist.go:171-188` likewise writes a 403 with no log line — the only `logger` call in either file is a `Debug` on a **JSON encoding failure** (`blacklist.go:185`, `ratelimit.go:183`, `:203`). `sanitize.go` imports no logger at all and modifies request data silently. No middleware in the package records a metric; `panic.go:19` is the package's only `metrics` call, and it reaches a no-op (finding 4). **Failure scenario.** During an attack an operator cannot answer the first questions asked: is the rate limiter firing, for which clients, and at what rate? Nothing distinguishes "no attack" from "limiter bypassed via finding 2" from "limiter not mounted at all" (finding 1) — all three produce identical silence. There is no signal to alert on, no data to tune `rate_limit_rps` with, and no forensic record of which addresses were blocked or why. For the blacklist, an operator adding an entry gets no confirmation that it ever matched. And because the sanitizer edits values without logging, the data corruption in finding 6 is undiagnosable from the server side — the request that arrived and the value that was stored differ, with nothing recording the difference. **Recommendation.** Log every enforcement action at `Info` with the client IP, the matched rule and the request path, and add counters: ```go if !limiter.Allow() { logger.Info("rate limit exceeded: client=%s path=%s rps=%v burst=%d", key, r.URL.Path, rl.rate, rl.burst) metrics.GetProvider().RecordRateLimited(key) // new interface method ... } ``` Log at `Info`, not `Warn`: per `_CROSS-CUTTING.audit.md` finding X8 every `Warn` is forwarded to the error tracker, so logging a rate-limit event at `Warn` would turn a volumetric attack into an equal-volume flood of Sentry events — a second outage caused by the instrumentation. The `metrics.Provider` interface has no method for this today (`pkg/metrics/interfaces.go:12-48`); adding `RecordRateLimited`/`RecordIPBlocked` is the right place, and note the label must not be the raw client IP unless bounded — see `metrics.audit.md` findings 2 and 5. --- ### 12. Medium — `UnblockCIDR` cannot unblock a non-canonical range `blacklist.go:82-94`: ```go func (bl *IPBlacklist) UnblockCIDR(cidr string) { bl.mu.Lock() defer bl.mu.Unlock() // Find and remove the CIDR for i, ipNet := range bl.cidrs { if ipNet.String() == cidr { bl.cidrs = append(bl.cidrs[:i], bl.cidrs[i+1:]...) break } } delete(bl.reason, cidr) } ``` `BlockCIDR` stores the parsed network in `bl.cidrs` but files the reason under the **caller's original string** (`:65-68`), while `UnblockCIDR` compares against `ipNet.String()` — the *canonical* form. **Failure scenario.** An operator blocks `BlockCIDR("10.0.0.1/8", "abuse")`. `net.ParseCIDR` normalizes the network to `10.0.0.0/8`, so `bl.cidrs` holds `10.0.0.0/8` while `bl.reason` holds the key `10.0.0.1/8`. Calling `UnblockCIDR("10.0.0.1/8")` then: - fails the comparison (`"10.0.0.0/8" != "10.0.0.1/8"`), so **the range stays blocked**, and - succeeds at `delete(bl.reason, cidr)`, so the reason is erased. The operator's own input, echoed back verbatim, does not undo their own action; the range remains blocked with no recorded reason, and nothing is logged or returned — `UnblockCIDR` has no error return. Restoring service to a wrongly blocked customer requires knowing to pass the canonical form, which is never displayed. `GetBlacklist` reports `ipNet.String()` (`:147`), so the value shown to the operator *is* the one that works — but the value they typed is not, and no message connects the two. The same mismatch makes the reason unreachable in `IsBlocked`, which looks it up by `ipNet.String()` (`:114-118`): a non-canonically-blocked range is reported with an empty reason, which is what the dead fallback loop at `:120-124` was evidently meant to fix. That loop cannot work — it tests `key == cidr` after the map lookup for exactly that key already failed — and `if i < len(bl.cidrs)` at `:126` is always true inside `range bl.cidrs`, so the whole block reduces to `return true, ""`. **Recommendation.** Canonicalize on the way in and key everything consistently: ```go func (bl *IPBlacklist) BlockCIDR(cidr, reason string) error { _, ipNet, err := net.ParseCIDR(cidr) if err != nil { return err } key := ipNet.String() // canonical, for both maps bl.mu.Lock() defer bl.mu.Unlock() bl.cidrs = append(bl.cidrs, ipNet) if reason != "" { bl.reason[key] = reason } return nil } ``` and have `UnblockCIDR` parse its argument the same way, returning an error when the CIDR is invalid or not present, so a failed unblock is visible. Delete the dead loop at `:120-128`. Storing `cidrs` as a map keyed by the canonical string would remove the O(n) removal and the possibility of duplicate entries at the same time. --- ### 13. Medium — `IsBlocked` fails open, and IPs are compared as raw strings `blacklist.go:97-133`: ```go // Check individual IPs if bl.ips[ip] { return true, bl.reason[ip] } // Check CIDR ranges parsedIP := net.ParseIP(ip) if parsedIP == nil { return false, "" } ``` Two problems. **Fail-open on unparseable input**: when `net.ParseIP` fails the function returns `false` — allow. **String-keyed exact matching**: `BlockIP` stores the caller's string verbatim (`:48`) and the lookup at `:102` is a raw map hit, so the textual form must match exactly. **Failure scenario — fail-open.** With `UseProxy: true` (finding 3), `getClientIP` returns arbitrary attacker text. `X-Forwarded-For: blocked` is not in `bl.ips`, does not parse as an IP, and therefore **returns allow** — the CIDR checks are skipped entirely. Any garbage value bypasses every range rule. A security control should fail closed on input it cannot interpret, or at minimum log and fall back to `RemoteAddr`; this does neither, silently. **Failure scenario — IPv6 evasion.** `BlockIP("2001:db8::1", …)` stores that exact string. The same host presenting `2001:0db8:0:0:0:0:0:1` or `2001:DB8::1` — all valid textual forms of one address — produces a different map key, misses, and (being parseable) is only caught if a CIDR happens to cover it. IPv4-mapped forms (`::ffff:192.0.2.1` vs `192.0.2.1`) diverge the same way. Blocking a single IPv6 address is therefore unreliable in a way that is invisible in testing, because tests naturally reuse the same string on both sides. **Recommendation.** Normalize once, at both ends, and fail closed: ```go func normalizeIP(s string) (string, bool) { ip := net.ParseIP(strings.Trim(strings.TrimSpace(s), "[]")) if ip == nil { return "", false } return ip.String(), true // canonical form for map keys } ``` Use it in `BlockIP`, `UnblockIP` and `IsBlocked`. When the address cannot be parsed, log it and decide deliberately — for a blacklist, treating an uninterpretable client address as blocked is the defensible default, and is only safe to do once finding 2's trusted-proxy handling guarantees the value comes from the connection rather than a header. --- ### 14. Medium — byte-slicing truncation produces invalid UTF-8 `sanitize.go:97-100`: ```go // Apply max length if s.MaxStringLength > 0 && len(value) > s.MaxStringLength { value = value[:s.MaxStringLength] } ``` and `SanitizeFilename` (`:217-219`) does the same at 255. `len()` counts **bytes** and the slice cuts at a byte offset, so a multi-byte rune can be split. Verified: truncating `"héllo wörld"` at 2 yields `"h\xc3"` — not valid UTF-8. **Failure scenario.** `StrictSanitizer` sets `MaxStringLength: 10000` (`sanitize.go:59`), so any string field at the limit whose 10 000th byte falls inside a multi-byte character is corrupted. Consequences downstream: - `encoding/json` replaces the invalid byte with U+FFFD when marshalling, so the stored value silently gains a replacement character. - PostgreSQL **rejects** invalid UTF-8 outright (`invalid byte sequence for encoding "UTF8"`), so the insert fails with a 500 that names an encoding problem, pointing an engineer at the database rather than at this middleware. Either way the trigger is a specific input length combined with non-ASCII text, so it reproduces for some users and never for others. Any non-Latin script hits the limit sooner and more often, so the bug lands hardest on non-English data. **Recommendation.** Truncate on rune boundaries, and count what the limit is meant to mean: ```go if s.MaxStringLength > 0 && utf8.RuneCountInString(value) > s.MaxStringLength { runes := []rune(value) value = string(runes[:s.MaxStringLength]) } ``` If the limit is genuinely a byte budget (a database column width), keep `len` but back off to the last valid boundary, e.g. with `for !utf8.ValidString(value) { value = value[:len(value)-1] }` or `utf8.DecodeLastRuneInString`. Apply the same fix at `sanitize.go:218`. --- ### 15. Medium — sanitizing one parameter rewrites the whole query string `sanitize.go:142-160`: ```go if r.URL.RawQuery != "" { q := r.URL.Query() sanitized := false for key, values := range q { for i, value := range values { sanitizedValue := s.Sanitize(value) if sanitizedValue != value { values[i] = sanitizedValue sanitized = true } } if sanitized { q[key] = values } } if sanitized { r.URL.RawQuery = q.Encode() } } ``` **Failure scenario.** A single modified value triggers `r.URL.RawQuery = q.Encode()`, which re-serializes the query from the parsed map. That loses information `url.Values` cannot represent: - **Malformed pairs are discarded.** `r.URL.Query()` drops any pair with invalid percent-encoding (and returns an error the code never checks). Those parameters vanish from the rewritten query, so a request that would have failed validation visibly instead proceeds with fields **missing**. - **Bare keys gain `=`.** `?flag` re-encodes as `flag=`, changing presence semantics for any handler distinguishing the two. - **Order is destroyed.** `Encode` sorts keys alphabetically, so any handler reading repeated parameters positionally sees a different request. Given finding 6 — where `EscapeHTML` alters almost any value containing `&`, `<`, `>` or a quote — the `sanitized` flag is set on most real requests, so this rewrite is the common path rather than the exception. The `sanitized` flag is also never reset between keys (it is declared outside the `for key` loop at `:144`), so once any value changes, `q[key] = values` executes for every later key too; that assignment is harmless — `values` already aliases the map's slice — but it shows the flag is not doing what it appears to. **Recommendation.** Do not rewrite `RawQuery`. Under finding 5's recommendation the middleware rejects rather than edits, which removes this code path entirely. If values must be modified, attach the sanitized `url.Values` to the request context and have handlers read from there, leaving `r.URL` untouched: ```go ctx := context.WithValue(r.Context(), sanitizedQueryKey{}, q) next.ServeHTTP(w, r.WithContext(ctx)) ``` Also check the error from `url.ParseQuery` and reject a malformed query string outright instead of silently discarding parameters. --- ### 16. Medium — the cleanup goroutine cannot be stopped and has no recover `NewRateLimiter` starts a goroutine (`ratelimit.go:36`) with no corresponding `Stop`/`Close`: ```go // Start cleanup goroutine go rl.cleanupRoutine() ``` `cleanupRoutine` loops `for range ticker.C` forever (`:69`); the `defer ticker.Stop()` at `:67` is unreachable because the loop has no exit. There is no `recover()` in the goroutine. **Failure scenario.** Every `RateLimiter` ever constructed leaks one goroutine and one live `time.Ticker` for the process lifetime. With a single limiter created at startup that is negligible — but the type is constructed per configuration, and any test suite, config reload, or per-tenant limiter accumulates them. The goroutine also retains the whole `RateLimiter`, so every limiter map it ever held stays reachable and unreclaimable. The missing `recover()` is the more serious half: a panic anywhere in this goroutine — today only a map operation, but any future eviction logic — crashes the **entire process**, because a panic in a goroutine cannot be recovered by `PanicRecovery` or by any handler in the stack. This is the pattern flagged in `_CROSS-CUTTING.audit.md` finding X7. **Recommendation.** Give it a lifecycle and a guard: ```go func (rl *RateLimiter) cleanupRoutine() { defer logger.CatchPanicCallback("middleware.cleanupRoutine", nil)() ticker := time.NewTicker(rl.cleanup) defer ticker.Stop() for { select { case <-ticker.C: rl.evictIdle() // finding 8 case <-rl.stop: return } } } // Close stops the cleanup goroutine. Safe to call more than once. func (rl *RateLimiter) Close() { rl.stopOnce.Do(func() { close(rl.stop) }) } ``` Use `sync.Once` so a double `Close` cannot panic — the defect `metrics.audit.md` finding 8 records for `StopAutoPush`. Have `pkg/server` call `Close` during shutdown once the limiter is wired (finding 1). --- ### 17. Low — `removeControlCharacters` keeps DEL and the C1 range `sanitize.go:186-195`: ```go // removeControlCharacters removes control characters except \n, \r, \t func removeControlCharacters(s string) string { var result strings.Builder for _, r := range s { // Keep newline, carriage return, tab, and non-control characters if r == '\n' || r == '\r' || r == '\t' || r >= 32 { result.WriteRune(r) } } return result.String() } ``` `r >= 32` admits everything above the C0 block. Verified: `\x7f` (DEL) and `U+0085` (NEL, a C1 control) both survive, contradicting the doc comment. **Failure scenario.** Minor in isolation. DEL and C1 controls reaching logs can corrupt or forge log lines — `U+0085` is a line terminator to some log processors, enabling log injection by an attacker who controls a sanitized field. The function also passes Unicode characters that matter more than C1 controls: U+202E (right-to-left override) for filename and display spoofing, and zero-width characters (U+200B, U+FEFF) for filter evasion and homograph tricks. The contract says control characters are removed, so callers reasonably trust it. **Recommendation.** Use the standard predicate and extend to the formatting category: ```go for _, r := range s { switch { case r == '\n' || r == '\t' || r == '\r': result.WriteRune(r) case unicode.IsControl(r), unicode.Is(unicode.Cf, r): // Cf: bidi + zero-width // drop default: result.WriteRune(r) } } ``` `unicode.IsControl` covers both C0 and C1; `unicode.Cf` covers the bidi overrides and zero-width characters. Also consider normalizing to NFC. --- ### 18. Low — 429 responses are mislabelled and omit `Retry-After` `ratelimit.go:88` and `:108`: ```go http.Error(w, `{"error":"rate_limit_exceeded","message":"Too many requests"}`, http.StatusTooManyRequests) ``` `http.Error` sets `Content-Type: text/plain; charset=utf-8` and appends a newline, so a JSON body is served declared as plain text. There is no `Retry-After`. **Failure scenario.** A client that dispatches on `Content-Type` — the correct behaviour — treats the body as text and cannot read the `error` code, so it surfaces a generic failure instead of "rate limited". Without `Retry-After`, a well-behaved client has no idea how long to wait and will typically retry immediately, so the limiter produces **more** load from compliant clients than it would with the header. `rate.Limiter` can supply the delay directly, and the blacklist path does this correctly (`blacklist.go:181-183` sets the header and uses `json.NewEncoder`), so the package is internally inconsistent. **Recommendation.** Mirror the blacklist's approach and include the delay: ```go res := limiter.Reserve() if !res.OK() || res.Delay() > 0 { if d := res.Delay(); d > 0 { res.Cancel() w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(d.Seconds())))) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusTooManyRequests) _ = json.NewEncoder(w).Encode(map[string]string{ "error": "rate_limit_exceeded", "message": "Too many requests", }) return } ``` Note `Reserve` must be `Cancel`ed when rejecting, or the token is consumed twice. Adding `X-RateLimit-Limit`/`-Remaining` is conventional, but see finding 10 — they disclose budget state, so expose them only to authenticated clients. --- ### 19. Low — `IsBlocked` is O(n) per request with a redundant nested scan `blacklist.go:112-130` walks every CIDR on every request under `RLock`, and for each *match* additionally iterates the whole `reason` map (`:120-124`) in a loop that cannot succeed (finding 12). **Failure scenario.** A sizeable blacklist — a few thousand ranges from a threat feed is ordinary — means a few thousand `ipNet.Contains` calls per request, each allocating nothing but branching over 4 or 16 bytes. Measurable but not fatal; the concern is that the work happens while holding `RLock`, so it contends with `BlockIP`/`BlockCIDR` writers, and grows linearly with a list operators are encouraged to extend. Coupled with finding 8's write-lock flush in the rate limiter, a large deployment sees both maps serialized on the request path. **Recommendation.** Use a prefix-trie lookup (`cidranger`, or `netipx.IPSet` with `net/netip`) for O(prefix-length) matching instead of O(n), and drop the nested reason scan. Migrating to `net/netip` also removes the per-request `net.IP` allocation that `net.ParseIP` makes today. --- ### 20. Low — `GetAllRateLimitInfo` takes n+1 lock acquisitions `ratelimit.go:162-171`: ```go func (rl *RateLimiter) GetAllRateLimitInfo() []*RateLimitInfo { ips := rl.GetTrackedIPs() info := make([]*RateLimitInfo, 0, len(ips)) for _, ip := range ips { info = append(info, rl.GetRateLimitInfo(ip)) } return info } ``` `GetTrackedIPs` takes `RLock` once (`:127`), then `GetRateLimitInfo` takes it again per IP (`:139`). **Failure scenario.** Serving the stats endpoint acquires and releases the read lock once per tracked IP. Reads do not block each other, but each acquisition contends with the write lock, and under finding 2 the map can hold millions of entries — so one stats request performs millions of lock operations while `getLimiter` writers and finding 8's flush are trying to acquire the write lock. An unauthenticated endpoint (finding 10) that scales its own cost with attacker-controlled map size is a small amplification primitive. Entries added between the snapshot and the per-IP read are also reported with default values, so the output is mildly inconsistent. **Recommendation.** Collect everything under one `RLock`: ```go func (rl *RateLimiter) GetAllRateLimitInfo() []*RateLimitInfo { rl.mu.RLock() defer rl.mu.RUnlock() info := make([]*RateLimitInfo, 0, len(rl.limiters)) for ip, l := range rl.limiters { info = append(info, &RateLimitInfo{ IP: ip, TokensRemaining: l.Tokens(), Limit: float64(rl.rate), Burst: rl.burst, }) } return info } ``` and bound or paginate the result. --- ### 21. Low — `SanitizeURL` and `SanitizeFilename` are easily evaded `sanitize.go:236-251`: ```go // Block javascript: and data: protocols if strings.HasPrefix(strings.ToLower(url), "javascript:") { return "" } if strings.HasPrefix(strings.ToLower(url), "data:") { return "" } ``` Only `\x00` is stripped beforehand (`:240`), so `"\x01javascript:alert(1)"` fails both prefix tests and is returned unchanged — browsers ignore the leading control character and execute it. `vbscript:`, `file:` and `blob:` are not covered, and a scheme-relative `//evil.com` passes. `SanitizeFilename` (`:207-222`) removes `..`, `/` and `\` textually, so it misses percent-encoded traversal (`%2e%2e%2f`, since nothing decodes) and Windows drive prefixes (`C:`), while mangling legitimate names containing `..`. **Failure scenario.** A stored URL passes `SanitizeURL` and is rendered into an `href`, giving XSS on click. An uploaded filename passes `SanitizeFilename` in encoded form and escapes the intended directory once some later layer decodes it. Both functions are exported helpers, so callers reasonably treat them as sufficient — that assumption is the risk, more than the specific gaps. **Recommendation.** Parse rather than pattern-match. For URLs, use `net/url.Parse` and allowlist the scheme: ```go u, err := url.Parse(strings.TrimSpace(raw)) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { return "" } ``` For filenames, take `filepath.Base` of the **decoded** value and validate the result against `^[A-Za-z0-9._-]{1,255}$`, rejecting `.`/`..` explicitly, rather than deleting substrings. Never construct a path by removing characters. --- ### 22. Low — `getClientIP` mishandles port-less and bracketed addresses `ratelimit.go:228-232`: ```go if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 { return r.RemoteAddr[:idx] } return r.RemoteAddr ``` **Failure scenario.** For the normal `"[::1]:54321"` this happens to work, giving `"[::1]"` — but the brackets are kept, while a client-supplied `X-Forwarded-For` would give the same address as `"::1"`. The two forms are different map keys, so one client can occupy two rate-limit buckets (and evade one blacklist entry). `blacklist.go:160` and `:168` paper over this with `strings.Trim(clientIP, "[]")` while `ratelimit.go` does not — so the two middlewares key on different strings for the same client. If `RemoteAddr` ever lacks a port (a synthetic request, a test, a non-TCP listener), `LastIndex` finds a colon **inside** the IPv6 address and truncates it — `"::1"` becomes `"::"` — silently merging unrelated clients into one bucket. **Recommendation.** Use the standard parser and normalize, once, in the shared helper from finding 2: ```go host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr } if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil { return ip.String() } return host ``` `net.ParseIP(...).String()` yields one canonical form, which fixes the rate-limit/blacklist key mismatch and finding 13's IPv6 evasion together. --- ### 23. Low — `//nolint:all` suppresses linting on the import block `ratelimit.go:4`: ```go // Package middleware provides HTTP middleware functionalities such as rate limiting and IP blacklisting. package middleware //nolint:all import ( ``` **Failure scenario.** A blanket `//nolint:all` with no rule named and no justification. Attached to the import declaration its effect is narrow, but it suppresses *every* linter there — including `depguard` and `gosec` import rules, which is exactly where a prohibited or vulnerable dependency would be flagged. More practically it sets a precedent: the directive gives no reason, so nobody can tell whether it is still needed, and it survives indefinitely. Given `gosec` is not enabled at all (`_CROSS-CUTTING.audit.md` finding X4), suppressions that hide security linting deserve removal on principle. **Recommendation.** Delete it and fix whatever it was hiding; if a suppression is genuinely required, name the rule and give a reason — `//nolint:depguard // x/time/rate is an approved dependency`. `golangci-lint`'s `nolintlint` setting enforces exactly this and is worth enabling repo-wide. --- ## What looks right - **`getLimiter` implements correct double-checked locking** (`ratelimit.go:42-62`): read under `RLock`, release, take `Lock`, **re-check** before creating. The re-check at `:55` is the step usually omitted, and getting it right means two goroutines racing on a new key cannot produce two limiters — which would silently double a client's allowance. This is the pattern `pkg/cache`'s `defaultCache` singleton should adopt (`cache.audit.md` finding 3). - **Every shared field is consistently guarded.** `IPBlacklist` takes `Lock` for all four mutators (`blacklist.go:45`, `:62`, `:74`, `:83`) and `RLock` for both readers (`:98`, `:137`); `RateLimiter` does the same. There are **no unsynchronized globals in this package**, unlike most others in the repo (`_CROSS-CUTTING.audit.md` finding X5), and I found no data race on any path. - Locks are held for short, bounded critical sections with no I/O, no callbacks and no lock nesting inside them, so deadlock is structurally impossible here. - `blacklist.go` returns its 403 correctly: sets `Content-Type` **before** `WriteHeader`, then encodes (`:181-186`) — the ordering `ratelimit.go:88` gets wrong (finding 18). - `BlockIP` validates with `net.ParseIP` before storing (`blacklist.go:41-43`) and `BlockCIDR` propagates `net.ParseCIDR`'s error (`:57-60`), so invalid rules are rejected at the point of entry rather than silently ignored. - `RequestSizeLimiter` uses `http.MaxBytesReader` (`sizelimit.go:36`) rather than trusting `Content-Length` — the correct choice, since `Content-Length` is attacker-supplied and absent on chunked requests. `NewRequestSizeLimiter` defaults a non-positive `maxSize` to 10 MB (`:24-26`) rather than to unlimited, which is the safe direction. (It is still never mounted — finding 1.) - `PanicRecovery` passes the request context into `logger.HandlePanic` (`panic.go:24-25`) so the error tracker can correlate the panic with the request trace — a deliberate touch, and the comment explains why. - `pkg/server` applies `PanicRecovery` **inside** `trackRequestsMiddleware` (`manager.go:466` vs `:540`), so in-flight accounting survives a panicking handler, and offers `PanicHandler` (`interfaces.go:47`) as a documented override for embedders who need different behaviour. - `sanitizeValue` recurses correctly through nested maps and slices (`sanitize.go:120-135`) with a `default` branch that passes non-strings through untouched, so numbers and booleans are not stringified. - `Sanitizer`'s fields are read-only after construction and `regexp.Regexp` is safe for concurrent matching, so a shared `*Sanitizer` across handlers is race-free — provided callers do not mutate the exported fields at runtime, which nothing guards against but nothing does. - **1 127 lines of tests against 799 lines of code**, the best ratio in the repository, including concurrency tests. The tests are why findings 12, 13 and 17 are the only correctness bugs of their kind left; they are also why finding 4 is *locked in* rather than merely present. ## Suggested follow-up 1. **Decide the perimeter story (finding 1).** Either wire rate limiting, size limiting and blacklisting into `pkg/server` from `MiddlewareConfig`, or delete them and the 18 KB README that documents them as available. The present state — configurable, documented, tested, unmounted — is the one that misleads operators into believing they are protected. 2. **Fix the trusted-proxy model before mounting anything IP-based** (findings 2, 3, 22). One shared, normalized, default-deny `ClientIP` helper for both middlewares, with `TrustedProxies` in config. Mounting the current `getClientIP` gives an attacker an unauthenticated memory-growth primitive and a header that switches the blacklist off. 3. **Stop returning the panic value** (finding 4) and update `panic_test.go:57` to assert the opposite. Smallest diff, largest security win, and it is the only middleware currently in the request path. 4. **Do not mount the sanitizer as it stands** (findings 5, 6, 7, 14, 15). Convert it from strip-and-continue to validate-and-reject, set `EscapeHTML: false`, and move XSS defense to context-aware output encoding. Mounting it today would corrupt filter values and JSON parameters on the first request while leaving multi-line `