Skip to content

feat(auth+config): defense-in-depth (#249, #266, #261, #250) - #287

Merged
TexasCoding merged 4 commits into
mainfrom
feat/249-250-266-261-auth-config
May 21, 2026
Merged

TexasCoding merged 4 commits into
mainfrom
feat/249-250-266-261-auth-config

Conversation

@TexasCoding

Copy link
Copy Markdown
Owner

Closes #249
Closes #266
Closes #261
Closes #250

Part of #273 (post-v2.4 audit), Wave 2.

…249)

The sync/async constructors silently preferred private_key_path over
private_key when both were passed; KalshiAuth.from_env / try_from_env
similarly preferred the inline PEM when both KALSHI_PRIVATE_KEY and
KALSHI_PRIVATE_KEY_PATH were exported. Either path made 401/403 incidents
look like a Kalshi-side credential issue when the SDK had really picked
the wrong key under a precedence rule the caller never asked for.

* KalshiClient.__init__ / AsyncKalshiClient.__init__: raise ValueError
  when both private_key_path and private_key are non-None.
* KalshiAuth.from_env / try_from_env: raise KalshiAuthError naming both
  env vars when both are populated to non-empty values.
* Regression tests in tests/test_client.py, tests/test_async_client.py,
  and tests/test_auth.py (replacing the precedence-pinning tests, which
  are now actively misleading).
* tests/conftest.py: strip the KALSHI_* env vars at import so tests stay
  hermetic against the user's shell environment.

Closes #249
KalshiClient.from_env / AsyncKalshiClient.from_env used `**kwargs: object`,
which leaked into the public, py.typed surface: mypy --strict would accept
`from_env(time_out=10)` silently and IDEs gave no signature help. The
`# type: ignore[arg-type]` on the internal cls(...) call was the smell.

* Add ClientInitKwargs TypedDict (total=False) in kalshi/client.py and
  kalshi/async_client.py mirroring __init__'s keyword surface. The async
  variant uses httpx.AsyncBaseTransport for the transport slot.
* Switch from_env to `**kwargs: Unpack[ClientInitKwargs]` and drop the
  type: ignore.
* Use kwargs.setdefault for the env-derived auth/demo/base_url so the
  forwarder can pass through any caller-supplied override without a
  duplicate-keyword TypeError (the typed signature now permits it).
* Runtime smoke test in tests/test_client.py + tests/test_async_client.py
  that timeout/max_retries propagate via the typed kwargs path.

Static catch of typos like `from_env(time_out=10)` is enforced via mypy
strict in CI.

