This repository was archived by the owner on May 27, 2026. It is now read-only.
Python code injection vulnerability#1392
Merged
Merged
Conversation
rodaine
reviewed
May 27, 2026
… 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 bufbuild#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`
evilgensec
force-pushed
the
fix-python-string-injection
branch
from
May 27, 2026 14:48
488fe69 to
457e34b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Unescaped
validate.rulesstring values are interpolated into Python source bypython/protoc_gen_validate/validator.py. The rendered source is then executed viaexec(), so a malicious.protowhose validation rule contains a Python-source breakout (a literal"followed by arbitrary expressions) achieves arbitrary code execution the first timevalidate(msg)is called on a message of that type.This is the Python analogue of the Java vulnerability tracked in #1385.
Description
exec().validate.rulesoption values (string.const,string.prefix,string.suffix,string.contains,string.not_contains,string.pattern, and the same set insidein_template/const_template).python/protoc_gen_validate/validator.py, executed atvalidator.py:68and:217:funcbeing the rendered template source."{{ … }}"interpolations between literal Python quote characters):const_template(line 303-314):if {{ name }} != "{{ o.string['const'] }}":, error-message string, plus bool/bytes equivalents.in_template(line 325, 329): error-message strings referencingvalue['in']/value['not_in'].string_template(line 379-396):pattern,prefix,suffix,contains,not_containsand their error messages..protoand callvalidate(msg)" — realistic for CI pipelines, multi-tenant build systems, schema-registry /buf pushingestion, and code that usesgoogle.protobuf.Anywith dynamic descriptor lookup.Reproducer (against current
main)Generated source produced by the unpatched template (excerpt):
os.systemruns when the if-expression is evaluated (the subsequentNameErroron bareevil_fieldis harmless — the side effect already fired). With small payload adjustments the same pattern fires in every other sink listed above.Fix in this PR
Add
_pyrepr(value)near the top ofvalidator.py— a thin wrapper aroundrepr()so the helper's intent is explicit at the call site. Passrepr=_pyreprinto every affectedTemplate(...).render(...)call. Replace each unsafe"{{ field }}"interpolation with{{ repr(field) }}so the rendered token is a single, properly-escaped Python literal. Restructure error-message strings to do runtime concatenation with a separately-escaped literal:raise ValidationFailed("{{ name }} does not contain " + {{ repr(s['contains']) }})This preserves the existing user-visible error text on benign input, keeps
re.searchregex semantics for{{ s['pattern'] }}(Python'sreprproduces a non-raw literal whose backslashes evaluate back to the original regex), and refuses injection on crafted input.Verification
Direct exec of the generated source with the original payload across all five string sinks (
contains,prefix,suffix,pattern,const) no longer creates the/tmp/pgv-pwnedsentinel. The payload is embedded as a Python string literal'x" + __import__(\'os\').system(...) + "y'and no command runs. Legitimate regex patterns (^\d+@example\.com$) are preserved.Sinks closed
const_templateconst(value comparison and error message)in_templatein/not_inerror messagesstring_templatepattern,prefix,suffix,contains,not_contains(condition and error message)Out of scope / follow-ups
bytes_template) already relies on Python'sstr(bytes)repr at most sites, so direct injection there is harder. A follow-up could route bytes interpolations through_pyreprfor defense-in-depth.{{ num['const'] }},{{ s['len'] }}etc.) consume numeric or boolean values and are safe.Related
Diff stats
python/protoc_gen_validate/validator.py— +38 / -24, no other files touched.