Skip to content

Commit bf489e6

Browse files
committed
added more advanced validation
1 parent b63b457 commit bf489e6

2 files changed

Lines changed: 258 additions & 7 deletions

File tree

packages/google-api-core/google/api_core/path_template.py

Lines changed: 146 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,147 @@
6363
_MULTI_SEGMENT_PATTERN = r"(.+)"
6464

6565

66+
def _validate_multi_segment_value(val):
67+
"""Validate a multi-segment wildcard value for path traversal vulnerabilities.
68+
69+
This function implements the dot and double-dot traversal validation rule
70+
for values matching '**'. It splits the value by '/' into segments and
71+
simulates path traversal:
72+
- '.' segments do not modify the segment count.
73+
- '..' segments decrease the leftover segment count by 1.
74+
- Any other segments increase the leftover segment count by 1.
75+
76+
Validation fails if:
77+
- Path traversal overflows to the left (leftover segment count drops below 0),
78+
meaning it would traverse out of the multi-segment value boundaries.
79+
- No segments remain after executing all path traversal commands (leftover
80+
segment count is 0), meaning the entire value is consumed.
81+
82+
Args:
83+
val (str): The value matched to the '**' wildcard to validate.
84+
85+
Returns:
86+
bool: True if the value is valid and does not violate traversal boundaries,
87+
False otherwise.
88+
"""
89+
segments = val.split("/")
90+
leftover_segments = 0
91+
unseen_segments = len(segments)
92+
93+
for segment in segments:
94+
unseen_segments -= 1
95+
if segment == "..":
96+
leftover_segments -= 1
97+
elif segment != ".":
98+
leftover_segments += 1
99+
100+
if leftover_segments < 0:
101+
return False
102+
if leftover_segments > unseen_segments:
103+
return True
104+
105+
return leftover_segments > 0
106+
107+
108+
def _build_capture_pattern(template_str):
109+
"""Build a regex pattern to capture wildcard matches from a template.
110+
111+
This function parses a template string containing positional/named
112+
wildcards ('*' or '**'), compiles it into a regular expression, and
113+
records the order and types of all wildcards to allow extracting
114+
sub-segments for individual validation.
115+
116+
Args:
117+
template_str (str): The template string (e.g. "projects/*/locations/*").
118+
119+
Returns:
120+
tuple[str, list[str]]: A tuple containing:
121+
- The compiled regex pattern string with capture groups.
122+
- A list of wildcard type strings ('*' or '**') in matching order.
123+
"""
124+
wildcard_types = []
125+
126+
def replacer(match):
127+
positional = match.group("positional")
128+
name = match.group("name")
129+
template = match.group("template")
130+
131+
if positional == "*":
132+
wildcard_types.append("*")
133+
return r"([^/]+)"
134+
elif positional == "**":
135+
wildcard_types.append("**")
136+
return r"(.+)"
137+
elif name is not None:
138+
if not template or template == "*":
139+
wildcard_types.append("*")
140+
return r"([^/]+)"
141+
elif template == "**":
142+
wildcard_types.append("**")
143+
return r"(.+)"
144+
else:
145+
sub_pattern, sub_types = _build_capture_pattern(template)
146+
wildcard_types.extend(sub_types)
147+
return sub_pattern
148+
return match.group(0)
149+
150+
parts = []
151+
last_idx = 0
152+
for match in _VARIABLE_RE.finditer(template_str):
153+
literal = template_str[last_idx : match.start()]
154+
parts.append(re.escape(literal))
155+
replaced = replacer(match)
156+
parts.append(replaced)
157+
last_idx = match.end()
158+
literal = template_str[last_idx:]
159+
parts.append(re.escape(literal))
160+
161+
pattern = "".join(parts)
162+
return pattern, wildcard_types
163+
164+
165+
def _validate_value_against_template(val, template_str, property_name=None):
166+
"""Validate a variable's value against its template structure.
167+
168+
This function extracts substrings matching individual wildcards in the
169+
variable's template structure and enforces safety constraints:
170+
- Single-segment matches ('*') must not be exactly '.' or '..' because
171+
this breaks the URI routing contract and leads to path traversal.
172+
- Multi-segment matches ('**') are checked using _validate_multi_segment_value
173+
to ensure path traversal commands do not consume the entire value or
174+
escape the starting boundaries of the matched parameter.
175+
176+
Args:
177+
val (str): The raw string value to validate.
178+
template_str (str): The template string of the variable (e.g. 'projects/*/locations/*').
179+
property_name (str | None): The name of the property being validated, used
180+
to construct descriptive error messages.
181+
182+
Raises:
183+
ValueError: If a wildcard within the value violates path traversal rules.
184+
"""
185+
if template_str is None or template_str == "*":
186+
pattern, wildcard_types = r"^([^/]+)$", ["*"]
187+
elif template_str == "**":
188+
pattern, wildcard_types = r"^(.+)$", ["**"]
189+
else:
190+
pattern_body, wildcard_types = _build_capture_pattern(template_str)
191+
pattern = "^" + pattern_body + "$"
192+
193+
m = re.match(pattern, val)
194+
if m is not None:
195+
for i, wildcard_type in enumerate(wildcard_types):
196+
captured_val = m.group(i + 1)
197+
if wildcard_type == "*":
198+
if captured_val in (".", ".."):
199+
name_str = property_name if property_name else "positional variable"
200+
raise ValueError("Invalid value {} for {}.".format(val, name_str))
201+
elif wildcard_type == "**":
202+
if not _validate_multi_segment_value(captured_val):
203+
name_str = property_name if property_name else "positional variable"
204+
raise ValueError("Invalid value {} for {}.".format(val, name_str))
205+
206+
66207
def _expand_variable_match(positional_vars, named_vars, match):
67208
"""Expand a matched variable with its value.
68209
@@ -82,10 +223,12 @@ def _expand_variable_match(positional_vars, named_vars, match):
82223
"""
83224
positional = match.group("positional")
84225
name = match.group("name")
226+
template = match.group("template")
227+
85228
if name is not None:
86229
try:
87230
val = str(named_vars[name])
88-
# Percent-encode while keeping '/' safe to preserve existing route and slash validation
231+
_validate_value_against_template(val, template, name)
89232
return urllib.parse.quote(val, safe="/")
90233
except KeyError:
91234
raise ValueError(
@@ -95,7 +238,7 @@ def _expand_variable_match(positional_vars, named_vars, match):
95238
elif positional is not None:
96239
try:
97240
val = str(positional_vars.pop(0))
98-
# Percent-encode while keeping '/' safe to preserve existing route and slash validation
241+
_validate_value_against_template(val, positional, None)
99242
return urllib.parse.quote(val, safe="/")
100243
except IndexError:
101244
raise ValueError(
@@ -326,8 +469,7 @@ def transcode(http_options, message=None, **request_kwargs):
326469
return request
327470

328471
bindings_description = [
329-
'\n\tURI: "{}"'
330-
"\n\tRequired request fields:\n\t\t{}".format(
472+
'\n\tURI: "{}"\n\tRequired request fields:\n\t\t{}'.format(
331473
uri,
332474
"\n\t\t".join(
333475
[

packages/google-api-core/tests/unit/test_path_template.py

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,24 @@
6666
# Encoding / Metacharacters in positional and named params
6767
["/v1/*", ["..?$httpMethod=DELETE#"], {}, "/v1/..%3F%24httpMethod%3DDELETE%23"],
6868
["/v1/**", ["path/../with/?and#"], {}, "/v1/path/../with/%3Fand%23"],
69-
["/v1/{name}", [], {"name": "..?$httpMethod=DELETE#"}, "/v1/..%3F%24httpMethod%3DDELETE%23"],
70-
["/v1/{name=**}", [], {"name": "path/../with/?and#"}, "/v1/path/../with/%3Fand%23"],
69+
[
70+
"/v1/{name}",
71+
[],
72+
{"name": "..?$httpMethod=DELETE#"},
73+
"/v1/..%3F%24httpMethod%3DDELETE%23",
74+
],
75+
[
76+
"/v1/{name=**}",
77+
[],
78+
{"name": "path/../with/?and#"},
79+
"/v1/path/../with/%3Fand%23",
80+
],
7181
[
7282
"/v3/{session=projects/*/locations/*/agents/*/sessions/*}:detectIntent",
7383
[],
74-
{"session": "projects/cx/locations/global/agents/a1/sessions/..?$httpMethod=DELETE#"},
84+
{
85+
"session": "projects/cx/locations/global/agents/a1/sessions/..?$httpMethod=DELETE#"
86+
},
7587
"/v3/projects/cx/locations/global/agents/a1/sessions/..%3F%24httpMethod%3DDELETE%23:detectIntent",
7688
],
7789
],
@@ -661,3 +673,100 @@ def helper_test_transcode(http_options_list, expected_result_list):
661673
if expected_result_list[2]:
662674
expected_result["body"] = expected_result_list[2]
663675
return (http_options, expected_result)
676+
677+
678+
def test_path_traversal_dots_validation_star():
679+
# 1. Dots in values matched to *
680+
# Single-segment positional variable * with exactly '.' or '..'
681+
with pytest.raises(
682+
ValueError, match="Invalid value \\. for positional variable\\."
683+
):
684+
path_template.expand("/v1/*", ".")
685+
with pytest.raises(
686+
ValueError, match="Invalid value \\.\\. for positional variable\\."
687+
):
688+
path_template.expand("/v1/*", "..")
689+
690+
# Named variable matching * with exactly '.' or '..'
691+
with pytest.raises(ValueError, match="Invalid value \\.\\. for region\\."):
692+
path_template.expand(
693+
"/compute/v1/projects/{project}/regions/{region}/addresses",
694+
project="my-project",
695+
region="..",
696+
)
697+
698+
# Sub-template named variable where a segment matching * is exactly '.' or '..'
699+
with pytest.raises(
700+
ValueError,
701+
match="Invalid value projects/my-project/locations/\\.\\. for parent\\.",
702+
):
703+
path_template.expand(
704+
"/v2/{parent=projects/*/locations/*}/content:inspect",
705+
parent="projects/my-project/locations/..",
706+
)
707+
with pytest.raises(
708+
ValueError,
709+
match="Invalid value projects/my-project/locations/\\. for parent\\.",
710+
):
711+
path_template.expand(
712+
"/v2/{parent=projects/*/locations/*}/content:inspect",
713+
parent="projects/my-project/locations/.",
714+
)
715+
716+
717+
def test_path_traversal_dots_validation_double_star():
718+
# 2. Dots in values matched to **
719+
# Valid cases: leftover_segments > 0
720+
# leftover_segments == 1
721+
assert (
722+
path_template.expand(
723+
"/v3/{name=projects/*/monitoredResourceDescriptors/**}",
724+
name="projects/my-project/monitoredResourceDescriptors/instance/my-instance/..",
725+
)
726+
== "/v3/projects/my-project/monitoredResourceDescriptors/instance/my-instance/.."
727+
)
728+
729+
assert (
730+
path_template.expand(
731+
"/v3/{name=projects/*/monitoredResourceDescriptors/**}",
732+
name="projects/my-project/monitoredResourceDescriptors/instance/my-instance/.",
733+
)
734+
== "/v3/projects/my-project/monitoredResourceDescriptors/instance/my-instance/."
735+
)
736+
737+
assert (
738+
path_template.expand(
739+
"/v3/{name=projects/*/monitoredResourceDescriptors/**}",
740+
name="projects/my-project/monitoredResourceDescriptors/a/b/c/d/e/../../../..",
741+
)
742+
== "/v3/projects/my-project/monitoredResourceDescriptors/a/b/c/d/e/../../../.."
743+
)
744+
745+
# Invalid cases: resulting path traversal consumes whole value or overflows left
746+
invalid_cases = [
747+
"projects/my-project/monitoredResourceDescriptors/.",
748+
"projects/my-project/monitoredResourceDescriptors/instance/my-instance/../..",
749+
"projects/my-project/monitoredResourceDescriptors/instance/../my-instance/..",
750+
"projects/my-project/monitoredResourceDescriptors/..",
751+
"projects/my-project/monitoredResourceDescriptors/instance/../..",
752+
"projects/my-project/monitoredResourceDescriptors/a/b/../../../c/d/e/..",
753+
]
754+
for case in invalid_cases:
755+
with pytest.raises(ValueError, match="Invalid value .* for name\\."):
756+
path_template.expand(
757+
"/v3/{name=projects/*/monitoredResourceDescriptors/**}", name=case
758+
)
759+
760+
761+
def test_percent_encoding_unreserved_characters():
762+
# 3. Values for all variable parts should be percent-encoded except for [-_.~/0-9a-zA-Z] characters.
763+
# We should keep [-_.~/0-9a-zA-Z] safe for both single-star and double-star
764+
result = path_template.expand("/v1/{name}", name="abc-._~")
765+
assert result == "/v1/abc-._~"
766+
767+
result = path_template.expand("/v1/{name=**}", name="abc-._~/")
768+
assert result == "/v1/abc-._~/"
769+
770+
# Other characters like '$', '?', '=', '#', ' ' should be encoded
771+
result = path_template.expand("/v1/{name}", name="a$b?c=d#e f")
772+
assert result == "/v1/a%24b%3Fc%3Dd%23e%20f"

0 commit comments

Comments
 (0)