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 DictLikeHandler
|
||||||
from .. import j2
|
from .. import j2
|
||||||
from ..escape import escape_jinja_literal
|
from ..escape import escape_jinja_literal
|
||||||
from ..loop_analyzer import LoopCandidate
|
from ..loop_analyzer import LoopCandidate, is_safe_loop_field_key
|
||||||
|
|
||||||
|
|
||||||
class JsonHandler(DictLikeHandler):
|
class JsonHandler(DictLikeHandler):
|
||||||
|
|
@ -383,6 +383,14 @@ class JsonHandler(DictLikeHandler):
|
||||||
] # first line has no indent; we prepend `inner` when emitting
|
] # first line has no indent; we prepend `inner` when emitting
|
||||||
for i, key in enumerate(keys):
|
for i, key in enumerate(keys):
|
||||||
comma = "," if i < len(keys) - 1 else ""
|
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
|
# 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
|
# 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
|
# 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 DictLikeHandler
|
||||||
from .. import j2
|
from .. import j2
|
||||||
from ..escape import escape_jinja_literal
|
from ..escape import escape_jinja_literal
|
||||||
from ..loop_analyzer import LoopCandidate
|
from ..loop_analyzer import LoopCandidate, is_safe_loop_field_key
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import tomllib
|
import tomllib
|
||||||
|
|
@ -438,6 +438,18 @@ class TomlHandler(DictLikeHandler):
|
||||||
for key, value in sample_item.items():
|
for key, value in sample_item.items():
|
||||||
if key == "_key":
|
if key == "_key":
|
||||||
continue
|
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):
|
if isinstance(value, str):
|
||||||
out_lines.append(
|
out_lines.append(
|
||||||
f"{escape_jinja_literal(str(key))} = "
|
f"{escape_jinja_literal(str(key))} = "
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from typing import Any
|
||||||
from .dict import DictLikeHandler
|
from .dict import DictLikeHandler
|
||||||
from .. import j2
|
from .. import j2
|
||||||
from ..escape import escape_jinja_literal
|
from ..escape import escape_jinja_literal
|
||||||
|
from ..loop_analyzer import is_safe_loop_field_key
|
||||||
from ..loop_analyzer import LoopCandidate
|
from ..loop_analyzer import LoopCandidate
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -547,6 +548,18 @@ class YamlHandler(DictLikeHandler):
|
||||||
# Special key for dict collections - output as comment or skip
|
# Special key for dict collections - output as comment or skip
|
||||||
continue
|
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:
|
if first_key and is_list_item:
|
||||||
# First key gets the list marker
|
# First key gets the list marker
|
||||||
value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value)
|
value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value)
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,34 @@ instead of flattened scalar variables.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Any, Literal
|
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:
|
class LoopCandidate:
|
||||||
"""
|
"""
|
||||||
Represents a detected loop opportunity in the config structure.
|
Represents a detected loop opportunity in the config structure.
|
||||||
|
|
@ -265,6 +289,19 @@ class LoopAnalyzer:
|
||||||
if not dicts:
|
if not dicts:
|
||||||
return "heterogeneous"
|
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
|
# Get key sets from each dict
|
||||||
key_sets = [set(d.keys()) for d in dicts]
|
key_sets = [set(d.keys()) for d in dicts]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -132,13 +132,51 @@ def _strip_ws_control(body: str) -> str:
|
||||||
return body.strip()
|
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:
|
def _expr_is_allowed(body: str) -> bool:
|
||||||
body = _strip_ws_control(body)
|
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)
|
return any(p.match(body) for p in _EXPR_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
def _stmt_is_allowed(body: str) -> bool:
|
def _stmt_is_allowed(body: str) -> bool:
|
||||||
body = _strip_ws_control(body)
|
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)
|
return any(p.match(body) for p in _STMT_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue