From 054b90ebde1709d6f9dc1823e3c6d23770a24b55 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 2 Jul 2026 14:22:07 +1000 Subject: [PATCH] Don't reference built-in globals via loop-items in Jinja2 into keys --- src/jinjaturtle/handlers/json.py | 10 ++++++++- src/jinjaturtle/handlers/toml.py | 14 +++++++++++- src/jinjaturtle/handlers/yaml.py | 13 +++++++++++ src/jinjaturtle/loop_analyzer.py | 37 +++++++++++++++++++++++++++++++ src/jinjaturtle/safety.py | 38 ++++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src/jinjaturtle/handlers/json.py b/src/jinjaturtle/handlers/json.py index 0268666..41699c9 100644 --- a/src/jinjaturtle/handlers/json.py +++ b/src/jinjaturtle/handlers/json.py @@ -8,7 +8,7 @@ from typing import Any from . import DictLikeHandler from .. import j2 from ..escape import escape_jinja_literal -from ..loop_analyzer import LoopCandidate +from ..loop_analyzer import LoopCandidate, is_safe_loop_field_key class JsonHandler(DictLikeHandler): @@ -383,6 +383,14 @@ class JsonHandler(DictLikeHandler): ] # first line has no indent; we prepend `inner` when emitting for i, key in enumerate(keys): comma = "," if i < len(keys) - 1 else "" + # Defence in depth: never interpolate a raw source key into an + # ``item_var.key`` reference. A key such as ``a }}{{ x`` would break + # out of the value placeholder and inject a live construct that the + # output-safety gate cannot distinguish from a legitimate variable. + if not is_safe_loop_field_key(key): + raise ValueError( + f"refusing to emit loop-item field reference for unsafe key: {key!r}" + ) # The literal key text is emitted verbatim into the template; escape any # Jinja markup in it. The value side ({item_var}.{key}) is constrained by # the output safety gate's dotted-name allowlist, which fails closed on diff --git a/src/jinjaturtle/handlers/toml.py b/src/jinjaturtle/handlers/toml.py index 6d25cff..b2aa6e5 100644 --- a/src/jinjaturtle/handlers/toml.py +++ b/src/jinjaturtle/handlers/toml.py @@ -6,7 +6,7 @@ from typing import Any from . import DictLikeHandler from .. import j2 from ..escape import escape_jinja_literal -from ..loop_analyzer import LoopCandidate +from ..loop_analyzer import LoopCandidate, is_safe_loop_field_key try: import tomllib @@ -438,6 +438,18 @@ class TomlHandler(DictLikeHandler): for key, value in sample_item.items(): if key == "_key": continue + # Defence in depth: the loop analyzer refuses a + # dict-loop whose items contain a non-identifier + # key, so ``key`` is always a plain identifier + # here. Never interpolate a raw key into an + # ``item_var.key`` reference: a key such as + # ``a }}{{ x`` would break out of the placeholder + # and inject a live construct. + if not is_safe_loop_field_key(key): + raise ValueError( + "refusing to emit loop-item field " + f"reference for unsafe key: {key!r}" + ) if isinstance(value, str): out_lines.append( f"{escape_jinja_literal(str(key))} = " diff --git a/src/jinjaturtle/handlers/yaml.py b/src/jinjaturtle/handlers/yaml.py index d8131b0..a1447bb 100644 --- a/src/jinjaturtle/handlers/yaml.py +++ b/src/jinjaturtle/handlers/yaml.py @@ -7,6 +7,7 @@ from typing import Any from .dict import DictLikeHandler from .. import j2 from ..escape import escape_jinja_literal +from ..loop_analyzer import is_safe_loop_field_key from ..loop_analyzer import LoopCandidate @@ -547,6 +548,18 @@ class YamlHandler(DictLikeHandler): # Special key for dict collections - output as comment or skip continue + # Defence in depth: the loop analyzer already refuses a dict-loop + # whose items contain a non-identifier key (see _analyze_dict_schema), + # so ``key`` should always be a plain identifier here. Assert it + # rather than interpolate a raw key into a Jinja reference: a key such + # as ``a }}{{ x`` would otherwise close the placeholder and inject a + # live construct that the output gate cannot distinguish from a + # legitimate variable. + if not is_safe_loop_field_key(key): + raise ValueError( + f"refusing to emit loop-item field reference for unsafe key: {key!r}" + ) + if first_key and is_list_item: # First key gets the list marker value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value) diff --git a/src/jinjaturtle/loop_analyzer.py b/src/jinjaturtle/loop_analyzer.py index 23702d8..608c8e4 100644 --- a/src/jinjaturtle/loop_analyzer.py +++ b/src/jinjaturtle/loop_analyzer.py @@ -7,10 +7,34 @@ instead of flattened scalar variables. from __future__ import annotations +import re from collections import Counter from typing import Any, Literal +# A dict-loop emits per-item field references of the form ``loopvar.``. +# ```` is derived from a source key, which is attacker-influenceable when +# JinjaTurtle is fed harvested config. The output-safety gate only accepts a +# reference whose every hop matches this identifier class, so a key must reduce +# to exactly this shape before it can be used as a loop-item field. Anything else +# (a key containing ``}}``, quotes, ``.``, ``__``, ...) must not be turned into a +# loop; the caller falls back to scalar generation, where every value goes through +# make_var_name() and all verbatim text is escaped. +_SAFE_LOOP_FIELD_RE = re.compile(r"(?!\w*__)[A-Za-z_][A-Za-z0-9_]*\Z") + + +def is_safe_loop_field_key(key: Any) -> bool: + """Return True if *key* is safe to emit as a ``loopvar.`` field access. + + The check mirrors the output-safety gate's identifier class (single + identifier, no double underscore). A key that does not match cannot be + expressed as a dotted loop-item reference without risking template + injection, so a loop candidate containing such a key is rejected upstream. + """ + + return bool(_SAFE_LOOP_FIELD_RE.match(str(key))) + + class LoopCandidate: """ Represents a detected loop opportunity in the config structure. @@ -265,6 +289,19 @@ class LoopAnalyzer: if not dicts: return "heterogeneous" + # Security: a dict-loop emits ``loopvar.`` field references. If any + # item key is not a plain identifier, it cannot be expressed safely as a + # dotted reference (a key such as ``a }}{{ x`` would break out of the + # placeholder and inject a live construct). Refuse the loop so the caller + # falls back to scalar generation, which escapes all verbatim text and + # routes every value through make_var_name(). + for d in dicts: + for k in d.keys(): + if k == "_key": + continue + if not is_safe_loop_field_key(k): + return "heterogeneous" + # Get key sets from each dict key_sets = [set(d.keys()) for d in dicts] diff --git a/src/jinjaturtle/safety.py b/src/jinjaturtle/safety.py index 5ed2fe1..e049dd3 100644 --- a/src/jinjaturtle/safety.py +++ b/src/jinjaturtle/safety.py @@ -132,13 +132,51 @@ def _strip_ws_control(body: str) -> str: return body.strip() +# Built-in Jinja2/Ansible globals that JinjaTurtle never emits as a reference +# head. The allowlist grammar necessarily accepts any bare identifier (it cannot +# tell an injected global from a legitimate generated variable), so these names +# are rejected explicitly: their presence as the head of a live reference means +# source text leaked into a live construct. This is a targeted backstop and does +# not replace the grammar check. +_FORBIDDEN_REFERENCE_HEADS = frozenset( + { + "range", + "dict", + "lipsum", + "cycler", + "joiner", + "namespace", + "config", + "self", + "cycle", + "request", + "get_flashed_messages", + "url_for", + } +) + + +def _reference_head(body: str) -> str: + """Return the leading identifier (before any dot/filter) of an expression.""" + body = _strip_ws_control(body) + m = re.match(r"[A-Za-z_][A-Za-z0-9_]*", body) + return m.group(0) if m else "" + + def _expr_is_allowed(body: str) -> bool: body = _strip_ws_control(body) + if _reference_head(body) in _FORBIDDEN_REFERENCE_HEADS: + return False return any(p.match(body) for p in _EXPR_PATTERNS) def _stmt_is_allowed(body: str) -> bool: body = _strip_ws_control(body) + # A ``for ... in `` whose collection is a forbidden global is + # rejected for the same reason as an expression head. + m = re.match(r"for\s+[A-Za-z_]\w*\s+in\s+([A-Za-z_][A-Za-z0-9_]*)", body) + if m and m.group(1) in _FORBIDDEN_REFERENCE_HEADS: + return False return any(p.match(body) for p in _STMT_PATTERNS)