Merging to release-5.15.0: [TT-17921] Improve header handling for GraphQL-based APIs (#8602) - #8643
Conversation
## Summary Values that belong to a single request — the round tripper, the resolved upstream headers, the upstream response — were being written into, or keyed on, state that outlives the request: the shared per-API `http.Client`, the plan cache, and the upstream connection-reuse key. Under concurrency the result was not deterministic: which request's values a fetch used depended on timing. This makes that state request-scoped, and fixes the header handling that depended on it. Seven commits, each independently revertable. `internal/graphengine` goes from 22 to 76 test functions (273 including subtests), green under `-race -count=10`. Behavioural detail and reproduction steps are on the ticket. ## What changed ### scope transports to requests `reverseProxyPreHandlerV1/V2.PreHandle` assigned `params.RoundTripper` onto the per-API `httpClient.Transport` on every request, so concurrent requests overwrote each other's. The round tripper and headers config now travel in the request context (`SetGraphQLEngineTransportContextValue`), and `GraphQLEngineTransport.RoundTrip` builds a per-request view from them, falling back to the client's own transport. ### keep subscriptions alive, isolate proxy-only responses Three defects, two of them introduced by the commit above: - **WebSocket subscriptions never delivered.** The handovers passed `WithContext(params.OutRequest.Context())`, but `net/http` calls `w.cancelCtx()` as soon as `ServeHTTP` returns — before the hijacked check in `conn.serve` — and the gateway returns as soon as the connection is hijacked. The subscription context was cancelled before anything was sent. `subscriptionRequestContext` roots the subscription at the long-lived engine spec context while carrying the opening request's transport values across. EngineV1 passes no context: its legacy data sources drop it, so inheriting the request's cancellation could only break the subscription. - **`handleProxyOnly` rewrote the outgoing method**, turning a proxy-only upstream WebSocket handshake into a POST, which WebSocket servers reject. Handshakes now stay `GET`. - **`GraphQLProxyOnlyContextValues.upstreamResponse` was unsynchronised.** The transport writes it on the resolver's trigger goroutine while the engine reads it (~1 in 20 under `-race`). Now behind a mutex. ### cover the request isolation matrix `credential_isolation_test.go`: proxy-only, UDG and supergraph × HTTP, SSE, `graphql-ws` and `graphql-transport-ws` × EngineV2 and EngineV3. Each test drives more than one request through one engine and asserts what each upstream connection actually received, with negative controls (`strip_auth_data`, a custom auth header name, a request that sets no header after one that did), 20-request concurrent bursts, a per-request round tripper test that shared mutable transport state cannot pass, and the client WebSocket-upgrade path that the regression above had slipped through. ### move to the fixed graphql-go-tools, cover the cold path Both module pins move to the library branch; `coder/websocket` becomes a direct dependency. Two races are fixed upstream — v1 `Schema.Hash` writing `s.hash` on first use, and v2 `ResolveGraphQLResponse` writing `Info` on the cached plan — so the cold-start concurrent burst becomes permanent coverage instead of something a warm-up had to work around. ### resolve dynamic upstream headers before the fetch is keyed The v1 header modifier merged Tyk's additional headers and stopped, leaving `tykVariableReplacer` injected but never read, so `$tyk_context.*` in an upstream header reached the fetch unresolved on config version 2. That matters for more than the value that arrives upstream: the library applies the modifier before it derives the key that groups equivalent subscriptions onto one upstream connection (`connectionKey` in the v1 subscription client, `UniqueRequestID` in the v2 resolver). An unresolved template is the same string for every request, so requests that should have been distinct were treated as equivalent and pooled together. Resolving there is only correct now that the library moved header modification to the execution phase. TT-14357 (`adca34d46`) had removed it because the modifier ran at plan post-process time and the result was cached into the plan, and added `variableReplaceRoundTripper` as the workaround. That wrapper is retired here: it ran after the grouping key was derived, it collapsed multi-value headers via `Get`/`Set` on the way to the wire, and it expanded variables in header values that did not come from the API definition. Its one remaining job, `request_headers_rewrite`, is resolved once where the rules are built, from configuration only. ### restore the gateway round tripper for EngineV1 fetches Regression from `cf506a971`, caught while verifying the above: `TestGraphQL_InternalDataSource/graphql_engine_v1` fails at the branch tip and passes at the base — a config version 1 API with a `tyk://` data source answers 500. The transport assignment that commit removed was also the only thing giving EngineV1 the gateway round tripper, and only that round tripper can route `tyk://`. Since EngineV1's data sources drop the execution context, the round tripper is handed over as a mutex-guarded transport-level fallback, used only when a request carries none of its own. EngineV2 and EngineV3 stay fully request-scoped. That fallback is the one piece of transport state shared between requests, and it is only sound because the round tripper the gateway hands over no longer carries request-specific state — the wrapper retired in `4d67f9af4` did. **The two commits must not be reverted independently**; both sides carry comments saying so. ### forward a consumer header to the upstream once Two writers put the consumer's headers on the upstream request and neither knew about the other: `propagateAuthHeaders` adds them to the fetch input when `strip_auth_data` is off, and `setProxyOnlyHeaders` then forwards every consumer header again with `Header.Add`. With `use_immutable_headers` off nothing removed the first copy, so a header present in both arrived duplicated. It went unnoticed because the wrapper retired in `4d67f9af4` had been collapsing repeated values with `Get`/`Set` since before the second writer was added. `setProxyOnlyHeaders` now skips a value the engine already placed there, compared against a snapshot taken before forwarding so a consumer that genuinely sent the same value twice keeps both. It also hoists the `use_immutable_headers` delete out of the value loop, where it removed the value added on the previous pass and left a multi-value consumer header holding only its last value. ## Behaviour changes worth a reviewer's sign-off 1. **An API reload or release now ends that API's subscriptions.** Previously they ran on `context.Background()` and outlived the reload. 2. **No header value is resolved twice** on config version 2 or 3-preview. Values that do not come from the API definition are no longer expanded, and a value that resolved into another template token no longer gets a second pass. 3. **An exact overlap between a configured `request_headers` value and a consumer value now reaches the upstream once**, not twice. Differing values still both go upstream — TT-11990 / TT-12190 semantics are unchanged. 4. **Multi-value headers survive to the upstream.** `Accept-Encoding: gzip, deflate, br` used to arrive as `gzip` alone. <!---TykTechnologies/jira-linter starts here--> ### Ticket Details <details> <summary> <a href="https://tyktech.atlassian.net/browse/TT-17921" title="TT-17921" target="_blank">TT-17921</a> </summary> | | | |---------|----| | Status | Merge | | Summary | Federated supergraph reuses first caller’s JWT across users and can share HTTP responses via single-flight | Generated at: 2026-08-20 13:03:45 </details> <!---TykTechnologies/jira-linter ends here--> (cherry picked from commit d55ab6b)
|
SentinelOne CNS Hardcoded Secret Detector SentinelOne CNS is a cloud-agnostic, agentless CSPM & CWPP solution that continuously detects and prevents vulnerabilities that have the highest probability of being exploited in Azure, AWS, Google Cloud, and Kubernetes. |
🚨 Jira Linter FailedCommit: The Jira linter failed to validate your PR. Please check the error details below: 🔍 Click to view error detailsNext Steps
This comment will be automatically deleted once the linter passes. |
🎯 Recommended Merge TargetsBased on JIRA ticket TT-17921: Federated supergraph reuses first caller’s JWT across users and can share HTTP responses via single-flight Fix Version: Tyk 5.15.0Required:
Recommended:
Fix Version: Tyk 5.13.2Required:
📋 Workflow
|
|
This pull request addresses a critical race condition in the GraphQL gateway where concurrent requests could inadvertently share state, leading to non-deterministic behavior and potential credential leakage. The fix refactors the handling of request-specific data, such as transport configurations and headers, to be scoped to individual requests via Files Changed Analysis
Architecture & Impact Assessment
graph TD
subgraph "before" ["Before: Mutable Shared State"]
SharedClient[Shared http.Client] --> SharedTransport("Transport")
R1[Request 1] -- modifies --> SharedTransport
R2[Request 2] -- modifies --> SharedTransport
SharedTransport --|"uses latest state (race condition)"|--> Upstream
end
subgraph "after" ["After: Request-Scoped State via Context"]
SharedClient_A[Shared http.Client] --> WrapperTransport("GraphQLEngineTransport")
subgraph "req1" ["Request 1"]
R1_A[http.Request] --> C1("Context w/ RT1, Cfg1")
end
subgraph "req2" ["Request 2"]
R2_A[http.Request] --> C2("Context w/ RT2, Cfg2")
end
R1_A -- passed to --> WrapperTransport
R2_A -- passed to --> WrapperTransport
WrapperTransport --|reads C1|--> Fetch1("Fetch for Request 1")
WrapperTransport --|reads C2|--> Fetch2("Fetch for Request 2")
Fetch1 --|uses RT1, Cfg1|--> Upstream_A[Upstream]
Fetch2 --|uses RT2, Cfg2|--> Upstream_A
end
Scope Discovery & Context ExpansionThe changes are well-contained within the GraphQL subsystem but are fundamental to its security and correctness. The investigation appears exhaustive, demonstrated by the comprehensive new test suite covering interactions between different features and engine versions under concurrent load. References
Metadata
Powered by Visor from Probelabs Last updated: 2026-08-24T08:21:08.903Z | Triggered by: pr_opened | Commit: b8cef30 💡 TIP: You can chat with Visor using |
✅ Security Check PassedNo security issues found – changes LGTM. ✅ Architecture Check PassedNo architecture issues found – changes LGTM. Performance Issues (1)
Powered by Visor from Probelabs Last updated: 2026-08-24T08:20:48.533Z | Triggered by: pr_opened | Commit: b8cef30 💡 TIP: You can chat with Visor using |
|



TT-17921 Improve header handling for GraphQL-based APIs (#8602)
Summary
Values that belong to a single request — the round tripper, the resolved
upstream headers, the
upstream response — were being written into, or keyed on, state that
outlives the request: the
shared per-API
http.Client, the plan cache, and the upstreamconnection-reuse key. Under
concurrency the result was not deterministic: which request's values a
fetch used depended on
timing.
This makes that state request-scoped, and fixes the header handling that
depended on it. Seven
commits, each independently revertable.
internal/graphenginegoes from 22 to 76 test functions (273 includingsubtests), green under
-race -count=10.Behavioural detail and reproduction steps are on the ticket.
What changed
scope transports to requests
reverseProxyPreHandlerV1/V2.PreHandleassignedparams.RoundTripperonto the per-API
httpClient.Transporton every request, so concurrent requestsoverwrote each other's. The round
tripper and headers config now travel in the request context
(
SetGraphQLEngineTransportContextValue), andGraphQLEngineTransport.RoundTripbuilds aper-request view from them, falling back to the client's own transport.
keep subscriptions alive, isolate proxy-only responses
Three defects, two of them introduced by the commit above:
WithContext(params.OutRequest.Context()), butnet/httpcallsw.cancelCtx()as soon asServeHTTPreturns — before the hijacked check inconn.serve— andthe gateway returns as soon
as the connection is hijacked. The subscription context was cancelled
before anything was sent.
subscriptionRequestContextroots the subscription at the long-livedengine spec context while
carrying the opening request's transport values across. EngineV1 passes
no context: its legacy
data sources drop it, so inheriting the request's cancellation could
only break the subscription.
handleProxyOnlyrewrote the outgoing method, turning aproxy-only upstream WebSocket
handshake into a POST, which WebSocket servers reject. Handshakes now
stay
GET.GraphQLProxyOnlyContextValues.upstreamResponsewasunsynchronised. The transport writes it
on the resolver's trigger goroutine while the engine reads it (~1 in 20
under
-race). Nowbehind a mutex.
cover the request isolation matrix
credential_isolation_test.go: proxy-only, UDG and supergraph × HTTP,SSE,
graphql-wsandgraphql-transport-ws× EngineV2 and EngineV3. Each test drives morethan one request through one
engine and asserts what each upstream connection actually received, with
negative controls
(
strip_auth_data, a custom auth header name, a request that sets noheader after one that did),
20-request concurrent bursts, a per-request round tripper test that
shared mutable transport state
cannot pass, and the client WebSocket-upgrade path that the regression
above had slipped through.
move to the fixed graphql-go-tools, cover the cold path
Both module pins move to the library branch;
coder/websocketbecomes adirect dependency. Two
races are fixed upstream — v1
Schema.Hashwritings.hashon firstuse, and v2
ResolveGraphQLResponsewritingInfoon the cached plan — so thecold-start concurrent burst
becomes permanent coverage instead of something a warm-up had to work
around.
resolve dynamic upstream headers before the fetch is keyed
The v1 header modifier merged Tyk's additional headers and stopped,
leaving
tykVariableReplacerinjected but never read, so
$tyk_context.*in an upstream headerreached the fetch unresolved on
config version 2. That matters for more than the value that arrives
upstream: the library applies
the modifier before it derives the key that groups equivalent
subscriptions onto one upstream
connection (
connectionKeyin the v1 subscription client,UniqueRequestIDin the v2 resolver).An unresolved template is the same string for every request, so requests
that should have been
distinct were treated as equivalent and pooled together.
Resolving there is only correct now that the library moved header
modification to the execution
phase. TT-14357 (
adca34d46) had removed it because the modifier ran atplan post-process time and
the result was cached into the plan, and added
variableReplaceRoundTripperas the workaround.That wrapper is retired here: it ran after the grouping key was derived,
it collapsed multi-value
headers via
Get/Seton the way to the wire, and it expandedvariables in header values that did
not come from the API definition. Its one remaining job,
request_headers_rewrite, is resolvedonce where the rules are built, from configuration only.
restore the gateway round tripper for EngineV1 fetches
Regression from
cf506a971, caught while verifying the above:TestGraphQL_InternalDataSource/graphql_engine_v1fails at the branchtip and passes at the base —
a config version 1 API with a
tyk://data source answers 500. Thetransport assignment that
commit removed was also the only thing giving EngineV1 the gateway round
tripper, and only that
round tripper can route
tyk://. Since EngineV1's data sources drop theexecution context, the
round tripper is handed over as a mutex-guarded transport-level
fallback, used only when a request
carries none of its own. EngineV2 and EngineV3 stay fully
request-scoped.
That fallback is the one piece of transport state shared between
requests, and it is only sound
because the round tripper the gateway hands over no longer carries
request-specific state — the
wrapper retired in
4d67f9af4did. The two commits must not bereverted independently; both
sides carry comments saying so.
forward a consumer header to the upstream once
Two writers put the consumer's headers on the upstream request and
neither knew about the other:
propagateAuthHeadersadds them to the fetch input whenstrip_auth_datais off, andsetProxyOnlyHeadersthen forwards every consumer header again withHeader.Add. Withuse_immutable_headersoff nothing removed the first copy, so a headerpresent in both arrived
duplicated. It went unnoticed because the wrapper retired in
4d67f9af4had been collapsing
repeated values with
Get/Setsince before the second writer wasadded.
setProxyOnlyHeadersnow skips a value the engine already placed there,compared against a
snapshot taken before forwarding so a consumer that genuinely sent the
same value twice keeps both.
It also hoists the
use_immutable_headersdelete out of the value loop,where it removed the value
added on the previous pass and left a multi-value consumer header
holding only its last value.
Behaviour changes worth a reviewer's sign-off
Previously they ran on
context.Background()and outlived the reload.3-preview. Values that do not come
from the API definition are no longer expanded, and a value that
resolved into another template
token no longer gets a second pass.
request_headersvalue and aconsumer value now reaches
the upstream once, not twice. Differing values still both go upstream
— TT-11990 / TT-12190
semantics are unchanged.
Accept-Encoding: gzip, deflate, brused toarrive as
gzipalone.Ticket Details
TT-17921
Generated at: 2026-08-20 13:03:45