Skip to content

fix(authz): commit recorded status before flushing in ResponseFilteri… - #6335

Open
Yanhaoxi wants to merge 1 commit into
stacklok:mainfrom
Yanhaoxi:fix/response-filter-commit-recorded-status-on-flush
Open

fix(authz): commit recorded status before flushing in ResponseFilteri…#6335
Yanhaoxi wants to merge 1 commit into
stacklok:mainfrom
Yanhaoxi:fix/response-filter-commit-recorded-status-on-flush

Conversation

@Yanhaoxi

Copy link
Copy Markdown
Contributor

Summary

ResponseFilteringWriter filters 2xx list responses (tools/list, prompts/list, resources/list, find_tool) against Cedar policies. Its non-2xx passthrough branch (FlushAndFilter) is safe only because a fetch-based MCP client gates list delivery on response.ok (200-299). In the production transparent-proxy path (httputil.ReverseProxy with FlushInterval: -1, pkg/transport/proxy/transparent/transparent_proxy.go:1220-1221), the proxy calls Flush() while copying the backend response, and the first Flush() on a fresh net/http writer commits the headers with an implicit WriteHeader(200) before FlushAndFilter() runs. FlushAndFilter then takes the non-2xx passthrough branch from the recorded status (e.g. 500), its WriteHeader(500) is a no-op, and the unfiltered buffered list body is delivered under a fabricated 200 — the #5257-class bypass on the non-2xx branch. Legitimate 4xx/5xx statuses are also silently rewritten to 200 (functional breakage).

WHAT changed:

  • ResponseFilteringWriter.Flush() now commits the recorded status (rfw.ResponseWriter.WriteHeader(rfw.statusCode)) before flushing the downstream writer, so the wire status reflects the real backend status instead of the implicit 200. SSE (statusCode 200) is unaffected; later WriteHeader calls in FlushAndFilter become no-ops instead of corrupting the status.
  • Add a regression test over the production wiring (real HTTP server + ReverseProxy FlushInterval:-1 + ResponseFilteringWriter) asserting a 500/404 list response reaches the client with the non-2xx status while 2xx responses are still filtered.

Fixes #6332

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Run: go test ./pkg/authz/.... New test: TestResponseFilteringWriter_Non2xxStatusPreservedOnWire (in pkg/authz/response_filter_status_commit_test.go). Verified the new test FAILS without the source fix (backend 500/404 rewritten to 200 on the wire) and PASSES with it; full pkg/authz/... suite passes.

Lint: golangci-lint run ./pkg/authz/ reports only 3 pre-existing gci issues in files this PR does not touch (pkg/authz/annotation_cache.go, pkg/authz/authorizers.go, pkg/authz/config.go); neither of this PR's files is flagged. go vet clean. (gofmt on a Windows checkout with core.autocrlf=true flags every Go file in the repo due to CRLF working-tree line endings; commits are LF-normalized and CI runs on Linux.)

API Compatibility

  • This PR does not break the v1beta1 API.

Changes

File Change
pkg/authz/response_filter.go Flush() commits the recorded status (WriteHeader(rfw.statusCode)) before flushing downstream
pkg/authz/response_filter_status_commit_test.go Add regression test asserting non-2xx status survives on the wire and 2xx filtering is preserved

Does this introduce a user-facing change?

Yes: a backend answering a list method with a non-2xx status (e.g. 500) can no longer have the full, unfiltered tools/prompts/resources list delivered to the client as HTTP 200; the client now observes the real non-2xx status. Legitimate 4xx/5xx backend statuses are no longer silently rewritten to 200.

Implementation plan

Approved implementation plan

One root cause: the deferred-write design assumes FlushAndFilter controls header-commit timing, but the streaming-support Flush() touches the downstream writer first and commits an implicit 200.

  • Flush() (response_filter.go): after deleting Content-Length, commit the recorded status before flusher.Flush(). Idempotent for repeated flushes (headers already committed → no-op).
  • Non-2xx passthrough branch unchanged: it stays body-preserving for error responses by design; the security property is restored because the client observes the real non-2xx status.
  • Regression test drives the real HTTP server + ReverseProxy FlushInterval:-1 chain and asserts 500/404 reach the client unchanged while a 200 list is still filtered.

Special notes for reviewers

  • The fix is a one-line behavior change in Flush() plus a comment explaining why. SSE (statusCode 200) and the 2xx filtering path are unaffected — committing 200 is identical to the previous implicit 200.
  • Defense-in-depth (fail-closed sniffing of a result-bearing body in the non-2xx passthrough branch) is deliberately left out of this PR: it would change error-body passthrough semantics and is orthogonal to the root cause here.
  • The writer in the regression test is constructed directly rather than via AuthorizationMiddleware; the exercised behavior (deferred write + early streaming flush + filter) is identical.

…ngWriter

ResponseFilteringWriter is write-behind: Write buffers the body and
WriteHeader only records the status, with the real write and filter
happening in FlushAndFilter. Its non-2xx passthrough branch is safe only
because a fetch-based MCP client gates list delivery on response.ok
(200-299), so a non-2xx list body is never consumed.

In the production transparent-proxy path (httputil.ReverseProxy with
FlushInterval: -1), the proxy calls Flush() while copying the backend
response. The first Flush() on a fresh net/http writer commits the
headers with an implicit WriteHeader(200) before FlushAndFilter() runs.
FlushAndFilter then takes the non-2xx passthrough branch from the
recorded status (e.g. 500), its WriteHeader(500) is a no-op, and the
unfiltered buffered list body is delivered under a fabricated 200 --
the stacklok#5257-class bypass on the non-2xx branch. Legitimate 4xx/5xx
statuses are silently rewritten to 200 as well.

Commit the recorded status (rfw.statusCode) in Flush() before flushing
downstream, so the wire status reflects the real backend status. SSE
(statusCode 200) is unaffected and later WriteHeader calls in
FlushAndFilter become no-ops instead of corrupting the status.

Adds a regression test over the production wiring (real HTTP server +
ReverseProxy FlushInterval:-1) asserting a 500/404 list response reaches
the client with the non-2xx status while 2xx responses are still
filtered. The test fails without the fix (status rewritten to 200).
@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.96%. Comparing base (8f294e2) to head (51eb938).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6335      +/-   ##
==========================================
- Coverage   72.96%   72.96%   -0.01%     
==========================================
  Files         742      742              
  Lines       78236    78237       +1     
==========================================
- Hits        57088    57084       -4     
- Misses      17168    17177       +9     
+ Partials     3980     3976       -4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Non-2xx list responses are delivered as HTTP 200 unfiltered

1 participant