Summary
DefaultRedirectStep coordinates with the AUTH pillar through a private sentinel header,
X-Dexpace-Internal-Cross-Origin-Redirect: 1 (CrossOriginRedirectMarker.MARKER_HEADER,
CrossOriginRedirectMarker.kt:37). When it follows a cross-origin redirect it stamps this
marker on the re-issued request (DefaultRedirectStep.kt:285, inside stripCredentialHeaders,
after clearing any inbound copy at :281). The contract is that the auth step recognises the
marker, skips credential stamping, and strips the marker before the request continues
downstream, so it never reaches the transport.
The only two places that strip the marker are the shipped auth steps: AuthStep.process
(AuthStep.kt:80 detects, :82 strips) and AsyncAuthStep.processAsync (AsyncAuthStep.kt:53
detects, :57 strips). No other component removes it.
But the shipped standard preset installs no auth step. HttpPipeline.standard(transport)
(HttpPipeline.kt:118-120) delegates to HttpPipelineBuilder.appendStandardResilience()
(HttpPipelineBuilder.kt:160-169), which installs exactly DefaultRedirectStep, DefaultRetryStep,
and DefaultInstrumentationStep — the REDIRECT, RETRY, and LOGGING pillars, and nothing at AUTH.
So on a followed cross-origin redirect through a pipeline that has no auth step (the standard preset,
or any hand-built pipeline that authenticates by some other means), nothing strips the marker. The
re-issued request carries it into the terminal dispatch — PipelineNext.process() (PipelineNext.kt:40,
state.pipeline.httpClient.execute(...)) — and onto the wire, addressed to the server-chosen foreign
host that the redirect targeted.
Impact
This is a platform-wide defect in a core primitive. HttpPipeline.standard(...) is the turnkey
pipeline the SDK advertises for "give me a sensible pipeline over my transport," so every consumer
and every generated client that uses the standard preset — or any pipeline that follows redirects
without an AUTH pillar (unauthenticated public APIs, clients that authenticate via a query
parameter, mTLS, or a custom header set outside the auth pillar) — leaks an SDK-internal header to
an arbitrary, redirect-target-controlled origin. The header advertises the SDK vendor
(X-Dexpace-Internal-...) and exposes internal pipeline mechanics to a third party the caller
never intended to talk to. Because the leak lives in sdk-core's redirect primitive, no consumer
can work around it: they do not own the terminal dispatch boundary and cannot interpose a strip
step at Stage.SEND, which Stage.kt:59 documents as terminal: HttpClient.execute (not a user-step slot).
The reachable leak today is the synchronous path — that is where a redirect step exists and stamps
the marker. The async pipeline ships no redirect step and follows no redirects (REDIR-25), and
CrossOriginRedirectMarker is internal (CrossOriginRedirectMarker.kt:31), so no shipped or
consumer code sets the marker on the async side. The async terminal boundary should still enforce
the same strip, for symmetry and to keep the invariant structural rather than dependent on which
steps happen to be installed.
Where
- Marker set on a cross-origin re-issue:
sdk-core/.../http/pipeline/steps/DefaultRedirectStep.kt:285
(inbound copy cleared at :281), in stripCredentialHeaders (:273-288).
- Marker definition:
sdk-core/.../http/pipeline/steps/CrossOriginRedirectMarker.kt:37
(internal object, :31); value "1" at :40.
- Only strip sites:
sdk-core/.../http/pipeline/steps/AuthStep.kt:80,82 and
sdk-core/.../http/pipeline/steps/AsyncAuthStep.kt:53,57.
- Standard preset composition (no auth step):
sdk-core/.../http/pipeline/HttpPipelineBuilder.kt:160-169
(appendStandardResilience()), reached via sdk-core/.../http/pipeline/HttpPipeline.kt:118-120
(standard(transport)).
- Sync terminal dispatch:
sdk-core/.../http/pipeline/PipelineNext.kt:40
(state.pipeline.httpClient.execute(state.request, state.options)).
- Async terminal dispatch:
sdk-core/.../http/pipeline/AsyncPipelineNext.kt:47
(state.pipeline.httpClient.executeAsync(state.request, state.options)).
- Stale guarantees in KDoc that this bug contradicts:
CrossOriginRedirectMarker.kt:26-27 ("strips the marker before the request continues downstream
— so it never reaches the wire") and :34-35 ("the unconditional strip in AuthStep.process
guarantee it never escapes to a transport"). Both hold only when an auth step is present.
Expected vs. actual
Expected: the internal marker never reaches a transport, for any pipeline configuration. The
X-Dexpace-Internal- prefix exists precisely so this header is recognisable as SDK-internal and
guaranteed not to escape.
Actual: with the standard preset (or any redirect-following pipeline that lacks an AUTH pillar), a
cross-origin redirect carries X-Dexpace-Internal-Cross-Origin-Redirect: 1 into
HttpClient.execute(...) and onto the wire, sent to the foreign host chosen by the redirect
Location.
Proposed fix
Strip any header matching the X-Dexpace-Internal- prefix at the pipeline terminal SEND boundary,
independent of whether an auth step ran, in both dispatch sites:
PipelineNext.process() — before httpClient.execute(...) at PipelineNext.kt:40.
AsyncPipelineNext.processAsync() — before httpClient.executeAsync(...) at AsyncPipelineNext.kt:47.
Stripping by the X-Dexpace-Internal- prefix (rather than by the single known marker name) keeps
the boundary a general invariant: any future internal coordination header is contained the same
way. This makes the guarantee structural — enforced once at the only point every request must pass
through — rather than an obligation delegated to whichever optional step happens to be installed.
The fix stays entirely within sdk-core and adds no dependency.
Update the now-inaccurate KDoc in CrossOriginRedirectMarker.kt (:26-27, :34-35) and AuthStep.kt
(:52), and the mirrored async KDoc in AsyncAuthStep.kt (:31-32), so the "never reaches the wire"
guarantee is attributed to the terminal boundary rather than to an auth step being present.
Acceptance criteria
- A pipeline built via
HttpPipeline.standard(transport) that follows a cross-origin redirect
dispatches a terminal request carrying no X-Dexpace-Internal-* header (assert on the request
observed by the transport, e.g. via mockwebserver3 recorded requests).
- The same holds for a hand-built sync pipeline that has a REDIRECT step but no AUTH pillar.
- A sync pipeline that does install an AUTH step is unchanged: the marker is still stripped and the
caller's credential is not leaked cross-origin (no regression to REDIR-7/8/9, REDIR-11, AUTH-29).
- The async terminal boundary (
AsyncPipelineNext.processAsync()) strips any X-Dexpace-Internal-*
header before executeAsync(...).
- The strip matches the
X-Dexpace-Internal- prefix case-insensitively (not just the single marker
name), so any internal coordination header is removed at the boundary.
- KDoc in
CrossOriginRedirectMarker, AuthStep, and AsyncAuthStep attributes the "never reaches
the wire" guarantee to the terminal boundary.
References
- REDIR-11 (MUST) — the cross-origin suppression signal "MUST … (c) be removed by the
credential-attaching layer before dispatch." Its own reference/porter caveat already records this
exact gap: "only the auth step strips the marker, so a pipeline with no auth step (including the
sync standard-resilience preset) forwards the internal marker to the transport; a robust port
should strip the signal independently of whether a credential layer runs." The proposed fix is
exactly what that caveat prescribes.
- REDIR-24 (MUST) — the redirect follower wraps the auth layer per hop, which is what
necessitates the out-of-band suppression signal in the first place.
- AUTH-29 (MUST) — the auth step must strip the marker so it never reaches the wire; this is the
property that silently lapses when no auth step is present.
- REDIR-7 / REDIR-8 / REDIR-9 — the cross-origin credential-stripping guarantees the marker
mechanism exists to protect; leaking the marker undermines the "nothing internal crosses to a
foreign origin" posture those requirements establish.
- REDIR-25 (MUST) — the async pipeline follows no redirects and ships no async redirect step;
context for why the async marker set is not reachable today and why the async terminal strip is
defensive symmetry rather than a fix for a live leak.
Summary
DefaultRedirectStepcoordinates with the AUTH pillar through a private sentinel header,X-Dexpace-Internal-Cross-Origin-Redirect: 1(CrossOriginRedirectMarker.MARKER_HEADER,CrossOriginRedirectMarker.kt:37). When it follows a cross-origin redirect it stamps thismarker on the re-issued request (
DefaultRedirectStep.kt:285, insidestripCredentialHeaders,after clearing any inbound copy at
:281). The contract is that the auth step recognises themarker, skips credential stamping, and strips the marker before the request continues
downstream, so it never reaches the transport.
The only two places that strip the marker are the shipped auth steps:
AuthStep.process(
AuthStep.kt:80detects,:82strips) andAsyncAuthStep.processAsync(AsyncAuthStep.kt:53detects,
:57strips). No other component removes it.But the shipped standard preset installs no auth step.
HttpPipeline.standard(transport)(
HttpPipeline.kt:118-120) delegates toHttpPipelineBuilder.appendStandardResilience()(
HttpPipelineBuilder.kt:160-169), which installs exactlyDefaultRedirectStep,DefaultRetryStep,and
DefaultInstrumentationStep— the REDIRECT, RETRY, and LOGGING pillars, and nothing at AUTH.So on a followed cross-origin redirect through a pipeline that has no auth step (the standard preset,
or any hand-built pipeline that authenticates by some other means), nothing strips the marker. The
re-issued request carries it into the terminal dispatch —
PipelineNext.process()(PipelineNext.kt:40,state.pipeline.httpClient.execute(...)) — and onto the wire, addressed to the server-chosen foreignhost that the redirect targeted.
Impact
This is a platform-wide defect in a core primitive.
HttpPipeline.standard(...)is the turnkeypipeline the SDK advertises for "give me a sensible pipeline over my transport," so every consumer
and every generated client that uses the standard preset — or any pipeline that follows redirects
without an AUTH pillar (unauthenticated public APIs, clients that authenticate via a query
parameter, mTLS, or a custom header set outside the auth pillar) — leaks an SDK-internal header to
an arbitrary, redirect-target-controlled origin. The header advertises the SDK vendor
(
X-Dexpace-Internal-...) and exposes internal pipeline mechanics to a third party the callernever intended to talk to. Because the leak lives in
sdk-core's redirect primitive, no consumercan work around it: they do not own the terminal dispatch boundary and cannot interpose a strip
step at
Stage.SEND, whichStage.kt:59documents asterminal: HttpClient.execute (not a user-step slot).The reachable leak today is the synchronous path — that is where a redirect step exists and stamps
the marker. The async pipeline ships no redirect step and follows no redirects (REDIR-25), and
CrossOriginRedirectMarkerisinternal(CrossOriginRedirectMarker.kt:31), so no shipped orconsumer code sets the marker on the async side. The async terminal boundary should still enforce
the same strip, for symmetry and to keep the invariant structural rather than dependent on which
steps happen to be installed.
Where
sdk-core/.../http/pipeline/steps/DefaultRedirectStep.kt:285(inbound copy cleared at
:281), instripCredentialHeaders(:273-288).sdk-core/.../http/pipeline/steps/CrossOriginRedirectMarker.kt:37(
internal object,:31); value"1"at:40.sdk-core/.../http/pipeline/steps/AuthStep.kt:80,82andsdk-core/.../http/pipeline/steps/AsyncAuthStep.kt:53,57.sdk-core/.../http/pipeline/HttpPipelineBuilder.kt:160-169(
appendStandardResilience()), reached viasdk-core/.../http/pipeline/HttpPipeline.kt:118-120(
standard(transport)).sdk-core/.../http/pipeline/PipelineNext.kt:40(
state.pipeline.httpClient.execute(state.request, state.options)).sdk-core/.../http/pipeline/AsyncPipelineNext.kt:47(
state.pipeline.httpClient.executeAsync(state.request, state.options)).CrossOriginRedirectMarker.kt:26-27("strips the marker before the request continues downstream— so it never reaches the wire") and
:34-35("the unconditional strip inAuthStep.processguarantee it never escapes to a transport"). Both hold only when an auth step is present.
Expected vs. actual
Expected: the internal marker never reaches a transport, for any pipeline configuration. The
X-Dexpace-Internal-prefix exists precisely so this header is recognisable as SDK-internal andguaranteed not to escape.
Actual: with the standard preset (or any redirect-following pipeline that lacks an AUTH pillar), a
cross-origin redirect carries
X-Dexpace-Internal-Cross-Origin-Redirect: 1intoHttpClient.execute(...)and onto the wire, sent to the foreign host chosen by the redirectLocation.Proposed fix
Strip any header matching the
X-Dexpace-Internal-prefix at the pipeline terminal SEND boundary,independent of whether an auth step ran, in both dispatch sites:
PipelineNext.process()— beforehttpClient.execute(...)atPipelineNext.kt:40.AsyncPipelineNext.processAsync()— beforehttpClient.executeAsync(...)atAsyncPipelineNext.kt:47.Stripping by the
X-Dexpace-Internal-prefix (rather than by the single known marker name) keepsthe boundary a general invariant: any future internal coordination header is contained the same
way. This makes the guarantee structural — enforced once at the only point every request must pass
through — rather than an obligation delegated to whichever optional step happens to be installed.
The fix stays entirely within
sdk-coreand adds no dependency.Update the now-inaccurate KDoc in
CrossOriginRedirectMarker.kt(:26-27,:34-35) andAuthStep.kt(
:52), and the mirrored async KDoc inAsyncAuthStep.kt(:31-32), so the "never reaches the wire"guarantee is attributed to the terminal boundary rather than to an auth step being present.
Acceptance criteria
HttpPipeline.standard(transport)that follows a cross-origin redirectdispatches a terminal request carrying no
X-Dexpace-Internal-*header (assert on the requestobserved by the transport, e.g. via
mockwebserver3recorded requests).caller's credential is not leaked cross-origin (no regression to REDIR-7/8/9, REDIR-11, AUTH-29).
AsyncPipelineNext.processAsync()) strips anyX-Dexpace-Internal-*header before
executeAsync(...).X-Dexpace-Internal-prefix case-insensitively (not just the single markername), so any internal coordination header is removed at the boundary.
CrossOriginRedirectMarker,AuthStep, andAsyncAuthStepattributes the "never reachesthe wire" guarantee to the terminal boundary.
References
credential-attaching layer before dispatch." Its own reference/porter caveat already records this
exact gap: "only the auth step strips the marker, so a pipeline with no auth step (including the
sync standard-resilience preset) forwards the internal marker to the transport; a robust port
should strip the signal independently of whether a credential layer runs." The proposed fix is
exactly what that caveat prescribes.
necessitates the out-of-band suppression signal in the first place.
property that silently lapses when no auth step is present.
mechanism exists to protect; leaking the marker undermines the "nothing internal crosses to a
foreign origin" posture those requirements establish.
context for why the async marker set is not reachable today and why the async terminal strip is
defensive symmetry rather than a fix for a live leak.