Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 3 additions & 16 deletions core/listener/extproc/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -912,7 +912,7 @@ func (s *Server) handleResponseBody(ctx context.Context, body []byte, pctx *pipe
// unparseable halves — and on the Anthropic dialect the event at risk is message_delta, so
// what goes missing is the OUTPUT tally while the response still settles at the prompt-only
// floor.
if isEventStream(pctx.ResponseHeaders.Get("Content-Type")) {
if httpx.IsEventStream(pctx.ResponseHeaders.Get("Content-Type")) {
// BOUNDED LIKE THE OTHER ARM. The carry itself is capped at one frame's worth, but the join
// below is carry + THIS MESSAGE, and nothing else caps the message: the maxBodySize check in
// Process covers the REQUEST body only. appendBoundedBody puts both under the same ceiling
Expand Down Expand Up @@ -1396,7 +1396,7 @@ func countHeader(headers *corev3.HeaderMap, key string) int {
// counts of every event that DID complete were dispatched when their message arrived, and the
// terminal marker below is what turns them into a settled figure.
func dispatchTerminalFrame(ctx context.Context, p *pipeline.Holder, pctx *pipeline.Context) {
if isEventStream(pctx.ResponseHeaders.Get("Content-Type")) {
if httpx.IsEventStream(pctx.ResponseHeaders.Get("Content-Type")) {
_ = p.RunResponseFrame(ctx, pctx, nil, true)
return
}
Expand All @@ -1405,7 +1405,7 @@ func dispatchTerminalFrame(ctx context.Context, p *pipeline.Holder, pctx *pipeli

func dispatchBufferedFrames(ctx context.Context, p *pipeline.Holder, pctx *pipeline.Context, last bool) pipeline.Action {
contentType := pctx.ResponseHeaders.Get("Content-Type")
if isEventStream(contentType) && len(pctx.ResponseBody) > 0 {
if httpx.IsEventStream(contentType) && len(pctx.ResponseBody) > 0 {
// PARSE ONLY WHAT IS COMPLETE, AND KEEP THE REST FOR THE NEXT MESSAGE. sseframe
// delivers an unterminated trailing event at EOF — correct for a stream that really
// ended, wrong for one that merely ran out of THIS chunk — so a straddling event would
Expand Down Expand Up @@ -1448,16 +1448,3 @@ func dispatchBufferedFrames(ctx context.Context, p *pipeline.Holder, pctx *pipel
}
return p.RunResponseFrame(ctx, pctx, pctx.ResponseBody, true)
}

// isEventStream reports whether a Content-Type header value names the
// SSE media type. Tolerates parameters and ASCII case differences.
// Mirrors the helpers in forwardproxy/reverseproxy.
func isEventStream(contentType string) bool {
if contentType == "" {
return false
}
if idx := strings.IndexByte(contentType, ';'); idx >= 0 {
contentType = contentType[:idx]
}
return strings.EqualFold(strings.TrimSpace(contentType), "text/event-stream")
}
92 changes: 92 additions & 0 deletions core/listener/forwardproxy/hopbyhop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package forwardproxy

import (
"io"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/rossoctl/cortex/core/pipeline"
)

// The tests in this file pin issue #1567 item 1. Hop-by-hop headers describe ONE
// connection and must not be forwarded. The four response-header copy loops used to
// relay them verbatim — requests were stripped (stripForUpstream) but responses were
// not, and a comment on one of the loops even claimed the stripping happened. Through
// the TLS bridge an HTTP/1.1 origin's Keep-Alive reaches an HTTP/2 client inside a
// HEADERS frame, which Node's http2 rejects with ERR_HTTP2_STREAM_ERROR.

// hopByHopUpstream answers every request with a body carrying the headers under test.
func hopByHopUpstream(t *testing.T, contentType string) *httptest.Server {
t.Helper()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", contentType)
// A hop-by-hop header by name...
w.Header().Set("Keep-Alive", "timeout=5")
// ...and one made hop-by-hop by being named in Connection.
w.Header().Set("Connection", "X-Hop-Extra")
w.Header().Set("X-Hop-Extra", "hop value")
// And one that must survive.
w.Header().Set("X-Normal", "kept")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "{}")
}))
t.Cleanup(upstream.Close)
return upstream
}

// proxyGet fetches url through a plain forward proxy (no plugins) and returns the
// response the proxy handed the client, body consumed.
func proxyGet(t *testing.T, url string) *http.Response {
t.Helper()
pipe, err := pipeline.New(nil)
if err != nil {
t.Fatalf("pipeline.New: %v", err)
}
srv, err := NewServer(pipeline.NewHolder(pipe), nil, nil)
if err != nil {
t.Fatalf("NewServer: %v", err)
}
proxy := httptest.NewServer(srv.Handler())
t.Cleanup(proxy.Close)
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(proxy.URL))},
Timeout: 10 * time.Second,
}
resp, err := client.Get(url + "/data")
if err != nil {
t.Fatalf("GET through the proxy: %v", err)
}
t.Cleanup(func() { _ = resp.Body.Close() })
_, _ = io.Copy(io.Discard, resp.Body)
return resp
}

func assertNoHopByHop(t *testing.T, resp *http.Response) {
t.Helper()
if v := resp.Header.Get("Keep-Alive"); v != "" {
t.Errorf("Keep-Alive = %q, want absent — hop-by-hop headers describe one connection "+
"and must not be forwarded to the client", v)
}
if v := resp.Header.Get("X-Hop-Extra"); v != "" {
t.Errorf("X-Hop-Extra = %q, want absent — a header named in Connection is hop-by-hop "+
"for this connection, whatever its usual meaning", v)
}
if v := resp.Header.Get("X-Normal"); v != "kept" {
t.Errorf("X-Normal = %q, want %q — end-to-end headers must still be forwarded", v, "kept")
}
}

func TestForwardProxy_HopByHopResponseHeadersStripped(t *testing.T) {
resp := proxyGet(t, hopByHopUpstream(t, "application/json").URL)
assertNoHopByHop(t, resp)
}

// The streaming path has its own copy loop (streamPassthrough); the same rule must
// hold there. No StreamingResponder is configured, so the passthrough loop is the
// one that runs.
func TestForwardProxy_HopByHopResponseHeadersStrippedOnStream(t *testing.T) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: these two tests pin the buffered path (serveOutbound) and streamPassthrough. The other two call sites, handleStreamingResponse (when a StreamingResponder is configured) and streamFallbackBuffered (an SSE stream with a WritesResponseBody plugin), aren't covered. The helper is shared, so the logic is covered once. But the bug in #1567 was literally a copy loop at one site, so if someone inlines a loop back into one of those two paths, nothing would catch it. A small table that adds a no-op StreamingResponder plugin and a no-op response mutator would cover all four sites.

resp := proxyGet(t, hopByHopUpstream(t, "text/event-stream").URL)
assertNoHopByHop(t, resp)
}
121 changes: 50 additions & 71 deletions core/listener/forwardproxy/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -658,13 +658,13 @@ func (s *Server) serveOutbound(w http.ResponseWriter, r *http.Request, tl *tunne
// x-api-key). Content-Length / Content-Encoding are managed by the
// body-rewrite block below and the transport, so leave them untouched.
// Mirrors reverseproxy's forwarded-request header sync.
syncHeaders(r, pctx.Headers)
httpx.SyncRequestHeaders(r, pctx.Headers)

// If a WritesRequestBody plugin rewrote pctx.Body, ship the new bytes
// upstream and clear Content-Encoding (see forwardproxy response
// path for the rationale).
if pctx.BodyMutated() {
setBody(r, pctx.Body)
httpx.SetRequestBody(r, pctx.Body)
}

// The pipeline this request runs on for the rest of the handler: every
Expand Down Expand Up @@ -851,7 +851,7 @@ func (s *Server) serveOutbound(w http.ResponseWriter, r *http.Request, tl *tunne
// `{"detail":"Method Not Allowed"}` arrived as the literal string "Unknown error"
// once isKnownMislabeledSSE started matching every status, not just success.
mislabeled := resp.StatusCode/100 == 2 && isKnownMislabeledSSE(r.Host, r.URL.Path)
if (isEventStream(resp.Header.Get("Content-Type")) || mislabeled) && resp.Body != nil {
if (httpx.IsEventStream(resp.Header.Get("Content-Type")) || mislabeled) && resp.Body != nil {
if pinned.WritesResponseBody() {
// A response mutator needs the whole response to rewrite it, so
// it can't stream — fall back to the buffered path with a warning.
Expand Down Expand Up @@ -956,18 +956,14 @@ func (s *Server) serveOutbound(w http.ResponseWriter, r *http.Request, tl *tunne

}

for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
copyResponseHeaders(w, resp)
w.WriteHeader(resp.StatusCode)
// A skipped response of unknown length — an event stream, a long poll — is relayed as
// it arrives, as httputil.ReverseProxy does; buffered, its first bytes would wait for
// the next 4KB. skip_hosts never sent one here, but self-traffic does: OpenCode's TUI
// follows its service's event stream through this path.
var dst io.Writer = w
if f, ok := w.(http.Flusher); ok && skipped && (resp.ContentLength == -1 || isEventStream(resp.Header.Get("Content-Type"))) {
if f, ok := w.(http.Flusher); ok && skipped && (resp.ContentLength == -1 || httpx.IsEventStream(resp.Header.Get("Content-Type"))) {
f.Flush()
dst = flushWriter{w: w, f: f}
}
Expand Down Expand Up @@ -1893,31 +1889,50 @@ func (s *Server) attributeForRecording(pctx *pipeline.Context, clientHeaders htt
// recordOutboundRequestEvent records pctx's request row under sid. Its pair is

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this doc comment belongs to recordOutboundRequestEvent (around line 2054). On main it was already stranded above syncHeaders. Now that syncHeaders is gone, it sits directly above hopByHopHeaders, separated only by a blank line. go doc won't attach it to either one, and a reader will think it describes the map. Since you're in this block anyway, it's worth moving it down to the function.

// recordOutboundResponseEvent, which files under pctx.OutboundSessionID, so the
// caller pins that to sid first.
// syncHeaders makes r's headers the pipeline's: pctx.Headers started as a clone of
// r.Header, so a plugin's set, replace and delete on it are the upstream-facing
// header set. Content-Length and Content-Encoding are left to setBody and the
// transport.
func syncHeaders(r *http.Request, h http.Header) {
skip := func(k string) bool {
return strings.EqualFold(k, "Content-Length") || strings.EqualFold(k, "Content-Encoding")
}
for k := range r.Header {
if skip(k) {
continue
}
if _, ok := h[k]; !ok {
r.Header.Del(k) // plugin removed it

// hopByHopHeaders is the response-side twin of the request-side list stripForUpstream
// deletes. RFC 9110 §7.6.3: these describe one connection, not the resource, and must
// not be forwarded in either direction.
var hopByHopHeaders = map[string]bool{
"Connection": true,
"Keep-Alive": true,
"Proxy-Authenticate": true,
"Proxy-Authorization": true,
"Proxy-Connection": true,
"Te": true,
"Trailer": true,
"Transfer-Encoding": true,
"Upgrade": true,
}

// copyResponseHeaders forwards resp's end-to-end headers to w, skipping the hop-by-hop
// set — including anything the response's own Connection header names as hop-by-hop for
// this connection (#1567). The four response paths used to copy resp.Header verbatim
// while requests were stripped, so an HTTP/1.1 origin's Keep-Alive reached the client;
// through the TLS bridge that lands inside an HTTP/2 HEADERS frame, which clients like
// Node's http2 reject outright.
//
// Content-Length is NOT skipped here: the buffered paths forward a body that matches it,
// and the streaming paths delete it themselves after this call.
func copyResponseHeaders(w http.ResponseWriter, resp *http.Response) {
// The Connection header itself is in the skip list; its values are read first
// because a response can demote a normally end-to-end header to hop-by-hop by
// naming it ("Connection: X-Hop-Extra" makes X-Hop-Extra per-connection).
named := map[string]bool{}
for _, v := range resp.Header.Values("Connection") {
for _, name := range strings.Split(v, ",") {
if name = strings.TrimSpace(name); name != "" {
named[http.CanonicalHeaderKey(name)] = true
}
}
}
for k, vv := range h {
if skip(k) {
for key, values := range resp.Header {
if hopByHopHeaders[http.CanonicalHeaderKey(key)] || named[http.CanonicalHeaderKey(key)] {
continue
}
if len(vv) == 0 {
r.Header.Del(k) // pctx.Headers[k] = nil is a delete, same as Del(k)
continue
for _, value := range values {
w.Header().Add(key, value)
}
r.Header[k] = append([]string(nil), vv...) // set / overwrite
}
}

Expand Down Expand Up @@ -1979,15 +1994,6 @@ func stripForUpstream(r *http.Request, ownEncoding, keepUpgrade bool) {
}
}

// setBody gives r the body a plugin wrote, and clears Content-Encoding, since the
// bytes are the plugin's and carry none.
func setBody(r *http.Request, body []byte) {
r.Body = io.NopCloser(bytes.NewReader(body))
r.ContentLength = int64(len(body))
r.Header.Set("Content-Length", fmt.Sprintf("%d", len(body)))
r.Header.Del("Content-Encoding")
}

// resendIfAsked gives the outbound pipeline's Resenders a look at each refused answer,
// at most pipeline.MaxResends times, and sends the request again when one asks. It
// returns the answer to relay: resp itself, or the last send's.
Expand Down Expand Up @@ -2031,8 +2037,8 @@ func (s *Server) resendIfAsked(r *http.Request, pctx *pipeline.Context, client *
s.recordOutboundRequestEvent(tl, pctx, pctx.OutboundSessionID)
}
again := r.Clone(r.Context())
syncHeaders(again, pctx.Headers)
setBody(again, pctx.Body)
httpx.SyncRequestHeaders(again, pctx.Headers)
httpx.SetRequestBody(again, pctx.Body)
// No keepUpgrade: a resent request is never a forwarded upgrade — the
// 101 branch in serveOutbound skips resendIfAsked entirely, and only that
// branch leaves the headers unstripped.
Expand Down Expand Up @@ -2461,21 +2467,6 @@ func classifyBrokenStream(pctx *pipeline.Context, r *http.Request, resp *http.Re
return fail, true
}

// isEventStream reports whether a Content-Type header value names the
// SSE media type. Content-Type may carry parameters (charset=, boundary=,
// etc.) so we match on the bare type/subtype prefix and tolerate any
// suffix. Case-insensitive per RFC 9110 §8.3.1.
func isEventStream(contentType string) bool {
if contentType == "" {
return false
}
// Strip parameters: "text/event-stream; charset=utf-8" → "text/event-stream".
if idx := strings.IndexByte(contentType, ';'); idx >= 0 {
contentType = contentType[:idx]
}
return strings.EqualFold(strings.TrimSpace(contentType), "text/event-stream")
}

// isKnownMislabeledSSE reports whether host+path is a known endpoint that sends real SSE
// wire bytes (event:/data: lines) under the wrong Content-Type.
//
Expand Down Expand Up @@ -2557,12 +2548,8 @@ func (s *Server) handleStreamingResponse(w http.ResponseWriter, r *http.Request,

// Forward headers and the streaming status code BEFORE the first
// frame is written. Strip Content-Length since we'll be writing
// chunked, and clear hop-by-hop headers as net/http would.
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
// chunked; copyResponseHeaders clears the hop-by-hop set as net/http would.
copyResponseHeaders(w, resp)
w.Header().Del("Content-Length")
w.WriteHeader(resp.StatusCode)
flusher.Flush()
Expand Down Expand Up @@ -2676,11 +2663,7 @@ func (s *Server) streamPassthrough(w http.ResponseWriter, r *http.Request, resp

// Forward headers + status before the first byte. Drop Content-Length since
// we relay an open-ended chunked stream.
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
copyResponseHeaders(w, resp)
w.Header().Del("Content-Length")
w.WriteHeader(resp.StatusCode)
flusher.Flush()
Expand Down Expand Up @@ -2793,11 +2776,7 @@ func (s *Server) streamFallbackBuffered(w http.ResponseWriter, r *http.Request,
}
}
s.recordOutboundResponseEvent(pctx, resp.StatusCode, nil)
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
copyResponseHeaders(w, resp)
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
slog.Debug("response copy error", "host", r.Host, "error", err)
Expand Down
22 changes: 22 additions & 0 deletions core/listener/httpx/eventstream.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package httpx

import "strings"

// IsEventStream reports whether a Content-Type header value names the SSE media
// type. Tolerates parameters ("text/event-stream; charset=utf-8") and ASCII case
// differences.
//
// ONE definition for all three listeners (#1567): the forward proxy, reverse proxy
// and ext_proc listeners each carried their own copy, and a fix to one — the
// parameter tolerance — reached the others only by re-editing every copy. The
// copies were identical only because someone kept them that way.
func IsEventStream(contentType string) bool {
if contentType == "" {
return false
}
// Strip parameters: "text/event-stream; charset=utf-8" → "text/event-stream".
if idx := strings.IndexByte(contentType, ';'); idx >= 0 {
contentType = contentType[:idx]
}
return strings.EqualFold(strings.TrimSpace(contentType), "text/event-stream")
}
Loading
Loading