Skip to content

Fix: strip hop-by-hop response headers, share SSE normalization, and dedupe the listener helpers - #1605

Merged
huang195 merged 1 commit into
rossoctl:mainfrom
huang195:fix/parser-drift-1567
Oct 30, 2026
Merged

huang195 merged 1 commit into
rossoctl:mainfrom
huang195:fix/parser-drift-1567

Conversation

@huang195

Copy link
Copy Markdown
Member

Summary

Closes #1567 — three places where copied listener/parser code drifted apart, one copy getting a fix its twins did not.

1. Hop-by-hop response headers reached the client

The forward proxy's four response-header copy loops relayed resp.Header verbatim. Requests were stripped (stripForUpstream), responses were not — and the comment on one of the loops claimed the stripping happened. Through the TLS bridge, an HTTP/1.1 origin's Keep-Alive lands inside an HTTP/2 HEADERS frame, which Node's http2 rejects with ERR_HTTP2_STREAM_ERROR (reproduced in the issue).

Fix: one copyResponseHeaders(w, resp) used at all four sites. It skips the hop-by-hop set — the response-side twin of stripForUpstream's list — plus anything the response's own Connection header names, which RFC 9110 §7.6.3 also makes per-connection. Content-Length is left to the sites that manage it.

2. Only the inference parsers survived a BOM or CR-only SSE body

The buffered data: scanners in a2a-parser and mcp-parser split on LF over the raw body. A BOM-prefixed body defeats the prefix check on the first line, and a CR-only body is one line to an LF split — so the extension stayed empty and IBAC treated the exchange as unclassified, which passes by default. inference-parser already had normalizeSSE for exactly this.

Fix: normalizeSSE moved to parsercommon.NormalizeSSE, its doc comment carried over, and all six buffered scanners now use it (inference ×3, a2a ×2, mcp ×1).

3. Request helpers copied between listeners

isEventStream was defined three times; the reverse proxy inlined copies of setBody and syncHeaders. All three now live in core/listener/httpx (IsEventStream, SetRequestBody, SyncRequestHeaders) — byte-identical logic moved, six call sites updated, net −100 lines.

Testing

Every fix is pinned by a test that was red on main and green after:

  • TestForwardProxy_HopByHopResponseHeadersStripped / ...StrippedOnStream — Keep-Alive and a Connection-named header absent end-to-end, end-to-end header still forwarded, on both the buffered and streaming paths.
  • TestA2AParser_OnResponse_BufferedSSELeadingBOM / ...CROnlyLineEndings, TestMCPParser_OnResponse_SSELeadingBOM / ...CROnlyLineEndings — a wire-legal-shaped stream now classifies. (The MCP BOM fixture deliberately makes the first data frame the only parseable one; a second result frame would let both shapes pass for the wrong reason.)

Full core/listener/... and core/plugins/... suites green; go vet and gofmt clean.

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

@huang195
huang195 requested a review from a team as a code owner October 30, 2026 13:51
@coderabbitai

coderabbitai Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 8 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e5e0a460-f653-4144-bf78-ec56ed52770c
Commits

Reviewing files that changed from the base of the PR and between 4c1a9b0 and d2b6d63.

Files selected for processing: 14
  • core/listener/extproc/server.go
  • core/listener/forwardproxy/hopbyhop_test.go
  • core/listener/forwardproxy/server.go
  • core/listener/httpx/eventstream.go
  • core/listener/httpx/request.go
  • core/listener/reverseproxy/server.go
  • core/plugins/a2aparser/plugin.go
  • core/plugins/a2aparser/sse_shapes_test.go
  • core/plugins/inferenceparser/anthropic.go
  • core/plugins/inferenceparser/plugin.go
  • core/plugins/inferenceparser/responses.go
  • core/plugins/internal/parsercommon/sse.go
  • core/plugins/mcpparser/plugin.go
  • core/plugins/mcpparser/sse_shapes_test.go
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…dedupe the listener helpers

Three drift defects from the listener/parser audit (rossoctl#1567), where one copy
got a fix its twins did not:

1. Hop-by-hop response headers reached the client. The forward proxy's four
   response-header copy loops relayed resp.Header verbatim — requests were
   stripped (stripForUpstream), responses were not, and a comment on one
   loop even claimed the stripping happened. Through the TLS bridge an
   HTTP/1.1 origin's Keep-Alive lands inside an HTTP/2 HEADERS frame,
   which Node's http2 rejects with ERR_HTTP2_STREAM_ERROR. All four sites
   now share copyResponseHeaders, which skips the hop-by-hop set plus
   anything the response's own Connection header names.

2. Only the inference parsers handled a BOM or CR-only line endings. A
   BOM-prefixed or CR-only MCP/A2A stream matched no data: line, the
   extension stayed empty, and IBAC treats an unclassified exchange as one
   that passes by default. normalizeSSE moved to parsercommon.NormalizeSSE
   and is now used by all six buffered scanners.

3. Request helpers copied between listeners. isEventStream was defined
   three times and setBody/syncHeaders were inlined in the reverse proxy;
   all now live in core/listener/httpx (IsEventStream, SetRequestBody,
   SyncRequestHeaders) with one definition and six call sites.

Every fix is pinned by a test that was red on main: hop-by-hop headers
absent end-to-end on both the buffered and streaming paths, and BOM/CR-only
MCP and A2A streams classifying.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195
huang195 force-pushed the fix/parser-drift-1567 branch from c83faa1 to d2b6d63 Compare October 30, 2026 13:52

@clawgenti clawgenti left a comment

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.

Summary

Clean, well-scoped fix for the three drift defects in #1567. copyResponseHeaders now strips the RFC 9110 hop-by-hop set (plus Connection-named headers) on all four non-101 response paths, while the 101 splice still copies headers raw, which it has to. NormalizeSSE was moved, not changed, and every buffered data: line scanner in the parsers now goes through it (I grepped: no LF-only split is left over a raw body). The helpers moved into httpx are the same code, so the dedupe doesn't change behaviour.

Verified locally on d2b6d63: go vet and gofmt -l are clean on the touched packages, and go test ./listener/... ./plugins/{a2a,mcp,inference}parser/ ./plugins/internal/... is green. I also copied the six new tests onto origin/main and all six fail there, so they really are regression pins.

Two small comments inline. Neither blocks the merge.

Author: huang195 (MEMBER, maintainer)
Areas reviewed: Go (listener forward/reverse/extproc, httpx, a2a/mcp/inference parsers), tests
Agent/IDE config (.claude/.vscode): none
Commits: 1 commit, signed off: yes (nit: the subject is 96 chars, over the advisory 72)
CI status: DCO, pre-commit, lint, action-pinning and dependency review pass; core Go CI and CodeQL were still pending when this was posted (local run green)

// 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.

@@ -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.

@huang195
huang195 merged commit a6b60d4 into rossoctl:main Oct 30, 2026
40 checks passed
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Oct 30, 2026
@huang195
huang195 deleted the fix/parser-drift-1567 branch October 30, 2026 14:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

bug: Copied listener and parser code has drifted (hop-by-hop response headers, SSE BOM/CR handling)

2 participants