Files
pad/internal/server/middleware_compress.go
T
xarmian 08a362ee5d fix(server): /mcp middleware refusals get the response write deadline (BUG-3834) (#2199)
boundResponseWrites exempted /mcp by path, and mcpBoundWrites wraps only the
mounted transport, so a response the /mcp middleware chain writes before the
transport (the pre-auth 401 and its per-address 429, the per-token limiter's
429, the body limit's 413, a scope refusal) had no write deadline. A
keep-alive client that pipelines requests and never reads filled the socket
buffers with those refusals and left a handler blocked in net/http's flush,
holding the connection, with no deadline. The unauthenticated 401 path
reaches it with no credential at all.

ownsWriteDeadline no longer exempts /mcp, so the middleware bounds the whole
route. The transport keeps its own bound: mcpBoundWrites' sseStream sets its
deadline through its ResponseController, which lands on
deadlineWriter.SetWriteDeadline and makes the deadline the handler's from
then on. compressSkips still skips /mcp, through isMCPPath now that the
shared predicate no longer covers it.

Test: 2000 pipelined DELETEs to a client that never reads, refused by
middleware only (unauthenticated, and a valid token with an empty bucket);
the connection must close on a write deadline. With /mcp back in
ownsWriteDeadline both legs fail: no write fails within 60s.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-15 09:28:16 -04:00

105 lines
4.9 KiB
Go

package server
import (
"net/http"
"strings"
chimiddleware "github.com/go-chi/chi/v5/middleware"
)
// Response compression (TASK-2225, audit C18). The server ignored
// Accept-Encoding entirely: the items index (about 2.1 MB for a large
// workspace, 6.8x smaller gzipped), every JS chunk (3.1x) and every API poll
// went out uncompressed. On a LAN that is invisible; over a self-hosted WAN
// link it was most of a page's load time. Pad Cloud already gets Brotli from
// Cloudflare in front of it, so this is the self-host win.
//
// chi's Compress handles negotiation, Vary: Accept-Encoding and the content
// types worth compressing (text, JSON, JavaScript, CSS, SVG; never images,
// archives or the export bundle, which is already gzip). What it cannot know
// is which responses must NOT be wrapped, which compressSkips decides.
// compressSkips reports whether a request's response must go out as written.
func compressSkips(r *http.Request) bool {
// GETs only. A HEAD sends no body, and compressing one would only strip
// the Content-Length its caller asked for (attachment HEADs report the
// file's size that way; codex r3). Every secret the API mints (session and API tokens, share-
// link tokens, invitation codes, recovery codes, OAuth grants) comes back
// from a write, and a compressed secret beside request-influenced text is
// the BREACH shape; write responses are small, so leaving them alone costs
// nothing, while every large response worth compressing (the items index,
// the bundle's JS, polls, exports) is a GET (codex r1: a path list missed
// invitations and share links).
if r.Method != http.MethodGet {
return true
}
p := r.URL.Path
switch {
// Routes that bound their own writes: the SSE streams, where an event
// held in a compressor's window is an event the client does not get; the
// collab WebSocket, whose connection is hijacked and is not an HTTP
// response body; and an app's attachment download, covered by the
// attachments rule below as well.
// boundResponseWrites exempts the same set (BUG-3821).
case ownsWriteDeadline(r):
return true
// MCP's streamable HTTP, for the streams' reason. Bounded by
// boundResponseWrites since BUG-3834, so not in ownsWriteDeadline.
case isMCPPath(r):
return true
// Any other upgrade, for the collab WebSocket's reason. Not in
// ownsWriteDeadline: a client chooses this header, and it must not opt a
// response out of its write deadline.
case r.Header.Get("Upgrade") != "":
return true
// A Range request's 206 names byte offsets of the identity body; a
// compressed body would contradict its Content-Range.
case r.Header.Get("Range") != "":
return true
// An attachment's bytes (and its metadata, which is small): a user's file,
// streamed under a route write deadline. The compressor holds the headers
// until its first flush, and a large, very compressible file (24 MiB of
// repeated text compresses ~1000:1) can spend that whole deadline before a
// byte reaches the wire, so the client gets EOF instead of a download (CI
// on #1934, TestTask3401c_AStalledClientDoesNotOutliveTheDeadline). The
// list at `/attachments` has no trailing segment and still compresses.
// THE RULE, for the next route the census classifies: any GET whose body
// is streamed under a write deadline must skip, because the compressor's
// buffering spends the deadline before the headers go out.
case strings.Contains(p, "/attachments/"):
return true
// Reads under the credential routes as well, for the same reason: they
// are small, and nothing is lost by leaving them alone.
case strings.HasPrefix(p, "/api/v1/auth/"), strings.HasPrefix(p, "/oauth/"), strings.HasPrefix(p, "/api/v1/oauth/"),
strings.HasSuffix(p, "/tokens") || strings.Contains(p, "/tokens/"),
// Reads that return a live secret, found by an exhaustive sweep of the
// GET routes: a workspace's claim code (codex r2)
// and the members list, which still carries the plaintext code of a
// legacy pending invitation (codex r4). Webhook secrets are masked on
// reads; every other secret comes back from a write.
strings.HasSuffix(p, "/claim-code"), strings.HasSuffix(p, "/members") || strings.HasSuffix(p, "/members/"),
// A share view can carry signed, short-lived attachment references
// (an exp and a signature each; codex r5's exhaustive GET sweep).
strings.HasPrefix(p, "/api/v1/s/"):
return true
}
return false
}
// CompressResponses gzips (or deflates) what the client accepts, except what
// compressSkips names.
func CompressResponses(next http.Handler) http.Handler {
// chi's compressor drops flush errors (BUG-3827); the handler gets a
// writer that reports them.
compressed := chimiddleware.Compress(5)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(withFlushError(w), r)
}))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if compressSkips(r) {
next.ServeHTTP(w, r)
return
}
compressed.ServeHTTP(w, r)
})
}