carlos / platform Public

edge: isUpgradeRequest reads Connection across every header line

Connection is list-valued (RFC 9110 §5.3), and Header.Get returns only
the first line — the last known instance of the Header.Get-on-a-list-
valued-header class that produced four real bugs on the cache-before-
wake branch (PR #10). A client splitting "Connection: keep-alive" and
"Connection: Upgrade" across two header lines bypassed the untouched-
passthrough branch and fell through to the cache/recording path
instead.

Reuses joinHeader (already doing exactly this for Vary/Cache-Control)
rather than adding a new parsing path.
Paul Campbell c4e3ea4314dacd5924f3db521c935a0da326b192 parent 41d99f4
2 files changed, +25 −2
  • internal/edge/edge.go +5 −2
  • internal/edge/edge_test.go +20 −0
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index ed9ec5a03..3e9f2f30e 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -330,9 +330,12 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// isUpgradeRequest reports whether r is a protocol-upgrade request (e.g. a
// WebSocket handshake) per the Connection header's token list (RFC 7230
// §6.7: comma-separated, case-insensitive — "Upgrade", "keep-alive, Upgrade",
-// etc. all qualify).
+// etc. all qualify). Connection is list-valued (RFC 9110 §5.3), so this reads
+// every line via joinHeader rather than r.Header.Get, which returns only the
+// first — the same class of bug joinHeader's doc comment describes for Vary
+// and Cache-Control, here on the request's Connection header instead.
func isUpgradeRequest(r *http.Request) bool {
- for _, token := range strings.Split(r.Header.Get("Connection"), ",") {
+ for _, token := range strings.Split(joinHeader(r.Header, "Connection"), ",") {
if strings.EqualFold(strings.TrimSpace(token), "Upgrade") {
return true
}
diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go
index 492479c7e..08a1f2fd3 100644
--- a/internal/edge/edge_test.go
+++ b/internal/edge/edge_test.go
@@ -1143,6 +1143,26 @@ func TestServeHTTPUpgradeRequestBypassesRecording(t *testing.T) {
e.ServeHTTP(rec, req)
}
+// TestIsUpgradeRequestDetectsConnectionSplitAcrossHeaderLines is the last of
+// the four Header.Get-on-a-list-valued-header bugs found on this branch
+// (see joinHeader's doc comment): Connection is legitimately list-valued
+// (RFC 9110 §5.3), and a client that sends "Connection: keep-alive" and
+// "Connection: Upgrade" as two separate header lines is just as much an
+// upgrade request as one that sends them comma-joined on one line — but
+// http.Header.Get only ever returns the first line, so isUpgradeRequest
+// would silently treat the split form as an ordinary request and let it
+// fall through to the cache/recordingResponseWriter path instead of the
+// untouched-passthrough branch.
+func TestIsUpgradeRequestDetectsConnectionSplitAcrossHeaderLines(t *testing.T) {
+ req := httptest.NewRequest("GET", "http://ws.example.com/socket", nil)
+ req.Header.Add("Connection", "keep-alive")
+ req.Header.Add("Connection", "Upgrade")
+
+ if !isUpgradeRequest(req) {
+ t.Fatal("Connection split across two header lines must still be detected as an upgrade request")
+ }
+}
+
// activatorFunc adapts a plain function to the Activator interface, for
// tests that want custom wake-counting behavior without a full fake type.
type activatorFunc func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool