fix(security): KalshiConfig.extra_headers frozen post-construction + single header pipeline#361
Conversation
…ingle header pipeline #313: KalshiConfig.extra_headers was a plain dict on a frozen dataclass. The #298 auth-header fence only ran at construction, so post-construction mutation (config.extra_headers['kalshi-access-key'] = 'forged') re-opened the duplicate-auth-header forge surface that #298 had closed. Wrap the field in types.MappingProxyType over a defensive copy in __post_init__ so the stored mapping rejects mutation and is decoupled from the caller's original dict. #341: SyncTransport and AsyncTransport were attaching config.extra_headers to httpx.Client AND re-merging them into every request via _ci_merge. The wire output was correct but the documented precedence (config < per-call < signed) routed through two pipelines, and httpx walked the default headers on every request. Drop the httpx.Client attachment so _ci_merge is the single source of truth and the precedence contract is authoritative. Type annotation widened to Mapping[str, str]; _ci_merge accepts Mapping layers. New regression tests at config level and wire level cover both issues; the prior mutate-bleed default-factory test is reframed to assert per-instance isolation under the freeze. Closes #313, #341
Code Review — PR #361:
|
…eaders Mapping accept
|
Round-2 review addressed in 028c1f7:
|
Code Review — PR #361OverviewThis PR closes two related security/correctness issues in the header pipeline: it freezes ✅ What's done well
Issues1. Multi-line comment blocks violate project style —
|
… for single header pipeline
|
Round-3 review addressed in 00f8422: trimmed multi-line comment blocks to single lines per CLAUDE.md; added async wire-level test mirroring the sync single-pipeline check. Deferring: cross-instance isolation explicit regression test (low-priority); MappingProxyType field annotation precision (cosmetic, current Mapping[str,str] is defensible). |
Code Review — PR #361 (Round 4 / Final)Three rounds of feedback have been addressed. This is a final pass to confirm the round-3 changes are correct and flag whether anything remains blocking. Round-3 fixes verified
Remaining deferred items — appropriate to defer
No new findingsThe core fix is sound:
LGTM. No blocking issues. Ready to merge. 🤖 Generated with Claude Code |
Summary
Freeze
KalshiConfig.extra_headersat construction so it cannot be mutated after the#298auth-header guard has run, and drop the duplicate header pipeline so_ci_mergeis the single source of truth for per-request header layering. Together these close a forge surface that survived the#298fix and remove the implementation lie in the documented header-precedence contract.Issues closed
KalshiConfig.extra_headersis mutable post-construction, bypassing the#298auth-header guardconfig.extra_headersattached tohttpx.ClientAND re-merged per request#298 lineage
#298closed the auth-header forge surface at two boundaries: per-requestextra_headers=(rejected inSyncTransport.request/AsyncTransport.request) and config construction (KalshiConfig.__post_init__rejectsKALSHI-ACCESS-*keys). But@dataclass(frozen=True)only forbids attribute reassignment — the dict stored inextra_headerswas still mutable, soconfig.extra_headers["kalshi-access-key"] = "forged"after construction re-opened exactly the surface#298had closed. The forged lowercase key would survive_ci_merge(no case-twin inbase_headersat that point) and the final{**base_headers, **auth_headers}is case-sensitive, so on httpx 0.28 it would have shipped as two distinct raw header lines.Behavioral changes
KalshiConfig.extra_headersis now exposed as atypes.MappingProxyTypeover a defensive copy. Read access is unchanged; attempting to mutate it raisesTypeError, and mutating the dict the caller originally passed in no longer affects the config. Type annotation widened fromdict[str, str]toMapping[str, str].httpx.Client(headers=config.extra_headers, ...)is no longer set in eitherSyncTransportorAsyncTransport. The same headers are still applied to every request —_ci_merge(self._config.extra_headers, extra_headers, headers)was already merging them — but the duplicate httpx-side pipeline is gone, so the documented precedence (config defaults < per-call extras < signed auth) is now authoritative.Tests
tests/test_config.pytest_issue_313_extra_headers_immutable_post_construction— frozen view rejects__setitem__/__delitem__, and mutating the original passed-in dict after construction is a no-op on the config.test_extra_headers_defaults_to_empty_mapping— replaces the prior mutate-bleed test (which mutated the dict directly, now aTypeError) with a per-instance isolation assertion that survives the freeze.tests/test_extra_headers_plumbing.pytest_issue_313_extra_headers_post_mutation_does_not_send_auth_forge— wire-level: post-construction mutation of a forgedkalshi-access-keyraises and never reaches the wire; exactly one signedKALSHI-ACCESS-KEYline ships and its value is the SDK signature, not the forged value.test_issue_341_extra_headers_single_pipeline— wire-level: per-callx-trace-idoverrides the config-defaultX-Trace-Idcase-insensitively with exactly one raw header line, and config-only headers still reach the wire when no per-call override is present.Full
tests/test_config.py tests/test_extra_headers_plumbing.py tests/test_base_helpers.py tests/test_client.py tests/test_async_client.py: 524 passed.Source
Round-3 independent audit closure plan, wave
W1.