Skip to content

Commit 4e459b3

Browse files
fix: sanitize endpoint path params
1 parent 459ee57 commit 4e459b3

File tree

5 files changed

+243
-26
lines changed

5 files changed

+243
-26
lines changed

src/supermemory/_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/supermemory/_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/supermemory/resources/connections.py

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
connection_delete_by_provider_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 (
@@ -93,7 +93,7 @@ def create(
9393
if not provider:
9494
raise ValueError(f"Expected a non-empty value for `provider` but received {provider!r}")
9595
return self._post(
96-
f"/v3/connections/{provider}",
96+
path_template("/v3/connections/{provider}", provider=provider),
9797
body=maybe_transform(
9898
{
9999
"container_tag": container_tag,
@@ -171,7 +171,7 @@ def configure(
171171
if not connection_id:
172172
raise ValueError(f"Expected a non-empty value for `connection_id` but received {connection_id!r}")
173173
return self._post(
174-
f"/v3/connections/{connection_id}/configure",
174+
path_template("/v3/connections/{connection_id}/configure", connection_id=connection_id),
175175
body=maybe_transform({"resources": resources}, connection_configure_params.ConnectionConfigureParams),
176176
options=make_request_options(
177177
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -208,7 +208,7 @@ def delete_by_id(
208208
if not connection_id:
209209
raise ValueError(f"Expected a non-empty value for `connection_id` but received {connection_id!r}")
210210
return self._delete(
211-
f"/v3/connections/{connection_id}",
211+
path_template("/v3/connections/{connection_id}", connection_id=connection_id),
212212
options=make_request_options(
213213
extra_headers=extra_headers,
214214
extra_query=extra_query,
@@ -250,7 +250,7 @@ def delete_by_provider(
250250
if not provider:
251251
raise ValueError(f"Expected a non-empty value for `provider` but received {provider!r}")
252252
return self._delete(
253-
f"/v3/connections/{provider}",
253+
path_template("/v3/connections/{provider}", provider=provider),
254254
body=maybe_transform(
255255
{"container_tags": container_tags},
256256
connection_delete_by_provider_params.ConnectionDeleteByProviderParams,
@@ -287,7 +287,7 @@ def get_by_id(
287287
if not connection_id:
288288
raise ValueError(f"Expected a non-empty value for `connection_id` but received {connection_id!r}")
289289
return self._get(
290-
f"/v3/connections/{connection_id}",
290+
path_template("/v3/connections/{connection_id}", connection_id=connection_id),
291291
options=make_request_options(
292292
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
293293
),
@@ -323,7 +323,7 @@ def get_by_tag(
323323
if not provider:
324324
raise ValueError(f"Expected a non-empty value for `provider` but received {provider!r}")
325325
return self._post(
326-
f"/v3/connections/{provider}/connection",
326+
path_template("/v3/connections/{provider}/connection", provider=provider),
327327
body=maybe_transform(
328328
{"container_tags": container_tags}, connection_get_by_tag_params.ConnectionGetByTagParams
329329
),
@@ -363,7 +363,7 @@ def import_(
363363
raise ValueError(f"Expected a non-empty value for `provider` but received {provider!r}")
364364
extra_headers = {"Accept": "text/plain", **(extra_headers or {})}
365365
return self._post(
366-
f"/v3/connections/{provider}/import",
366+
path_template("/v3/connections/{provider}/import", provider=provider),
367367
body=maybe_transform({"container_tags": container_tags}, connection_import_params.ConnectionImportParams),
368368
options=make_request_options(
369369
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -400,7 +400,7 @@ def list_documents(
400400
if not provider:
401401
raise ValueError(f"Expected a non-empty value for `provider` but received {provider!r}")
402402
return self._post(
403-
f"/v3/connections/{provider}/documents",
403+
path_template("/v3/connections/{provider}/documents", provider=provider),
404404
body=maybe_transform(
405405
{"container_tags": container_tags}, connection_list_documents_params.ConnectionListDocumentsParams
406406
),
@@ -438,7 +438,7 @@ def resources(
438438
if not connection_id:
439439
raise ValueError(f"Expected a non-empty value for `connection_id` but received {connection_id!r}")
440440
return self._get(
441-
f"/v3/connections/{connection_id}/resources",
441+
path_template("/v3/connections/{connection_id}/resources", connection_id=connection_id),
442442
options=make_request_options(
443443
extra_headers=extra_headers,
444444
extra_query=extra_query,
@@ -507,7 +507,7 @@ async def create(
507507
if not provider:
508508
raise ValueError(f"Expected a non-empty value for `provider` but received {provider!r}")
509509
return await self._post(
510-
f"/v3/connections/{provider}",
510+
path_template("/v3/connections/{provider}", provider=provider),
511511
body=await async_maybe_transform(
512512
{
513513
"container_tag": container_tag,
@@ -587,7 +587,7 @@ async def configure(
587587
if not connection_id:
588588
raise ValueError(f"Expected a non-empty value for `connection_id` but received {connection_id!r}")
589589
return await self._post(
590-
f"/v3/connections/{connection_id}/configure",
590+
path_template("/v3/connections/{connection_id}/configure", connection_id=connection_id),
591591
body=await async_maybe_transform(
592592
{"resources": resources}, connection_configure_params.ConnectionConfigureParams
593593
),
@@ -626,7 +626,7 @@ async def delete_by_id(
626626
if not connection_id:
627627
raise ValueError(f"Expected a non-empty value for `connection_id` but received {connection_id!r}")
628628
return await self._delete(
629-
f"/v3/connections/{connection_id}",
629+
path_template("/v3/connections/{connection_id}", connection_id=connection_id),
630630
options=make_request_options(
631631
extra_headers=extra_headers,
632632
extra_query=extra_query,
@@ -668,7 +668,7 @@ async def delete_by_provider(
668668
if not provider:
669669
raise ValueError(f"Expected a non-empty value for `provider` but received {provider!r}")
670670
return await self._delete(
671-
f"/v3/connections/{provider}",
671+
path_template("/v3/connections/{provider}", provider=provider),
672672
body=await async_maybe_transform(
673673
{"container_tags": container_tags},
674674
connection_delete_by_provider_params.ConnectionDeleteByProviderParams,
@@ -705,7 +705,7 @@ async def get_by_id(
705705
if not connection_id:
706706
raise ValueError(f"Expected a non-empty value for `connection_id` but received {connection_id!r}")
707707
return await self._get(
708-
f"/v3/connections/{connection_id}",
708+
path_template("/v3/connections/{connection_id}", connection_id=connection_id),
709709
options=make_request_options(
710710
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
711711
),
@@ -741,7 +741,7 @@ async def get_by_tag(
741741
if not provider:
742742
raise ValueError(f"Expected a non-empty value for `provider` but received {provider!r}")
743743
return await self._post(
744-
f"/v3/connections/{provider}/connection",
744+
path_template("/v3/connections/{provider}/connection", provider=provider),
745745
body=await async_maybe_transform(
746746
{"container_tags": container_tags}, connection_get_by_tag_params.ConnectionGetByTagParams
747747
),
@@ -781,7 +781,7 @@ async def import_(
781781
raise ValueError(f"Expected a non-empty value for `provider` but received {provider!r}")
782782
extra_headers = {"Accept": "text/plain", **(extra_headers or {})}
783783
return await self._post(
784-
f"/v3/connections/{provider}/import",
784+
path_template("/v3/connections/{provider}/import", provider=provider),
785785
body=await async_maybe_transform(
786786
{"container_tags": container_tags}, connection_import_params.ConnectionImportParams
787787
),
@@ -820,7 +820,7 @@ async def list_documents(
820820
if not provider:
821821
raise ValueError(f"Expected a non-empty value for `provider` but received {provider!r}")
822822
return await self._post(
823-
f"/v3/connections/{provider}/documents",
823+
path_template("/v3/connections/{provider}/documents", provider=provider),
824824
body=await async_maybe_transform(
825825
{"container_tags": container_tags}, connection_list_documents_params.ConnectionListDocumentsParams
826826
),
@@ -858,7 +858,7 @@ async def resources(
858858
if not connection_id:
859859
raise ValueError(f"Expected a non-empty value for `connection_id` but received {connection_id!r}")
860860
return await self._get(
861-
f"/v3/connections/{connection_id}/resources",
861+
path_template("/v3/connections/{connection_id}/resources", connection_id=connection_id),
862862
options=make_request_options(
863863
extra_headers=extra_headers,
864864
extra_query=extra_query,

src/supermemory/resources/documents.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
document_upload_file_params,
1717
)
1818
from .._types import Body, Omit, Query, Headers, NoneType, NotGiven, FileTypes, SequenceNotStr, omit, not_given
19-
from .._utils import extract_files, maybe_transform, deepcopy_minimal, async_maybe_transform
19+
from .._utils import extract_files, path_template, maybe_transform, deepcopy_minimal, async_maybe_transform
2020
from .._compat import cached_property
2121
from .._resource import SyncAPIResource, AsyncAPIResource
2222
from .._response import (
@@ -115,7 +115,7 @@ def update(
115115
if not id:
116116
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
117117
return self._patch(
118-
f"/v3/documents/{id}",
118+
path_template("/v3/documents/{id}", id=id),
119119
body=maybe_transform(
120120
{
121121
"container_tag": container_tag,
@@ -225,7 +225,7 @@ def delete(
225225
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
226226
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
227227
return self._delete(
228-
f"/v3/documents/{id}",
228+
path_template("/v3/documents/{id}", id=id),
229229
options=make_request_options(
230230
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
231231
),
@@ -423,7 +423,7 @@ def get(
423423
if not id:
424424
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
425425
return self._get(
426-
f"/v3/documents/{id}",
426+
path_template("/v3/documents/{id}", id=id),
427427
options=make_request_options(
428428
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
429429
),
@@ -602,7 +602,7 @@ async def update(
602602
if not id:
603603
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
604604
return await self._patch(
605-
f"/v3/documents/{id}",
605+
path_template("/v3/documents/{id}", id=id),
606606
body=await async_maybe_transform(
607607
{
608608
"container_tag": container_tag,
@@ -712,7 +712,7 @@ async def delete(
712712
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
713713
extra_headers = {"Accept": "*/*", **(extra_headers or {})}
714714
return await self._delete(
715-
f"/v3/documents/{id}",
715+
path_template("/v3/documents/{id}", id=id),
716716
options=make_request_options(
717717
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
718718
),
@@ -910,7 +910,7 @@ async def get(
910910
if not id:
911911
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
912912
return await self._get(
913-
f"/v3/documents/{id}",
913+
path_template("/v3/documents/{id}", id=id),
914914
options=make_request_options(
915915
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
916916
),

0 commit comments

Comments
 (0)