More JSON defenses
This commit is contained in:
parent
661320558c
commit
4a1f2ac15e
5 changed files with 203 additions and 7 deletions
|
|
@ -10,6 +10,7 @@ import yaml
|
|||
from .loop_analyzer import LoopAnalyzer, LoopCandidate
|
||||
from .safety import (
|
||||
verify_jinja2_template_safe,
|
||||
verify_no_live_jinja_in_json_keys,
|
||||
)
|
||||
from .handlers import (
|
||||
BaseHandler,
|
||||
|
|
@ -394,6 +395,13 @@ def generate_jinja2_template(
|
|||
# verbatim source text, the un-escaped payload shows up here as a live tag
|
||||
# and generation aborts instead of emitting an injectable template.
|
||||
verify_jinja2_template_safe(template)
|
||||
|
||||
# Format-specific backstop: JinjaTurtle never emits Jinja inside a JSON object
|
||||
# key, so a live construct in key position means source key text leaked into
|
||||
# the template unescaped. This is independent of per-handler escaping.
|
||||
if fmt == "json":
|
||||
verify_no_live_jinja_in_json_keys(template)
|
||||
|
||||
return template
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from typing import Any
|
|||
|
||||
from . import DictLikeHandler
|
||||
from .. import j2
|
||||
from ..escape import escape_jinja_literal
|
||||
from ..loop_analyzer import LoopCandidate
|
||||
|
||||
|
||||
|
|
@ -109,10 +110,18 @@ class JsonHandler(DictLikeHandler):
|
|||
chunks: list[str] = []
|
||||
pos = 0
|
||||
for path, start, end in spans:
|
||||
chunks.append(text[pos:start])
|
||||
# Text between scalar values (object keys, structural punctuation,
|
||||
# whitespace, and any comment-like trailing text) is copied verbatim
|
||||
# from the source file. Like every other text-emitting handler, this
|
||||
# verbatim text must be neutralised: if it contains Jinja2 markup it
|
||||
# would otherwise become live template code at apply time. The value
|
||||
# itself is replaced with a safe placeholder below. ``escape_jinja_literal``
|
||||
# is a no-op on text without Jinja markers, so benign JSON is unchanged
|
||||
# byte-for-byte and a later render reproduces the original characters.
|
||||
chunks.append(escape_jinja_literal(text[pos:start]))
|
||||
chunks.append(self._json_value_expr(self.make_var_name(role_prefix, path)))
|
||||
pos = end
|
||||
chunks.append(text[pos:])
|
||||
chunks.append(escape_jinja_literal(text[pos:]))
|
||||
return "".join(chunks)
|
||||
|
||||
def _collect_json_scalar_spans(
|
||||
|
|
@ -213,7 +222,12 @@ class JsonHandler(DictLikeHandler):
|
|||
|
||||
def _walk(obj: Any, path: tuple[str, ...] = ()) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return {k: _walk(v, path + (str(k),)) for k, v in obj.items()}
|
||||
# Keys are emitted verbatim into the template, so neutralise any
|
||||
# Jinja markup in them (see _generate_json_template_from_text).
|
||||
return {
|
||||
escape_jinja_literal(str(k)): _walk(v, path + (str(k),))
|
||||
for k, v in obj.items()
|
||||
}
|
||||
if isinstance(obj, list):
|
||||
return [_walk(v, path + (str(i),)) for i, v in enumerate(obj)]
|
||||
# scalar - use marker that will be replaced with to_json
|
||||
|
|
@ -261,7 +275,12 @@ class JsonHandler(DictLikeHandler):
|
|||
return f"__LOOP_DICT__{collection_var}__{item_var}__"
|
||||
|
||||
if isinstance(obj, dict):
|
||||
return {k: _walk(v, current_path + (str(k),)) for k, v in obj.items()}
|
||||
# Keys are emitted verbatim into the template, so neutralise any
|
||||
# Jinja markup in them (see _generate_json_template_from_text).
|
||||
return {
|
||||
escape_jinja_literal(str(k)): _walk(v, current_path + (str(k),))
|
||||
for k, v in obj.items()
|
||||
}
|
||||
if isinstance(obj, list):
|
||||
# Check if this list is a loop candidate
|
||||
if current_path in loop_paths:
|
||||
|
|
@ -364,8 +383,13 @@ 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 ""
|
||||
# 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
|
||||
# anything that is not a plain identifier path.
|
||||
dict_lines.append(
|
||||
f'{field}"{key}": ' f"{j2.to_json(f'{item_var}.{key}')}{comma}"
|
||||
f'{field}"{escape_jinja_literal(str(key))}": '
|
||||
f"{j2.to_json(f'{item_var}.{key}')}{comma}"
|
||||
)
|
||||
# Comma between *items* goes after the closing brace.
|
||||
dict_lines.append(f"{inner}}}{j2.if_not_loop_last()},{j2.endif()}")
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import xml.etree.ElementTree as ET # nosec
|
|||
from . import j2
|
||||
from .core import dump_yaml, flatten_config, make_var_name, parse_config
|
||||
from .handlers.xml import XmlHandler
|
||||
from .safety import verify_jinja2_template_safe
|
||||
from .safety import verify_jinja2_template_safe, verify_no_live_jinja_in_json_keys
|
||||
from .escape import escape_jinja_literal
|
||||
|
||||
|
||||
|
|
@ -784,5 +784,7 @@ def process_directory(
|
|||
# Any un-neutralised source text that became a live tag aborts generation.
|
||||
for out in outputs:
|
||||
verify_jinja2_template_safe(out.template)
|
||||
if out.fmt == "json":
|
||||
verify_no_live_jinja_in_json_keys(out.template)
|
||||
|
||||
return defaults_yaml, outputs
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ __all__ = [
|
|||
"TemplateSafetyError",
|
||||
"verify_jinja2_template_safe",
|
||||
"verify_erb_template_safe",
|
||||
"verify_no_live_jinja_in_json_keys",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -208,7 +209,88 @@ def verify_jinja2_template_safe(template_text: str) -> None:
|
|||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ERB gate.
|
||||
# JSON-key gate (defence in depth, format-specific).
|
||||
#
|
||||
# JinjaTurtle never emits a live Jinja construct inside a JSON *object key*: keys
|
||||
# are copied verbatim from the source and (after escape.py) are wrapped in
|
||||
# ``{% raw %}`` if they contain markup, so a key never lexes as a live tag. A
|
||||
# live construct in key position can therefore only mean source key text leaked
|
||||
# into the template unescaped (the json-handler blind spot). This gate is
|
||||
# independent of the escaper: it inspects the finished template, replaces every
|
||||
# *live* Jinja construct with an inert sentinel (raw-wrapped literal text stays
|
||||
# literal), and rejects any sentinel that lands in a JSON key slot.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Sentinel byte that cannot occur in normal generated template text.
|
||||
_LIVE_SENTINEL = "\x00"
|
||||
|
||||
# A JSON key is a double-quoted string immediately followed (after optional
|
||||
# whitespace) by a colon. We only need to detect a sentinel *inside* such a
|
||||
# string, so match a quoted run that ends in `":` and look for the sentinel.
|
||||
_JSON_KEY_RE = re.compile(r'"((?:[^"\\]|\\.)*)"\s*:', re.S)
|
||||
|
||||
_KEY_JINJA_DELIMS = ("{{", "}}", "{%", "%}", "{#", "#}")
|
||||
|
||||
|
||||
def _contains_jinja_delim(text: str) -> bool:
|
||||
"""True if *text* contains any Jinja delimiter (live or escaped-literal)."""
|
||||
return any(d in text for d in _KEY_JINJA_DELIMS)
|
||||
|
||||
|
||||
def verify_no_live_jinja_in_json_keys(template_text: str) -> None:
|
||||
"""Reject a JSON template that carries Jinja markup in an object key.
|
||||
|
||||
JinjaTurtle never templates a JSON *object key*: keys come straight from the
|
||||
source and a key is an identifier/string, never a value placeholder. Any
|
||||
Jinja in key position therefore means attacker-influenced source key text
|
||||
reached the template. This gate fails closed on it, independent of whether a
|
||||
handler left the markup *live* (a raw ``{{ ... }}`` in the key) or *escaped*
|
||||
it into a ``{% raw %}`` wrapper -- both indicate a key that should never have
|
||||
contained templating, so generation aborts rather than emitting it.
|
||||
|
||||
Detection is done on the lexer token stream: we reconstruct the document with
|
||||
every live tag collapsed to a sentinel and every ``{% raw %}``-wrapped region
|
||||
also marked, then reject a sentinel that lands inside a JSON key string.
|
||||
"""
|
||||
import jinja2
|
||||
|
||||
env = jinja2.Environment(autoescape=True)
|
||||
try:
|
||||
tokens = list(env.lex(template_text))
|
||||
except jinja2.TemplateSyntaxError as exc:
|
||||
raise TemplateSafetyError(
|
||||
f"generated JSON template does not lex as the JinjaTurtle subset: {exc}"
|
||||
) from exc
|
||||
|
||||
out: list[str] = []
|
||||
in_tag = False
|
||||
for _lineno, tok_type, value in tokens:
|
||||
if tok_type in ("variable_begin", "block_begin", "comment_begin"):
|
||||
# A live construct: collapse to a sentinel so it is detectable if it
|
||||
# sits in key position. (raw_begin/raw_end are *not* live; the data
|
||||
# inside a raw block is preserved verbatim below, so an escaped key
|
||||
# still shows its literal Jinja delimiters to the key check.)
|
||||
in_tag = True
|
||||
out.append(_LIVE_SENTINEL)
|
||||
elif tok_type in ("variable_end", "block_end", "comment_end"):
|
||||
in_tag = False
|
||||
elif not in_tag:
|
||||
# data, whitespace, raw_begin/raw_end markers, and inert raw content.
|
||||
out.append(value if isinstance(value, str) else str(value))
|
||||
|
||||
reconstructed = "".join(out)
|
||||
|
||||
for match in _JSON_KEY_RE.finditer(reconstructed):
|
||||
key_text = match.group(1)
|
||||
if _LIVE_SENTINEL in key_text or _contains_jinja_delim(key_text):
|
||||
raise TemplateSafetyError(
|
||||
"refusing to emit JSON template: Jinja markup appears inside a "
|
||||
"JSON object key. JinjaTurtle never templates keys, so this "
|
||||
"indicates attacker-influenced source key text (possible "
|
||||
"template injection)."
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# JinjaTurtle's ERB output is produced by translating the (already-verified)
|
||||
# Jinja2 subset, so the Jinja2 gate is the primary guarantee. As an independent
|
||||
|
|
|
|||
|
|
@ -152,6 +152,86 @@ def test_generated_template_is_renderable(tmp_path, fmt, name, body):
|
|||
_render_jinja(template_text, defaults)
|
||||
|
||||
|
||||
# --- JSON object-key injection ------------------------------------------------
|
||||
#
|
||||
# The JSON handler copies the text *between* scalar values (object keys,
|
||||
# punctuation) verbatim. A key is never a value placeholder, so Jinja markup in a
|
||||
# key can only come from attacker-influenced source text. The output gate
|
||||
# (verify_no_live_jinja_in_json_keys) must fail closed on it -- including the
|
||||
# "benign-looking name" form (e.g. ``{{ ansible_hostname }}``) that the generic
|
||||
# allowlist would otherwise accept as an ordinary variable reference, and which
|
||||
# could leak an in-scope variable's value into the rendered config at apply time.
|
||||
|
||||
JSON_KEY_INJECTION_BODIES = [
|
||||
# benign-looking variable reference (the residual bypass: passes the generic
|
||||
# allowlist but must still be rejected in *key* position)
|
||||
'{ "{{ ansible_hostname }}": "v" }',
|
||||
# dotted reference (e.g. dumping another host's vars)
|
||||
'{ "{{ hostvars.localhost }}": 1 }',
|
||||
# self-referencing a sibling-derived variable name
|
||||
'{ "{{ role_port }}": "x", "port": 8080 }',
|
||||
# classic gadget (already rejected historically; kept as a guard)
|
||||
'{ "{{ cycler.__init__.__globals__ }}": 1 }',
|
||||
# statement injection in a key
|
||||
'{ "{% for x in y %}k{% endfor %}": 1 }',
|
||||
# nested object key
|
||||
'{ "ok": { "{{ ansible_hostname }}": 2 } }',
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", JSON_KEY_INJECTION_BODIES)
|
||||
def test_json_key_injection_fails_closed_cli(tmp_path, body):
|
||||
src = tmp_path / "evil.json"
|
||||
src.write_text(body, encoding="utf-8")
|
||||
out = tmp_path / "out.j2"
|
||||
res = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"jinjaturtle.cli",
|
||||
str(src),
|
||||
"-f",
|
||||
"json",
|
||||
"--role-name",
|
||||
"role",
|
||||
"-t",
|
||||
str(out),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert res.returncode == 2, f"expected fail-closed, got rc={res.returncode}"
|
||||
assert "refusing to generate unsafe template" in res.stderr
|
||||
assert not out.exists(), "no template may be written when the gate refuses"
|
||||
|
||||
|
||||
def test_json_benign_keys_still_generate(tmp_path):
|
||||
src = tmp_path / "ok.json"
|
||||
src.write_text('{ "host": "localhost", "port": 8080 }', encoding="utf-8")
|
||||
out = tmp_path / "out.j2"
|
||||
res = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"jinjaturtle.cli",
|
||||
str(src),
|
||||
"-f",
|
||||
"json",
|
||||
"--role-name",
|
||||
"demo",
|
||||
"-t",
|
||||
str(out),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert res.returncode == 0, res.stderr
|
||||
template_text = out.read_text()
|
||||
# Keys stay literal; values become placeholders.
|
||||
assert '"host":' in template_text
|
||||
assert "demo_host" in template_text
|
||||
|
||||
|
||||
# --- Unit-level guarantees for the escaper itself ---------------------------
|
||||
|
||||
SSTI_PAYLOADS = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue