Skip to content

Resources: URL-encode user-supplied path segments (urllib.parse.quote) #211

Closed

Description

@TexasCoding

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 sites

Two failure modes:

  1. Innocent input: a ticker containing /, , ?, or # silently misroutes — caller sees a confusing 404 instead of a clean ValueError.
  2. Path traversal-adjacent: milestone_type=".." or order_id="foo/../../bar" gets URL-normalized by httpx into a path outside the intended endpoint while still signed under self._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_encodes
  • test_ticker_with_space_encoded
  • test_empty_string_path_segment_raises
  • test_dotdot_path_segment_rejected

Severity & category

medium / security, correctness

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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