Skip to content
This repository was archived by the owner on May 27, 2026. It is now read-only.

Commit 488fe69

Browse files
committed
python: escape rule values in generated validator source (string code injection)
The Python validator generates Python source from Jinja2 templates and then exec()'s it (`python/protoc_gen_validate/validator.py:68` and `:217`). Six template sites in `string_template` and one in `const_template` interpolate string-typed `validate.rules` option values between literal Python quote characters, e.g. if {{ name }} != "{{ o.string['const'] }}": if not "{{ s['contains'] }}" in {{ name }}: if re.search(r'{{ s['pattern'] }}', {{ name }}) is None: A `.proto` file with a crafted rule string can close the surrounding Python string literal and append arbitrary expressions or statements, which run on the first call to `validate(msg)` for that descriptor (the generated function is cached via `@lru_cache`). This is the Python analogue of the Java code-injection class tracked in issue/PR #1385. Fix --- Add a small `_pyrepr` helper that returns `repr(value)`, the canonical Python-source literal representation, and pass it as `repr=` into every affected `Template(...).render(...)` call. Rewrite each unsafe `"{{ ... }}"` interpolation as `{{ repr(...) }}` so the value becomes a single, properly-escaped Python literal. Error messages that previously interpolated the same value inside their format string are restructured to do runtime string concatenation with a separately-escaped literal, e.g. raise ValidationFailed("p.field does not contain " + {{ repr(s['contains']) }}) This preserves the existing user-visible error text on benign input, keeps regex `{{ s['pattern'] }}` semantics (Python's `repr` produces a non-raw literal whose backslashes evaluate back to the original regex), and refuses injection on crafted input. Reproduction (before patch) --------------------------- string evil = 1 [(validate.rules).string.contains = "x\" + __import__('os').system('touch /tmp/pgv-pwned') + \"y"]; Running validate(msg) on a message of this type touches `/tmp/pgv-pwned`. After this patch the same payload is embedded as a Python string literal `'x" + __import__(\'os\').system(...) + "y'` and no command runs. Sinks closed ------------ * `const_template` string/bool/bytes `const` * `in_template` `in` / `not_in` (error message) * `string_template` `pattern`, `prefix`, `suffix`, `contains`, `not_contains`
1 parent 92b9a7d commit 488fe69

1 file changed

Lines changed: 38 additions & 24 deletions

File tree

python/protoc_gen_validate/validator.py

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@
2121

2222
printer = ""
2323

