fix: keep the API key on the origin it was configured for - #85
Merged
Conversation
The async query client attaches the API key as an X-API-Key header. reqwest strips only the standard credential headers on a cross-origin redirect — Authorization, Cookie, cookie2, Proxy-Authorization and WWW-Authenticate — so a custom header rides along to whatever the Location names. A 307 or 308 also preserves the method and the body, so the whole credentialed POST would be replayed to the new origin. Both HTTP clients in the crate are now built from one helper that installs a same-origin redirect policy, so the policy cannot be set on one construction path and missed on another. Because Policy::custom does not inherit the default hop limit (reqwest's own docs note the closure must handle loops itself), the policy enforces the bound itself, compared the way Policy::limited compares it. Refs: spiceai/spiceai#12502
There was a problem hiding this comment.
Pull request overview
This PR hardens the Rust SDK’s async query HTTP client against credential leakage by enforcing a same-origin redirect policy, preventing X-API-Key from being replayed to a different origin on 307/308 redirects.
Changes:
- Added a shared
reqwest::ClientBuilderhelper configured with a same-origin redirect policy. - Applied that helper to both HTTP-client construction sites to ensure consistent redirect behavior.
- Added regression/invariant tests covering cross-origin and same-origin redirects, plus origin comparison edge cases (scheme/port).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/redirect_test.rs | New integration regression tests verifying API key is not sent off-origin and same-origin redirects still work. |
| src/redirect.rs | New redirect policy + shared client builder enforcing same-origin redirects with a hop limit. |
| src/query.rs | Updates QueryHttpClient::new to build from the shared credentialed client builder. |
| src/lib.rs | Wires in the new redirect module. |
| src/client.rs | Uses the shared credentialed client builder in SpiceClientBuilder::build HTTP client construction. |
…ting The repo's conventions treat clippy::expect_used and clippy::pedantic as errors, so QueryHttpClient::new returns a Result rather than expecting on the client build. Defaulting past that failure was never an option: a substituted default client would silently not carry the redirect policy. Also satisfies pedantic on the new code: is_none_or over a match on Option, and must_use on the builder helper.
Contributor
Author
|
Filed #86 for the convention-vs-CI gap this review surfaced: the four clippy lints |
… must_use Two clippy findings from code scanning on the previous commit: - QueryHttpClient::new is only ever called from test modules, so it read as dead code in a non-test build. Gating it behind cfg(test) says so, and leaves with_client as production's single construction path — which is what makes the redirect policy impossible to miss. - reqwest::ClientBuilder is already #[must_use], so the attribute on credentialed_client_builder was redundant (double_must_use).
phillipleblanc
approved these changes
Aug 5, 2026
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The async query client attaches the API key as an
X-API-Keyheader. On a redirect that leaves the origin,reqweststrips only the standard credential headers, so a custom header rides along to whatever theLocationnames — and a 307/308 preserves the method and the body, so the entire credentialed POST is replayed to the new origin.Verified against the pinned major rather than assumed.
reqwestv0.12.24,src/redirect.rs:X-API-Keyis not in that set, so nothing stripped it. This is the SDK half of spiceai/spiceai#12502: the CLI set a same-origin policy on its own client for spiceai/spiceai#12495, butspice queryandspice nsql analyzebuild this crate's client instead, and the policy cannot be set from the CLI — the SDK pinsreqwest0.12 while the workspace is on 0.13, so the twoClienttypes are not interchangeable andClientBuilderexposes no redirect setter. The crate that attaches the credential is the one that has to own the policy.Changes
src/redirect.rs—credentialed_client_builder(), areqwest::ClientBuildercarrying a same-origin redirect policy.SpiceClientBuilder::build(the production path) andQueryHttpClient::new. Both now come from the one helper, so the policy cannot be set on one path and missed on another.Design notes worth a reviewer's eye
The hop limit is enforced by hand, on purpose.
Policy::customdoes not inherit the default limit — reqwest's own docs say "The defaultPolicyhandles a maximum loop chain, but the custom variant does not do that for you automatically." Dropping it would have turned a same-origin redirect loop into an infinite one, so the policy applies the bound itself, compared the wayPolicy::limitedcompares it (previous()includes the originating URL, so the bound is exclusive).Origin means scheme, host and effective port — deliberately stricter than the
cross_hostcheck quoted above, which never looks at scheme. Anhttps://host:8443→http://host:8443downgrade keeps the host and port identical, so reqwest would not call it cross-host, but it is exactly where a credential must not go. There is a test for that specific case.It stops rather than errors. The callers in
query.rsalready report an unexpected status with its code, so a refused redirect surfaces asFailed to submit query (HTTP 307)instead of an opaque transport error.Secure by default, with no opt-out. This is a behaviour change: a deployment that today relies on the SDK following an off-origin redirect will now get a 307 back instead. That configuration is precisely the one leaking the key, so it fails closed rather than continuing to disclose the credential — and it matches the choice already made for the CLI client in spiceai/spiceai#12495.
Test plan
cargo fmt --all --check,cargo clippy --all-featuresandcargo test(what CI runs).tests/redirect_test.rs::test_cross_origin_redirect_does_not_carry_the_api_key— the regression test, driven through the public production path (ClientBuilder::build). Two mock servers: the runtime answers the credentialed POST with a 307 pointing at a second origin, which is mounted to accept it. Asserts the second origin received zero requests, and that the refused hop surfaces as HTTP 307. On the old code the redirect is followed and the off-origin server receives the POST withX-API-Key, so this fails without the fix.tests/redirect_test.rs::test_same_origin_redirect_is_still_followed— guards against over-correcting: a 307 to a different path on the same origin is still followed, the body is replayed, and the submit succeeds.src/redirect.rsunit tests — origin equality across implicit/explicit default ports, differing host, differing port, scheme downgrade on an identical port, and path/query/fragment being irrelevant.Notes
I could not build or run these locally (no Rust toolchain on the machine that wrote the patch), so CI is the first real execution — every external API used here was instead checked against the pinned sources:
reqwestv0.12.24 forAttempt/Policy/Action::Stopsemantics, andwiremockv0.6.5 forinsert_header,set_body_jsonandreceived_requests. Flagging it rather than implying a green local run. The adversarial review pass I would normally run on the diff could not execute either (the review engine's workspace is out of credits).Follow-up, not in this PR: once this lands and the
spiceairev is bumped in spiceai/spiceai,spice queryandspice nsql analyzeinherit the policy and spiceai/spiceai#12502 can close. Whether the CLI should build SDK clients per command at all — versus routing both through the shared context client — is the broader question that issue also raises, and is left open.Refs: spiceai/spiceai#12502