Skip to content

Commit b59444c

Browse files
fix: sanitize endpoint path params
1 parent e723bef commit b59444c

21 files changed

Lines changed: 347 additions & 122 deletions

src/postgrid/_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/postgrid/_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/postgrid/resources/print_mail/bank_accounts.py

Lines changed: 5 additions & 5 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, NotGiven, Base64FileInput, omit, not_given
11-
from ..._utils import required_args, maybe_transform, async_maybe_transform
11+
from ..._utils import path_template, required_args, maybe_transform, async_maybe_transform
1212
from ..._compat import cached_property
1313
from ..._resource import SyncAPIResource, AsyncAPIResource
1414
from ..._response import (
@@ -338,7 +338,7 @@ def retrieve(
338338
if not id:
339339
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
340340
return self._get(
341-
f"/print-mail/v1/bank_accounts/{id}",
341+
path_template("/print-mail/v1/bank_accounts/{id}", id=id),
342342
options=make_request_options(
343343
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
344344
),
@@ -423,7 +423,7 @@ def delete(
423423
if not id:
424424
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
425425
return self._delete(
426-
f"/print-mail/v1/bank_accounts/{id}",
426+
path_template("/print-mail/v1/bank_accounts/{id}", id=id),
427427
options=make_request_options(
428428
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
429429
),
@@ -742,7 +742,7 @@ async def retrieve(
742742
if not id:
743743
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
744744
return await self._get(
745-
f"/print-mail/v1/bank_accounts/{id}",
745+
path_template("/print-mail/v1/bank_accounts/{id}", id=id),
746746
options=make_request_options(
747747
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
748748
),
@@ -827,7 +827,7 @@ async def delete(
827827
if not id:
828828
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
829829
return await self._delete(
830-
f"/print-mail/v1/bank_accounts/{id}",
830+
path_template("/print-mail/v1/bank_accounts/{id}", id=id),
831831
options=make_request_options(
832832
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
833833
),

src/postgrid/resources/print_mail/campaigns.py

Lines changed: 9 additions & 9 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, 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 (
@@ -161,7 +161,7 @@ def retrieve(
161161
if not id:
162162
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
163163
return self._get(
164-
f"/print-mail/v1/campaigns/{id}",
164+
path_template("/print-mail/v1/campaigns/{id}", id=id),
165165
options=make_request_options(
166166
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
167167
),
@@ -228,7 +228,7 @@ def update(
228228
if not id:
229229
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
230230
return self._post(
231-
f"/print-mail/v1/campaigns/{id}",
231+
path_template("/print-mail/v1/campaigns/{id}", id=id),
232232
body=maybe_transform(
233233
{
234234
"cheque_profile": cheque_profile,
@@ -332,7 +332,7 @@ def delete(
332332
if not id:
333333
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
334334
return self._delete(
335-
f"/print-mail/v1/campaigns/{id}",
335+
path_template("/print-mail/v1/campaigns/{id}", id=id),
336336
options=make_request_options(
337337
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
338338
),
@@ -373,7 +373,7 @@ def send(
373373
if not id:
374374
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
375375
return self._post(
376-
f"/print-mail/v1/campaigns/{id}/send",
376+
path_template("/print-mail/v1/campaigns/{id}/send", id=id),
377377
body=maybe_transform({"send_date": send_date}, campaign_send_params.CampaignSendParams),
378378
options=make_request_options(
379379
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
@@ -512,7 +512,7 @@ async def retrieve(
512512
if not id:
513513
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
514514
return await self._get(
515-
f"/print-mail/v1/campaigns/{id}",
515+
path_template("/print-mail/v1/campaigns/{id}", id=id),
516516
options=make_request_options(
517517
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
518518
),
@@ -579,7 +579,7 @@ async def update(
579579
if not id:
580580
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
581581
return await self._post(
582-
f"/print-mail/v1/campaigns/{id}",
582+
path_template("/print-mail/v1/campaigns/{id}", id=id),
583583
body=await async_maybe_transform(
584584
{
585585
"cheque_profile": cheque_profile,
@@ -683,7 +683,7 @@ async def delete(
683683
if not id:
684684
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
685685
return await self._delete(
686-
f"/print-mail/v1/campaigns/{id}",
686+
path_template("/print-mail/v1/campaigns/{id}", id=id),
687687
options=make_request_options(
688688
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
689689
),
@@ -724,7 +724,7 @@ async def send(
724724
if not id:
725725
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
726726
return await self._post(
727-
f"/print-mail/v1/campaigns/{id}/send",
727+
path_template("/print-mail/v1/campaigns/{id}/send", id=id),
728728
body=await async_maybe_transform({"send_date": send_date}, campaign_send_params.CampaignSendParams),
729729
options=make_request_options(
730730
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout

src/postgrid/resources/print_mail/cheques.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import httpx
1010

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 (
@@ -255,7 +255,7 @@ def retrieve(
255255
if not id:
256256
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
257257
return self._get(
258-
f"/print-mail/v1/cheques/{id}",
258+
path_template("/print-mail/v1/cheques/{id}", id=id),
259259
options=make_request_options(
260260
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
261261
),
@@ -340,7 +340,7 @@ def delete(
340340
if not id:
341341
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
342342
return self._delete(
343-
f"/print-mail/v1/cheques/{id}",
343+
path_template("/print-mail/v1/cheques/{id}", id=id),
344344
options=make_request_options(
345345
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
346346
),
@@ -378,7 +378,7 @@ def retrieve_url(
378378
if not id:
379379
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
380380
return self._get(
381-
f"/print-mail/v1/cheques/{id}/url",
381+
path_template("/print-mail/v1/cheques/{id}/url", id=id),
382382
options=make_request_options(
383383
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
384384
),
@@ -415,7 +415,7 @@ def retrieve_with_deposit_ready_pdf(
415415
if not id:
416416
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
417417
return self._get(
418-
f"/print-mail/v1/cheques/{id}/with_deposit_ready_pdf",
418+
path_template("/print-mail/v1/cheques/{id}/with_deposit_ready_pdf", id=id),
419419
options=make_request_options(
420420
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
421421
),
@@ -649,7 +649,7 @@ async def retrieve(
649649
if not id:
650650
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
651651
return await self._get(
652-
f"/print-mail/v1/cheques/{id}",
652+
path_template("/print-mail/v1/cheques/{id}", id=id),
653653
options=make_request_options(
654654
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
655655
),
@@ -734,7 +734,7 @@ async def delete(
734734
if not id:
735735
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
736736
return await self._delete(
737-
f"/print-mail/v1/cheques/{id}",
737+
path_template("/print-mail/v1/cheques/{id}", id=id),
738738
options=make_request_options(
739739
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
740740
),
@@ -772,7 +772,7 @@ async def retrieve_url(
772772
if not id:
773773
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
774774
return await self._get(
775-
f"/print-mail/v1/cheques/{id}/url",
775+
path_template("/print-mail/v1/cheques/{id}/url", id=id),
776776
options=make_request_options(
777777
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
778778
),
@@ -809,7 +809,7 @@ async def retrieve_with_deposit_ready_pdf(
809809
if not id:
810810
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
811811
return await self._get(
812-
f"/print-mail/v1/cheques/{id}/with_deposit_ready_pdf",
812+
path_template("/print-mail/v1/cheques/{id}/with_deposit_ready_pdf", id=id),
813813
options=make_request_options(
814814
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
815815
),

src/postgrid/resources/print_mail/contacts.py

Lines changed: 5 additions & 5 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, NotGiven, omit, not_given
11-
from ..._utils import required_args, maybe_transform, async_maybe_transform
11+
from ..._utils import path_template, required_args, maybe_transform, async_maybe_transform
1212
from ..._compat import cached_property
1313
from ..._resource import SyncAPIResource, AsyncAPIResource
1414
from ..._response import (
@@ -302,7 +302,7 @@ def retrieve(
302302
if not id:
303303
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
304304
return self._get(
305-
f"/print-mail/v1/contacts/{id}",
305+
path_template("/print-mail/v1/contacts/{id}", id=id),
306306
options=make_request_options(
307307
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
308308
),
@@ -388,7 +388,7 @@ def delete(
388388
if not id:
389389
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
390390
return self._delete(
391-
f"/print-mail/v1/contacts/{id}",
391+
path_template("/print-mail/v1/contacts/{id}", id=id),
392392
options=make_request_options(
393393
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
394394
),
@@ -672,7 +672,7 @@ async def retrieve(
672672
if not id:
673673
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
674674
return await self._get(
675-
f"/print-mail/v1/contacts/{id}",
675+
path_template("/print-mail/v1/contacts/{id}", id=id),
676676
options=make_request_options(
677677
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
678678
),
@@ -758,7 +758,7 @@ async def delete(
758758
if not id:
759759
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
760760
return await self._delete(
761-
f"/print-mail/v1/contacts/{id}",
761+
path_template("/print-mail/v1/contacts/{id}", id=id),
762762
options=make_request_options(
763763
extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
764764
),

0 commit comments

Comments
 (0)