Files
pad/internal/server/middleware_logging.go
T
xarmian 8a1aaf61a8 fix(server): a failed flush reaches the handler through chi's wrappers (BUG-3827) (#2186)
* fix(server): a failed flush reaches the handler through chi's wrappers (BUG-3827)

chi's WrapResponseWriter (logger, metrics, MCP audit) and its compressor
implement Flush but not FlushError, so http.ResponseController.Flush called
Flush and returned nil. A flush to a client that had stopped reading
"succeeded", and sseStream's Dead/Err never saw it; slots were released only
because the request context ended.

flushErrorWriter wraps each of the four chi writers. Its FlushError runs
chi's own Flush (status bookkeeping, the compressor's encoder) and then
flushes the writer beneath through a ResponseController, which returns
net/http's sticky write error. It unwraps to the chi writer, so write
deadlines still reach the connection.

Pins: a stalled flush through the outer chain (identity and gzip), the
activity stream seeing its failed keepalive flush (test-only seam
Server.sseStreamFailedForTesting), and MCPAuditLog with its audit writer
started (it is a passthrough without one). With FlushError renamed away,
all four fail.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(server): the flush-error wrapper keeps exactly the chi writer's interfaces (BUG-3827 codex r1)

The r0 wrapper hid io.ReaderFrom (io.Copy's sendfile path) and http.Pusher,
and added Hijacker and Flusher where chi had none. withFlushError now leaves
a non-Flusher alone and returns a variant with exactly the chi writer's
Hijacker / ReaderFrom / Pusher set; a shape chi never produces is left
unwrapped. TestBUG3827_WrapperKeepsTheChiWritersInterfaces pins all seven
NewWrapResponseWriter shapes plus the compressor; against the r0 wrapper 7
of 8 fail.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* test(server): each chain leg proves its encoder, and deflate gets a leg (BUG-3827 codex r2)

openStalledStream returns the response head; every leg of the stalled-flush
chain test asserts its Content-Encoding, and a deflate leg joins identity and
gzip. With compression bypassed, gzip and deflate fail on that assertion.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 01:02:42 -04:00

74 lines
2.4 KiB
Go

package server
import (
"log/slog"
"net/http"
"regexp"
"time"
chimiddleware "github.com/go-chi/chi/v5/middleware"
)
// redactedQueryKeys are query-string keys whose values must never appear
// in request logs. The server's POST endpoints reject these via header-
// only contracts, but a misuse via GET (e.g. an operator pasting
// /setup?token=<x> instead of using the fragment form, or a bug in a
// future feature that accepts the same key) would otherwise persist
// the secret in `logs/server.log` AND in any log-aggregation pipeline.
//
// Add new entries here whenever a new sensitive query key is
// introduced. The list is intentionally short — most secrets should
// move to the request body or a header instead.
var redactedQueryKeys = regexp.MustCompile(`(?i)(\b(?:token|password|secret|api[_-]?key)=)[^&]*`)
// redactQueryString replaces sensitive query-key values with REDACTED
// while leaving the rest of the query string intact for diagnostics.
// Operates on the raw query string to avoid the alphabetization that
// url.Values.Encode() would impose (which would break log diffing
// against the actual request).
func redactQueryString(raw string) string {
if raw == "" {
return raw
}
return redactedQueryKeys.ReplaceAllString(raw, "${1}REDACTED")
}
// StructuredLogger is a chi-compatible request logger that writes structured
// log entries via slog. It replaces chi's default Logger middleware.
func StructuredLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := chimiddleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(withFlushError(ww), r) // BUG-3827: chi drops flush errors
duration := time.Since(start)
status := ww.Status()
level := slog.LevelInfo
if status >= 500 {
level = slog.LevelError
} else if status >= 400 {
level = slog.LevelWarn
}
attrs := []slog.Attr{
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", status),
slog.Duration("duration", duration),
slog.Int("bytes", ww.BytesWritten()),
}
if reqID := chimiddleware.GetReqID(r.Context()); reqID != "" {
attrs = append(attrs, slog.String("request_id", reqID))
}
if r.URL.RawQuery != "" {
attrs = append(attrs, slog.String("query", redactQueryString(r.URL.RawQuery)))
}
slog.LogAttrs(r.Context(), level, "http request", attrs...)
})
}