Fixes
All checks were successful
CI / test (push) Successful in 47s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m19s
Lint / test (push) Successful in 37s

This commit is contained in:
Miguel Jacq 2026-06-24 17:42:18 +10:00
parent a9d56b66c5
commit 094c4d2274
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
12 changed files with 614 additions and 27 deletions

6
debian/changelog vendored
View file

@ -1,3 +1,9 @@
jinjaturtle (0.5.7) unstable; urgency=medium
* More hardening measures
-- Miguel Jacq <mig@mig5.net> 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

View file

@ -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" },

View file

@ -46,7 +46,6 @@ REPO_ROOT="${HOME}/git/repo_rpm"
REMOTE="ashpool.mig5.net:/opt/repo_rpm"
DISTS=(
fedora:44
fedora:43
)

View file

@ -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 <mig@mig5.net> - %{version}-%{release}
- More hardening
* Tue Jun 23 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Try to prevent what could lead to execution of embedded jinja in original files when converting
* Sat Jun 20 2026 Miguel Jacq <mig@mig5.net> - %{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 <mig@mig5.net> - %{version}-%{release}
- Empty dicts and lists are now emitted as leaf defaults.
* Tue May 11 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
* Mon May 11 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Support ssh configs
* Tue Jan 06 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Support converting systemd files and postfix main.cf

View file

@ -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

View file

@ -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,15 +382,22 @@ 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
return handler.generate_jinja2_template(
template = 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(
role_prefix: str,
@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

255
src/jinjaturtle/safety.py Normal file
View file

@ -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)."
)

View file

@ -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",
[

View file

@ -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()