Repository navigation
feat(auth+config): defense-in-depth (#249, #266, #261, #250) - #287
Conversation
…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
Code Review — PR #287: feat(auth+config): defense-in-depthGood focused security hardening wave. Four independent issues, each with clear motivation, tight implementation, and matching tests. Some issues to flag below. Overview
Issues to Fix1. In the refactored kwargs.setdefault("auth", KalshiAuth.try_from_env())
client = cls(**kwargs)
client._auth_owned = kwargs["auth"] is not None # ← problemIf a caller passes The intent (expressed in the comment "from_env constructs auth locally — caller never owned it") is to set 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 NoneSame issue exists in 2. The test-suite escape hatch set at import time in os.environ["KALSHI_ALLOW_UNKNOWN_HOST"] = "1"Tests that construct Consider a brief comment in Minor Issues3. Pre-existing
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. The only difference is What's Well Done
SummaryThe |
Closes #249
Closes #266
Closes #261
Closes #250
Part of #273 (post-v2.4 audit), Wave 2.