Skip to content

Commit 6993e56

Browse files
fix: sanitize endpoint path params
1 parent 46bea8a commit 6993e56

27 files changed

Lines changed: 657 additions & 228 deletions

src/keycardai_api/_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/keycardai_api/_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/keycardai_api/resources/invitations.py

Lines changed: 5 additions & 5 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 strip_not_given
8+
from .._utils import path_template, strip_not_given
99
from .._compat import cached_property
1010
from .._resource import SyncAPIResource, AsyncAPIResource
1111
from .._response import (
@@ -69,7 +69,7 @@ def retrieve(
6969
raise ValueError(f"Expected a non-empty value for `token` but received {token!r}")
7070
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
7171
return self._get(
72-
f"/invitations/{token}",
72+
path_template("/invitations/{token}", token=token),
7373
options=make_request_options(
7474
extra_headers=extra_headers,
7575
extra_query=extra_query,
@@ -108,7 +108,7 @@ def accept(
108108
raise ValueError(f"Expected a non-empty value for `token` but received {token!r}")
109109
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
110110
return self._post(
111-
f"/invitations/{token}/accept",
111+
path_template("/invitations/{token}/accept", token=token),
112112
options=make_request_options(
113113
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
114114
),
@@ -164,7 +164,7 @@ async def retrieve(
164164
raise ValueError(f"Expected a non-empty value for `token` but received {token!r}")
165165
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
166166
return await self._get(
167-
f"/invitations/{token}",
167+
path_template("/invitations/{token}", token=token),
168168
options=make_request_options(
169169
extra_headers=extra_headers,
170170
extra_query=extra_query,
@@ -203,7 +203,7 @@ async def accept(
203203
raise ValueError(f"Expected a non-empty value for `token` but received {token!r}")
204204
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
205205
return await self._post(
206-
f"/invitations/{token}/accept",
206+
path_template("/invitations/{token}/accept", token=token),
207207
options=make_request_options(
208208
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
209209
),

src/keycardai_api/resources/organizations/invitations.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import httpx
99

1010
from ..._types import Body, Omit, Query, Headers, NoneType, NotGiven, omit, not_given
11-
from ..._utils import maybe_transform, strip_not_given, async_maybe_transform
11+
from ..._utils import path_template, maybe_transform, strip_not_given, async_maybe_transform
1212
from ..._compat import cached_property
1313
from ..._resource import SyncAPIResource, AsyncAPIResource
1414
from ..._response import (
@@ -82,7 +82,7 @@ def create(
8282
raise ValueError(f"Expected a non-empty value for `organization_id` but received {organization_id!r}")
8383
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
8484
return self._post(
85-
f"/organizations/{organization_id}/invitations",
85+
path_template("/organizations/{organization_id}/invitations", organization_id=organization_id),
8686
body=maybe_transform(
8787
{
8888
"email": email,
@@ -139,7 +139,7 @@ def list(
139139
raise ValueError(f"Expected a non-empty value for `organization_id` but received {organization_id!r}")
140140
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
141141
return self._get(
142-
f"/organizations/{organization_id}/invitations",
142+
path_template("/organizations/{organization_id}/invitations", organization_id=organization_id),
143143
options=make_request_options(
144144
extra_headers=extra_headers,
145145
extra_query=extra_query,
@@ -194,7 +194,11 @@ def delete(
194194
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
195195
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
196196
return self._delete(
197-
f"/organizations/{organization_id}/invitations/{invitation_id}",
197+
path_template(
198+
"/organizations/{organization_id}/invitations/{invitation_id}",
199+
organization_id=organization_id,
200+
invitation_id=invitation_id,
201+
),
198202
options=make_request_options(
199203
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
200204
),
@@ -258,7 +262,7 @@ async def create(
258262
raise ValueError(f"Expected a non-empty value for `organization_id` but received {organization_id!r}")
259263
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
260264
return await self._post(
261-
f"/organizations/{organization_id}/invitations",
265+
path_template("/organizations/{organization_id}/invitations", organization_id=organization_id),
262266
body=await async_maybe_transform(
263267
{
264268
"email": email,
@@ -315,7 +319,7 @@ async def list(
315319
raise ValueError(f"Expected a non-empty value for `organization_id` but received {organization_id!r}")
316320
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
317321
return await self._get(
318-
f"/organizations/{organization_id}/invitations",
322+
path_template("/organizations/{organization_id}/invitations", organization_id=organization_id),
319323
options=make_request_options(
320324
extra_headers=extra_headers,
321325
extra_query=extra_query,
@@ -370,7 +374,11 @@ async def delete(
370374
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
371375
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
372376
return await self._delete(
373-
f"/organizations/{organization_id}/invitations/{invitation_id}",
377+
path_template(
378+
"/organizations/{organization_id}/invitations/{invitation_id}",
379+
organization_id=organization_id,
380+
invitation_id=invitation_id,
381+
),
374382
options=make_request_options(
375383
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
376384
),

src/keycardai_api/resources/organizations/organizations.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
organization_list_identities_params,
2626
)
2727
from ..._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
28-
from ..._utils import maybe_transform, strip_not_given, async_maybe_transform
28+
from ..._utils import path_template, maybe_transform, strip_not_given, async_maybe_transform
2929
from ..._compat import cached_property
3030
from ..._resource import SyncAPIResource, AsyncAPIResource
3131
from ..._response import (
@@ -175,7 +175,7 @@ def retrieve(
175175
raise ValueError(f"Expected a non-empty value for `organization_id` but received {organization_id!r}")
176176
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
177177
return self._get(
178-
f"/organizations/{organization_id}",
178+
path_template("/organizations/{organization_id}", organization_id=organization_id),
179179
options=make_request_options(
180180
extra_headers=extra_headers,
181181
extra_query=extra_query,
@@ -219,7 +219,7 @@ def update(
219219
raise ValueError(f"Expected a non-empty value for `organization_id` but received {organization_id!r}")
220220
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
221221
return self._patch(
222-
f"/organizations/{organization_id}",
222+
path_template("/organizations/{organization_id}", organization_id=organization_id),
223223
body=maybe_transform({"name": name}, organization_update_params.OrganizationUpdateParams),
224224
options=make_request_options(
225225
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -314,7 +314,7 @@ def exchange_token(
314314
raise ValueError(f"Expected a non-empty value for `organization_id` but received {organization_id!r}")
315315
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
316316
return self._post(
317-
f"/organizations/{organization_id}/token",
317+
path_template("/organizations/{organization_id}/token", organization_id=organization_id),
318318
options=make_request_options(
319319
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
320320
),
@@ -367,7 +367,7 @@ def list_identities(
367367
raise ValueError(f"Expected a non-empty value for `organization_id` but received {organization_id!r}")
368368
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
369369
return self._get(
370-
f"/organizations/{organization_id}/identities",
370+
path_template("/organizations/{organization_id}/identities", organization_id=organization_id),
371371
options=make_request_options(
372372
extra_headers=extra_headers,
373373
extra_query=extra_query,
@@ -433,7 +433,7 @@ def list_roles(
433433
raise ValueError(f"Expected a non-empty value for `organization_id` but received {organization_id!r}")
434434
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
435435
return self._get(
436-
f"/organizations/{organization_id}/roles",
436+
path_template("/organizations/{organization_id}/roles", organization_id=organization_id),
437437
options=make_request_options(
438438
extra_headers=extra_headers,
439439
extra_query=extra_query,
@@ -555,7 +555,7 @@ async def retrieve(
555555
raise ValueError(f"Expected a non-empty value for `organization_id` but received {organization_id!r}")
556556
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
557557
return await self._get(
558-
f"/organizations/{organization_id}",
558+
path_template("/organizations/{organization_id}", organization_id=organization_id),
559559
options=make_request_options(
560560
extra_headers=extra_headers,
561561
extra_query=extra_query,
@@ -601,7 +601,7 @@ async def update(
601601
raise ValueError(f"Expected a non-empty value for `organization_id` but received {organization_id!r}")
602602
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
603603
return await self._patch(
604-
f"/organizations/{organization_id}",
604+
path_template("/organizations/{organization_id}", organization_id=organization_id),
605605
body=await async_maybe_transform({"name": name}, organization_update_params.OrganizationUpdateParams),
606606
options=make_request_options(
607607
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -696,7 +696,7 @@ async def exchange_token(
696696
raise ValueError(f"Expected a non-empty value for `organization_id` but received {organization_id!r}")
697697
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
698698
return await self._post(
699-
f"/organizations/{organization_id}/token",
699+
path_template("/organizations/{organization_id}/token", organization_id=organization_id),
700700
options=make_request_options(
701701
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
702702
),
@@ -749,7 +749,7 @@ async def list_identities(
749749
raise ValueError(f"Expected a non-empty value for `organization_id` but received {organization_id!r}")
750750
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
751751
return await self._get(
752-
f"/organizations/{organization_id}/identities",
752+
path_template("/organizations/{organization_id}/identities", organization_id=organization_id),
753753
options=make_request_options(
754754
extra_headers=extra_headers,
755755
extra_query=extra_query,
@@ -815,7 +815,7 @@ async def list_roles(
815815
raise ValueError(f"Expected a non-empty value for `organization_id` but received {organization_id!r}")
816816
extra_headers = {**strip_not_given({"X-Client-Request-ID": x_client_request_id}), **(extra_headers or {})}
817817
return await self._get(
818-
f"/organizations/{organization_id}/roles",
818+
path_template("/organizations/{organization_id}/roles", organization_id=organization_id),
819819
options=make_request_options(
820820
extra_headers=extra_headers,
821821
extra_query=extra_query,

0 commit comments

Comments
 (0)