Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
run: uv build

- name: Publish to TestPyPI
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@v1.14.0
with:
repository-url: https://test.pypi.org/legacy/

Expand Down Expand Up @@ -64,4 +64,4 @@ jobs:
run: uv build

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@v1.14.0
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [ main, master, develop ]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Security

- Caller-supplied symbols are now percent-encoded in request paths, preventing path traversal and query smuggling via untrusted input; valid symbols are unaffected
- `options.lookup()` neutralizes dot-segments in the lookup string so it cannot traverse to a different endpoint; valid lookup strings (including dates with slashes) are unaffected
- Token obfuscation in logs no longer reveals the token length, and never reveals any characters of short tokens
- API error messages extracted from response bodies are now bounded, so a malformed or hostile response cannot balloon exception messages and logs
- Malformed rate-limit headers no longer crash a successful request with a raw `KeyError`/`ValueError`; the SDK logs a warning and keeps the previous limits
- The PyPI publish action is pinned to a fixed release tag instead of a moving branch ref; the test workflow token is now read-only

## [1.3.0] - 2026-06-10

### Fixed
Expand Down
5 changes: 5 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ Out of scope:
(`uv.lock`) are tracked by Dependabot (see `.github/dependabot.yml`); report
them upstream. We will bump the affected dependency here once a fixed version
exists.
- **CSV formula injection.** CSV output is intentionally byte-faithful to the
API response: the SDK does not escape or rewrite cell values (e.g. values
beginning with `=`, `+`, `-`, `@`). Guarding against formula execution when a
CSV is opened in a spreadsheet application is the consuming application's
responsibility.

## Security Fix Policy

Expand Down
37 changes: 25 additions & 12 deletions src/marketdata/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from marketdata.resources.stocks import StocksResource
from marketdata.settings import settings
from marketdata.types import UserRateLimits
from marketdata.utils import format_duration_log, obfuscate_token
from marketdata.utils import format_duration_log, obfuscate_token, resume_long_text


class MarketDataClient:
Expand Down Expand Up @@ -100,11 +100,14 @@ def _validate_response_status_code(
raise_for_status: bool,
) -> None:
def _get_response_errmsg(response: Response):
# Bound the error message so a malformed or hostile response body
# cannot balloon exception messages and log output.
try:
data = response.json()
return data["errmsg"]
except:
return response.text
errmsg = data["errmsg"]
except Exception:
errmsg = response.text
return resume_long_text(str(errmsg), max_length=500)

def _validate_status(response: Response):
try:
Expand Down Expand Up @@ -170,14 +173,22 @@ def _setup_rate_limits(self):
response_log_level=DEBUG,
)

def _extract_rate_limits(self, response: Response) -> UserRateLimits:
def _extract_rate_limits(self, response: Response) -> UserRateLimits | None:
self.logger.debug(f"Extracting response rate limits from response headers")
return UserRateLimits(
requests_limit=int(response.headers["x-api-ratelimit-limit"]),
requests_remaining=int(response.headers["x-api-ratelimit-remaining"]),
requests_reset=int(response.headers["x-api-ratelimit-reset"]),
requests_consumed=int(response.headers["x-api-ratelimit-consumed"]),
)
try:
return UserRateLimits(
requests_limit=int(response.headers["x-api-ratelimit-limit"]),
requests_remaining=int(response.headers["x-api-ratelimit-remaining"]),
requests_reset=int(response.headers["x-api-ratelimit-reset"]),
requests_consumed=int(response.headers["x-api-ratelimit-consumed"]),
)
except (KeyError, ValueError) as e:
# Malformed response (e.g. missing or non-numeric rate-limit
# headers) must not crash the request that already succeeded.
self.logger.warning(
f"Could not extract rate limits from response headers: {e!r}"
)
return None

def _pre_request_logs(self, method: str, url: str, **kwargs):
self.logger.debug(f"Making request to URL: {self.base_url}/{url}")
Expand Down Expand Up @@ -221,6 +232,8 @@ def _make_request(
)

if populate_rate_limits:
self.rate_limits = self._extract_rate_limits(response)
rate_limits = self._extract_rate_limits(response)
if rate_limits is not None:
self.rate_limits = rate_limits

return response
4 changes: 2 additions & 2 deletions src/marketdata/resources/funds/candles.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from marketdata.params import universal_params
from marketdata.resources.base import BaseResource
from marketdata.sdk_error import MarketDataClientErrorResult, handle_exceptions
from marketdata.utils import get_data_records
from marketdata.utils import encode_path_segment, get_data_records


