Skip to content

Commit 896338f

Browse files
fix: sanitize endpoint path params
1 parent 0774137 commit 896338f

12 files changed

Lines changed: 254 additions & 36 deletions

File tree

src/finch/_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/finch/_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/finch/resources/hris/benefits/benefits.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from .... import _legacy_response
1010
from ...._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given
11-
from ...._utils import maybe_transform, async_maybe_transform
11+
from ...._utils import path_template, maybe_transform, async_maybe_transform
1212
from ...._compat import cached_property
1313
from .individuals import (
1414
Individuals,
@@ -157,7 +157,7 @@ def retrieve(
157157
if not benefit_id:
158158
raise ValueError(f"Expected a non-empty value for `benefit_id` but received {benefit_id!r}")
159159
return self._get(
160-
f"/employer/benefits/{benefit_id}",
160+
path_template("/employer/benefits/{benefit_id}", benefit_id=benefit_id),
161161
options=make_request_options(
162162
extra_headers=extra_headers,
163163
extra_query=extra_query,
@@ -201,7 +201,7 @@ def update(
201201
if not benefit_id:
202202
raise ValueError(f"Expected a non-empty value for `benefit_id` but received {benefit_id!r}")
203203
return self._post(
204-
f"/employer/benefits/{benefit_id}",
204+
path_template("/employer/benefits/{benefit_id}", benefit_id=benefit_id),
205205
body=maybe_transform({"description": description}, benefit_update_params.BenefitUpdateParams),
206206
options=make_request_options(
207207
extra_headers=extra_headers,
@@ -414,7 +414,7 @@ async def retrieve(
414414
if not benefit_id:
415415
raise ValueError(f"Expected a non-empty value for `benefit_id` but received {benefit_id!r}")
416416
return await self._get(
417-
f"/employer/benefits/{benefit_id}",
417+
path_template("/employer/benefits/{benefit_id}", benefit_id=benefit_id),
418418
options=make_request_options(
419419
extra_headers=extra_headers,
420420
extra_query=extra_query,
@@ -460,7 +460,7 @@ async def update(
460460
if not benefit_id:
461461
raise ValueError(f"Expected a non-empty value for `benefit_id` but received {benefit_id!r}")
462462
return await self._post(
463-
f"/employer/benefits/{benefit_id}",
463+
path_template("/employer/benefits/{benefit_id}", benefit_id=benefit_id),
464464
body=await async_maybe_transform({"description": description}, benefit_update_params.BenefitUpdateParams),
465465
options=make_request_options(
466466
extra_headers=extra_headers,

src/finch/resources/hris/benefits/individuals.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from .... import _legacy_response
1010
from ...._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, omit, not_given
11-
from ...._utils import maybe_transform, async_maybe_transform
11+
from ...._utils import path_template, maybe_transform, async_maybe_transform
1212
from ...._compat import cached_property
1313
from ...._resource import SyncAPIResource, AsyncAPIResource
1414
from ...._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper
@@ -84,7 +84,7 @@ def enroll_many(
8484
if not benefit_id:
8585
raise ValueError(f"Expected a non-empty value for `benefit_id` but received {benefit_id!r}")
8686
return self._post(
87-
f"/employer/benefits/{benefit_id}/individuals",
87+
path_template("/employer/benefits/{benefit_id}/individuals", benefit_id=benefit_id),
8888
body=maybe_transform(individuals, Iterable[individual_enroll_many_params.Individual]),
8989
options=make_request_options(
9090
extra_headers=extra_headers,
@@ -128,7 +128,7 @@ def enrolled_ids(
128128
if not benefit_id:
129129
raise ValueError(f"Expected a non-empty value for `benefit_id` but received {benefit_id!r}")
130130
return self._get(
131-
f"/employer/benefits/{benefit_id}/enrolled",
131+
path_template("/employer/benefits/{benefit_id}/enrolled", benefit_id=benefit_id),
132132
options=make_request_options(
133133
extra_headers=extra_headers,
134134
extra_query=extra_query,
@@ -175,7 +175,7 @@ def retrieve_many_benefits(
175175
if not benefit_id:
176176
raise ValueError(f"Expected a non-empty value for `benefit_id` but received {benefit_id!r}")
177177
return self._get_api_list(
178-
f"/employer/benefits/{benefit_id}/individuals",
178+
path_template("/employer/benefits/{benefit_id}/individuals", benefit_id=benefit_id),
179179
page=SyncSinglePage[IndividualBenefit],
180180
options=make_request_options(
181181
extra_headers=extra_headers,
@@ -226,7 +226,7 @@ def unenroll_many(
226226
if not benefit_id:
227227
raise ValueError(f"Expected a non-empty value for `benefit_id` but received {benefit_id!r}")
228228
return self._delete(
229-
f"/employer/benefits/{benefit_id}/individuals",
229+
path_template("/employer/benefits/{benefit_id}/individuals", benefit_id=benefit_id),
230230
body=maybe_transform(
231231
{"individual_ids": individual_ids}, individual_unenroll_many_params.IndividualUnenrollManyParams
232232
),
@@ -300,7 +300,7 @@ async def enroll_many(
300300
if not benefit_id:
301301
raise ValueError(f"Expected a non-empty value for `benefit_id` but received {benefit_id!r}")
302302
return await self._post(
303-
f"/employer/benefits/{benefit_id}/individuals",
303+
path_template("/employer/benefits/{benefit_id}/individuals", benefit_id=benefit_id),
304304
body=await async_maybe_transform(individuals, Iterable[individual_enroll_many_params.Individual]),
305305
options=make_request_options(
306306
extra_headers=extra_headers,
@@ -344,7 +344,7 @@ async def enrolled_ids(
344344
if not benefit_id:
345345
raise ValueError(f"Expected a non-empty value for `benefit_id` but received {benefit_id!r}")
346346
return await self._get(
347-
f"/employer/benefits/{benefit_id}/enrolled",
347+
path_template("/employer/benefits/{benefit_id}/enrolled", benefit_id=benefit_id),
348348
options=make_request_options(
349349
extra_headers=extra_headers,
350350
extra_query=extra_query,
@@ -391,7 +391,7 @@ def retrieve_many_benefits(
391391
if not benefit_id:
392392
raise ValueError(f"Expected a non-empty value for `benefit_id` but received {benefit_id!r}")
393393
return self._get_api_list(
394-
f"/employer/benefits/{benefit_id}/individuals",
394+
path_template("/employer/benefits/{benefit_id}/individuals", benefit_id=benefit_id),
395395
page=AsyncSinglePage[IndividualBenefit],
396396
options=make_request_options(
397397
extra_headers=extra_headers,
@@ -442,7 +442,7 @@ async def unenroll_many(
442442
if not benefit_id:
443443
raise ValueError(f"Expected a non-empty value for `benefit_id` but received {benefit_id!r}")
444444
return await self._delete(
445-
f"/employer/benefits/{benefit_id}/individuals",
445+
path_template("/employer/benefits/{benefit_id}/individuals", benefit_id=benefit_id),
446446
body=await async_maybe_transform(
447447
{"individual_ids": individual_ids}, individual_unenroll_many_params.IndividualUnenrollManyParams
448448
),

src/finch/resources/hris/company/pay_statement_item/rules.py

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

1010
from ..... import _legacy_response
1111
from ....._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, 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
@@ -143,7 +143,7 @@ def update(
143143
if not rule_id:
144144
raise ValueError(f"Expected a non-empty value for `rule_id` but received {rule_id!r}")
145145
return self._put(
146-
f"/employer/pay-statement-item/rule/{rule_id}",
146+
path_template("/employer/pay-statement-item/rule/{rule_id}", rule_id=rule_id),
147147
body=maybe_transform({"optional_property": optional_property}, rule_update_params.RuleUpdateParams),
148148
options=make_request_options(
149149
extra_headers=extra_headers,
@@ -224,7 +224,7 @@ def delete(
224224
if not rule_id:
225225
raise ValueError(f"Expected a non-empty value for `rule_id` but received {rule_id!r}")
226226
return self._delete(
227-
f"/employer/pay-statement-item/rule/{rule_id}",
227+
path_template("/employer/pay-statement-item/rule/{rule_id}", rule_id=rule_id),
228228
options=make_request_options(
229229
extra_headers=extra_headers,
230230
extra_query=extra_query,
@@ -351,7 +351,7 @@ async def update(
351351
if not rule_id:
352352
raise ValueError(f"Expected a non-empty value for `rule_id` but received {rule_id!r}")
353353
return await self._put(
354-
f"/employer/pay-statement-item/rule/{rule_id}",
354+
path_template("/employer/pay-statement-item/rule/{rule_id}", rule_id=rule_id),
355355
body=await async_maybe_transform(
356356
{"optional_property": optional_property}, rule_update_params.RuleUpdateParams
357357
),
@@ -434,7 +434,7 @@ async def delete(
434434
if not rule_id:
435435
raise ValueError(f"Expected a non-empty value for `rule_id` but received {rule_id!r}")
436436
return await self._delete(
437-
f"/employer/pay-statement-item/rule/{rule_id}",
437+
path_template("/employer/pay-statement-item/rule/{rule_id}", rule_id=rule_id),
438438
options=make_request_options(
439439
extra_headers=extra_headers,
440440
extra_query=extra_query,

src/finch/resources/hris/documents.py

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

1010
from ... import _legacy_response
1111
from ..._types import Body, Omit, Query, Headers, NotGiven, SequenceNotStr, 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
@@ -137,7 +137,7 @@ def retreive(
137137
return cast(
138138
DocumentRetreiveResponse,
139139
self._get(
140-
f"/employer/documents/{document_id}",
140+
path_template("/employer/documents/{document_id}", document_id=document_id),
141141
options=make_request_options(
142142
extra_headers=extra_headers,
143143
extra_query=extra_query,
@@ -269,7 +269,7 @@ async def retreive(
269269
return cast(
270270
DocumentRetreiveResponse,
271271
await self._get(
272-
f"/employer/documents/{document_id}",
272+
path_template("/employer/documents/{document_id}", document_id=document_id),
273273
options=make_request_options(
274274
extra_headers=extra_headers,
275275
extra_query=extra_query,

src/finch/resources/jobs/automated.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from ... import _legacy_response
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 to_streamed_response_wrapper, async_to_streamed_response_wrapper
@@ -182,7 +182,7 @@ def retrieve(
182182
if not job_id:
183183
raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
184184
return self._get(
185-
f"/jobs/automated/{job_id}",
185+
path_template("/jobs/automated/{job_id}", job_id=job_id),
186186
options=make_request_options(
187187
extra_headers=extra_headers,
188188
extra_query=extra_query,
@@ -405,7 +405,7 @@ async def retrieve(
405405
if not job_id:
406406
raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
407407
return await self._get(
408-
f"/jobs/automated/{job_id}",
408+
path_template("/jobs/automated/{job_id}", job_id=job_id),
409409
options=make_request_options(
410410
extra_headers=extra_headers,
411411
extra_query=extra_query,

src/finch/resources/jobs/manual.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from ... import _legacy_response
88
from ..._types import Body, Query, Headers, NotGiven, not_given
9+
from ..._utils import path_template
910
from ..._compat import cached_property
1011
from ..._resource import SyncAPIResource, AsyncAPIResource
1112
from ..._response import to_streamed_response_wrapper, async_to_streamed_response_wrapper
@@ -63,7 +64,7 @@ def retrieve(
6364
if not job_id:
6465
raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
6566
return self._get(
66-
f"/jobs/manual/{job_id}",
67+
path_template("/jobs/manual/{job_id}", job_id=job_id),
6768
options=make_request_options(
6869
extra_headers=extra_headers,
6970
extra_query=extra_query,
@@ -123,7 +124,7 @@ async def retrieve(
123124
if not job_id:
124125
raise ValueError(f"Expected a non-empty value for `job_id` but received {job_id!r}")
125126
return await self._get(
126-
f"/jobs/manual/{job_id}",
127+
path_template("/jobs/manual/{job_id}", job_id=job_id),
127128
options=make_request_options(
128129
extra_headers=extra_headers,
129130
extra_query=extra_query,

0 commit comments

Comments
 (0)