Repository navigation
fix(transport): reject KALSHI-ACCESS-* in extra_headers; case-insensitive merge; explicit Content-Type (#298) - #308
Conversation
…tive merge; explicit Content-Type on JSON body helpers
Two wire-level header bugs surfaced in the post-v2.5 audit:
* The per-request header merge in SyncTransport.request / AsyncTransport.request
used plain dict-unpacking, which is case-sensitive on keys. A caller-supplied
'kalshi-access-key' (lowercase) co-existed with the SDK-signed
'KALSHI-ACCESS-KEY' and httpx 0.28 shipped both raw header lines. RFC 7230
leaves multi-instance handling implementation-defined, so a tolerant
intermediary could observe the user-supplied value — a forge surface even
though the documented contract promises auth headers are SDK-managed.
* _post(json=...) / _put(json=...) (and async mirrors) relied on httpx's
implicit Content-Type inference. httpx only infers when no Content-Type is
present, so extra_headers={'content-type': 'text/plain'} shipped a JSON body
labelled text/plain. _post_json / _delete_with_body_json already pinned
Content-Type explicitly; the inferring siblings now match that pattern.
Fix:
* New module-level helpers in kalshi/_base_client.py:
- _AUTH_HEADER_PREFIX constant, _strip_auth_headers() raises ValueError
naming the leaked key when extra_headers contains any 'kalshi-access-*'
key (case-insensitive). Called at the top of both request methods,
before any merging.
- _ci_merge(): last-layer-wins, case-insensitive merge that drops the
earlier-case key when a later layer supplies the same lowercase form.
Replaces the {**a, **b, **c} construction for base_headers in both
SyncTransport.request and AsyncTransport.request.
* kalshi/resources/_base.py: _post, _put, async _post, async _put now pass
headers={'Content-Type': 'application/json'} alongside json=..., matching
the existing _post_json / _delete_with_body_json pattern. The per-attempt
{**base_headers, **auth_headers} merge stays a plain dict-unpack: auth
keys are case-canonical (always uppercase KALSHI-ACCESS-*) and base_headers
is already case-deduped, so no further normalization is needed.
Behavioral fence (note for v2.6 release notes): extra_headers keys starting
with 'kalshi-access-' (any case) now raise ValueError where they previously
silently leaked alongside the SDK-signed pair. Three previously-passing tests
(test_create_order_extra_headers_cannot_override_auth and siblings) and the
matching transport-level test in test_client.py asserted the silent-override
behaviour and have been rewritten to assert the new ValueError contract.
Tests:
* tests/test_extra_headers_plumbing.py — three new wire-assertion cases
under TestHeaderCollision298: lowercase 'kalshi-access-key' raises,
'content-type: text/plain' is overridden to 'application/json' on the
wire (with exactly one Content-Type line via respx route.calls.last.
request.headers.raw), and 'x-request-id' round-trips unchanged.
* tests/test_client.py — TestExtraHeadersPerRequest.test_extra_headers_
rejects_KALSHI_ACCESS_headers asserts the boundary raises for both the
uppercase and lowercase variants.
Verification: uv run mypy kalshi/ (clean), uv run ruff check . (clean),
uv run pytest tests/test_extra_headers_plumbing.py tests/test_client.py
tests/test_async_client.py -q (437 passed).
Closes #298.
Code Review — PR #308Good fix overall. The two bugs are real and the core implementation is sound. Three issues worth addressing before merge: Bug:
|
…-Type on _delete_with_body; rename helper; fix test indent Bot review on PR #308 flagged two real bugs plus style nits: - KalshiConfig(extra_headers={'kalshi-access-key': ...}) bypassed the per-request _strip_auth_headers guard, leaving a forge surface on the httpx.Client default headers. __post_init__ now rejects KALSHI-ACCESS-* keys in extra_headers at construction (inlined prefix check to avoid a circular import with kalshi._base_client). New tests in tests/test_config.py pin the uppercase, lowercase, KALSHI-ACCESS-SIGNATURE, and KALSHI-ACCESS-TIMESTAMP cases plus a non-auth sanity check. - Sync and async _delete_with_body in kalshi/resources/_base.py now pass headers={'Content-Type': 'application/json'} into the transport, matching the _post / _put / _delete_with_body_json pattern. tests/test_extra_headers_plumbing.py adds wire-assertion tests via batch_cancel_v2 (sync + async) that pin the Content-Type even when the caller passes extra_headers={'content-type': 'text/plain'}. - Renamed _strip_auth_headers -> _assert_no_auth_headers in kalshi/_base_client.py (3 sites: definition + sync transport + async transport). The helper raises; the new name matches the behaviour. - Fixed over-indented 'with (...)' bodies in tests/test_extra_headers_plumbing.py (four blocks; bodies were at 16 spaces instead of the standard 12).
|
Follow-up commit 80e4014 addresses bot review:
Verified: |
|
Code Review PR 308 TL;DR: Solid fix for a real security surface. The approach (early reject + case-insensitive merge + Content-Type pinning) is correct. A few small issues worth addressing before merge. Overview: ISSUE 1 - Misleading return type on _assert_no_auth_headers: The signature is -> dict[str, str] | None and it ends with return h, implying a transform. But both call sites ignore the return value and the function only ever raises or does nothing. The return type should be -> None. A future caller could mistakenly rely on the return being a filtered dict, and mypy would not catch it. ISSUE 2 - PR description names a function that does not exist: The Fix section says _strip_auth_headers(h) but the implementation is _assert_no_auth_headers(h). The code name is better; the description just needs a quick update before landing in history. ISSUE 3 - Duplicated kalshi-access- literal in config.py: The prefix is inlined rather than importing _AUTH_HEADER_PREFIX from _base_client.py. The comment explains the circular-import blocker, which is fair. A follow-up extracting the constant to a minimal kalshi/_constants.py (no imports from either file) would eliminate drift risk. ISSUE 4 - Dead mock registration in test_extra_headers_rejects_KALSHI_ACCESS_headers: The ValueError is raised before any request is sent, so the respx.get().mock() inside the test body is never triggered. The @respx.mock decorator should stay to sandbox real calls, but the inline mock registration is dead code and should be removed. ISSUE 5 - Async test uses assert_all_called=False on a route expected to be called: test_delete_with_body_async_pins_content_type sets assert_all_called=False but then accesses route.calls.last, implying it expects the route to be hit. If the request is ever silently skipped, route.calls.last raises AttributeError instead of a clean assertion failure. The sync mirror uses @respx.mock (strict default). Either drop the flag or set assert_all_called=True explicitly. Minor Notes: Post-construction mutation bypass: KalshiConfig.extra_headers is a plain dict; a caller can mutate it after init and bypass validation. Low-impact but worth a follow-up. _ci_merge algorithm traced through same-layer case conflicts and cross-layer last-wins scenarios - correct. Content-Type ordering in _base.py: the Content-Type pin is the rightmost non-auth layer in _ci_merge so it correctly wins over a caller-supplied content-type in extra_headers; the ct_lines assertion verifies exactly one header line on the wire. Summary: Correctness=sound, Security=hard-reject closes the forge surface, Test coverage=wire-level assertions for all three scenarios, Blocking issues=none, Recommended before merge=Issues 1, 4, 5. |
…TH_HEADER_PREFIX; clean up tests - Return type on _assert_no_auth_headers is now None (drops misleading dict return). - Extract AUTH_HEADER_PREFIX to kalshi/_constants.py (no imports, no circular hazard); import from both _base_client.py and config.py. - Drop dead respx.get().mock() registration in test_extra_headers_rejects_KALSHI_ACCESS_headers (ValueError fires before any request). - Drop assert_all_called=False on test_delete_with_body_async_pins_content_type to mirror the sync test (route is expected to be called).
|
Round-2 fixes pushed (957efb4):
Not addressed — post-construction mutation of Verify: |
Code Review — PR #308 (updated state)This is a review of the current diff. The previous bot comment flagged three issues (config bypass, missing OverviewTwo real wire-level bugs, both fixed cleanly:
What's correct
Remaining observations (non-blocking)1. If a single dict layer contains two keys that are case-variants of the same logical header (e.g. 2.
3.
4. Double blank line at Not a ruff violation (two blank lines between top-level defs is PEP 8 compliant), but there appear to be two consecutive blank lines after Test coverage
VerdictThe security fix is correct and complete. The Content-Type pin closes the stated gap. Tests are thorough and verify at the wire level. The observations above are minor and none block merge. Approves. |
|
Round-2 bot review: all 5 items addressed in 957efb4. Remaining 4 observations are explicitly non-blocking ("Approves"). The double blank line at _base_client.py:164-165 is the standard PEP 8 separation between top-level defs (ruff E303 is for >2 blanks, not for the 2 we have); confirmed locally. Merging on green. |
Closes #298.
What
Two wire-level header bugs surfaced in the post-v2.5 audit:
Sync/AsyncTransport.requestused plain dict-unpacking, which is case-sensitive on keys. A caller-suppliedkalshi-access-key(lowercase) co-existed with the SDK-signedKALSHI-ACCESS-KEYand httpx 0.28 shipped both raw header lines. RFC 7230 leaves multi-instance handling implementation-defined, so a tolerant intermediary could observe the user-supplied value — a forge surface even though the documented contract promises auth headers are SDK-managed._post(json=...)/_put(json=...)(and async mirrors) relied on httpx's implicit Content-Type inference. httpx only infers when no Content-Type is present, soextra_headers={'content-type': 'text/plain'}shipped a JSON body labelledtext/plain._post_json/_delete_with_body_jsonalready pinned Content-Type explicitly; the inferring siblings now match.Fix
kalshi/_base_client.py:_assert_no_auth_headers(h)— raisesValueErrornaming the leaked key whenextra_headerscontains any case-form ofkalshi-access-*._ci_merge(*layers)— last-layer-wins, case-insensitive on key.extra_headersthrough_assert_no_auth_headersand buildbase_headersvia_ci_merge. Auth merge is unchanged (auth keys are always canonical uppercase; base keys are now case-deduped)._post/_putand async mirrors now pinheaders={"Content-Type": "application/json"}alongsidejson=....Tests
Three new wire-assertion tests in
tests/test_extra_headers_plumbing.py:extra_headers={'kalshi-access-key': 'X'}→ValueErrornaming the leaked key.extra_headers={'content-type': 'text/plain'}on a POST with JSON body → wireContent-Typeisapplication/json(verified viarespxroute inspection).extra_headers={'x-request-id': 'abc'}→ round-trips unchanged on the wire.Four previously-passing tests asserted the silent-override behavior and were rewritten to assert the new
ValueErrorcontract. All 437 transport/header tests pass.Behavioral fence
extra_headerskeys starting withkalshi-access-(any case) now raiseValueErrorwhere they previously silently leaked. Worth a "Breaking" entry in the v2.6.0 release notes.Part of the post-v2.5 audit closure (Wave W1).