@handle_exceptions
Expand All @@ -32,7 +32,7 @@ def candles(
)

url = self._build_url(
path=f"funds/candles/{input_params.resolution}/{symbol}/",
path=f"funds/candles/{encode_path_segment(input_params.resolution)}/{encode_path_segment(symbol)}/",
user_universal_params=user_universal_params,
input_params=input_params,
extra_params=kwargs,
Expand Down
3 changes: 2 additions & 1 deletion src/marketdata/resources/options/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from marketdata.params import universal_params
from marketdata.resources.base import BaseResource
from marketdata.sdk_error import MarketDataClientErrorResult, handle_exceptions
from marketdata.utils import encode_path_segment


@handle_exceptions
Expand All @@ -36,7 +37,7 @@ def chain(
)

url = self._build_url(
path=f"options/chain/{symbol}/",
path=f"options/chain/{encode_path_segment(symbol)}/",
user_universal_params=user_universal_params,
input_params=input_params,
extra_params=kwargs,
Expand Down
3 changes: 2 additions & 1 deletion src/marketdata/resources/options/expirations.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from marketdata.params import universal_params
from marketdata.resources.base import BaseResource
from marketdata.sdk_error import MarketDataClientErrorResult, handle_exceptions
from marketdata.utils import encode_path_segment


@handle_exceptions
Expand Down Expand Up @@ -45,7 +46,7 @@ def expirations(
user_universal_params.date_format = None

url = self._build_url(
path=f"options/expirations/{symbol}/",
path=f"options/expirations/{encode_path_segment(symbol)}/",
user_universal_params=user_universal_params,
input_params=input_params,
extra_params=kwargs,
Expand Down
4 changes: 2 additions & 2 deletions src/marketdata/resources/options/lookup.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from typing import Annotated, Any
from urllib.parse import quote

from marketdata.api_error import api_error_handler
from marketdata.docs import docs
Expand All @@ -13,6 +12,7 @@
from marketdata.params import universal_params
from marketdata.resources.base import BaseResource
from marketdata.sdk_error import MarketDataClientErrorResult, handle_exceptions
from marketdata.utils import encode_path


@handle_exceptions
Expand Down Expand Up @@ -44,7 +44,7 @@ def lookup(
# All params are already in the path
_format_date = lambda date: date.strftime("%d-%M-%Y")
excluded_params = OptionsLookupInput.model_fields.keys()
lookup_quote = quote(input_params.lookup)
lookup_quote = encode_path(input_params.lookup)

url = self._build_url(
path=f"options/lookup/{lookup_quote}/",
Expand Down
4 changes: 2 additions & 2 deletions src/marketdata/resources/options/quotes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from marketdata.params import universal_params
from marketdata.resources.base import BaseResource
from marketdata.sdk_error import MarketDataClientErrorResult, handle_exceptions
from marketdata.utils import merge_csv_texts
from marketdata.utils import encode_path_segment, merge_csv_texts


@handle_exceptions
Expand Down Expand Up @@ -50,7 +50,7 @@ def quotes(

def _get_response(symbol: str) -> Response:
url = self._build_url(
path=f"options/quotes/{symbol}/",
path=f"options/quotes/{encode_path_segment(symbol)}/",
user_universal_params=user_universal_params,
input_params=input_params,
extra_params=kwargs,
Expand Down
3 changes: 2 additions & 1 deletion src/marketdata/resources/options/strikes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from marketdata.params import universal_params
from marketdata.resources.base import BaseResource
from marketdata.sdk_error import MarketDataClientErrorResult, handle_exceptions
from marketdata.utils import encode_path_segment


@handle_exceptions
Expand Down Expand Up @@ -40,7 +41,7 @@ def strikes(
)

url = self._build_url(
path=f"options/strikes/{symbol}/",
path=f"options/strikes/{encode_path_segment(symbol)}/",
user_universal_params=user_universal_params,
input_params=input_params,
extra_params=kwargs,
Expand Down
3 changes: 2 additions & 1 deletion src/marketdata/resources/stocks/candles.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from marketdata.resources.base import BaseResource
from marketdata.sdk_error import MarketDataClientErrorResult, handle_exceptions
from marketdata.utils import (
encode_path_segment,
get_data_records,
merge_csv_texts,
split_dates_by_timeframe,
Expand Down Expand Up @@ -68,7 +69,7 @@ def _get_response(
input_params.to_date = to_date

url = self._build_url(
path=f"stocks/candles/{input_params.resolution}/{symbol}/",
path=f"stocks/candles/{encode_path_segment(input_params.resolution)}/{encode_path_segment(symbol)}/",
user_universal_params=user_universal_params,
input_params=input_params,
extra_params=kwargs,
Expand Down
3 changes: 2 additions & 1 deletion src/marketdata/resources/stocks/earnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from marketdata.params import universal_params
from marketdata.resources.base import BaseResource
from marketdata.sdk_error import MarketDataClientErrorResult, handle_exceptions
from marketdata.utils import encode_path_segment


@handle_exceptions
Expand Down Expand Up @@ -40,7 +41,7 @@ def earnings(
)

url = self._build_url(
path=f"stocks/earnings/{symbol}/",
path=f"stocks/earnings/{encode_path_segment(symbol)}/",
user_universal_params=user_universal_params,
input_params=input_params,
extra_params=kwargs,
Expand Down
4 changes: 2 additions & 2 deletions src/marketdata/resources/stocks/news.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from marketdata.params import universal_params
from marketdata.resources.base import BaseResource
from marketdata.sdk_error import MarketDataClientErrorResult, handle_exceptions
from marketdata.utils import get_data_records
from marketdata.utils import encode_path_segment, get_data_records


@handle_exceptions
Expand Down Expand Up @@ -38,7 +38,7 @@ def news(
)

url = self._build_url(
path=f"stocks/news/{symbol}/",
path=f"stocks/news/{encode_path_segment(symbol)}/",
user_universal_params=user_universal_params,
input_params=input_params,
extra_params=kwargs,
Expand Down
4 changes: 1 addition & 3 deletions src/marketdata/retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,7 @@ def _compute_wait(retry_state) -> float:
if exc is not None:
response = getattr(exc, "response", None)
if response is not None:
retry_after = parse_retry_after(
response.headers.get("Retry-After")
)
retry_after = parse_retry_after(response.headers.get("Retry-After"))
if retry_after is not None:
return retry_after
return initial_delay * 2 ** (retry_state.attempt_number - 1)
Expand Down
28 changes: 26 additions & 2 deletions src/marketdata/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from enum import Enum
from io import StringIO
from typing import Any
from urllib.parse import quote

import pytz

Expand Down Expand Up @@ -143,8 +144,31 @@ def format_duration_log(duration_ms: float) -> str:


def obfuscate_token(token: str) -> str:
# Fixed-width mask so the log output never reveals the token length,
# and the last 4 chars are only shown when they are a small fraction of the token.
if not isinstance(token, str):
return str(token)
if len(token) <= 4:
if len(token) <= 8:
return "****"
return "*" * (len(token) - 4) + token[-4:]
return "****" + token[-4:]


def encode_path_segment(value: Any) -> str:
# Percent-encode a single URL path segment so caller-supplied input
# (e.g. a symbol) cannot smuggle extra path segments ("AAPL/../../user"),
# query params or fragments into the request. Valid symbols
# (alphanumerics, ".", "-", "_") are unaffected.
return quote(str(value), safe="")


def encode_path(value: str) -> str:
# Percent-encode a multi-segment URL path (e.g. an options lookup string,
# where "/" is valid inside dates). Literal slashes are kept, but
# dot-segments ("." / "..") are neutralized so caller-supplied input
# cannot traverse to a different endpoint.
quoted = quote(str(value))
segments = [
s.replace(".", "%2E") if s and s.strip(".") == "" else s
for s in quoted.split("/")
]
return "/".join(segments)
9 changes: 3 additions & 6 deletions src/tests/test_api_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,7 @@ def test_api_status_data(load_json, respx_mock, client):

API_STATUS_DATA.refresh(client)
for service in ALL_SERVICES:
assert (
API_STATUS_DATA.get_api_status(client, service)
== APIStatusResult.ONLINE
)
assert API_STATUS_DATA.get_api_status(client, service) == APIStatusResult.ONLINE


def test_api_status_data_offline(load_json, respx_mock, client):
Expand All @@ -52,8 +49,7 @@ def test_api_status_data_offline(load_json, respx_mock, client):
API_STATUS_DATA.refresh(client)
for service in ALL_SERVICES:
assert (
API_STATUS_DATA.get_api_status(client, service)
== APIStatusResult.OFFLINE
API_STATUS_DATA.get_api_status(client, service) == APIStatusResult.OFFLINE
)


Expand Down Expand Up @@ -180,6 +176,7 @@ def fake_thread(*args, **kwargs):
return m

import marketdata.api_status as mod

original_thread = mod.threading.Thread
mod.threading.Thread = fake_thread
try:
Expand Down
Loading