Repository navigation
Description
Summary
The per-request header merge in _base_client.py:230-238 (sync) and :382-388 (async) treats header names case-sensitively because it uses a plain Python dict. When a caller-supplied extra_headers entry differs in case from an SDK-managed header, httpx receives both entries and emits both on the wire as separate raw header lines. This affects two security/correctness invariants the SDK currently advertises:
Case A — KALSHI-ACCESS-* forge surface
docs/configuration.md:86-89 and kalshi/_base_client.py:209-210 promise that auth headers cannot be forged via extra_headers. The merge logic enforces this only when the caller's key matches the SDK's case exactly. With a case-mismatched key, both go out.
Case B — Content-Type flap on _post(json=...) / _put(json=...)
_post_json / _delete_with_body_json explicitly set Content-Type: application/json (kalshi/resources/_base.py:206). The matching _post / _put JSON helpers (:178-185, :212-226) rely on httpx's implicit Content-Type inference from json=. httpx only infers when no Content-Type header is present — a caller-supplied extra_headers={'content-type': 'text/plain'} suppresses the inference and the server reads text/plain even though the body is JSON.
Reproducers (httpx 0.28.1)
import httpx
# Case A: two KALSHI-ACCESS-KEY lines on the wire
user_extra = {'kalshi-access-key': 'FORGED'}
auth = {'KALSHI-ACCESS-KEY': 'REAL'}
base = {**user_extra}
merged = {**base, **auth}
req = httpx.Request('POST', 'https://x/', headers=merged)
for k, v in req.headers.raw:
print(k, v)
# b'kalshi-access-key' b'FORGED'
# b'KALSHI-ACCESS-KEY' b'REAL'# Case B: JSON body shipped as text/plain
user_extra = {'content-type': 'text/plain'}
auth = {'KALSHI-ACCESS-KEY': 'k'}
merged = {**{**user_extra}, **auth}
req = httpx.Request('POST', 'https://x/', json={'foo': 'bar'}, headers=merged)
for k, v in req.headers.raw:
print(k, v)
# b'content-type' b'text/plain' ← user-supplied, sent as-is
# b'KALSHI-ACCESS-KEY' b'k'
# b'Content-Length' b'13'
# (no application/json — httpx skipped inference because user set content-type)
print(req.content) # b'{"foo":"bar"}'RFC 7230 §3.2.2 leaves multi-instance header handling implementation-defined; a tolerant intermediary or backend may use the first occurrence (the user-supplied one). Even if the Kalshi backend rejects the request, an attacker who can influence extra_headers has a confusion surface that should not exist.
Suggested fix
Two-part fix in _base_client.py request methods (sync + async):
-
Case-fold the base-header merge so a case-mismatched user key is overwritten cleanly by the SDK-managed key, instead of co-existing. Either case-normalize keys before merging:
def _ci_merge(*layers: dict[str, str] | None) -> dict[str, str]: # Last layer wins, case-insensitive on key. out: dict[str, str] = {} lower_to_canonical: dict[str, str] = {} for layer in layers: if not layer: continue for k, v in layer.items(): lk = k.lower() canonical = lower_to_canonical.get(lk) if canonical is not None: out.pop(canonical, None) lower_to_canonical[lk] = k out[k] = v return out
then use
_ci_merge(self._config.extra_headers, extra_headers, headers)instead of dict-unpacking. -
Reject (or strip)
kalshi-access-*from user-suppliedextra_headersat the public API boundary so the contract is enforced explicitly rather than relying on merge order:_AUTH_HEADER_PREFIX = "kalshi-access-" def _strip_auth_headers(h: dict[str, str] | None) -> dict[str, str] | None: if not h: return h leaked = [k for k in h if k.lower().startswith(_AUTH_HEADER_PREFIX)] if leaked: raise ValueError( f"extra_headers must not include KALSHI-ACCESS-* keys " f"(got: {sorted(leaked)!r}). These are reserved for " f"SDK-managed RSA-PSS signing and cannot be overridden." ) return h
-
As a small companion, set
Content-Type: application/jsonexplicitly on the_post(json=...)/_put(json=...)body paths inkalshi/resources/_base.pyso the body-type contract doesn't depend on httpx's inference heuristic:self._transport.request( "POST", path, params=params, json=json, headers={"Content-Type": "application/json"}, # ← add extra_headers=extra_headers, )
Tests
Add to tests/test_extra_headers_plumbing.py:
- Posting with
extra_headers={'kalshi-access-key': 'X'}raisesValueError. - Posting with
extra_headers={'content-type': 'text/plain'}shipsapplication/jsonon the wire (assert viarespxroute inspection). - Posting with
extra_headers={'x-request-id': 'abc'}round-trips unchanged.
Discovered by
Post-v2.5 multi-reviewer audit (HTTP transport + Security surfaces). Empirically verified against httpx 0.28.1.
Activity
release: 2.6.0 — post-v2.5 independent reviewer SDK audit closure (#295-
fix(security): KalshiConfig.extra_headers immutable post-construction…