Don't reference built-in globals via loop-items in Jinja2 into keys
All checks were successful
CI / test (push) Successful in 52s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m31s
Lint / test (push) Successful in 43s

This commit is contained in:
Miguel Jacq 2026-07-02 14:22:07 +10:00
parent 575c2b79f9
commit 054b90ebde
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
5 changed files with 110 additions and 2 deletions

View file

@ -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

View file

@ -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))} = "

View file

@ -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)

View file

@ -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.<field>``.
# ``<field>`` 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.<key>`` 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.<key>`` 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]

View file

@ -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 <collection>`` 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)