fetch: reject the fetch when an HTTP proxy refuses CONNECT to an https origin - #35965
fetch: reject the fetch when an HTTP proxy refuses CONNECT to an https origin#35965robobun wants to merge 6 commits into
Conversation
A non-2xx response to CONNECT travels over the plaintext client->proxy hop with no TLS handshake to the https origin. Surfacing it as a Response with res.url set to the https origin lets a hostile proxy (or any MITM on that hop) hand JS attacker-controlled status/headers/body (Set-Cookie, Location, HTML) under the https origin's identity, and a non-200 2xx like 201 even passed res.ok. RFC 9110 s9.3.6: any 2xx to CONNECT switches to tunnel mode (so 201+body now feeds the inner TLS handshake, which fails, instead of resolving as ok content); non-2xx now rejects with code 'ProxyConnectFailed' whose message carries the proxy's status code. Matches curl, Node/undici, and browsers.
|
Warning Review limit reached
Next review available in: 5 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (7)
Comment |
|
Updated 2:05 PM PT - Jul 26th, 2026
❌ @robobun, your commit 9dd5bbe has 1 failures in
Add 🧪 To try this PR locally: bunx bun-pr 35965That installs a local version of the PR into your bun-35965 --bun |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
…ONNECT check above 204/304 content_length write A 204 CONNECT reply would have written state.content_length = Some(0) before the tunnel-established return, which leaks into the origin leg (ProxyTunnel does not reset state) and rejects the origin's Content-Length as a duplicate-CL conflict. Move the CONNECT dispatch above the status-code-driven state writes; add 202/204-tunnel tests.
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/http/lib.rs:4920-4940— The b62c16c hoist put the CONNECT dispatch above the status-code-driven writes, but the header loop at 4770–4914 still runs first and its Content-Encoding (4812–4834), Connection (4874–4888), and Alt-Svc (4897–4910) arms write intoself.statewithout theproxy_tunneling && proxy_tunnel.is_none()skip that Content-Length (4780) and Transfer-Encoding (4840) have — so a hostile proxy sendingContent-Encoding: gzipon its 2xx CONNECT reply still leaksstate.encodinginto the origin leg (ProxyTunnel doesn't reset it → the origin's identity body is fed to the gzip decoder → CompressionFailed). This is pre-existing for thestatus==200case, but it's the same class you just hoisted for, and hoisting the CONNECT dispatch above the header loop instead lets both per-headercontinueskips at 4780/4840 (and their comment-cop-flagged justifying comments) be deleted.Extended reasoning...
What
The b62c16c follow-up hoisted the
proxy_tunneling && proxy_tunnel.is_none()dispatch (now lib.rs:4933–4940) above the status-code-driven state writes —pretend_304and the 204/304content_length = Some(0)block — so those can no longer leak from the CONNECT leg into the origin leg. But the header loop at lib.rs:4770–4914 still runs before the CONNECT dispatch, and three of its arms mutateself.statewithout the CONNECT-leg skip that Content-Length (4780) and Transfer-Encoding (4840) already have:- Content-Encoding (4812–4834): sets
self.state.encoding = Gzip/Deflate/Brotli/Zstdandself.state.content_encoding_i. - Connection (4874–4888): a 2xx with
Connection: closeclearsself.state.flags.allow_keepalive. - Alt-Svc (4897–4910): records against
self.url.hostname— the origin's hostname.
Nothing on the tunnel-transition path resets these:
start_proxy_handshakedoesn't touchstate.encoding, andgrep 'encoding' src/http/ProxyTunnel.rshas no matches (on_open/on_handshakereset onlyresponse_stage/request_stage/request_sent_len).Step-by-step: Content-Encoding leak
- Client sends
CONNECT origin:443 HTTP/1.1. - A hostile proxy replies
HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\n\r\n. handle_response_metadataruns: the header loop reaches the Content-Encoding arm at 4812 (no CONNECT skip) →self.state.encoding = Gzip,content_encoding_i = i.- Line 4933 matches (
proxy_tunneling && proxy_tunnel.is_none()), line 4934 matches (2xx) →return Ok(ContinueStreaming).state.encodingis stillGzip. - Inner TLS handshake completes;
ProxyTunnel::on_handshakeresets stages but notstate.encoding. - Origin replies
HTTP/1.1 200 OK\r\nContent-Length: N\r\n\r\n<plain body>(no Content-Encoding header, so the arm at 4812 never fires to overwrite the stale value). handle_response_bodyseesstate.encoding.is_compressed()→ feeds the origin's identity bytes to the gzip decoder →CompressionFailed, andcontent_encoding_ipoints at a header index in the origin's header list that isn't a Content-Encoding header.
The Connection leak is benign (keepalive suppression on a tunnel that isn't pooled the same way). The Alt-Svc leak is gated on
self.is_https(), which at lib.rs:2686–2691 returnsproxy.is_https()whenhttp_proxyis set — so anhttp://proxy can't hit it, but anhttps://proxy's CONNECT-reply Alt-Svc would be recorded against the origin's hostname (further gated behind the experimentalh3_alt_svc_enabled()flag).Why raise it here even though it's pre-existing
For the
status==200case this leak already existed before this PR — the header loop ran before the oldstatus == 200check too — so this is not a regression the PR introduces. But:- REVIEW.md, Correctness: "Fix the whole class in the same PR (same-class sites are ONE concern, not scope creep)". The class here is "CONNECT-leg state writes leak into the origin leg because ProxyTunnel doesn't reset
state", and b62c16c already fixed the status-code-driven half of it in response to review. - The comment-cop bot has already flagged both per-header skip comments (line 4779 and the block above 4932) with "if you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code". Hoisting above the header loop is that fix.
- The comment at 4929–4932 is carefully scoped to "the status-code-driven state writes below", so it isn't strictly wrong — but the header-driven writes above it are the same hazard and the reader is left to infer that they're handled ad-hoc per-arm rather than structurally.
Fix
Hoist the
if self.flags.proxy_tunneling && self.proxy_tunnel.is_none() { … }block from line 4933 to immediately before the header loop (after computingresponse's status but before thefor (header_i, header) in …at 4770). Every output of the header loop —location,pretend_304,is_server_sent_events, and everyself.statewrite — is irrelevant to a CONNECT reply (2xx → tunnel, non-2xx →Err), so nothing is lost. The verboseprint_responseat 4916–4918 can either move above the dispatch too or stay where it is (CONNECT replies then wouldn't be printed, which is arguably fine). Then delete the two per-headercontinueskips at 4780 and 4840 and their justifying comments — net simplification.Severity
Nit. Pre-existing for the only status any conforming proxy sends (200); requires a hostile or non-conforming proxy to send unusual headers on a 2xx CONNECT reply; concrete impact is DoS (CompressionFailed) or keepalive suppression rather than the origin-attribution attack this PR fixes; the Alt-Svc case is https-proxy-only and behind an experimental flag. Worth fixing here because it's the same class the author just did one round of hoisting for, the fix is a net simplification, and it resolves the two open comment-cop findings.
- Content-Encoding (4812–4834): sets
The header loop's Content-Encoding / Connection / Alt-Svc arms wrote self.state before the CONNECT check ran, so a hostile proxy sending Content-Encoding: gzip on its 2xx CONNECT reply leaked state.encoding into the origin leg and the origin's identity body was fed to the gzip decoder (pre-existing for status==200). Dispatching before the loop also deletes the per-header CL/TE skips.
|
Addressed the header-loop state-leak finding in 5d4e1ae: hoisted the CONNECT dispatch above the header loop so none of the CONNECT reply's headers touch The |
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/http/lib.rs:4915-4924— The b62c16c hoist puts the CONNECT dispatch above the status-code-driven state writes, but the header loop above it still writesself.statefor a 2xx CONNECT reply: theContent-Encodingarm (4807-4829) setsstate.encodingand theConnectionarm (4869-4883) setsstate.flags.allow_keepalive, neither with theproxy_tunneling && proxy_tunnel.is_none()skip that CL (4775) and TE (4835) have — so a proxy answering CONNECT with e.g.Content-Encoding: gzipleavesstate.encoding = Gzipinto the origin leg and the origin's plaintext body is fed to the gzip decoder. This is pre-existing for CONNECT → 200 (the PR only widened it to 201-299), and requires a non-compliant proxy putting Content-Encoding on a bodiless CONNECT reply, so not blocking — mentioning as the same-class sibling of thecontent_lengthleak just fixed. The comment's "before the state writes below" is also slightly off since these header-loop writes are above.Extended reasoning...
What
The b62c16c follow-up hoisted the CONNECT dispatch above
pretend_304and the 204/304content_length = Some(0)write so status-code-driven state can't leak into the origin leg. But the response-header loop at [lib.rs:4770-4909] runs before that dispatch and two of its arms also mutateself.statewithout theself.flags.proxy_tunneling && self.proxy_tunnel.is_none()skip that this PR just widened on the Content-Length (line 4775) and Transfer-Encoding (line 4835) arms:Content-Encoding([lib.rs:4807-4829]) — writesself.state.encoding = Gzip/Deflate/Brotli/Zstdandself.state.content_encoding_iwith no CONNECT-leg skip.Connection([lib.rs:4869-4883]) — gated on200..=299, writesself.state.flags.allow_keepalivewith no CONNECT-leg skip.
Nothing on the tunnel-start path resets these:
start_proxy_handshaketouches onlyresponse_message_buffer, andProxyTunnel'son_open/on_handshakereset onlyresponse_stage/request_stage/request_sent_len(grep ofProxyTunnel.rsforencoding/allow_keepalive/state.reset→ no matches).Step-by-step (Content-Encoding)
- Client sends
CONNECT origin:443 HTTP/1.1. - Proxy replies
HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\n\r\n(RFC-violating — a CONNECT reply has no body — but nothing forbids the header). handle_response_metadataruns the header loop: theContent-Encodingarm at 4807 setsself.state.encoding = Encoding::Gzip. Then at 4921proxy_tunneling && proxy_tunnel.is_none() && 200..300→return Ok(ContinueStreaming).- Inner TLS handshake completes;
on_handshakeresetsresponse_stage = ProxyHeadersbut leavesstate.encoding = Gzip. - Origin replies
HTTP/1.1 200 OK\r\nContent-Length: N\r\n\r\n<plaintext>with noContent-Encodingheader.handle_response_metadatare-enters (nowproxy_tunnel.is_some()), and since there is no Content-Encoding header the arm at 4807 never runs to overwrite the stale value —state.encodingis stillGzip. - Body handling reaches
InternalState::process_body_buffer, which seesencoding == Gzipand feeds the origin's plaintext bytes to the gzip decoder → the fetch rejects with a decompression error instead of resolving.
For
Connection: closeon a 2xx CONNECT reply,state.flags.allow_keepalive = falsesimilarly survives into the origin leg and prevents the tunnel from being pooled at the keep-alive check (~lib.rs:2170) — a much milder effect (perf only).Why the earlier fix doesn't cover it
The comment at [lib.rs:4918-4920] says "Dispatched here, before the state writes below, because ProxyTunnel does not reset
statebetween the CONNECT leg and the origin leg." That's accurate for the writes below (pretend_304, the 204/304content_lengthwrite), but the header loop's Content-Encoding/Connection writes are above the dispatch, so the ordering guard doesn't protect them. The CL and TE arms are safe only because they carry an explicitif self.flags.proxy_tunneling && self.proxy_tunnel.is_none() { continue; }— the same guard the two remaining state-writing arms need.Relation to the PR / severity
This is pre-existing for CONNECT → 200: before this PR, a 200 CONNECT reply already went through the same unguarded arms and returned
ContinueStreaming. The PR only widened the surface to 201-299 (which previously fell through to the surface-as-Response else-branch), and it touched the immediately-adjacent CL/TE skips by dropping their&& status_code == 200— making Content-Encoding/Connection the only header-loop arms still writingself.stateon the CONNECT leg. AContent-Encodingheader on a bodiless CONNECT reply is unusual and the impact (decompression error / unpooled tunnel) is DoS-class only; a hostile proxy can already DoS by not tunneling. So: nit, not blocking — flagged because REVIEW.md's "Fix the whole class in the same PR … grep for every sibling site sharing the pattern" applies directly, and the added comment's wording invites a future reader to move the block back down.Fix
Add the same skip to the two remaining arms:
h if h == hash_header_const(b"Content-Encoding") => { if self.flags.proxy_tunneling && self.proxy_tunnel.is_none() { continue; } if !self.flags.disable_decompression { ... } } ... h if h == hash_header_const(b"Connection") => { if self.flags.proxy_tunneling && self.proxy_tunnel.is_none() { continue; } if response.status_code >= 200 && response.status_code <= 299 { ... } }
(or, equivalently, hoist a single
if proxy_tunneling && proxy_tunnel.is_none() { continue; }to the top of the loop body and drop the per-arm copies — every header on a CONNECT reply is discarded either way now that non-2xx returnsErrand 2xx returnsContinueStreaming). Either also makes the "before the state writes below" comment fully accurate. -
🟡
src/http/lib.rs:4922— The WebSocket client's CONNECT dispatch at WebSocketUpgradeClient.rs:990-998/1025 still checks onlyb"HTTP/1.1 200 "/status_code != 200, so a proxy that answers CONNECT with 202/204 — which this PR now accepts (and tests) forfetch— still failsnew WebSocket("wss://…", { proxy })withProxyConnectFailed. WebSocket already had the security-correct reject-on-non-200 behavior so the CVE fix isn't needed there; only the RFC 9110 §9.3.6 any-2xx widening is missing. Worth applying the same200..300range at both sites (and widening or dropping the byte-prefix fast path) so the two CONNECT parsers stay consistent — or noting the exclusion in the PR per REVIEW.md's "grep for every sibling site" rule.Extended reasoning...
What
This PR widens
fetch()'s CONNECT tunnel-established check fromstatus_code == 200to>= 200 && < 300(src/http/lib.rs:4922), citing RFC 9110 §9.3.6: "Any 2xx (Successful) response indicates that the sender … will switch to tunnel mode immediately after the response header section." The sibling CONNECT dispatch in the WebSocket client was not updated:- WebSocketUpgradeClient.rs:990-998 — byte-prefix fast path:
!body.starts_with(b"HTTP/1.1 200 ") && !body.starts_with(b"HTTP/1.0 200 ")→terminate(ProxyConnectFailed) - WebSocketUpgradeClient.rs:1025 — parsed check:
if response.status_code != 200→terminate(ProxyConnectFailed)(orProxyAuthenticationRequiredfor 407)
So a proxy that replies
HTTP/1.1 204 No Content\r\n\r\nnow establishes the tunnel forfetch("https://…", { proxy })but still failsnew WebSocket("wss://…", { proxy })withProxyConnectFailed— the two clients disagree on the same RFC-defined protocol point.Why REVIEW.md flags this
Correctness → Fix the whole class in the same PR: "Grep for every sibling site sharing the pattern: parallel switch arms, sync/async twins, fast/slow paths, POSIX/Windows branches, SSL/non-SSL variants … If a site is intentionally excluded, say so in the PR." The WebSocket CONNECT parser is exactly the sibling site of the fetch CONNECT parser this PR changes, and the PR description doesn't mention excluding it.
Why nothing prevents it
The WebSocket client has its own hand-rolled CONNECT-reply parser (it doesn't route through
handle_response_metadata), so the fix insrc/http/lib.rsdoesn't reach it. The byte-prefix fast path at :990-998 fires before the picohttp parse and rejects on the first packet if the status literal isn't200; even if that fast path is bypassed (e.g. short first read), the parsed check at :1025 rejects any non-200.Step-by-step
new WebSocket("wss://origin/", { proxy: "http://127.0.0.1:PORT" }).- Client sends
CONNECT origin:443 HTTP/1.1\r\n…\r\n\r\n. - Proxy replies
HTTP/1.1 204 No Content\r\n\r\nand starts relaying — a valid tunnel-established reply per the RFC section this PR cites, and one this PR added an explicit fetch test for atproxy-stress-errors.test.ts:135("CONNECT → 204 establishes the tunnel and the origin body arrives intact"). handle_proxy_responseruns:is_first && body.len() > 13→ true;body.starts_with(b"HTTP/1.1 200 ")→ false;body.starts_with(b"HTTP/1.0 200 ")→ false →terminate(ProxyConnectFailed).- WebSocket
onerrorfires; the origin is never reached. The equivalentfetchthrough the same proxy succeeds.
Impact
No security or correctness regression: WebSocket already rejected non-200 CONNECT replies before this PR (so the CVE-2009-2062 fix isn't needed there), and its behavior is unchanged. The gap is RFC-compliance/consistency only, and non-200 2xx CONNECT replies are rare in practice. But it is exactly the sibling-site pattern REVIEW.md calls out, and after this PR the two clients observably diverge on identical proxy behavior.
Fix
Widen both checks to the same range fetch now uses:
// drop the byte-prefix fast path (or widen it to check for "HTTP/1." + digit + " 2") // ... if !(200..300).contains(&response.status_code) { if response.status_code == 407 { … } else { terminate(ProxyConnectFailed) } return; }
If intentionally deferred, a mention in the PR description satisfies the REVIEW.md rule.
Severity
Nit — no regression, no security impact, rare in practice; consistency/RFC-compliance only.
- WebSocketUpgradeClient.rs:990-998 — byte-prefix fast path:
Match the fetch client's RFC 9110 s9.3.6 behaviour so a proxy that replies 202/204 to CONNECT works for both clients. Drop the byte-prefix fast path; the picohttp parse with the max_http_header_size() cap handles it.
|
On the two additional findings:
|
|
CI status: the only hard failure across the last four builds (82568, 82587, 82607, 82626) is Ready for review. |
There was a problem hiding this comment.
The prior review findings (204/header-loop state leak, Display dropping the status, WebSocket sibling) are all addressed in the current diff, and this run found no new issues. Deferring to a human for sign-off since this is a security-relevant behavior change (CVE-2009-2062 class) that flips fetch's contract from resolve→reject on non-2xx CONNECT and has an acknowledged bun install retry-on-4xx side effect left for follow-up.
What was reviewed:
- CONNECT dispatch is now above the header loop and the 204/304
content_lengthwrite; verified noself.statemutation precedes the early return, and theContinueStreamingpath still reachesstart_proxy_handshake. - 101 CONNECT still fails with
UnrequestedUpgrade(handled at lib.rs:3689 beforehandle_response_metadata), so that existing test remains valid. - Absolute-form (http origin) 407/403 path is untouched — the early return is gated on
proxy_tunneling && proxy_tunnel.is_none().
Extended reasoning...
Overview
Changes handle_response_metadata in src/http/lib.rs to dispatch on the CONNECT reply before any header/status processing: 2xx → ContinueStreaming (start inner TLS), non-2xx → Err(ProxyConnectFailed(status)) → close_and_fail. Adds the ProxyConnectFailed(u32) error variant with a status-carrying Display, a matching FetchTasklet message, and widens the WebSocket client's CONNECT check to (200..300) while dropping its byte-prefix fast path. Three test files gain ~20 new cases and update ~10 existing ones from expect-resolve to expect-reject.
Security risks
This is the security fix: it stops a hostile proxy (or any MITM on the plaintext client→proxy hop) from surfacing arbitrary status/headers/body under an https origin's identity. The change fails closed — anything that isn't 2xx now rejects instead of resolving. The 2xx-widening is RFC-mandated and matches curl; a non-conforming proxy that sends 2xx + body feeds those bytes into the TLS handshake, which fails loudly. I did not find a way for a hostile proxy to reach a worse state than before.
Level of scrutiny
High. This is core HTTP client code shared by fetch, bun install, S3, and (via the sibling change) WebSocket. It changes the observable contract for a documented option, and the placement of the early return relative to self.state writes was already shown to be load-bearing twice in this PR's review history. A maintainer should confirm the resolve→reject flip and the deferred bun install retry/.name() follow-ups are acceptable.
Other factors
All four of my earlier findings are resolved in the current head (9dd5bbe): the 204 content_length leak and the Content-Encoding/TE header-loop leak are fixed by hoisting the dispatch above both; #[error(...)] now carries the status; the WebSocket path agrees on the 2xx range. New tests cover 202/204/gzip-CE/chunked-TE CONNECT replies tunneling to a real https origin, and 403/302/407/500/201 rejection cases. The 101-CONNECT test is unaffected because that status is short-circuited before handle_response_metadata runs. The one remaining known behavior change — bun install now retrying 4xx CONNECT up to max_retry_count and losing the status in its message — is documented in the PR body as a bounded follow-up.
What
fetch("https://...", { proxy })now rejects when the proxy answersCONNECTwith a non-2xx status, instead of resolving to aResponsewhoseurlis the https origin but whose status/headers/body came from the plaintext proxy hop. Any 2xx (not just 200) now establishes the tunnel, per RFC 9110.Why
The
CONNECTleg of an HTTP proxy is plaintext. When a proxy (or any MITM on the client→proxy hop) replies toCONNECTwith e.g.403,407,302, or even201 Created, no TLS handshake to the origin has happened. Before this change Bun handed that reply to JS as aResponseattributed to the https origin:A hostile proxy could inject
Set-Cookie,Location, or HTML under the https origin's identity without a single verified TLS byte (the CVE-2009-2062 class). curl exits 56 (CONNECT tunnel failed, response 403); Node/undici throwsTypeError: fetch failed; browsers reportERR_TUNNEL_CONNECTION_FAILED. None surface the body.How
handle_response_metadatanow dispatches on the CONNECT reply before the header loop and the status-code-driven state writes, so nothing from the CONNECT leg touchesself.state(ProxyTunneldoes not reset it between the CONNECT leg and the origin leg):ContinueStreaming(start the inner TLS handshake). RFC 9110 §9.3.6: "Any 2xx (Successful) response indicates that the sender ... will switch to tunnel mode immediately after the response header section." A proxy that sends201+ body is non-conforming; those bytes feed the TLS handshake and it fails, which is what curl does.Err(ProxyConnectFailed(status)). Surfaces to JS as{ code: "ProxyConnectFailed", message: "CONNECT tunnel failed, proxy responded with status <n>" }so 407/502 are still debuggable. The caller'sclose_and_failtears the socket down.Dispatching before the header loop also fixes a pre-existing leak where a proxy sending
Content-Encoding: gzipon a200CONNECT reply leftstate.encoding = Gzipinto the origin leg and fed the origin's identity body to the gzip decoder. The per-header Content-Length / Transfer-Encodingcontinueskips and theis_proxy_connect_failureredirect guard are removed as dead.The WebSocket client's
handle_proxy_responseis widened to the same200..300range (and the byte-prefix fast path dropped) sonew WebSocket("wss://...", { proxy })agrees withfetchon the same RFC point.Absolute-form proxying (
http://target through a proxy) is unchanged: there is no CONNECT and no origin TLS, so a proxy's 407/403 there remains a realResponse.bun installthrough a refusing proxy now reportsProxyConnectFailed downloading package manifest ...(and retries up tomax_retry_count, since it is now a connect-level error) instead ofGET <url> - 407. Skipping retry forProxyConnectFailed(s)withs < 500, and surfacing the status througherr.name(), are left for a follow-up rather than widening this PR into the install retry classifier.Verification
Before / after
New
describe("CONNECT response is never attributed to the https origin")intest/js/bun/http/proxy.test.tscovers 403/302/407/500 + the 201 spec-break case;proxy-stress-errors.test.tsadds 202/204/Content-Encoding: gzip/Transfer-Encoding: chunkedCONNECT-reply state-leak cases that tunnel to a real https origin. All fail on main and pass with this change. Existingproxy-stress-errors.test.ts/proxy-stress-lifecycle.test.tsassertions that codified the old contract are updated to expect rejection.[review] gate passed · iteration 3 · 7 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 4 passed · 0 rejected · iteration 3
evidence per changed file