mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 22:37:03 +00:00
08a362ee5d
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
105 lines
4.9 KiB
Go
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)
|
|
})
|
|
}
|