Skip to content

bug(transport): case-mismatched extra_headers keys leak alongside auth/body Content-Type on the wire #298

Closed

Description

@TexasCoding

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):

  1. 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.

  2. Reject (or strip) kalshi-access-* from user-supplied extra_headers at 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
  3. As a small companion, set Content-Type: application/json explicitly on the _post(json=...) / _put(json=...) body paths in kalshi/resources/_base.py so 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'} raises ValueError.
  • Posting with extra_headers={'content-type': 'text/plain'} ships application/json on the wire (assert via respx route 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecuritySecurity-related concern

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions