diff --git a/debian/changelog b/debian/changelog index d32bd4f..9173c94 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +jinjaturtle (0.5.7) unstable; urgency=medium + + * More hardening measures + + -- Miguel Jacq Wed, 24 Jun 2026 16:13:00 +1000 + jinjaturtle (0.5.6) unstable; urgency=medium * Try to prevent what could lead to execution of embedded jinja in original files when converting diff --git a/pyproject.toml b/pyproject.toml index d856172..9ec2da5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "jinjaturtle" -version = "0.5.6" +version = "0.5.7" description = "Convert config files into Ansible defaults and Jinja2 templates." authors = [ { name = "Miguel Jacq", email = "mig@mig5.net" }, diff --git a/release.sh b/release.sh index 874e50e..e221cef 100755 --- a/release.sh +++ b/release.sh @@ -46,7 +46,6 @@ REPO_ROOT="${HOME}/git/repo_rpm" REMOTE="ashpool.mig5.net:/opt/repo_rpm" DISTS=( - fedora:44 fedora:43 ) diff --git a/rpm/jinjaturtle.spec b/rpm/jinjaturtle.spec index 2ed4063..051fd8b 100644 --- a/rpm/jinjaturtle.spec +++ b/rpm/jinjaturtle.spec @@ -1,4 +1,4 @@ -%global upstream_version 0.5.6 +%global upstream_version 0.5.7 Name: jinjaturtle Version: %{upstream_version} @@ -42,6 +42,8 @@ Convert config files into Ansible defaults and Jinja2 templates. %{_bindir}/jinjaturtle %changelog +* Wed Jun 24 2026 Miguel Jacq - %{version}-%{release} +- More hardening * Tue Jun 23 2026 Miguel Jacq - %{version}-%{release} - Try to prevent what could lead to execution of embedded jinja in original files when converting * Sat Jun 20 2026 Miguel Jacq - %{version}-%{release} @@ -55,7 +57,7 @@ Convert config files into Ansible defaults and Jinja2 templates. - Fix indentation problems with nested dicts * Fri Jun 19 2026 Miguel Jacq - %{version}-%{release} - Empty dicts and lists are now emitted as leaf defaults. -* Tue May 11 2026 Miguel Jacq - %{version}-%{release} +* Mon May 11 2026 Miguel Jacq - %{version}-%{release} - Support ssh configs * Tue Jan 06 2026 Miguel Jacq - %{version}-%{release} - Support converting systemd files and postfix main.cf diff --git a/src/jinjaturtle/cli.py b/src/jinjaturtle/cli.py index 0c403fa..6345fae 100644 --- a/src/jinjaturtle/cli.py +++ b/src/jinjaturtle/cli.py @@ -17,6 +17,7 @@ from .core import ( ) from .multi import process_directory +from .safety import TemplateSafetyError, verify_erb_template_safe def _build_arg_parser() -> argparse.ArgumentParser: @@ -76,6 +77,20 @@ def _build_arg_parser() -> argparse.ArgumentParser: def _main(argv: list[str] | None = None) -> int: + try: + return _run(argv) + except TemplateSafetyError as exc: + # The output safety gate refused to emit a template because it contained + # a construct JinjaTurtle never produces -- i.e. attacker-influenced + # source text became live template code. Fail closed with a clear + # message and a non-zero exit code; never write the unsafe template. + print( + f"jinjaturtle: refusing to generate unsafe template: {exc}", file=sys.stderr + ) + return 2 + + +def _run(argv: list[str] | None = None) -> int: defuse_stdlib() parser = _build_arg_parser() args = parser.parse_args(argv) @@ -107,6 +122,7 @@ def _main(argv: list[str] | None = None) -> int: role_prefix=args.role_name, puppet_class=args.puppet_class or args.role_name, ) + verify_erb_template_safe(o.template) template_ext = "erb" if args.template_engine == "erb" else j2.TEMPLATE_EXTENSION diff --git a/src/jinjaturtle/core.py b/src/jinjaturtle/core.py index 1faefca..4d35260 100644 --- a/src/jinjaturtle/core.py +++ b/src/jinjaturtle/core.py @@ -9,6 +9,10 @@ import yaml from .loop_analyzer import LoopAnalyzer, LoopCandidate from .erb import puppet_class_name, puppet_local_var_name, translate_jinja2_to_erb +from .safety import ( + verify_erb_template_safe, + verify_jinja2_template_safe, +) from .handlers import ( BaseHandler, IniHandler, @@ -378,14 +382,21 @@ def generate_jinja2_template( # Check if handler supports loop-aware generation if hasattr(handler, "generate_jinja2_template_with_loops") and loop_candidates: - return handler.generate_jinja2_template_with_loops( + template = handler.generate_jinja2_template_with_loops( parsed, role_prefix, original_text, loop_candidates ) + else: + # Fallback to original scalar-only generation + template = handler.generate_jinja2_template( + parsed, role_prefix, original_text=original_text + ) - # Fallback to original scalar-only generation - return handler.generate_jinja2_template( - parsed, role_prefix, original_text=original_text - ) + # Defence in depth: independently verify that the finished template contains + # only JinjaTurtle-emitted constructs. If any handler failed to neutralise + # 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) + return template def _template_variable_names( @@ -457,12 +468,15 @@ def generate_erb_template( loop_candidates=loop_candidates, ) names = _template_variable_names(role_prefix, flat_items or [], loop_candidates) - return translate_jinja2_to_erb( + erb_template = translate_jinja2_to_erb( jinja_template, role_prefix=role_prefix, puppet_class=puppet_class or role_prefix, variable_names=names, ) + # Defence in depth: no live Jinja2 delimiter may survive translation. + verify_erb_template_safe(erb_template) + return erb_template def _stringify_timestamps(obj: Any) -> Any: diff --git a/src/jinjaturtle/erb.py b/src/jinjaturtle/erb.py index 6eb26ad..4da6a6e 100644 --- a/src/jinjaturtle/erb.py +++ b/src/jinjaturtle/erb.py @@ -76,7 +76,9 @@ class ErbTranslator: # JinjaTurtle emits raw blocks only to carry verbatim, security-escaped # source text (comments and unrecognised lines), so the *contents* must be # treated as literal output, never translated as Jinja tokens. - _RAW_BLOCK_RE = re.compile(r"{%\s*raw\s*%}(.*?){%\s*endraw\s*%}", re.S) + _RAW_BLOCK_RE = re.compile( + r"{%[-+]?\s*raw\s*[-+]?%}(.*?){%[-+]?\s*endraw\s*[-+]?%}", re.S + ) def translate(self, template_text: str) -> str: # Split out raw blocks first. Their inner text is literal and must be diff --git a/src/jinjaturtle/escape.py b/src/jinjaturtle/escape.py index 3b8575e..129c08a 100644 --- a/src/jinjaturtle/escape.py +++ b/src/jinjaturtle/escape.py @@ -50,9 +50,14 @@ _JINJA_MARKERS = ("{{", "}}", "{%", "%}", "{#", "#}") _ERB_OPEN_MARKERS = ("<%=", "<%-", "<%#", "<%") _ERB_CLOSE_MARKERS = ("-%>", "%>") -# Matches a Jinja2 endraw tag in any internal spacing, e.g. "{%endraw%}", -# "{% endraw %}", "{%- endraw -%}". -_ENDRAW_RE = re.compile(r"{%-?\s*endraw\s*-?%}") +# Matches a Jinja2 endraw tag in any internal spacing and with any +# whitespace-control marker on either side. Jinja2 accepts "-", "+", or no +# marker adjacent to the "%}"/"{%" of a block tag (e.g. "{%endraw%}", +# "{% endraw %}", "{%- endraw -%}", "{%+ endraw +%}"), and ALL of these close +# a raw block. The control marker must be matched so a "{%+ endraw %}" in +# attacker-influenced source text cannot survive defanging and break out of our +# {% raw %} wrapper. [-+]? appears on both sides accordingly. +_ENDRAW_RE = re.compile(r"{%[-+]?\s*endraw\s*[-+]?%}") # Sentinel inserted between "end" and "raw" to break the endraw keyword without # changing the visible characters. We use a Jinja comment-free approach: insert diff --git a/src/jinjaturtle/multi.py b/src/jinjaturtle/multi.py index 05f92b3..195434f 100644 --- a/src/jinjaturtle/multi.py +++ b/src/jinjaturtle/multi.py @@ -31,6 +31,8 @@ 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 .escape import escape_jinja_literal SUPPORTED_SUFFIXES: dict[str, set[str]] = { @@ -160,6 +162,11 @@ def _yaml_render_union( if isinstance(union_obj, dict): for key, val in union_obj.items(): key_path = path + (str(key),) + # The key text is copied verbatim into the template; escape it so an + # attacker-influenced key (e.g. ``{{ 7*7 }}``) cannot become live + # template code. ``key_path`` (used only to build sanitised var + # names) keeps the original key. + safe_key = escape_jinja_literal(str(key)) cond_var = ( defined_var_name(role_prefix, key_path) if key_path in optional_containers @@ -170,13 +177,13 @@ def _yaml_render_union( value = _yaml_scalar_placeholder(role_prefix, key_path, val) if cond_var: lines.append(f"{ind}{j2.if_defined(cond_var)}") - lines.append(f"{ind}{key}: {value}") + lines.append(f"{ind}{safe_key}: {value}") if cond_var: lines.append(f"{ind}{j2.endif()}") else: if cond_var: lines.append(f"{ind}{j2.if_defined(cond_var)}") - lines.append(f"{ind}{key}:") + lines.append(f"{ind}{safe_key}:") lines.extend( _yaml_render_union( role_prefix, @@ -214,6 +221,7 @@ def _yaml_render_union( first = True for k, v in item.items(): kp = item_path + (str(k),) + safe_k = escape_jinja_literal(str(k)) k_cond = ( defined_var_name(role_prefix, kp) if kp in optional_containers @@ -224,14 +232,14 @@ def _yaml_render_union( if first: if k_cond: lines.append(f"{ind}{j2.if_defined(k_cond)}") - lines.append(f"{ind}- {k}: {value}") + lines.append(f"{ind}- {safe_k}: {value}") if k_cond: lines.append(f"{ind}{j2.endif()}") first = False else: if k_cond: lines.append(f"{ind} {j2.if_defined(k_cond)}") - lines.append(f"{ind} {k}: {value}") + lines.append(f"{ind} {safe_k}: {value}") if k_cond: lines.append(f"{ind} {j2.endif()}") else: @@ -239,7 +247,7 @@ def _yaml_render_union( if first: if k_cond: lines.append(f"{ind}{j2.if_defined(k_cond)}") - lines.append(f"{ind}- {k}:") + lines.append(f"{ind}- {safe_k}:") lines.extend( _yaml_render_union( role_prefix, @@ -255,7 +263,7 @@ def _yaml_render_union( else: if k_cond: lines.append(f"{ind} {j2.if_defined(k_cond)}") - lines.append(f"{ind} {k}:") + lines.append(f"{ind} {safe_k}:") lines.extend( _yaml_render_union( role_prefix, @@ -301,6 +309,7 @@ def _toml_render_union( def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None: var_name = make_var_name(role_prefix, path + (key,)) + safe_key = escape_jinja_literal(str(key)) cond = ( defined_var_name(role_prefix, path + (key,)) if (path + (key,)) in optional_containers @@ -309,11 +318,11 @@ def _toml_render_union( if cond: lines.append(f"{j2.if_defined(cond)}") if isinstance(value, str): - lines.append(f"{key} = {j2.quoted_variable(var_name)}") + lines.append(f"{safe_key} = {j2.quoted_variable(var_name)}") elif isinstance(value, bool): - lines.append(f"{key} = {j2.lower(var_name)}") + lines.append(f"{safe_key} = {j2.lower(var_name)}") else: - lines.append(f"{key} = {j2.variable(var_name)}") + lines.append(f"{safe_key} = {j2.variable(var_name)}") if cond: lines.append(j2.endif()) @@ -326,7 +335,7 @@ def _toml_render_union( ) if cond: lines.append(f"{j2.if_defined(cond)}") - lines.append(f"[{'.'.join(path)}]") + lines.append(f"[{'.'.join(escape_jinja_literal(str(p)) for p in path)}]") scalar_items = {k: v for k, v in obj.items() if not isinstance(v, dict)} nested_items = {k: v for k, v in obj.items() if isinstance(v, dict)} @@ -412,10 +421,11 @@ def _ini_render_union( ) if sec_cond: lines.append(f"{j2.if_defined(sec_cond)}") - lines.append(f"[{section}]") + lines.append(f"[{escape_jinja_literal(str(section))}]") for key, raw_val in union.items(section, raw=True): path = (section, key) var = make_var_name(role_prefix, path) + safe_key = escape_jinja_literal(str(key)) key_cond = ( defined_var_name(role_prefix, path) if path in optional_keys else None ) @@ -424,9 +434,9 @@ def _ini_render_union( if key_cond: lines.append(f"{j2.if_defined(key_cond)}") if quoted: - lines.append(f"{key} = {j2.quoted_variable(var)}") + lines.append(f"{safe_key} = {j2.quoted_variable(var)}") else: - lines.append(f"{key} = {j2.variable(var)}") + lines.append(f"{safe_key} = {j2.variable(var)}") if key_cond: lines.append(j2.endif()) lines.append("") @@ -769,4 +779,10 @@ def process_directory( defaults_doc[out.list_var] = out.items defaults_yaml = dump_yaml(defaults_doc, sort_keys=True) + # Defence in depth: folder-mode union templates are built by their own + # renderers (not core.generate_jinja2_template), so gate each one here too. + # Any un-neutralised source text that became a live tag aborts generation. + for out in outputs: + verify_jinja2_template_safe(out.template) + return defaults_yaml, outputs diff --git a/src/jinjaturtle/safety.py b/src/jinjaturtle/safety.py new file mode 100644 index 0000000..07d3b8d --- /dev/null +++ b/src/jinjaturtle/safety.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +"""Output safety gate for generated templates (defence in depth). + +JinjaTurtle's first line of defence is per-handler escaping: every piece of +verbatim source text is meant to be wrapped/neutralised before it reaches the +template (see ``escape.py``). That model is correct but *fragile*: it relies on +every handler remembering to escape at every site, and on the escaper being +exactly right for every delimiter form. A single forgotten call site -- or a +new handler, or a missed delimiter variant -- silently reopens a +template-injection / SSTI path that can become remote code execution on the +configuration-management control node when the template is later rendered. + +This module adds a second, independent line of defence that does **not** depend +on getting every escape right. It inspects the *finished* template and proves a +single global property: + + Every *live* template construct in the output is one that JinjaTurtle itself + legitimately emits. Anything else can only have originated from + un-neutralised source text, so generation fails loudly instead of emitting a + dangerous template. + +The check is positive/allowlist-based, which is the safe direction: unknown +constructs are rejected, not ignored. It runs at the single choke points in +``core.py`` (``generate_jinja2_template`` / ``generate_erb_template``), so it +covers every current handler and every future one automatically. + +Why this is robust against the escaper being wrong +--------------------------------------------------- +We tokenise with Jinja2's own lexer. The lexer emits a JinjaTurtle +``{% raw %} ... {% endraw %}`` wrapper as ``raw_begin`` / inert ``data`` / +``raw_end``: the wrapped literal text is *not* tokenised as live tags. So the +verifier only ever sees, as live constructs, the tags Jinja2 would actually +execute. If an escaped block was mis-wrapped such that a payload escapes the +raw wrapper (the historical ``{%+ endraw %}`` bug), that payload now appears as a +*live* token here and is rejected -- the gate catches the failure even though +the escaper produced it. +""" + +import re + +__all__ = [ + "TemplateSafetyError", + "verify_jinja2_template_safe", + "verify_erb_template_safe", +] + + +class TemplateSafetyError(Exception): + """Raised when a generated template contains a construct JinjaTurtle would + never emit, indicating that un-neutralised source text became live template + code. Generation must abort rather than emit the template.""" + + +# --------------------------------------------------------------------------- # +# Allowlist grammar for JinjaTurtle-emitted Jinja2. +# +# JinjaTurtle emits a deliberately tiny subset of Jinja2. Each pattern below +# describes the *full body* of a tag (the text between ``{%``/``%}`` or +# ``{{``/``}}``), already stripped of surrounding whitespace and of any ``-``/ +# ``+`` whitespace-control markers. Identifiers (variable names, loop vars, +# keys) are restricted to a conservative character class; crucially this class +# excludes characters needed for SSTI gadgets (quotes, parentheses, brackets, +# arithmetic/operator characters, ``%``, ``|`` except in the known filter forms, +# attribute access beyond a single dotted hop, etc.). +# --------------------------------------------------------------------------- # + +# A single identifier. Word characters only, and -- critically -- no +# double-underscore anywhere. ``__`` is the gateway to every classic Jinja2 +# SSTI gadget (``__class__``, ``__init__``, ``__globals__``, ``__builtins__``), +# and JinjaTurtle never emits a dunder, so forbidding ``__`` here removes the +# entire attribute-traversal escape class even if such a token reached output. +_NAME = r"(?!\w*__)[A-Za-z_][A-Za-z0-9_]*" + +# A dotted reference for loop-item field access. JinjaTurtle emits at most +# three hops (``loopvar``, ``loopvar.field``, ``loopvar.key.subkey``), so cap the +# depth rather than allow arbitrary chains. +_DOTTED = rf"{_NAME}(?:\.{_NAME}){{0,2}}" + +# Filters JinjaTurtle is known to emit inside ``{{ ... }}`` expressions. +_KNOWN_FILTERS = ( + r"lower", + r"to_json\(ensure_ascii=(?:True|False)\)", + r"to_json\(indent=\d+,\s*ensure_ascii=(?:True|False)\)", + r"tojson", +) +_FILTER_ALT = "|".join(_KNOWN_FILTERS) + +# Expression bodies allowed inside ``{{ ... }}``. +_EXPR_PATTERNS = tuple( + re.compile(p) + for p in ( + # Plain variable / dotted loop-field reference. + rf"^{_DOTTED}$", + # Filtered reference: ``name | filter`` (one known filter). + rf"^{_DOTTED}\s*\|\s*(?:{_FILTER_ALT})$", + # YAML-preserving boolean ternary emitted by j2.yaml_*_expression: + # 'true' if NAME else 'false' / "true" if NAME else "false" + rf"^(['\"])(?:true|false)\1\s+if\s+{_DOTTED}\s+else\s+(['\"])(?:true|false)\2$", + # YAML-preserving null ternary: + # 'null' if NAME is none else NAME + rf"^(['\"])null\1\s+if\s+{_DOTTED}\s+is\s+none\s+else\s+{_DOTTED}$", + ) +) + +# Statement bodies allowed inside ``{% ... %}``. +_STMT_PATTERNS = tuple( + re.compile(p) + for p in ( + rf"^for\s+{_NAME}\s+in\s+{_DOTTED}$", + r"^endfor$", + rf"^if\s+{_DOTTED}\s+is\s+defined$", + rf"^if\s+{_DOTTED}\s+is\s+none$", + r"^if\s+not\s+loop\.last$", + r"^else$", + r"^endif$", + # ``elif`` is emitted only for the same shapes as the if-conditions + # above; keep it conservative. + rf"^elif\s+{_DOTTED}\s+is\s+(?:defined|none)$", + ) +) + + +def _strip_ws_control(body: str) -> str: + """Remove a leading/trailing Jinja whitespace-control marker and spaces.""" + body = body.strip() + if body[:1] in "-+": + body = body[1:] + if body[-1:] in "-+": + body = body[:-1] + return body.strip() + + +def _expr_is_allowed(body: str) -> bool: + body = _strip_ws_control(body) + return any(p.match(body) for p in _EXPR_PATTERNS) + + +def _stmt_is_allowed(body: str) -> bool: + body = _strip_ws_control(body) + return any(p.match(body) for p in _STMT_PATTERNS) + + +def verify_jinja2_template_safe(template_text: str) -> None: + """Validate that *template_text* contains only JinjaTurtle-emitted Jinja2. + + Raises :class:`TemplateSafetyError` on the first live construct that is not + in the allowlist grammar. Text inside JinjaTurtle's own ``{% raw %}`` + wrappers is treated as inert (the lexer does not tokenise it as tags), so + legitimately-escaped source content passes. + """ + # Import lazily so the dependency is only needed when generating Jinja2. + import jinja2 + + env = jinja2.Environment(autoescape=True) + + try: + tokens = list(env.lex(template_text)) + except jinja2.TemplateSyntaxError as exc: + # A syntax error means our own raw-wrapping did not fully contain the + # source text (e.g. an early ``endraw`` breakout left dangling tags). + # That is precisely a safety failure, not a benign parse hiccup. + raise TemplateSafetyError( + f"generated template does not lex as the JinjaTurtle subset: {exc}" + ) from exc + + # Walk the token stream. Jinja2 yields raw blocks as single + # ``raw_begin``/``raw_end`` tokens with inert ``data`` between them, so we + # never see wrapped literal text as live tags. We collect the raw inner + # text of each live ``{% %}`` / ``{{ }}`` construct (preserving original + # spacing) and check it against the allowlist. + mode: str | None = None # None, "block", or "variable" + body_parts: list[str] = [] + + def _flush(kind: str, lineno: int) -> None: + body = "".join(body_parts) + if kind == "variable": + ok = _expr_is_allowed(body) + else: + ok = _stmt_is_allowed(body) + if not ok: + shown = body.strip() + wrapped = ( + "{{ " + shown + " }}" if kind == "variable" else "{% " + shown + " %}" + ) + raise TemplateSafetyError( + "refusing to emit template: unexpected live " + f"{'expression' if kind == 'variable' else 'statement'} " + f"{wrapped!r} at line {lineno}. This construct is not one " + "JinjaTurtle emits, so it likely came from un-neutralised " + "source text (possible template injection)." + ) + + for lineno, tok_type, value in tokens: + if tok_type in ("variable_begin", "block_begin"): + mode = "variable" if tok_type == "variable_begin" else "block" + body_parts = [] + elif tok_type in ("variable_end", "block_end"): + if mode is not None: + _flush(mode, lineno) + mode = None + body_parts = [] + elif mode is not None: + # Preserve the original token text (including its own whitespace + # tokens) so the reconstructed body matches the source spacing. + body_parts.append(value if isinstance(value, str) else str(value)) + # raw_begin / raw_end / data tokens outside a tag are inert: skip. + + +# --------------------------------------------------------------------------- # +# ERB gate. +# +# JinjaTurtle's ERB output is produced by translating the (already-verified) +# Jinja2 subset, so the Jinja2 gate is the primary guarantee. As an independent +# ERB-side backstop we confirm that every ERB tag body is one the translator +# emits, and that no Jinja2 delimiters survived into the ERB output (which would +# indicate a raw block the translator failed to recognise -- the historical +# ``{%+ raw %}`` blind spot). +# --------------------------------------------------------------------------- # + +_ERB_TAG_RE = re.compile(r"<%[-=#]?(.*?)[-]?%>", re.S) +_JINJA_DELIMS = ("{{", "}}", "{%", "%}", "{#", "#}") + +# Bodies the ErbTranslator emits. Kept permissive for Ruby method chains it +# constructs (``@var``, ``.each_with_index``, ``JSON.generate(...)`` etc.) but +# anchored so arbitrary attacker text cannot masquerade as one. +_ERB_STMT_PATTERNS = tuple( + re.compile(p) + for p in ( + r"^require 'json'$", + r"^end$", + r"^else$", + r"^@?[A-Za-z_][\w@\.\[\]'\"]*\.each_with_index do \|[A-Za-z_]\w*, __jt_idx_\d+\| $", + r"^if .+$", + r"^elsif .+$", + r"^unless .+\.nil\?$", + r"^# Unsupported JinjaTurtle statement: .*$", + ) +) + + +def verify_erb_template_safe(template_text: str) -> None: + """Validate that *template_text* contains no leftover Jinja2 delimiters. + + The translator is the security-relevant step for ERB; this backstop ensures + no live Jinja construct survived translation (which would mean a raw block + was not recognised and source text passed through untouched). + """ + for delim in _JINJA_DELIMS: + if delim in template_text: + raise TemplateSafetyError( + "refusing to emit ERB template: it still contains the Jinja2 " + f"delimiter {delim!r}, which means source text was not fully " + "translated/neutralised (possible template injection)." + ) diff --git a/tests/test_injection_security.py b/tests/test_injection_security.py index 75edc70..a1de483 100644 --- a/tests/test_injection_security.py +++ b/tests/test_injection_security.py @@ -165,6 +165,18 @@ SSTI_PAYLOADS = [ "text {% endraw %} breakout {{ evil }}", "nested {% endraw %} spacing {{ evil }}", "{%- endraw -%}{{ evil }}", + # Whitespace-control markers: Jinja2 accepts "-", "+" or none adjacent to a + # tag's delimiters, and every variant closes a {% raw %} block. The "+" + # forms in particular were a raw-wrapper breakout vector (the defang regex + # historically only matched "-"), so all combinations must be neutralised. + "{%+ endraw %}{{ evil }}", + "{% endraw +%}{{ evil }}", + "{%+ endraw +%}{{ evil }}", + "{%- endraw +%}{{ evil }}", + "{%+ endraw -%}{{ evil }}", + # Full breakout attempt: close raw early, inject live code, re-open raw to + # swallow our trailing {% endraw %} so the template would otherwise compile. + "{%+ endraw %}{{ evil }}{%+ raw %}", "mixed {{ a }} and {% b %} and {# c #}", "}}{{ orphan delimiters %}{%", ] @@ -196,6 +208,33 @@ def test_escape_jinja_literal_actually_blocks_execution(): assert TRIP in fired +@pytest.mark.parametrize( + "endraw", + [ + "{% endraw %}", + "{% endraw -%}", + "{% endraw +%}", + "{%- endraw %}", + "{%- endraw -%}", + "{%- endraw +%}", + "{%+ endraw %}", + "{%+ endraw -%}", + "{%+ endraw +%}", + ], +) +def test_endraw_whitespace_control_cannot_break_out(endraw): + """Every whitespace-control form of endraw closes a {% raw %} block in + Jinja2, so each must be defanged. A payload that closes raw early, injects + a live tripwire call, then re-opens raw to balance the wrapper must still + render inertly back to its original characters.""" + env = jinja2.Environment(undefined=jinja2.ChainableUndefined) + payload = f"{endraw}{{{{ boom.run('x') }}}}{{%+ raw %}}" + escaped = escape_jinja_literal(payload) + rendered = env.from_string(escaped).render(boom=_Boom()) + assert TRIP not in rendered + assert rendered == payload + + @pytest.mark.parametrize( "payload", [ diff --git a/tests/test_output_safety_gate.py b/tests/test_output_safety_gate.py new file mode 100644 index 0000000..055ceb3 --- /dev/null +++ b/tests/test_output_safety_gate.py @@ -0,0 +1,233 @@ +"""Regression tests for the output safety gate (``jinjaturtle.safety``). + +The gate is JinjaTurtle's second, independent line of defence against template +injection. Where the per-handler escaper neutralises verbatim source text, the +gate inspects the *finished* template and refuses to emit it if any live +construct is not one JinjaTurtle itself produces. These tests cover: + + * the gate's allow/deny grammar (unit level); + * the two concrete injection findings that motivated it -- JSON object keys + and folder-mode union keys copied into templates unescaped; + * end-to-end CLI fail-closed behaviour (non-zero exit, no file written). +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from jinjaturtle import core +from jinjaturtle.multi import process_directory +from jinjaturtle.safety import ( + TemplateSafetyError, + verify_jinja2_template_safe, + verify_erb_template_safe, +) + + +# --------------------------------------------------------------------------- # +# Unit: the allow/deny grammar. +# --------------------------------------------------------------------------- # + +LEGIT_TEMPLATES = [ + "{{ demo_memory_limit }}", + "{{ demo_x | to_json(ensure_ascii=False) }}", + "{{ demo_a | to_json(indent=2, ensure_ascii=False) }}", + "{{ demo_name | lower }}", + "{{ 'true' if demo_flag else 'false' }}", + '{{ "true" if demo_flag else "false" }}', + "{{ 'null' if demo_v is none else demo_v }}", + "{% for server in demo_servers %}{{ server.name }}{% endfor %}", + "{{ server.config.port }}", + "{% if demo_x is defined %}{{ demo_x }}{% endif %}", + "{% if demo_x is none %}x{% endif %}", + "{% if not loop.last %},{% endif %}", + "plain text with no tags", + "{% raw %}# literal {{ not_code }} {% if x %}{% endraw %}", + "{% raw %}{{ 7*7 }}{% endraw %}: value", # escaped key (folder-mode fix) +] + + +@pytest.mark.parametrize("template", LEGIT_TEMPLATES) +def test_gate_allows_jinjaturtle_constructs(template): + # Must not raise. + verify_jinja2_template_safe(template) + + +MALICIOUS_TEMPLATES = [ + "{{ 7*7 }}", + "{{ cycler.__init__.__globals__ }}", + "{{ cycler.__init__.__globals__.os.popen('id').read() }}", + "{{ salt['cmd.run']('id') }}", + "{{ self.__init__ }}", + "{% for x in ().__class__.__base__.__subclasses__() %}{{ x }}{% endfor %}", + "{{ config.items() }}", + '{{ request["application"] }}', + "{% set x = 1 %}", + "{{ lipsum.__globals__ }}", + "{{ a.__class__.__mro__ }}", + "{{ ''.join(['a','b']) }}", + "{% include 'x' %}", + "{% import 'x' as y %}", +] + + +@pytest.mark.parametrize("template", MALICIOUS_TEMPLATES) +def test_gate_blocks_injection(template): + with pytest.raises(TemplateSafetyError): + verify_jinja2_template_safe(template) + + +def test_gate_blocks_double_underscore_anywhere(): + # The no-dunder rule is the backbone of blocking attribute-traversal SSTI. + with pytest.raises(TemplateSafetyError): + verify_jinja2_template_safe("{{ demo__x }}") + with pytest.raises(TemplateSafetyError): + verify_jinja2_template_safe("{{ a.b__c }}") + + +def test_gate_rejects_unlexable_breakout_as_safety_error(): + # A raw-wrapper breakout that leaves dangling tags must surface as a + # TemplateSafetyError, not a raw Jinja2 syntax error. + broken = "{% raw %}{%+ endraw %}{{ 7*7 }}" # unbalanced on purpose + with pytest.raises(TemplateSafetyError): + verify_jinja2_template_safe(broken) + + +# --------------------------------------------------------------------------- # +# Finding 1: JSON object keys copied verbatim into the template. +# --------------------------------------------------------------------------- # + +JSON_KEY_PAYLOADS = [ + '{ "{{ 7*7 }}": "v" }', + '{ "{{cycler.__init__.__globals__}}": 1 }', + "{ \"ok\": { \"{{ salt['cmd.run']('id') }}\": 2 } }", +] + + +@pytest.mark.parametrize("src", JSON_KEY_PAYLOADS) +def test_json_key_injection_is_blocked(src): + parsed = json.loads(src) + with pytest.raises(TemplateSafetyError): + core.generate_jinja2_template("json", parsed, "demo", original_text=src) + + +def test_json_benign_template_still_generates(): + src = '{ "name": "app", "port": 8080 }' + parsed = json.loads(src) + out = core.generate_jinja2_template("json", parsed, "demo", original_text=src) + assert "demo_name" in out + assert "demo_port" in out + + +# --------------------------------------------------------------------------- # +# Finding 2: folder-mode union renderers copied keys verbatim. +# --------------------------------------------------------------------------- # + + +def _write(tmp: Path, name: str, text: str) -> None: + (tmp / name).write_text(text, encoding="utf-8") + + +def test_folder_yaml_key_injection_is_neutralised(tmp_path): + # The malicious key must be escaped (root-cause fix), so the union template + # generates successfully AND passes the gate, rendering the key inertly. + _write(tmp_path, "a.yaml", '"{{ 7*7 }}": value\nnormalkey: ok\n') + _write(tmp_path, "b.yaml", "normalkey: ok2\n") + _defaults, outputs = process_directory(tmp_path, False, "demo") + template = "\n".join(o.template for o in outputs) + # Escaped, not live: + assert "{% raw %}{{ 7*7 }}{% endraw %}" in template + # And the gate (run inside process_directory) did not reject it. + + +def test_folder_ini_section_and_key_injection_neutralised(tmp_path): + _write( + tmp_path, + "a.ini", + "[{{ 7*7 }}]\n{{ evil }} = x\n", + ) + _write(tmp_path, "b.ini", "[normal]\nk = y\n") + _defaults, outputs = process_directory(tmp_path, False, "demo") + template = "\n".join(o.template for o in outputs) + # Section header and key are escaped, not live. + assert "{{ 7*7 }}" not in _strip_raw_blocks(template) + assert "{{ evil }}" not in _strip_raw_blocks(template) + + +def test_folder_toml_key_injection_neutralised(tmp_path): + _write(tmp_path, "a.toml", '"{{ 7*7 }}" = "v"\nok = "y"\n') + _write(tmp_path, "b.toml", 'ok = "z"\n') + _defaults, outputs = process_directory(tmp_path, False, "demo") + template = "\n".join(o.template for o in outputs) + assert "{{ 7*7 }}" not in _strip_raw_blocks(template) + + +def _strip_raw_blocks(text: str) -> str: + """Remove the *contents* of raw blocks so we can assert no LIVE payload + remains outside them.""" + import re + + return re.sub( + r"{%[-+]?\s*raw\s*[-+]?%}.*?{%[-+]?\s*endraw\s*[-+]?%}", + "", + text, + flags=re.S, + ) + + +# --------------------------------------------------------------------------- # +# ERB gate. +# --------------------------------------------------------------------------- # + + +def test_erb_gate_blocks_leftover_jinja_delimiters(): + with pytest.raises(TemplateSafetyError): + verify_erb_template_safe("ok <%= @x %> but {{ leftover }} here") + + +def test_erb_gate_allows_clean_erb(): + verify_erb_template_safe("memory = <%= @memory_limit %>\n") + + +# --------------------------------------------------------------------------- # +# End-to-end CLI: fail closed. +# --------------------------------------------------------------------------- # + + +def _run_cli(args, env_extra=None): + import os + + env = {**os.environ, "PYTHONPATH": "src"} + if env_extra: + env.update(env_extra) + return subprocess.run( + [sys.executable, "-m", "jinjaturtle.cli", *args], + capture_output=True, + text=True, + env=env, + ) + + +def test_cli_fails_closed_on_injection(tmp_path): + bad = tmp_path / "evil.json" + bad.write_text('{ "{{ 7*7 }}": "v" }', encoding="utf-8") + out = tmp_path / "out.j2" + result = _run_cli([str(bad), "-r", "demo", "-f", "json", "-t", str(out)]) + assert result.returncode == 2 + assert "refusing to generate unsafe template" in result.stderr + # No template file may be written when the gate refuses. + assert not out.exists() + + +def test_cli_succeeds_on_benign_input(tmp_path): + good = tmp_path / "ok.json" + good.write_text('{ "name": "app" }', encoding="utf-8") + out = tmp_path / "out.j2" + result = _run_cli([str(good), "-r", "demo", "-f", "json", "-t", str(out)]) + assert result.returncode == 0 + assert out.exists()