Closes #266
…o '%' (#261)

Every signed REST request ran a regex scan over the path even though
the vast majority (e.g. /trade-api/v2/markets, /trade-api/v2/portfolio/...)
contain no percent-encoded octets. The regex compile is cached by re,
but the scan itself is still O(len(path)) bytes through the engine per
sign — pure waste in the hot path.

Adds a single `if '%' not in path: return path` guard; the regex only
runs when there's actually a percent-encoded octet to canonicalize.
The short-circuit preserves identity (returns the exact input object),
not just equality.

Regression tests:
* TestNormalizePercentEncoding.test_no_percent_returns_input_identity
  pins the identity contract (the function must not allocate when
  there's nothing to do).
* Roundtrip tests for ABC%2f -> ABC%2F and ABC%2F -> ABC%2F (existing
  parametrize coverage still passes; new unit tests assert the helper
  directly so a refactor that drops the short-circuit gets caught).

Closes #261
…in (#250)

A typo or hostile env value such as
`KALSHI_API_BASE_URL=https://api.elections.kalsi.com/trade-api/v2`
passed path-component validation (/trade-api/v2) and was only flagged
with a single logger.warning. In production stacks where stdlib warnings
are filtered, dropped, or merely indexed, every signed request — including
the KALSHI-ACCESS-KEY header and the matching RSA-PSS signature — kept
flowing to the attacker.

* KalshiConfig: add `allow_unknown_host: bool = False` field plus a
  process-wide `KALSHI_ALLOW_UNKNOWN_HOST=1` escape hatch read in
  __post_init__. The flag is forwarded into _validate_url which now
  raises ValueError by default on hosts outside _KNOWN_HOSTS ∪
  _LOCAL_HOSTS. When the opt-in is set the existing warning is still
  emitted so the choice stays visible in logs.
* Env var match is exact-literal "1" — a stale
  KALSHI_ALLOW_UNKNOWN_HOST=0 from a previous shell can't silently
  re-enable the bypass.
* tests/test_config.py::TestUnknownHostDefaultFail covers: known host
  OK, unknown host raises with discoverable error text, per-instance
  flag opt-in, env-var opt-in, exact-1 enforcement, localhost still
  allowed without opt-in, unknown ws_base_url also raises.
* tests/conftest.py: strip the KALSHI_* env vars at import (hermetic
  against the user's shell) and enable
  KALSHI_ALLOW_UNKNOWN_HOST=1 for the test suite so the existing
  ~25 files that build KalshiConfig against "https://test.kalshi.com"
  keep working. The new default-fail tests delenv it explicitly.
* docs/environment-variables.md: document
  KALSHI_ALLOW_UNKNOWN_HOST and the #249 precedence change.

Closes #250
@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

Code Review — PR #287: feat(auth+config): defense-in-depth

Good focused security hardening wave. Four independent issues, each with clear motivation, tight implementation, and matching tests. Some issues to flag below.


Overview

Issue Change Assessment
#249 Reject both KALSHI_PRIVATE_KEY + KALSHI_PRIVATE_KEY_PATH set simultaneously ✅ Correct
#266 from_env(**kwargs: Unpack[ClientInitKwargs]) instead of **kwargs: object ✅ Correct
#261 Short-circuit _normalize_percent_encoding when path has no % ✅ Correct
#250 Default-fail on unknown hosts; opt-in via flag or env var ✅ Correct

Issues to Fix

1. _auth_owned is incorrectly set when auth= is passed to from_env (semantic bug)

In the refactored from_env:

kwargs.setdefault("auth", KalshiAuth.try_from_env())
client = cls(**kwargs)
client._auth_owned = kwargs["auth"] is not None   # ← problem

If a caller passes from_env(auth=my_auth), setdefault won't override it, so kwargs["auth"] = my_auth, and client._auth_owned = True. The client then closes the caller's auth on client.close() — which the caller did not expect. The old code TypeError'd on duplicate kwargs, so this is a behavior change.

The intent (expressed in the comment "from_env constructs auth locally — caller never owned it") is to set _auth_owned = True only when from_env created the auth object itself. The correct fix:

env_auth = KalshiAuth.try_from_env()
kwargs.setdefault("auth", env_auth)
client = cls(**kwargs)
# Own the auth only when from_env built it (not when the caller passed one in).
client._auth_owned = kwargs.get("auth") is env_auth and env_auth is not None

Same issue exists in async_client.py.


2. conftest.py process-wide KALSHI_ALLOW_UNKNOWN_HOST=1 leaks into tests that don't use monkeypatch.delenv

The test-suite escape hatch set at import time in conftest.py:

os.environ["KALSHI_ALLOW_UNKNOWN_HOST"] = "1"

Tests that construct KalshiConfig with a mock host without calling monkeypatch.delenv("KALSHI_ALLOW_UNKNOWN_HOST", ...) will silently have the check disabled. For example, all existing fixture-based tests skip the host validation. This is fine for them, but any new test author who forgets the delenv call will get a false green on security-validation tests.

Consider a brief comment in conftest.py where this is set explaining that new tests which assert the default-fail behaviour must monkeypatch.delenv("KALSHI_ALLOW_UNKNOWN_HOST", raising=False) first. The new tests in test_config.py already do this correctly; it just isn't documented.


Minor Issues

3. Pre-existing KALSHI_DEMO docs/code mismatch (not introduced here, but touched)

docs/environment-variables.md says truthy values are "true", "1", "yes", "on" (case-insensitive). The from_env code (both sync and async) checks only == "true":

kwargs.setdefault("demo", os.environ.get("KALSHI_DEMO", "").lower() == "true")

This PR touches both files but doesn't fix the mismatch. Worth a follow-up issue if not already tracked.

4. ClientInitKwargs TypedDict duplicated across client.py and async_client.py

The only difference is transport: httpx.BaseTransport | None vs transport: httpx.AsyncBaseTransport | None. Not worth merging given the project's "simplicity first" guideline, but if this TypedDict ever grows it'll need syncing. Acceptable as-is.


What's Well Done

  • _normalize_percent_encoding short-circuit: Returning the original object (is identity) not just equality — the test confirms no allocation on the hot path. Clean.
  • KALSHI_ALLOW_UNKNOWN_HOST=1 strict equality: Only exact "1" opt-in; a stale =0, =true, or =yes from a prior shell doesn't silently disable the check. test_env_opt_in_only_honors_exact_one verifies this. Good defensive choice.
  • Test coverage for TestUnknownHostDefaultFail: Covers known host, unknown host, error message content, flag opt-in, env opt-in, strict "1" semantics, localhost passthrough, and WS URL independently. Thorough.
  • test_no_percent_returns_input_identity: Tests is not ==, which is the actual contract. Good discipline.
  • conftest.py env stripping at import: Prevents real credentials from polluting the test run. Cleaner than per-test fixtures.

Summary

The _auth_owned ownership issue in from_env (item 1) is a real semantic bug introduced by the refactor. The edge case (from_env(auth=...)) is unlikely in practice but the TypedDict makes it a valid, mypy-clean call. Everything else is solid. The _auth_owned fix is a one-liner per client.

@TexasCoding
TexasCoding merged commit e5fa48a into main May 21, 2026
5 checks passed
@TexasCoding
TexasCoding deleted the feat/249-250-266-261-auth-config branch May 21, 2026 12:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant