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 .loop_analyzer import LoopAnalyzer, LoopCandidate
|
||||||
from .safety import (
|
from .safety import (
|
||||||
verify_jinja2_template_safe,
|
verify_jinja2_template_safe,
|
||||||
|
verify_no_live_jinja_in_json_keys,
|
||||||
)
|
)
|
||||||
from .handlers import (
|
from .handlers import (
|
||||||
BaseHandler,
|
BaseHandler,
|
||||||
|
|
@ -394,6 +395,13 @@ def generate_jinja2_template(
|
||||||
# verbatim source text, the un-escaped payload shows up here as a live tag
|
# verbatim source text, the un-escaped payload shows up here as a live tag
|
||||||
# and generation aborts instead of emitting an injectable template.
|
# and generation aborts instead of emitting an injectable template.
|
||||||
verify_jinja2_template_safe(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
|
return template
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from typing import Any
|
||||||
|
|
||||||
from . import DictLikeHandler
|
from . import DictLikeHandler
|
||||||
from .. import j2
|
from .. import j2
|
||||||
|
from ..escape import escape_jinja_literal
|
||||||
from ..loop_analyzer import LoopCandidate
|
from ..loop_analyzer import LoopCandidate
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -109,10 +110,18 @@ class JsonHandler(DictLikeHandler):
|
||||||
chunks: list[str] = []
|
chunks: list[str] = []
|
||||||
pos = 0
|
pos = 0
|
||||||
for path, start, end in spans:
|
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)))
|
chunks.append(self._json_value_expr(self.make_var_name(role_prefix, path)))
|
||||||
pos = end
|
pos = end
|
||||||
chunks.append(text[pos:])
|
chunks.append(escape_jinja_literal(text[pos:]))
|
||||||
return "".join(chunks)
|
return "".join(chunks)
|
||||||
|
|
||||||
def _collect_json_scalar_spans(
|
def _collect_json_scalar_spans(
|
||||||
|
|
@ -213,7 +222,12 @@ class JsonHandler(DictLikeHandler):
|
||||||
|
|
||||||
def _walk(obj: Any, path: tuple[str, ...] = ()) -> Any:
|
def _walk(obj: Any, path: tuple[str, ...] = ()) -> Any:
|
||||||
if isinstance(obj, dict):
|
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):
|
if isinstance(obj, list):
|
||||||
return [_walk(v, path + (str(i),)) for i, v in enumerate(obj)]
|
return [_walk(v, path + (str(i),)) for i, v in enumerate(obj)]
|
||||||
# scalar - use marker that will be replaced with to_json
|
# 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}__"
|
return f"__LOOP_DICT__{collection_var}__{item_var}__"
|
||||||
|
|
||||||
if isinstance(obj, dict):
|
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):
|
if isinstance(obj, list):
|
||||||
# Check if this list is a loop candidate
|
# Check if this list is a loop candidate
|
||||||
if current_path in loop_paths:
|
if current_path in loop_paths:
|
||||||
|
|
@ -364,8 +383,13 @@ 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 ""
|
||||||
|
# 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(
|
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.
|
# Comma between *items* goes after the closing brace.
|
||||||
dict_lines.append(f"{inner}}}{j2.if_not_loop_last()},{j2.endif()}")
|
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 . import j2
|
||||||
from .core import dump_yaml, flatten_config, make_var_name, parse_config
|
from .core import dump_yaml, flatten_config, make_var_name, parse_config
|
||||||
from .handlers.xml import XmlHandler
|
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
|
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.
|
# Any un-neutralised source text that became a live tag aborts generation.
|
||||||
for out in outputs:
|
for out in outputs:
|
||||||
verify_jinja2_template_safe(out.template)
|
verify_jinja2_template_safe(out.template)
|
||||||
|
if out.fmt == "json":
|
||||||
|
verify_no_live_jinja_in_json_keys(out.template)
|
||||||
|
|
||||||
return defaults_yaml, outputs
|
return defaults_yaml, outputs
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ __all__ = [
|
||||||
"TemplateSafetyError",
|
"TemplateSafetyError",
|
||||||
"verify_jinja2_template_safe",
|
"verify_jinja2_template_safe",
|
||||||
"verify_erb_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)
|
# JinjaTurtle's ERB output is produced by translating the (already-verified)
|
||||||
# Jinja2 subset, so the Jinja2 gate is the primary guarantee. As an independent
|
# 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)
|
_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 ---------------------------
|
# --- Unit-level guarantees for the escaper itself ---------------------------
|
||||||
|
|
||||||
SSTI_PAYLOADS = [
|
SSTI_PAYLOADS = [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue