Skip to content

KalshiConfig.extra_headers is mutable post-construction, bypassing the #298 auth-header guard #313

Closed

Description

@TexasCoding

Problem

#298 closed the auth-header forge surface at two boundaries: per-request extra_headers= (rejected in SyncTransport.request / AsyncTransport.request) and config construction (KalshiConfig.__post_init__). But @dataclass(frozen=True) only forbids attribute reassignment — the dict stored in extra_headers is still mutable. A caller that mutates the dict after construction (config.extra_headers["kalshi-access-key"] = "forged") re-introduces exactly the forge surface #298 set out to close, because neither the per-request guard nor __post_init__ re-runs on the mutated dict.

The forged lowercase kalshi-access-key survives _ci_merge (no case-twin in base_headers), and the final merged_headers = {**base_headers, **auth_headers} is case-sensitive — so kalshi-access-key (forged) and KALSHI-ACCESS-KEY (SDK-signed) ship as two distinct raw header lines on httpx 0.28.

Evidence

  • kalshi/config.py:71 — extra_headers stored as a plain dict
extra_headers: dict[str, str] = field(default_factory=dict)
  • kalshi/config.py:144-153 — validation runs only at construction
if self.extra_headers:
    leaked = sorted(
        k for k in self.extra_headers if k.lower().startswith(AUTH_HEADER_PREFIX)
    )
    if leaked:
        raise ValueError(...)
  • kalshi/_base_client.py:255-291 — request path reads self._config.extra_headers straight into _ci_merge, and only the per-call extra_headers kwarg is validated
_assert_no_auth_headers(extra_headers)
...
base_headers: dict[str, str] = _ci_merge(
    self._config.extra_headers, extra_headers, headers,
)
...
merged_headers = {**base_headers, **auth_headers}

Suggested fix

Freeze the dict at construction so external mutation is ineffective. Mirror the _closed/idempotent pattern: in __post_init__, after validation, replace self.extra_headers with types.MappingProxyType(dict(self.extra_headers)) (or a frozendict shim if one is preferred). Belt-and-suspenders: in SyncTransport.request / AsyncTransport.request, also call _assert_no_auth_headers(self._config.extra_headers) once at the top — cheap, and keeps the contract explicit.

Add a regression test that mutates config.extra_headers after construction and asserts the next request() either raises or refuses to ship the forged header.

Source

Round-3 independent audit (reviewer: codex+security_auth).

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