Skip to content

Commit a6e6112

Browse files
fix(utils): percent-encode path parameters so an ID can't change which API route is called (#1230)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 702a5e1 commit a6e6112

3 files changed

Lines changed: 138 additions & 1 deletion

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
diff --git a/src/openrouter/utils/url.py b/src/openrouter/utils/url.py
2+
index 8eeaa7b0..3c8362df 100644
3+
--- a/src/openrouter/utils/url.py
4+
+++ b/src/openrouter/utils/url.py
5+
@@ -10,6 +10,8 @@ from typing import (
6+
get_args,
7+
get_origin,
8+
)
9+
+from urllib.parse import quote
10+
+
11+
from pydantic import BaseModel
12+
from pydantic.fields import FieldInfo
13+
14+
@@ -40,11 +42,19 @@ def generate_url(
15+
_populate_path_params(gbls, None, path_param_values, globals_already_populated)
16+
17+
for key, value in path_param_values.items():
18+
- path = path.replace("{" + key + "}", value, 1)
19+
+ path = path.replace("{" + key + "}", _encode_path_param(value), 1)
20+
21+
return remove_suffix(server_url, "/") + path
22+
23+
24+
+def _encode_path_param(value: str) -> str:
25+
+ # Percent-encode "/", "?", "#" etc. so a value can't change the request path.
26+
+ # "." and ".." contain no encodable chars and would still be dot segments.
27+
+ if value in (".", ".."):
28+
+ raise ValueError(f"path parameter cannot be {value!r}")
29+
+ return quote(value, safe="")
30+
+
31+
+
32+
def _populate_path_params(
33+
path_params: Any,
34+
gbls: Any,

‎src/openrouter/utils/url.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
get_args,
1111
get_origin,
1212
)
13+
from urllib.parse import quote
14+
1315
from pydantic import BaseModel
1416
from pydantic.fields import FieldInfo
1517

@@ -40,11 +42,19 @@ def generate_url(
4042
_populate_path_params(gbls, None, path_param_values, globals_already_populated)
4143

4244
for key, value in path_param_values.items():
43-
path = path.replace("{" + key + "}", value, 1)
45+
path = path.replace("{" + key + "}", _encode_path_param(value), 1)
4446

4547
return remove_suffix(server_url, "/") + path
4648

4749

50+
def _encode_path_param(value: str) -> str:
51+
# Percent-encode "/", "?", "#" etc. so a value can't change the request path.
52+
# "." and ".." contain no encodable chars and would still be dot segments.
53+
if value in (".", ".."):
54+
raise ValueError(f"path parameter cannot be {value!r}")
55+
return quote(value, safe="")
56+
57+
4858
def _populate_path_params(
4959
path_params: Any,
5060
gbls: Any,

‎tests/test_path_param_encoding.py‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import json
2+
import threading
3+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
4+
from typing import Iterator, List, Tuple
5+
6+
import pytest
7+
8+
from openrouter import OpenRouter, errors
9+
from openrouter.operations import DeleteFileRequest
10+
from openrouter.utils.url import generate_url
11+
12+
13+
class _RecordingHandler(BaseHTTPRequestHandler):
14+
requests: List[Tuple[str, str]] = []
15+
16+
def _respond(self) -> None:
17+
type(self).requests.append((self.command, self.path))
18+
body = json.dumps({"error": {"code": 404, "message": "not found"}}).encode()
19+
self.send_response(404)
20+
self.send_header("Content-Type", "application/json")
21+
self.send_header("Content-Length", str(len(body)))
22+
self.end_headers()
23+
self.wfile.write(body)
24+
25+
do_GET = do_DELETE = do_POST = do_PATCH = _respond
26+
27+
def log_message(self, *args, **kwargs) -> None: # pylint: disable=arguments-differ
28+
pass
29+
30+
31+
@pytest.fixture(name="mock_server")
32+
def fixture_mock_server() -> Iterator[str]:
33+
_RecordingHandler.requests = []
34+
server = ThreadingHTTPServer(("127.0.0.1", 0), _RecordingHandler)
35+
thread = threading.Thread(target=server.serve_forever, daemon=True)
36+
thread.start()
37+
try:
38+
yield f"http://127.0.0.1:{server.server_address[1]}/api/v1"
39+
finally:
40+
server.shutdown()
41+
server.server_close()
42+
43+
44+
def _sent(mock_server: str, call) -> Tuple[str, str]:
45+
with OpenRouter(api_key="sk-or-v1-fake", server_url=mock_server) as client:
46+
with pytest.raises(errors.OpenRouterError):
47+
call(client)
48+
assert len(_RecordingHandler.requests) == 1
49+
return _RecordingHandler.requests[0]
50+
51+
52+
def test_traversal_in_file_id_stays_on_files_download_route(mock_server: str):
53+
sent = _sent(mock_server, lambda c: c.files.download(file_id="../keys?x="))
54+
assert sent == ("GET", "/api/v1/files/..%2Fkeys%3Fx%3D/content")
55+
56+
57+
def test_traversal_in_file_id_stays_on_files_delete_route(mock_server: str):
58+
sent = _sent(mock_server, lambda c: c.files.delete(file_id="../keys/abc"))
59+
assert sent == ("DELETE", "/api/v1/files/..%2Fkeys%2Fabc")
60+
61+
62+
def test_normal_file_id_is_unchanged(mock_server: str):
63+
sent = _sent(mock_server, lambda c: c.files.download(file_id="file-abc_123.x~y"))
64+
assert sent == ("GET", "/api/v1/files/file-abc_123.x~y/content")
65+
66+
67+
def test_spaces_unicode_and_fragment_are_percent_encoded(mock_server: str):
68+
sent = _sent(mock_server, lambda c: c.files.delete(file_id="my file é#frag"))
69+
assert sent == ("DELETE", "/api/v1/files/my%20file%20%C3%A9%23frag")
70+
71+
72+
@pytest.mark.parametrize("file_id", [".", ".."])
73+
def test_dot_segment_ids_are_rejected_before_sending(mock_server: str, file_id: str):
74+
with OpenRouter(api_key="sk-or-v1-fake", server_url=mock_server) as client:
75+
with pytest.raises(ValueError):
76+
client.files.delete(file_id=file_id)
77+
assert not _RecordingHandler.requests
78+
79+
80+
@pytest.mark.parametrize(
81+
("value", "expected"),
82+
[
83+
("abc", "/files/abc"),
84+
("../keys", "/files/..%2Fkeys"),
85+
("%2e%2e", "/files/%252e%252e"),
86+
("a?b#c", "/files/a%3Fb%23c"),
87+
],
88+
)
89+
def test_generate_url_percent_encodes_path_params(value: str, expected: str):
90+
url = generate_url(
91+
"http://127.0.0.1/api/v1/", "/files/{file_id}", DeleteFileRequest(file_id=value)
92+
)
93+
assert url == "http://127.0.0.1/api/v1" + expected

0 commit comments

Comments
 (0)