Repository navigation
Closed
Labels
bugSomething isn't workingSomething isn't workingsecuritySecurity-related concernSecurity-related concern
Description
Summary
Every resource interpolates user kwargs directly into the URL path with an f-string and no encoding:
self._get(f"/portfolio/orders/{order_id}")
self._get(f"/markets/{ticker}")
self._get(f"/events/{event_ticker}")
self._get(f"/communications/rfqs/{rfq_id}")
self._get(f"/multivariate_event_collections/{collection_ticker}")
self._get(f"/portfolio/order_groups/{order_group_id}")
self._get(f"/api_keys/{api_key}")
self._get(f"/live_data/{milestone_type}/milestone/{milestone_id}")
self._get(f"/historical/markets/{ticker}/candlesticks")
self._get(f"/structured_targets/{structured_target_id}")
# ... ~20 more sitesTwo failure modes:
- Innocent input: a ticker containing
/,,?, or#silently misroutes — caller sees a confusing 404 instead of a cleanValueError. - Path traversal-adjacent:
milestone_type=".."ororder_id="foo/../../bar"gets URL-normalized by httpx into a path outside the intended endpoint while still signed underself._base_path + path. Auth still succeeds (path matches what was signed), but the request reaches the wrong handler depending on proxy/server behavior. At minimum produces opaque server-side errors; at worst hits an unintended endpoint.
No urllib.parse.quote call exists in kalshi/resources/.
Location
kalshi/resources/*.py (every f-string path with caller input)
Evidence
# kalshi/resources/orders.py:384
data = self._get(f"/portfolio/orders/{order_id}")
# kalshi/resources/live_data.py:60-63
data = self._get(f"/live_data/{milestone_type}/milestone/{milestone_id}", params=params)
# kalshi/resources/markets.py:287
data = self._get(f"/markets/{ticker}")Recommended fix
Add a _seg(value: str) -> str helper in kalshi/resources/_base.py:
import urllib.parse
def _seg(value: str, *, name: str = "path segment") -> str:
"""URL-encode a path segment and reject empty/whitespace input.
Reject characters that would change the routing semantics if normalized
by an intermediate proxy (e.g., literal `..`, control chars).
"""
if not value or not value.strip():
raise ValueError(f"{name} must be non-empty")
if value in {".", ".."}:
raise ValueError(f"{name} cannot be '.' or '..'")
return urllib.parse.quote(value, safe="")Replace every f-string path interpolation of caller input with _seg(value):
self._get(f"/portfolio/orders/{_seg(order_id, name='order_id')}")Hard-coded path constants (/portfolio/orders, /events) need no change.
Tests:
test_order_id_with_slash_raises_or_encodestest_ticker_with_space_encodedtest_empty_string_path_segment_raisestest_dotdot_path_segment_rejected
Severity & category
medium / security, correctness
Metadata
Metadata
Assignees
Labels
bugSomething isn't workingSomething isn't workingsecuritySecurity-related concernSecurity-related concern
Activity