Don't reference built-in globals via loop-items in Jinja2 into keys
This commit is contained in:
parent
575c2b79f9
commit
054b90ebde
5 changed files with 110 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))} = "
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue