Skip to content

Commit cc7fd9b

Browse files
karlwaldmanclaude
andauthored
fix(security): reject raw paths that change the authenticated API origin (#102) (#113)
`request()`, `request_with_headers()` and the async `request()` built their URL with a bare `urljoin(base_url + "/", path)`. urljoin implements RFC 3986 reference resolution, so a caller-supplied path beginning `//` is a network-path reference and replaces the authority outright. The Authorization header is set on the httpx client, not per request, so the customer's API key went with it. Reproduced on a mock transport with a fixture key, before this change: '//fixture.invalid/v1/prices' -> https://fixture.invalid/v1/prices Authorization: Token <key> '//user@fixture.invalid/v1/prices' -> https://user@fixture.invalid/v1/prices Authorization: Basic dXNlcjo= The second form is worse than a leak: httpx reads the userinfo and swaps our Token credential for a Basic one. New `oilpriceapi/_url.py` resolves the URL once, centrally, and refuses anything whose origin (scheme, host, port) is not exactly the configured base origin. It also refuses backslashes (parsers downstream normalize "\" to "/") and control characters (CR/LF request splitting) before resolution. An explicit `base_url` keeps working — the guard pins to whatever the caller configured, not to a hard-coded hostname — so proxies, staging hosts and test servers are unaffected. Valid paths, encoded segments and query parameters are unchanged. Same defect class is filed against node #80, go #32, php #14. Claude-Session: https://claude.ai/code/session_015ao5paex73xXvuM424Libo Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6e1a66c commit cc7fd9b

4 files changed

Lines changed: 318 additions & 14 deletions

File tree

‎oilpriceapi/_url.py‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Pin every request to the configured API origin (#102).
2+
3+
The clients used to build a request URL with a bare
4+
``urljoin(base_url + "/", path)``. ``urljoin`` implements RFC 3986 reference
5+
resolution, so a caller-supplied raw path that happens to be a *network-path
6+
reference* — anything starting ``//`` — replaces the authority outright:
7+
8+
urljoin("https://api.oilpriceapi.com/", "//elsewhere.example/v1/prices")
9+
-> "https://elsewhere.example/v1/prices"
10+
11+
The client's ``Authorization`` header is set on the httpx client, not per
12+
request, so the customer's API key travels to whatever host the caller's path
13+
resolved to. ``//user@elsewhere.example/...`` is worse still: httpx reads the
14+
userinfo and replaces the Token header with a Basic credential.
15+
16+
This module resolves the URL once, centrally, and refuses anything whose origin
17+
(scheme, host, port) is not exactly the configured base origin. An explicit
18+
``base_url`` — a proxy, a staging host, a test server — keeps working, because
19+
the guard pins to whatever the caller configured, not to a hard-coded hostname.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from typing import Any, Tuple
25+
from urllib.parse import urljoin, urlsplit
26+
27+
from .exceptions import ValidationError
28+
29+
__all__ = ["resolve_api_url"]
30+
31+
# Backslash: several URL parsers (WHATWG browsers, some proxies and gateways)
32+
# normalize "\" to "/", which turns "/\evil.example/x" into a network-path
33+
# reference after this SDK has already decided it was a plain path.
34+
# Control characters (including CR/LF and NUL) can split a request line or
35+
# smuggle a header. Neither can appear in a legitimate API path.
36+
_FORBIDDEN_CHARS = frozenset("\\") | frozenset(chr(c) for c in range(0x21)) | {chr(0x7F)}
37+
38+
_DEFAULT_PORTS = {"http": 80, "https": 443}
39+
40+
41+
def _origin(url: str) -> Tuple[str, str, int]:
42+
parts = urlsplit(url)
43+
scheme = (parts.scheme or "").lower()
44+
host = (parts.hostname or "").lower()
45+
port = parts.port or _DEFAULT_PORTS.get(scheme, 0)
46+
return scheme, host, port
47+
48+
49+
def _reject(path: Any, reason: str) -> "ValidationError":
50+
# `path` is caller-supplied and carries no credential; the API key lives in
51+
# a header, never in the path, so echoing it back is safe and is the only
52+
# way the caller can see which value was refused.
53+
return ValidationError(
54+
message=(
55+
f"Refusing to send this request: the path {reason}. "
56+
"API paths must be relative to the configured base URL "
57+
"(for example '/v1/prices/latest'). To talk to a different host, "
58+
"construct a client with that base_url instead — a raw path may "
59+
"not change the origin, because the API key would be sent to it."
60+
),
61+
field="path",
62+
value=path,
63+
)
64+
65+
66+
def resolve_api_url(base_url: str, path: Any) -> str:
67+
"""Resolve ``path`` against ``base_url``, refusing any origin change.
68+
69+
Args:
70+
base_url: The client's configured base URL (no trailing slash).
71+
path: Caller-supplied API path.
72+
73+
Returns:
74+
The absolute URL to request, guaranteed to share ``base_url``'s origin.
75+
76+
Raises:
77+
ValidationError: If ``path`` is not a string, contains a character that
78+
a URL parser could use to change the authority, or resolves to any
79+
origin other than ``base_url``'s.
80+
"""
81+
if not isinstance(path, str):
82+
raise _reject(path, f"must be a string, got {type(path).__name__}")
83+
84+
if not path:
85+
raise _reject(path, "is empty")
86+
87+
bad = _FORBIDDEN_CHARS.intersection(path)
88+
if bad:
89+
raise _reject(
90+
path,
91+
"contains a character that is not allowed in an API path "
92+
f"({''.join(sorted(repr(c) for c in bad))})",
93+
)
94+
95+
if "//" in path.split("?", 1)[0].split("#", 1)[0]:
96+
# Covers the plain scheme-relative "//host/..." form and any absolute
97+
# URL ("https://host/..."), before it can be resolved.
98+
raise _reject(path, "may not contain '//'")
99+
100+
normalized = path if path.startswith("/") else "/" + path
101+
102+
url = urljoin(base_url + "/", normalized)
103+
104+
if _origin(url) != _origin(base_url):
105+
raise _reject(path, "resolves to a different host than the configured base URL")
106+
107+
return url

‎oilpriceapi/async_client.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@
1111
import os
1212
from datetime import datetime
1313
from typing import Any, AsyncGenerator, Dict, List, Optional, Union, cast
14-
from urllib.parse import urljoin
1514

1615
import httpx
1716

1817
logger = logging.getLogger(__name__)
1918

2019
from ._subscriptions_common import unwrap_data
20+
from ._url import resolve_api_url
2121
from .async_resources import (
2222
AsyncAlertsResource,
2323
AsyncAnalyticsResource,
@@ -202,10 +202,10 @@ async def request(
202202
await self._ensure_client()
203203
assert self._client is not None # set by _ensure_client
204204

205-
# Ensure path starts with / for proper urljoin behavior
206-
if not path.startswith("/"):
207-
path = "/" + path
208-
url = urljoin(self.base_url + "/", path)
205+
# Pin the request to the configured API origin. A raw path may not
206+
# move the destination host, because the API key rides on this client
207+
# and would go with it (#102).
208+
url = resolve_api_url(self.base_url, path)
209209

210210
# Retry logic
211211
import time as _time

‎oilpriceapi/client.py‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import os
99
import time
1010
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
11-
from urllib.parse import urljoin
1211

1312
import httpx
1413

@@ -18,6 +17,7 @@
1817
logger = logging.getLogger(__name__)
1918

2019
from ._subscriptions_common import unwrap_data
20+
from ._url import resolve_api_url
2121
from .exceptions import (
2222
ConfigurationError,
2323
OilPriceAPIError,
@@ -231,10 +231,10 @@ def request(
231231
ServerError: On 5xx status
232232
TimeoutError: On request timeout
233233
"""
234-
# Ensure path starts with / for proper urljoin behavior
235-
if not path.startswith("/"):
236-
path = "/" + path
237-
url = urljoin(self.base_url + "/", path)
234+
# Pin the request to the configured API origin. A raw path may not
235+
# move the destination host, because the API key rides on this client
236+
# and would go with it (#102).
237+
url = resolve_api_url(self.base_url, path)
238238

239239
# Use provided timeout or default
240240
effective_timeout = timeout if timeout is not None else self.timeout
@@ -364,10 +364,10 @@ def request_with_headers(
364364
Returns:
365365
Tuple of (parsed JSON dict, httpx.Headers)
366366
"""
367-
# Ensure path starts with / for proper urljoin behavior
368-
if not path.startswith("/"):
369-
path = "/" + path
370-
url = urljoin(self.base_url + "/", path)
367+
# Pin the request to the configured API origin. A raw path may not
368+
# move the destination host, because the API key rides on this client
369+
# and would go with it (#102).
370+
url = resolve_api_url(self.base_url, path)
371371

372372
effective_timeout = timeout if timeout is not None else self.timeout
373373

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""Raw paths must never move the request off the authenticated API origin (#102).
2+
3+
Every assertion here is made on what reaches the transport, not on an internal
4+
flag: an off-origin path must produce ZERO outbound requests, so the customer's
5+
API key is never attached to a host that is not OilPriceAPI.
6+
"""
7+
8+
import asyncio
9+
10+
import httpx
11+
import pytest
12+
13+
from oilpriceapi import AsyncOilPriceAPI, OilPriceAPI
14+
from oilpriceapi.exceptions import OilPriceAPIError
15+
16+
# Not a credential: a fixture string used only against a mock transport.
17+
FIXTURE_KEY = "-".join(["fixture", "not", "a", "real", "key"])
18+
19+
FOREIGN_HOST = "fixture.invalid"
20+
21+
# Raw paths a caller could build from untrusted input. Each one either moves the
22+
# origin outright or is normalized into an origin change by some parser in the
23+
# chain (scheme-relative, userinfo authority, backslash, absolute URL).
24+
OFF_ORIGIN_PATHS = [
25+
"//fixture.invalid/v1/prices/latest",
26+
"///fixture.invalid/v1/prices/latest",
27+
"//user@fixture.invalid/v1/prices/latest",
28+
"//fixture.invalid:8443/v1/prices/latest",
29+
"/\\fixture.invalid/v1/prices/latest",
30+
"\\\\fixture.invalid/v1/prices/latest",
31+
"https://fixture.invalid/v1/prices/latest",
32+
"http://fixture.invalid/v1/prices/latest",
33+
"/v1/prices\nHost: fixture.invalid",
34+
]
35+
36+
ON_ORIGIN_PATHS = [
37+
"/v1/prices/latest",
38+
"v1/prices/latest",
39+
"/v1/prices/past_week",
40+
"/v1/commodities/BRENT%20CRUDE/summary",
41+
"/v1/subscriptions/9f3c1a2b-0000-4000-8000-000000000000",
42+
]
43+
44+
45+
class _Recorder:
46+
"""Mock transport that records every request that actually goes out."""
47+
48+
def __init__(self):
49+
self.requests = []
50+
51+
def __call__(self, request):
52+
self.requests.append(request)
53+
return httpx.Response(200, json={"status": "success", "data": {}})
54+
55+
@property
56+
def hosts(self):
57+
return [r.url.host for r in self.requests]
58+
59+
@property
60+
def authenticated_foreign_requests(self):
61+
return [
62+
r
63+
for r in self.requests
64+
if r.url.host != "api.oilpriceapi.com"
65+
and (r.headers.get("authorization") or r.headers.get("Authorization"))
66+
]
67+
68+
69+
def _sync_client(recorder, **kwargs):
70+
client = OilPriceAPI(api_key=FIXTURE_KEY, **kwargs)
71+
client._client = httpx.Client(
72+
base_url=client.base_url,
73+
headers=client.headers,
74+
transport=httpx.MockTransport(recorder),
75+
)
76+
return client
77+
78+
79+
def _async_client(recorder, **kwargs):
80+
client = AsyncOilPriceAPI(api_key=FIXTURE_KEY, **kwargs)
81+
client._client = httpx.AsyncClient(
82+
base_url=client.base_url,
83+
headers=client.headers,
84+
transport=httpx.MockTransport(recorder),
85+
)
86+
return client
87+
88+
89+
@pytest.mark.parametrize("path", OFF_ORIGIN_PATHS)
90+
def test_sync_request_sends_nothing_for_off_origin_path(path):
91+
recorder = _Recorder()
92+
client = _sync_client(recorder)
93+
94+
with pytest.raises(OilPriceAPIError):
95+
client.request("GET", path)
96+
97+
assert recorder.requests == [], (
98+
f"path {path!r} reached the wire at {recorder.hosts}"
99+
)
100+
101+
102+
@pytest.mark.parametrize("path", OFF_ORIGIN_PATHS)
103+
def test_sync_request_with_headers_sends_nothing_for_off_origin_path(path):
104+
recorder = _Recorder()
105+
client = _sync_client(recorder)
106+
107+
with pytest.raises(OilPriceAPIError):
108+
client.request_with_headers("GET", path)
109+
110+
assert recorder.requests == []
111+
112+
113+
@pytest.mark.parametrize("path", OFF_ORIGIN_PATHS)
114+
def test_async_request_sends_nothing_for_off_origin_path(path):
115+
recorder = _Recorder()
116+
117+
async def scenario():
118+
client = _async_client(recorder)
119+
with pytest.raises(OilPriceAPIError):
120+
await client.request("GET", path)
121+
await client._client.aclose()
122+
123+
asyncio.run(scenario())
124+
assert recorder.requests == []
125+
126+
127+
def test_api_key_never_reaches_a_foreign_host():
128+
"""The property that actually matters: the key stays on our origin."""
129+
recorder = _Recorder()
130+
client = _sync_client(recorder)
131+
132+
for path in OFF_ORIGIN_PATHS:
133+
try:
134+
client.request("GET", path)
135+
except OilPriceAPIError:
136+
pass
137+
138+
assert recorder.authenticated_foreign_requests == []
139+
assert FOREIGN_HOST not in recorder.hosts
140+
141+
142+
@pytest.mark.parametrize("path", ON_ORIGIN_PATHS)
143+
def test_valid_paths_still_reach_the_api_origin(path):
144+
recorder = _Recorder()
145+
client = _sync_client(recorder)
146+
147+
client.request("GET", path, params={"by_code": "BRENT_CRUDE_USD"})
148+
149+
assert len(recorder.requests) == 1
150+
sent = recorder.requests[0]
151+
assert sent.url.host == "api.oilpriceapi.com"
152+
assert sent.url.scheme == "https"
153+
assert sent.url.params.get("by_code") == "BRENT_CRUDE_USD"
154+
assert sent.headers["Authorization"].endswith(FIXTURE_KEY)
155+
156+
157+
def test_explicit_custom_base_url_is_preserved():
158+
"""Proxies and test servers stay supported — the guard pins to the CONFIGURED origin."""
159+
recorder = _Recorder()
160+
client = _sync_client(recorder, base_url="https://proxy.internal.example:8443/api")
161+
162+
client.request("GET", "/v1/prices/latest")
163+
164+
assert len(recorder.requests) == 1
165+
sent = recorder.requests[0]
166+
assert sent.url.host == "proxy.internal.example"
167+
assert sent.url.port == 8443
168+
169+
170+
def test_off_origin_path_is_still_rejected_under_a_custom_base_url():
171+
recorder = _Recorder()
172+
client = _sync_client(recorder, base_url="https://proxy.internal.example:8443/api")
173+
174+
with pytest.raises(OilPriceAPIError):
175+
client.request("GET", "//fixture.invalid/v1/prices/latest")
176+
177+
assert recorder.requests == []
178+
179+
180+
def test_non_string_path_is_rejected_before_any_request():
181+
recorder = _Recorder()
182+
client = _sync_client(recorder)
183+
184+
with pytest.raises(OilPriceAPIError):
185+
client.request("GET", None)
186+
187+
assert recorder.requests == []
188+
189+
190+
def test_rejection_message_does_not_leak_the_api_key():
191+
recorder = _Recorder()
192+
client = _sync_client(recorder)
193+
194+
with pytest.raises(OilPriceAPIError) as excinfo:
195+
client.request("GET", "//fixture.invalid/v1/prices/latest")
196+
197+
assert FIXTURE_KEY not in str(excinfo.value)

0 commit comments

Comments
 (0)