Skip to content

Commit a7e8834

Browse files
fix: sanitize endpoint path params
1 parent 931c0f4 commit a7e8834

34 files changed

+598
-232
lines changed

src/orb/_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/orb/_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/orb/resources/alerts.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
alert_create_for_external_customer_params,
2020
)
2121
from .._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given
22-
from .._utils import maybe_transform, async_maybe_transform
22+
from .._utils import path_template, maybe_transform, async_maybe_transform
2323
from .._compat import cached_property
2424
from .._resource import SyncAPIResource, AsyncAPIResource
2525
from .._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper
@@ -84,7 +84,7 @@ def retrieve(
8484
if not alert_id:
8585
raise ValueError(f"Expected a non-empty value for `alert_id` but received {alert_id!r}")
8686
return self._get(
87-
f"/alerts/{alert_id}",
87+
path_template("/alerts/{alert_id}", alert_id=alert_id),
8888
options=make_request_options(
8989
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
9090
),
@@ -125,7 +125,7 @@ def update(
125125
f"Expected a non-empty value for `alert_configuration_id` but received {alert_configuration_id!r}"
126126
)
127127
return self._put(
128-
f"/alerts/{alert_configuration_id}",
128+
path_template("/alerts/{alert_configuration_id}", alert_configuration_id=alert_configuration_id),
129129
body=maybe_transform({"thresholds": thresholds}, alert_update_params.AlertUpdateParams),
130130
options=make_request_options(
131131
extra_headers=extra_headers,
@@ -261,7 +261,7 @@ def create_for_customer(
261261
if not customer_id:
262262
raise ValueError(f"Expected a non-empty value for `customer_id` but received {customer_id!r}")
263263
return self._post(
264-
f"/alerts/customer_id/{customer_id}",
264+
path_template("/alerts/customer_id/{customer_id}", customer_id=customer_id),
265265
body=maybe_transform(
266266
{
267267
"currency": currency,
@@ -328,7 +328,9 @@ def create_for_external_customer(
328328
f"Expected a non-empty value for `external_customer_id` but received {external_customer_id!r}"
329329
)
330330
return self._post(
331-
f"/alerts/external_customer_id/{external_customer_id}",
331+
path_template(
332+
"/alerts/external_customer_id/{external_customer_id}", external_customer_id=external_customer_id
333+
),
332334
body=maybe_transform(
333335
{
334336
"currency": currency,
@@ -403,7 +405,7 @@ def create_for_subscription(
403405
if not subscription_id:
404406
raise ValueError(f"Expected a non-empty value for `subscription_id` but received {subscription_id!r}")
405407
return self._post(
406-
f"/alerts/subscription_id/{subscription_id}",
408+
path_template("/alerts/subscription_id/{subscription_id}", subscription_id=subscription_id),
407409
body=maybe_transform(
408410
{
409411
"thresholds": thresholds,
@@ -461,7 +463,7 @@ def disable(
461463
f"Expected a non-empty value for `alert_configuration_id` but received {alert_configuration_id!r}"
462464
)
463465
return self._post(
464-
f"/alerts/{alert_configuration_id}/disable",
466+
path_template("/alerts/{alert_configuration_id}/disable", alert_configuration_id=alert_configuration_id),
465467
options=make_request_options(
466468
extra_headers=extra_headers,
467469
extra_query=extra_query,
@@ -510,7 +512,7 @@ def enable(
510512
f"Expected a non-empty value for `alert_configuration_id` but received {alert_configuration_id!r}"
511513
)
512514
return self._post(
513-
f"/alerts/{alert_configuration_id}/enable",
515+
path_template("/alerts/{alert_configuration_id}/enable", alert_configuration_id=alert_configuration_id),
514516
options=make_request_options(
515517
extra_headers=extra_headers,
516518
extra_query=extra_query,
@@ -576,7 +578,7 @@ async def retrieve(
576578
if not alert_id:
577579
raise ValueError(f"Expected a non-empty value for `alert_id` but received {alert_id!r}")
578580
return await self._get(
579-
f"/alerts/{alert_id}",
581+
path_template("/alerts/{alert_id}", alert_id=alert_id),
580582
options=make_request_options(
581583
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
582584
),
@@ -617,7 +619,7 @@ async def update(
617619
f"Expected a non-empty value for `alert_configuration_id` but received {alert_configuration_id!r}"
618620
)
619621
return await self._put(
620-
f"/alerts/{alert_configuration_id}",
622+
path_template("/alerts/{alert_configuration_id}", alert_configuration_id=alert_configuration_id),
621623
body=await async_maybe_transform({"thresholds": thresholds}, alert_update_params.AlertUpdateParams),
622624
options=make_request_options(
623625
extra_headers=extra_headers,
@@ -753,7 +755,7 @@ async def create_for_customer(
753755
if not customer_id:
754756
raise ValueError(f"Expected a non-empty value for `customer_id` but received {customer_id!r}")
755757
return await self._post(
756-
f"/alerts/customer_id/{customer_id}",
758+
path_template("/alerts/customer_id/{customer_id}", customer_id=customer_id),
757759
body=await async_maybe_transform(
758760
{
759761
"currency": currency,
@@ -820,7 +822,9 @@ async def create_for_external_customer(
820822
f"Expected a non-empty value for `external_customer_id` but received {external_customer_id!r}"
821823
)
822824
return await self._post(
823-
f"/alerts/external_customer_id/{external_customer_id}",
825+
path_template(
826+
"/alerts/external_customer_id/{external_customer_id}", external_customer_id=external_customer_id
827+
),
824828
body=await async_maybe_transform(
825829
{
826830
"currency": currency,
@@ -895,7 +899,7 @@ async def create_for_subscription(
895899
if not subscription_id:
896900
raise ValueError(f"Expected a non-empty value for `subscription_id` but received {subscription_id!r}")
897901
return await self._post(
898-
f"/alerts/subscription_id/{subscription_id}",
902+
path_template("/alerts/subscription_id/{subscription_id}", subscription_id=subscription_id),
899903
body=await async_maybe_transform(
900904
{
901905
"thresholds": thresholds,
@@ -953,7 +957,7 @@ async def disable(
953957
f"Expected a non-empty value for `alert_configuration_id` but received {alert_configuration_id!r}"
954958
)
955959
return await self._post(
956-
f"/alerts/{alert_configuration_id}/disable",
960+
path_template("/alerts/{alert_configuration_id}/disable", alert_configuration_id=alert_configuration_id),
957961
options=make_request_options(
958962
extra_headers=extra_headers,
959963
extra_query=extra_query,
@@ -1004,7 +1008,7 @@ async def enable(
10041008
f"Expected a non-empty value for `alert_configuration_id` but received {alert_configuration_id!r}"
10051009
)
10061010
return await self._post(
1007-
f"/alerts/{alert_configuration_id}/enable",
1011+
path_template("/alerts/{alert_configuration_id}/enable", alert_configuration_id=alert_configuration_id),
10081012
options=make_request_options(
10091013
extra_headers=extra_headers,
10101014
extra_query=extra_query,

src/orb/resources/beta/beta.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from ... import _legacy_response
1010
from ...types import beta_create_plan_version_params, beta_set_default_plan_version_params
1111
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
12-
from ..._utils import maybe_transform, async_maybe_transform
12+
from ..._utils import path_template, maybe_transform, async_maybe_transform
1313
from ..._compat import cached_property
1414
from ..._resource import SyncAPIResource, AsyncAPIResource
1515
from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper
@@ -116,7 +116,7 @@ def create_plan_version(
116116
if not plan_id:
117117
raise ValueError(f"Expected a non-empty value for `plan_id` but received {plan_id!r}")
118118
return self._post(
119-
f"/plans/{plan_id}/versions",
119+
path_template("/plans/{plan_id}/versions", plan_id=plan_id),
120120
body=maybe_transform(
121121
{
122122
"version": version,
@@ -171,7 +171,7 @@ def fetch_plan_version(
171171
if not version:
172172
raise ValueError(f"Expected a non-empty value for `version` but received {version!r}")
173173
return self._get(
174-
f"/plans/{plan_id}/versions/{version}",
174+
path_template("/plans/{plan_id}/versions/{version}", plan_id=plan_id, version=version),
175175
options=make_request_options(
176176
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
177177
),
@@ -210,7 +210,7 @@ def set_default_plan_version(
210210
if not plan_id:
211211
raise ValueError(f"Expected a non-empty value for `plan_id` but received {plan_id!r}")
212212
return self._post(
213-
f"/plans/{plan_id}/set_default_version",
213+
path_template("/plans/{plan_id}/set_default_version", plan_id=plan_id),
214214
body=maybe_transform(
215215
{"version": version}, beta_set_default_plan_version_params.BetaSetDefaultPlanVersionParams
216216
),
@@ -313,7 +313,7 @@ async def create_plan_version(
313313
if not plan_id:
314314
raise ValueError(f"Expected a non-empty value for `plan_id` but received {plan_id!r}")
315315
return await self._post(
316-
f"/plans/{plan_id}/versions",
316+
path_template("/plans/{plan_id}/versions", plan_id=plan_id),
317317
body=await async_maybe_transform(
318318
{
319319
"version": version,
@@ -368,7 +368,7 @@ async def fetch_plan_version(
368368
if not version:
369369
raise ValueError(f"Expected a non-empty value for `version` but received {version!r}")
370370
return await self._get(
371-
f"/plans/{plan_id}/versions/{version}",
371+
path_template("/plans/{plan_id}/versions/{version}", plan_id=plan_id, version=version),
372372
options=make_request_options(
373373
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
374374
),
@@ -407,7 +407,7 @@ async def set_default_plan_version(
407407
if not plan_id:
408408
raise ValueError(f"Expected a non-empty value for `plan_id` but received {plan_id!r}")
409409
return await self._post(
410-
f"/plans/{plan_id}/set_default_version",
410+
path_template("/plans/{plan_id}/set_default_version", plan_id=plan_id),
411411
body=await async_maybe_transform(
412412
{"version": version}, beta_set_default_plan_version_params.BetaSetDefaultPlanVersionParams
413413
),

0 commit comments

Comments
 (0)