Skip to content

Commit ddb7735

Browse files
fix: sanitize endpoint path params
1 parent e904fd4 commit ddb7735

9 files changed

Lines changed: 351 additions & 54 deletions

File tree

src/dedalus_sdk/_utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from ._path import path_template as path_template
12
from ._sync import asyncify as asyncify
23
from ._proxy import LazyProxy as LazyProxy
34
from ._utils import (

src/dedalus_sdk/_utils/_path.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from typing import (
5+
Any,
6+
Mapping,
7+
Callable,
8+
)
9+
from urllib.parse import quote
10+
11+
# Matches '.' or '..' where each dot is either literal or percent-encoded (%2e / %2E).
12+
_DOT_SEGMENT_RE = re.compile(r"^(?:\.|%2[eE]){1,2}$")
13+
14+
_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
15+
16+
17+
def _quote_path_segment_part(value: str) -> str:
18+
"""Percent-encode `value` for use in a URI path segment.
19+
20+
Considers characters not in `pchar` set from RFC 3986 §3.3 to be unsafe.
21+
https://datatracker.ietf.org/doc/html/rfc3986#section-3.3
22+
"""
23+
# quote() already treats unreserved characters (letters, digits, and -._~)
24+
# as safe, so we only need to add sub-delims, ':', and '@'.
25+
# Notably, unlike the default `safe` for quote(), / is unsafe and must be quoted.
26+
return quote(value, safe="!$&'()*+,;=:@")
27+
28+
29+
def _quote_query_part(value: str) -> str:
30+
"""Percent-encode `value` for use in a URI query string.
31+
32+
Considers &, = and characters not in `query` set from RFC 3986 §3.4 to be unsafe.
33+
https://datatracker.ietf.org/doc/html/rfc3986#section-3.4
34+
"""
35+
return quote(value, safe="!$'()*+,;:@/?")
36+
37+
38+
def _quote_fragment_part(value: str) -> str:
39+
"""Percent-encode `value` for use in a URI fragment.
40+
41+
Considers characters not in `fragment` set from RFC 3986 §3.5 to be unsafe.
42+
https://datatracker.ietf.org/doc/html/rfc3986#section-3.5
43+
"""
44+
return quote(value, safe="!$&'()*+,;=:@/?")
45+
46+
47+
def _interpolate(
48+
template: str,
49+
values: Mapping[str, Any],
50+
quoter: Callable[[str], str],
51+
) -> str:
52+
"""Replace {name} placeholders in `template`, quoting each value with `quoter`.
53+
54+
Placeholder names are looked up in `values`.
55+
56+
Raises:
57+
KeyError: If a placeholder is not found in `values`.
58+
"""
59+
# re.split with a capturing group returns alternating
60+
# [text, name, text, name, ..., text] elements.
61+
parts = _PLACEHOLDER_RE.split(template)
62+
63+
for i in range(1, len(parts), 2):
64+
name = parts[i]
65+
if name not in values:
66+
raise KeyError(f"a value for placeholder {{{name}}} was not provided")
67+
val = values[name]
68+
if val is None:
69+
parts[i] = "null"
70+
elif isinstance(val, bool):
71+
parts[i] = "true" if val else "false"
72+
else:
73+
parts[i] = quoter(str(values[name]))
74+
75+
return "".join(parts)
76+
77+
78+
def path_template(template: str, /, **kwargs: Any) -> str:
79+
"""Interpolate {name} placeholders in `template` from keyword arguments.
80+
81+
Args:
82+
template: The template string containing {name} placeholders.
83+
**kwargs: Keyword arguments to interpolate into the template.
84+
85+
Returns:
86+
The template with placeholders interpolated and percent-encoded.
87+
88+
Safe characters for percent-encoding are dependent on the URI component.
89+
Placeholders in path and fragment portions are percent-encoded where the `segment`
90+
and `fragment` sets from RFC 3986 respectively are considered safe.
91+
Placeholders in the query portion are percent-encoded where the `query` set from
92+
RFC 3986 §3.3 is considered safe except for = and & characters.
93+
94+
Raises:
95+
KeyError: If a placeholder is not found in `kwargs`.
96+
ValueError: If resulting path contains /./ or /../ segments (including percent-encoded dot-segments).
97+
"""
98+
# Split the template into path, query, and fragment portions.
99+
fragment_template: str | None = None
100+
query_template: str | None = None
101+
102+
rest = template
103+
if "#" in rest:
104+
rest, fragment_template = rest.split("#", 1)
105+
if "?" in rest:
106+
rest, query_template = rest.split("?", 1)
107+
path_template = rest
108+
109+
# Interpolate each portion with the appropriate quoting rules.
110+
path_result = _interpolate(path_template, kwargs, _quote_path_segment_part)
111+
112+
# Reject dot-segments (. and ..) in the final assembled path. The check
113+
# runs after interpolation so that adjacent placeholders or a mix of static
114+
# text and placeholders that together form a dot-segment are caught.
115+
# Also reject percent-encoded dot-segments to protect against incorrectly
116+
# implemented normalization in servers/proxies.
117+
for segment in path_result.split("/"):
118+
if _DOT_SEGMENT_RE.match(segment):
119+
raise ValueError(f"Constructed path {path_result!r} contains dot-segment {segment!r} which is not allowed")
120+
121+
result = path_result
122+
if query_template is not None:
123+
result += "?" + _interpolate(query_template, kwargs, _quote_query_part)
124+
if fragment_template is not None:
125+
result += "#" + _interpolate(fragment_template, kwargs, _quote_fragment_part)
126+
127+
return result

src/dedalus_sdk/resources/workspaces/artifacts.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import httpx
66

77
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
8-
from ..._utils import maybe_transform
8+
from ..._utils import path_template, maybe_transform
99
from ..._compat import cached_property
1010
from ..._resource import SyncAPIResource, AsyncAPIResource
1111
from ..._response import (
@@ -71,7 +71,11 @@ def retrieve(
7171
if not artifact_id:
7272
raise ValueError(f"Expected a non-empty value for `artifact_id` but received {artifact_id!r}")
7373
return self._get(
74-
f"/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
74+
path_template(
75+
"/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
76+
workspace_id=workspace_id,
77+
artifact_id=artifact_id,
78+
),
7579
options=make_request_options(
7680
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
7781
),
@@ -106,7 +110,7 @@ def list(
106110
if not workspace_id:
107111
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
108112
return self._get_api_list(
109-
f"/v1/workspaces/{workspace_id}/artifacts",
113+
path_template("/v1/workspaces/{workspace_id}/artifacts", workspace_id=workspace_id),
110114
page=SyncCursorPage[Artifact],
111115
options=make_request_options(
112116
extra_headers=extra_headers,
@@ -156,7 +160,11 @@ def delete(
156160
if not artifact_id:
157161
raise ValueError(f"Expected a non-empty value for `artifact_id` but received {artifact_id!r}")
158162
return self._delete(
159-
f"/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
163+
path_template(
164+
"/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
165+
workspace_id=workspace_id,
166+
artifact_id=artifact_id,
167+
),
160168
options=make_request_options(
161169
extra_headers=extra_headers,
162170
extra_query=extra_query,
@@ -217,7 +225,11 @@ async def retrieve(
217225
if not artifact_id:
218226
raise ValueError(f"Expected a non-empty value for `artifact_id` but received {artifact_id!r}")
219227
return await self._get(
220-
f"/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
228+
path_template(
229+
"/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
230+
workspace_id=workspace_id,
231+
artifact_id=artifact_id,
232+
),
221233
options=make_request_options(
222234
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
223235
),
@@ -252,7 +264,7 @@ def list(
252264
if not workspace_id:
253265
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
254266
return self._get_api_list(
255-
f"/v1/workspaces/{workspace_id}/artifacts",
267+
path_template("/v1/workspaces/{workspace_id}/artifacts", workspace_id=workspace_id),
256268
page=AsyncCursorPage[Artifact],
257269
options=make_request_options(
258270
extra_headers=extra_headers,
@@ -302,7 +314,11 @@ async def delete(
302314
if not artifact_id:
303315
raise ValueError(f"Expected a non-empty value for `artifact_id` but received {artifact_id!r}")
304316
return await self._delete(
305-
f"/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
317+
path_template(
318+
"/v1/workspaces/{workspace_id}/artifacts/{artifact_id}",
319+
workspace_id=workspace_id,
320+
artifact_id=artifact_id,
321+
),
306322
options=make_request_options(
307323
extra_headers=extra_headers,
308324
extra_query=extra_query,

src/dedalus_sdk/resources/workspaces/executions.py

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import httpx
88

99
from ..._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given
10-
from ..._utils import maybe_transform, async_maybe_transform
10+
from ..._utils import path_template, maybe_transform, async_maybe_transform
1111
from ..._compat import cached_property
1212
from ..._resource import SyncAPIResource, AsyncAPIResource
1313
from ..._response import (
@@ -81,7 +81,7 @@ def create(
8181
if not workspace_id:
8282
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
8383
return self._post(
84-
f"/v1/workspaces/{workspace_id}/executions",
84+
path_template("/v1/workspaces/{workspace_id}/executions", workspace_id=workspace_id),
8585
body=maybe_transform(
8686
{
8787
"command": command,
@@ -132,7 +132,11 @@ def retrieve(
132132
if not execution_id:
133133
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
134134
return self._get(
135-
f"/v1/workspaces/{workspace_id}/executions/{execution_id}",
135+
path_template(
136+
"/v1/workspaces/{workspace_id}/executions/{execution_id}",
137+
workspace_id=workspace_id,
138+
execution_id=execution_id,
139+
),
136140
options=make_request_options(
137141
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
138142
),
@@ -167,7 +171,7 @@ def list(
167171
if not workspace_id:
168172
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
169173
return self._get_api_list(
170-
f"/v1/workspaces/{workspace_id}/executions",
174+
path_template("/v1/workspaces/{workspace_id}/executions", workspace_id=workspace_id),
171175
page=SyncCursorPage[Execution],
172176
options=make_request_options(
173177
extra_headers=extra_headers,
@@ -217,7 +221,11 @@ def delete(
217221
if not execution_id:
218222
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
219223
return self._delete(
220-
f"/v1/workspaces/{workspace_id}/executions/{execution_id}",
224+
path_template(
225+
"/v1/workspaces/{workspace_id}/executions/{execution_id}",
226+
workspace_id=workspace_id,
227+
execution_id=execution_id,
228+
),
221229
options=make_request_options(
222230
extra_headers=extra_headers,
223231
extra_query=extra_query,
@@ -259,7 +267,11 @@ def events(
259267
if not execution_id:
260268
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
261269
return self._get_api_list(
262-
f"/v1/workspaces/{workspace_id}/executions/{execution_id}/events",
270+
path_template(
271+
"/v1/workspaces/{workspace_id}/executions/{execution_id}/events",
272+
workspace_id=workspace_id,
273+
execution_id=execution_id,
274+
),
263275
page=SyncCursorPage[ExecutionEvent],
264276
options=make_request_options(
265277
extra_headers=extra_headers,
@@ -306,7 +318,11 @@ def output(
306318
if not execution_id:
307319
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
308320
return self._get(
309-
f"/v1/workspaces/{workspace_id}/executions/{execution_id}/output",
321+
path_template(
322+
"/v1/workspaces/{workspace_id}/executions/{execution_id}/output",
323+
workspace_id=workspace_id,
324+
execution_id=execution_id,
325+
),
310326
options=make_request_options(
311327
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
312328
),
@@ -369,7 +385,7 @@ async def create(
369385
if not workspace_id:
370386
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
371387
return await self._post(
372-
f"/v1/workspaces/{workspace_id}/executions",
388+
path_template("/v1/workspaces/{workspace_id}/executions", workspace_id=workspace_id),
373389
body=await async_maybe_transform(
374390
{
375391
"command": command,
@@ -420,7 +436,11 @@ async def retrieve(
420436
if not execution_id:
421437
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
422438
return await self._get(
423-
f"/v1/workspaces/{workspace_id}/executions/{execution_id}",
439+
path_template(
440+
"/v1/workspaces/{workspace_id}/executions/{execution_id}",
441+
workspace_id=workspace_id,
442+
execution_id=execution_id,
443+
),
424444
options=make_request_options(
425445
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
426446
),
@@ -455,7 +475,7 @@ def list(
455475
if not workspace_id:
456476
raise ValueError(f"Expected a non-empty value for `workspace_id` but received {workspace_id!r}")
457477
return self._get_api_list(
458-
f"/v1/workspaces/{workspace_id}/executions",
478+
path_template("/v1/workspaces/{workspace_id}/executions", workspace_id=workspace_id),
459479
page=AsyncCursorPage[Execution],
460480
options=make_request_options(
461481
extra_headers=extra_headers,
@@ -505,7 +525,11 @@ async def delete(
505525
if not execution_id:
506526
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
507527
return await self._delete(
508-
f"/v1/workspaces/{workspace_id}/executions/{execution_id}",
528+
path_template(
529+
"/v1/workspaces/{workspace_id}/executions/{execution_id}",
530+
workspace_id=workspace_id,
531+
execution_id=execution_id,
532+
),
509533
options=make_request_options(
510534
extra_headers=extra_headers,
511535
extra_query=extra_query,
@@ -547,7 +571,11 @@ def events(
547571
if not execution_id:
548572
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
549573
return self._get_api_list(
550-
f"/v1/workspaces/{workspace_id}/executions/{execution_id}/events",
574+
path_template(
575+
"/v1/workspaces/{workspace_id}/executions/{execution_id}/events",
576+
workspace_id=workspace_id,
577+
execution_id=execution_id,
578+
),
551579
page=AsyncCursorPage[ExecutionEvent],
552580
options=make_request_options(
553581
extra_headers=extra_headers,
@@ -594,7 +622,11 @@ async def output(
594622
if not execution_id:
595623
raise ValueError(f"Expected a non-empty value for `execution_id` but received {execution_id!r}")
596624
return await self._get(
597-
f"/v1/workspaces/{workspace_id}/executions/{execution_id}/output",
625+
path_template(
626+
"/v1/workspaces/{workspace_id}/executions/{execution_id}/output",
627+
workspace_id=workspace_id,
628+
execution_id=execution_id,
629+
),
598630
options=make_request_options(
599631
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
600632
),

0 commit comments

Comments
 (0)