24+
25+
def _pyrepr(value):
26+
"""Return a Python source literal that safely embeds `value` inside
27+
generated validator code.
28+
29+
Validation rules from `.proto` files are rendered into Python source via
30+
Jinja2 templates and then exec()'d. Embedding a string between literal
31+
Python quotes (e.g. ``"{{ s['contains'] }}"``) lets a crafted rule value
32+
escape the literal and inject arbitrary Python. Use this helper for
33+
every rule value that appears in generated source so that the resulting
34+
token is a single, properly-escaped Python literal.
35+
"""
36+
return repr(value)
37+
2438
# Well known regex mapping.
2539
regex_map = {
2640
"UNKNOWN": "",
@@ -300,36 +314,36 @@ def _has_field(message_pb, property_name):
300314

301315
def const_template(option_value, name):
302316
const_tmpl = """{%- if str(o.string) and o.string.HasField('const') -%}
303-
if {{ name }} != \"{{ o.string['const'] }}\":
304-
raise ValidationFailed(\"{{ name }} not equal to {{ o.string['const'] }}\")
317+
if {{ name }} != {{ repr(o.string['const']) }}:
318+
raise ValidationFailed("{{ name }} not equal to " + {{ repr(o.string['const']) }})
305319
{%- elif str(o.bool) and o.bool['const'] != "" -%}
306320
if {{ name }} != {{ o.bool['const'] }}:
307-
raise ValidationFailed(\"{{ name }} not equal to {{ o.bool['const'] }}\")
321+
raise ValidationFailed("{{ name }} not equal to " + {{ repr(str(o.bool['const'])) }})
308322
{%- elif str(o.bytes) and o.bytes.HasField('const') -%}
309323
{% if sys.version_info[0] >= 3 %}
310-
if {{ name }} != {{ o.bytes['const'] }}:
311-
raise ValidationFailed(\"{{ name }} not equal to {{ o.bytes['const'] }}\")
324+
if {{ name }} != {{ repr(o.bytes['const']) }}:
325+
raise ValidationFailed("{{ name }} not equal to " + {{ repr(repr(o.bytes['const'])) }})
312326
{% else %}
313-
if {{ name }} != b\"{{ o.bytes['const'].encode('string_escape') }}\":
314-
raise ValidationFailed(\"{{ name }} not equal to {{ o.bytes['const'].encode('string_escape') }}\")
327+
if {{ name }} != {{ repr(o.bytes['const']) }}:
328+
raise ValidationFailed("{{ name }} not equal to " + {{ repr(repr(o.bytes['const'])) }})
315329
{% endif %}
316330
{%- endif -%}
317331
"""
318-
return Template(const_tmpl).render(sys=sys, o=option_value, name=name, str=str)
332+
return Template(const_tmpl).render(sys=sys, o=option_value, name=name, str=str, repr=_pyrepr)
319333

320334

321335
def in_template(value, name):
322336
in_tmpl = """
323337
{%- if value['in'] %}
324-
if {{ name }} not in {{ value['in'] }}:
325-
raise ValidationFailed(\"{{ name }} not in {{ value['in'] }}\")
338+
if {{ name }} not in {{ repr(list(value['in'])) }}:
339+
raise ValidationFailed("{{ name }} not in " + {{ repr(repr(list(value['in']))) }})
326340
{%- endif -%}
327341
{%- if value['not_in'] %}
328-
if {{ name }} in {{ value['not_in'] }}:
329-
raise ValidationFailed(\"{{ name }} in {{ value['not_in'] }}\")
342+
if {{ name }} in {{ repr(list(value['not_in'])) }}:
343+
raise ValidationFailed("{{ name }} in " + {{ repr(repr(list(value['not_in']))) }})
330344
{%- endif -%}
331345
"""
332-
return Template(in_tmpl).render(value=value, name=name)
346+
return Template(in_tmpl).render(value=value, name=name, repr=_pyrepr, list=list)
333347

334348

335349
def string_template(option_value, name):
@@ -376,24 +390,24 @@ def string_template(option_value, name):
376390
raise ValidationFailed(\"{{ name }} length is greater than {{ s['max_bytes'] }}\")
377391
{%- endif -%}
378392
{%- if s['pattern'] %}
379-
if re.search(r\'{{ s['pattern'] }}\', {{ name }}) is None:
380-
raise ValidationFailed(\"{{ name }} pattern does not match {{ s['pattern'] }}\")
393+
if re.search({{ repr(s['pattern']) }}, {{ name }}) is None:
394+
raise ValidationFailed("{{ name }} pattern does not match " + {{ repr(s['pattern']) }})
381395
{%- endif -%}
382396
{%- if s['prefix'] %}
383-
if not {{ name }}.startswith(\"{{ s['prefix'] }}\"):
384-
raise ValidationFailed(\"{{ name }} does not start with prefix {{ s['prefix'] }}\")
397+
if not {{ name }}.startswith({{ repr(s['prefix']) }}):
398+
raise ValidationFailed("{{ name }} does not start with prefix " + {{ repr(s['prefix']) }})
385399
{%- endif -%}
386400
{%- if s['suffix'] %}
387-
if not {{ name }}.endswith(\"{{ s['suffix'] }}\"):
388-
raise ValidationFailed(\"{{ name }} does not end with suffix {{ s['suffix'] }}\")
401+
if not {{ name }}.endswith({{ repr(s['suffix']) }}):
402+
raise ValidationFailed("{{ name }} does not end with suffix " + {{ repr(s['suffix']) }})
389403
{%- endif -%}
390404
{%- if s['contains'] %}
391-
if not \"{{ s['contains'] }}\" in {{ name }}:
392-
raise ValidationFailed(\"{{ name }} does not contain {{ s['contains'] }}\")
405+
if {{ repr(s['contains']) }} not in {{ name }}:
406+
raise ValidationFailed("{{ name }} does not contain " + {{ repr(s['contains']) }})
393407
{%- endif -%}
394408
{%- if s['not_contains'] %}
395-
if \"{{ s['not_contains'] }}\" in {{ name }}:
396-
raise ValidationFailed(\"{{ name }} contains {{ s['not_contains'] }}\")
409+
if {{ repr(s['not_contains']) }} in {{ name }}:
410+
raise ValidationFailed("{{ name }} contains " + {{ repr(s['not_contains']) }})
397411
{%- endif -%}
398412
{%- if s['email'] %}
399413
if not _validateEmail({{ name }}):
@@ -446,7 +460,7 @@ def string_template(option_value, name):
446460
{%- endif -%}
447461
{% endfilter %}
448462
"""
449-
return Template(str_templ).render(o=option_value, name=name, const_template=const_template, in_template=in_template)
463+
return Template(str_templ).render(o=option_value, name=name, const_template=const_template, in_template=in_template, repr=_pyrepr)
450464

451465

452466
def required_template(value, name):

0 commit comments

Comments
 (0)