From 6f79642c6c2bfba8cb373598204427ed731ae9b7 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 5 Jun 2026 11:58:26 -0500 Subject: [PATCH] =?UTF-8?q?perps:=20config/init=20hardening=20=E2=80=94=20?= =?UTF-8?q?host-allowlist=20split,=20ws=5Fbase=5Furl=20+=20password=20kwar?= =?UTF-8?q?gs,=20config-vs-shorthand=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #412, closes #406, closes #410. Pre-release polish (3.2.0 unreleased). #412 — PerpsConfig validated both base_url and ws_base_url against the UNION of REST+WS hosts, so a swapped host (REST URL on a WS host, or vice versa) was accepted; and ws_base_url had no path check. Now base_url validates against `_PERPS_REST_HOSTS` and ws_base_url against `_PERPS_WS_HOSTS`, and ws_base_url must carry the `/trade-api/ws/v2/margin` path (mirrors the REST path check). Removed the now-unused `_PERPS_KNOWN_HOSTS` union. #406 — (a) passing `config=` together with `demo=True`/`base_url`/`ws_base_url` silently ignored the shorthand (a real-money footgun when demo=True is dropped); now raises. (b) added a `ws_base_url` kwarg to both clients + `from_env` (`KALSHI_PERPS_WS_BASE_URL`) so a REST override no longer leaves the WS feed on prod — the split is caught by PerpsConfig's split-env guard if mismatched. `from_env` only injects env endpoint overrides when no explicit `config=` is passed, so the new guard isn't tripped by env defaults. #410 — exposed a `password=` kwarg on both clients + `from_env` for encrypted private keys, threaded to `KalshiAuth.from_key_path`/`from_pem` and `try_perps_auth_from_env` (which already accepted it). `from_env` reads `KALSHI_PERPS_PRIVATE_KEY_PASSPHRASE`. Tests: host-swap + WS-path rejection (#412); config+shorthand guards + ws_base_url threading (#406); encrypted-PEM password threading, sync + async (#410). mypy strict + ruff + 1131 perps/contract tests green. --- kalshi/perps/async_client.py | 49 ++++++++++++++++++++-------- kalshi/perps/client.py | 52 ++++++++++++++++++++++-------- kalshi/perps/config.py | 26 +++++++++++---- tests/perps/test_client.py | 62 +++++++++++++++++++++++++++++++++++- tests/perps/test_config.py | 30 +++++++++++++++++ 5 files changed, 186 insertions(+), 33 deletions(-) diff --git a/kalshi/perps/async_client.py b/kalshi/perps/async_client.py index e612d63..3d66dca 100644 --- a/kalshi/perps/async_client.py +++ b/kalshi/perps/async_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +from collections.abc import Callable from pathlib import Path from types import TracebackType from typing import TypedDict, Unpack @@ -33,10 +34,12 @@ class PerpsClientInitKwargs(TypedDict, total=False): key_id: str | None private_key: str | bytes | None private_key_path: str | Path | None + password: bytes | str | Callable[[], bytes | str] | None auth: KalshiAuth | None config: PerpsConfig | None demo: bool base_url: str | None + ws_base_url: str | None timeout: float | None max_retries: int | None transport: httpx.AsyncBaseTransport | None @@ -61,10 +64,12 @@ def __init__( key_id: str | None = None, private_key_path: str | Path | None = None, private_key: str | bytes | None = None, + password: bytes | str | Callable[[], bytes | str] | None = None, auth: KalshiAuth | None = None, config: PerpsConfig | None = None, demo: bool = False, base_url: str | None = None, + ws_base_url: str | None = None, timeout: float | None = None, max_retries: int | None = None, transport: httpx.AsyncBaseTransport | None = None, @@ -79,10 +84,10 @@ def __init__( self._auth = auth self._auth_owned = False elif key_id and private_key_path: - self._auth = KalshiAuth.from_key_path(key_id, private_key_path) + self._auth = KalshiAuth.from_key_path(key_id, private_key_path, password=password) self._auth_owned = True elif key_id and private_key: - self._auth = KalshiAuth.from_pem(key_id, private_key) + self._auth = KalshiAuth.from_pem(key_id, private_key, password=password) self._auth_owned = True elif key_id or private_key or private_key_path: # Partial credentials — fail fast instead of silently building an @@ -98,21 +103,35 @@ def __init__( self._auth_owned = False if config is not None: + # A PerpsConfig fully fixes the REST + WS endpoints. Combining it + # with env-determining shorthand would silently ignore that + # shorthand — a real-money footgun if an explicit demo=True is + # dropped — so reject the combination outright. + if demo or base_url is not None or ws_base_url is not None: + raise ValueError( + "Pass either `config=` OR the demo/base_url/ws_base_url " + "shorthand, not both. A PerpsConfig already fixes the REST " + "and WS endpoints, so demo=True/base_url/ws_base_url would be " + "silently ignored. Drop whichever you don't need." + ) self._config: PerpsConfig = config else: - if demo and base_url is not None and base_url.rstrip("/") != PERPS_DEMO_BASE_URL: + if demo and ( + (base_url is not None and base_url.rstrip("/") != PERPS_DEMO_BASE_URL) + or (ws_base_url is not None and ws_base_url.rstrip("/") != PERPS_DEMO_WS_URL) + ): raise ValueError( - "Conflicting environment: demo=True together with explicit " - f"base_url={base_url!r}. demo=True implies base_url=" - f"{PERPS_DEMO_BASE_URL!r}; passing a different REST endpoint " - "would leave ws_base_url pinned to the demo WS feed, producing " - "a split REST/WS environment. Drop demo=True and pass both " - "base_url and ws_base_url via a PerpsConfig, or use " - "PerpsConfig.demo() / PerpsConfig.production()." + "Conflicting environment: demo=True together with an explicit " + f"base_url={base_url!r} / ws_base_url={ws_base_url!r} pointing " + "elsewhere. demo=True implies the demo REST + WS endpoints; " + "pass both base_url and ws_base_url via a PerpsConfig instead, " + "or use PerpsConfig.demo() / PerpsConfig.production()." ) config_kwargs: dict[str, object] = {} if base_url: config_kwargs["base_url"] = base_url + if ws_base_url: + config_kwargs["ws_base_url"] = ws_base_url if demo: config_kwargs.setdefault("base_url", PERPS_DEMO_BASE_URL) config_kwargs.setdefault("ws_base_url", PERPS_DEMO_WS_URL) @@ -152,11 +171,15 @@ def from_env(cls, **kwargs: Unpack[PerpsClientInitKwargs]) -> AsyncPerpsClient: ) env_built_auth: KalshiAuth | None = None if not caller_supplied_auth: - env_built_auth = try_perps_auth_from_env() + env_built_auth = try_perps_auth_from_env(password=kwargs.get("password")) if env_built_auth is not None: kwargs["auth"] = env_built_auth - kwargs.setdefault("demo", os.environ.get("KALSHI_PERPS_DEMO", "").lower() == "true") - kwargs.setdefault("base_url", os.environ.get("KALSHI_PERPS_API_BASE_URL")) + # Only inject env endpoint overrides when no explicit config is given — + # otherwise __init__'s config-vs-shorthand guard would reject them. + if "config" not in kwargs: + kwargs.setdefault("demo", os.environ.get("KALSHI_PERPS_DEMO", "").lower() == "true") + kwargs.setdefault("base_url", os.environ.get("KALSHI_PERPS_API_BASE_URL")) + kwargs.setdefault("ws_base_url", os.environ.get("KALSHI_PERPS_WS_BASE_URL")) client = cls(**kwargs) if env_built_auth is not None: client._auth_owned = True diff --git a/kalshi/perps/client.py b/kalshi/perps/client.py index cb56181..b434d77 100644 --- a/kalshi/perps/client.py +++ b/kalshi/perps/client.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +from collections.abc import Callable from pathlib import Path from types import TracebackType from typing import TypedDict, Unpack @@ -37,10 +38,12 @@ class PerpsClientInitKwargs(TypedDict, total=False): key_id: str | None private_key: str | bytes | None private_key_path: str | Path | None + password: bytes | str | Callable[[], bytes | str] | None auth: KalshiAuth | None config: PerpsConfig | None demo: bool base_url: str | None + ws_base_url: str | None timeout: float | None max_retries: int | None transport: httpx.BaseTransport | None @@ -70,10 +73,12 @@ def __init__( key_id: str | None = None, private_key_path: str | Path | None = None, private_key: str | bytes | None = None, + password: bytes | str | Callable[[], bytes | str] | None = None, auth: KalshiAuth | None = None, config: PerpsConfig | None = None, demo: bool = False, base_url: str | None = None, + ws_base_url: str | None = None, timeout: float | None = None, max_retries: int | None = None, transport: httpx.BaseTransport | None = None, @@ -88,10 +93,10 @@ def __init__( self._auth = auth self._auth_owned = False elif key_id and private_key_path: - self._auth = KalshiAuth.from_key_path(key_id, private_key_path) + self._auth = KalshiAuth.from_key_path(key_id, private_key_path, password=password) self._auth_owned = True elif key_id and private_key: - self._auth = KalshiAuth.from_pem(key_id, private_key) + self._auth = KalshiAuth.from_pem(key_id, private_key, password=password) self._auth_owned = True elif key_id or private_key or private_key_path: # Partial credentials — fail fast instead of silently building an @@ -107,21 +112,35 @@ def __init__( self._auth_owned = False if config is not None: + # A PerpsConfig fully fixes the REST + WS endpoints. Combining it + # with env-determining shorthand would silently ignore that + # shorthand — a real-money footgun if an explicit demo=True is + # dropped — so reject the combination outright. + if demo or base_url is not None or ws_base_url is not None: + raise ValueError( + "Pass either `config=` OR the demo/base_url/ws_base_url " + "shorthand, not both. A PerpsConfig already fixes the REST " + "and WS endpoints, so demo=True/base_url/ws_base_url would be " + "silently ignored. Drop whichever you don't need." + ) self._config: PerpsConfig = config else: - if demo and base_url is not None and base_url.rstrip("/") != PERPS_DEMO_BASE_URL: + if demo and ( + (base_url is not None and base_url.rstrip("/") != PERPS_DEMO_BASE_URL) + or (ws_base_url is not None and ws_base_url.rstrip("/") != PERPS_DEMO_WS_URL) + ): raise ValueError( - "Conflicting environment: demo=True together with explicit " - f"base_url={base_url!r}. demo=True implies base_url=" - f"{PERPS_DEMO_BASE_URL!r}; passing a different REST endpoint " - "would leave ws_base_url pinned to the demo WS feed, producing " - "a split REST/WS environment. Drop demo=True and pass both " - "base_url and ws_base_url via a PerpsConfig, or use " - "PerpsConfig.demo() / PerpsConfig.production()." + "Conflicting environment: demo=True together with an explicit " + f"base_url={base_url!r} / ws_base_url={ws_base_url!r} pointing " + "elsewhere. demo=True implies the demo REST + WS endpoints; " + "pass both base_url and ws_base_url via a PerpsConfig instead, " + "or use PerpsConfig.demo() / PerpsConfig.production()." ) config_kwargs: dict[str, object] = {} if base_url: config_kwargs["base_url"] = base_url + if ws_base_url: + config_kwargs["ws_base_url"] = ws_base_url if demo: config_kwargs.setdefault("base_url", PERPS_DEMO_BASE_URL) config_kwargs.setdefault("ws_base_url", PERPS_DEMO_WS_URL) @@ -153,12 +172,15 @@ def from_env(cls, **kwargs: Unpack[PerpsClientInitKwargs]) -> PerpsClient: Reads: KALSHI_PERPS_KEY_ID (optional — omit for unauthenticated access) KALSHI_PERPS_PRIVATE_KEY (PEM string) or KALSHI_PERPS_PRIVATE_KEY_PATH + KALSHI_PERPS_PRIVATE_KEY_PASSPHRASE (optional, for an encrypted key) KALSHI_PERPS_API_BASE_URL (optional, overrides base_url) + KALSHI_PERPS_WS_BASE_URL (optional, overrides ws_base_url) KALSHI_PERPS_DEMO (optional, "true" for the demo environment) Perps credentials are intentionally separate from the prediction-API ``KALSHI_*`` vars — Kalshi recommends a distinct API key for the perps exchange. Returns an unauthenticated client if no credentials are set. + An explicit ``password=`` (or ``config=``) overrides the env value. """ caller_supplied_auth = ( "auth" in kwargs @@ -168,11 +190,15 @@ def from_env(cls, **kwargs: Unpack[PerpsClientInitKwargs]) -> PerpsClient: ) env_built_auth: KalshiAuth | None = None if not caller_supplied_auth: - env_built_auth = try_perps_auth_from_env() + env_built_auth = try_perps_auth_from_env(password=kwargs.get("password")) if env_built_auth is not None: kwargs["auth"] = env_built_auth - kwargs.setdefault("demo", os.environ.get("KALSHI_PERPS_DEMO", "").lower() == "true") - kwargs.setdefault("base_url", os.environ.get("KALSHI_PERPS_API_BASE_URL")) + # Only inject env endpoint overrides when no explicit config is given — + # otherwise __init__'s config-vs-shorthand guard would reject them. + if "config" not in kwargs: + kwargs.setdefault("demo", os.environ.get("KALSHI_PERPS_DEMO", "").lower() == "true") + kwargs.setdefault("base_url", os.environ.get("KALSHI_PERPS_API_BASE_URL")) + kwargs.setdefault("ws_base_url", os.environ.get("KALSHI_PERPS_WS_BASE_URL")) client = cls(**kwargs) if env_built_auth is not None: client._auth_owned = True diff --git a/kalshi/perps/config.py b/kalshi/perps/config.py index 37abe66..31ce692 100644 --- a/kalshi/perps/config.py +++ b/kalshi/perps/config.py @@ -28,15 +28,15 @@ PERPS_PRODUCTION_WS_URL = "wss://external-api-margin-ws.kalshi.com/trade-api/ws/v2/margin" PERPS_DEMO_WS_URL = "wss://external-api-margin-ws.demo.kalshi.co/trade-api/ws/v2/margin" -# REST + WS hosts both validate against this union (the perps WS host differs -# from the perps REST host, unlike the prediction API where both share a host). +# Each surface validates against its OWN host set — the perps WS host differs +# from the perps REST host (unlike the prediction API where both share a host), +# so base_url must use a REST host and ws_base_url a WS host. _PERPS_REST_HOSTS = frozenset( {"external-api.kalshi.com", "external-api.demo.kalshi.co"} ) _PERPS_WS_HOSTS = frozenset( {"external-api-margin-ws.kalshi.com", "external-api-margin-ws.demo.kalshi.co"} ) -_PERPS_KNOWN_HOSTS = _PERPS_REST_HOSTS | _PERPS_WS_HOSTS _LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) # Perps prod/demo host pairs, used by the split REST/WS environment guard. @@ -81,11 +81,15 @@ def __post_init__(self) -> None: allow_unknown = self.allow_unknown_host or ( os.environ.get("KALSHI_PERPS_ALLOW_UNKNOWN_HOST", "").strip() == "1" ) + # Validate each field against its OWN host set — a REST URL must use a + # REST host and a WS URL a WS host (they differ for perps), so a swapped + # host is rejected rather than silently accepted via the union. PerpsConfig._validate_perps_url( self.base_url, "base_url", secure="https", plaintext="http", + known_hosts=_PERPS_REST_HOSTS, allow_unknown_host=allow_unknown, ) PerpsConfig._validate_perps_url( @@ -93,6 +97,7 @@ def __post_init__(self) -> None: "ws_base_url", secure="wss", plaintext="ws", + known_hosts=_PERPS_WS_HOSTS, allow_unknown_host=allow_unknown, ) # Enforce the /trade-api/v2 REST path component (identical to the @@ -103,6 +108,14 @@ def __post_init__(self) -> None: f"PerpsConfig.base_url must include the /trade-api/v2 path " f"component, got base_url={self.base_url!r}" ) + # Enforce the /trade-api/ws/v2/margin WS path component (mirrors the REST + # path check above). + ws_path = urlparse(self.ws_base_url).path + if ws_path != "/trade-api/ws/v2/margin": + raise ValueError( + f"PerpsConfig.ws_base_url must include the /trade-api/ws/v2/margin " + f"path component, got ws_base_url={self.ws_base_url!r}" + ) # Reject a split REST/WS environment (prod REST + demo WS, or vice # versa) — the same real-money-vs-demo footgun KalshiConfig guards. base_host = (urlparse(self.base_url).hostname or "").lower() @@ -148,6 +161,7 @@ def _validate_perps_url( *, secure: str, plaintext: str, + known_hosts: frozenset[str], allow_unknown_host: bool, ) -> None: """Reject URLs that would expose credentials (bad scheme or plaintext-to-remote).""" @@ -169,11 +183,11 @@ def _validate_perps_url( f"(url={url!r}). Plaintext to a remote host would " f"expose the KALSHI-ACCESS-KEY header and request signature." ) - if host not in _PERPS_KNOWN_HOSTS and host not in _LOCAL_HOSTS: + if host not in known_hosts and host not in _LOCAL_HOSTS: if not allow_unknown_host: raise ValueError( f"PerpsConfig.{field_name} host {host!r} is not a known " - f"Kalshi perps endpoint. Known hosts: {sorted(_PERPS_KNOWN_HOSTS)}. " + f"Kalshi perps endpoint. Known hosts: {sorted(known_hosts)}. " "If this is an intentional mock server, proxy, or alternate " "perps region, opt in with PerpsConfig(allow_unknown_host=True) " "or set the environment variable KALSHI_PERPS_ALLOW_UNKNOWN_HOST=1." @@ -185,7 +199,7 @@ def _validate_perps_url( "KALSHI_PERPS_ALLOW_UNKNOWN_HOST=1 silenced the default error.)", field_name, host, - sorted(_PERPS_KNOWN_HOSTS), + sorted(known_hosts), ) @classmethod diff --git a/tests/perps/test_client.py b/tests/perps/test_client.py index 29791e1..fb40cbf 100644 --- a/tests/perps/test_client.py +++ b/tests/perps/test_client.py @@ -3,10 +3,20 @@ from __future__ import annotations import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa from kalshi.auth import KalshiAuth from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig -from kalshi.perps.config import PERPS_DEMO_BASE_URL +from kalshi.perps.config import PERPS_DEMO_BASE_URL, PERPS_DEMO_WS_URL + + +def _encrypted_pem(key: rsa.RSAPrivateKey, password: bytes) -> bytes: + return key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.BestAvailableEncryption(password), + ) _RESOURCE_ATTRS = ( "exchange", @@ -142,3 +152,53 @@ async def test_async_from_env( assert client.is_authenticated is True assert client._auth_owned is True await client.close() + + +class TestIssue406ConfigShorthandGuards: + """#406: reject config= combined with env-determining shorthand; ws_base_url + shorthand reaches the config.""" + + def test_config_plus_demo_raises(self) -> None: + # The real-money footgun: demo=True silently ignored when config given. + with pytest.raises(ValueError, match="not both"): + PerpsClient(config=PerpsConfig.production(), demo=True) + + def test_config_plus_base_url_raises(self) -> None: + with pytest.raises(ValueError, match="not both"): + PerpsClient(config=PerpsConfig.demo(), base_url=PERPS_DEMO_BASE_URL) + + def test_config_plus_ws_base_url_raises(self) -> None: + with pytest.raises(ValueError, match="not both"): + PerpsClient(config=PerpsConfig.demo(), ws_base_url=PERPS_DEMO_WS_URL) + + def test_ws_base_url_shorthand_threads_to_config(self) -> None: + client = PerpsClient(base_url=PERPS_DEMO_BASE_URL, ws_base_url=PERPS_DEMO_WS_URL) + assert client._config.base_url == PERPS_DEMO_BASE_URL + assert client._config.ws_base_url == PERPS_DEMO_WS_URL + client.close() + + +class TestIssue410EncryptedKeyPassword: + """#410: a `password=` for an encrypted private key is threaded to the signer.""" + + def test_password_threaded_for_encrypted_pem( + self, rsa_private_key: rsa.RSAPrivateKey + ) -> None: + # Without threading the password, load of the encrypted PEM would raise + # at construction — so a successful authenticated client proves the fix. + enc = _encrypted_pem(rsa_private_key, b"sekret") + client = PerpsClient( + key_id="k", private_key=enc, password="sekret", config=PerpsConfig.demo() + ) + assert client.is_authenticated is True + client.close() + + async def test_password_threaded_async( + self, rsa_private_key: rsa.RSAPrivateKey + ) -> None: + enc = _encrypted_pem(rsa_private_key, b"sekret") + client = AsyncPerpsClient( + key_id="k", private_key=enc, password="sekret", config=PerpsConfig.demo() + ) + assert client.is_authenticated is True + await client.close() diff --git a/tests/perps/test_config.py b/tests/perps/test_config.py index 4ddf5f0..621598c 100644 --- a/tests/perps/test_config.py +++ b/tests/perps/test_config.py @@ -101,3 +101,33 @@ def test_localhost_allowed(self, monkeypatch: pytest.MonkeyPatch) -> None: ws_base_url="ws://localhost/trade-api/ws/v2/margin", ) assert c.base_url == "http://localhost/trade-api/v2" + + +class TestPerpsConfigHostSplit: + """#412: base_url validates against REST hosts, ws_base_url against WS hosts; + ws_base_url also requires the /trade-api/ws/v2/margin path.""" + + def test_base_url_rejects_ws_host(self, monkeypatch: pytest.MonkeyPatch) -> None: + # The perps test conftest sets the allow-unknown-host escape hatch + # process-wide; clear it so the host check hard-fails. + monkeypatch.delenv("KALSHI_PERPS_ALLOW_UNKNOWN_HOST", raising=False) + with pytest.raises(ValueError, match="not a known"): + PerpsConfig( + base_url="https://external-api-margin-ws.kalshi.com/trade-api/v2", + ws_base_url=PERPS_PRODUCTION_WS_URL, + ) + + def test_ws_base_url_rejects_rest_host(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KALSHI_PERPS_ALLOW_UNKNOWN_HOST", raising=False) + with pytest.raises(ValueError, match="not a known"): + PerpsConfig( + base_url=PERPS_PRODUCTION_BASE_URL, + ws_base_url="wss://external-api.kalshi.com/trade-api/ws/v2/margin", + ) + + def test_ws_base_url_rejects_wrong_path(self) -> None: + with pytest.raises(ValueError, match="/trade-api/ws/v2/margin"): + PerpsConfig( + base_url=PERPS_PRODUCTION_BASE_URL, + ws_base_url="wss://external-api-margin-ws.kalshi.com/trade-api/v2", + )