Fixes
This commit is contained in:
parent
a9d56b66c5
commit
094c4d2274
12 changed files with 614 additions and 27 deletions
6
debian/changelog
vendored
6
debian/changelog
vendored
|
|
@ -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
|
jinjaturtle (0.5.6) unstable; urgency=medium
|
||||||
|
|
||||||
* Try to prevent what could lead to execution of embedded jinja in original files when converting
|
* Try to prevent what could lead to execution of embedded jinja in original files when converting
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "jinjaturtle"
|
name = "jinjaturtle"
|
||||||
version = "0.5.6"
|
version = "0.5.7"
|
||||||
description = "Convert config files into Ansible defaults and Jinja2 templates."
|
description = "Convert config files into Ansible defaults and Jinja2 templates."
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Miguel Jacq", email = "mig@mig5.net" },
|
{ name = "Miguel Jacq", email = "mig@mig5.net" },
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ REPO_ROOT="${HOME}/git/repo_rpm"
|
||||||
REMOTE="ashpool.mig5.net:/opt/repo_rpm"
|
REMOTE="ashpool.mig5.net:/opt/repo_rpm"
|
||||||
|
|
||||||
DISTS=(
|
DISTS=(
|
||||||
fedora:44
|
|
||||||
fedora:43
|
fedora:43
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
%global upstream_version 0.5.6
|
%global upstream_version 0.5.7
|
||||||
|
|
||||||
Name: jinjaturtle
|
Name: jinjaturtle
|
||||||
Version: %{upstream_version}
|
Version: %{upstream_version}
|
||||||
|
|
@ -42,6 +42,8 @@ Convert config files into Ansible defaults and Jinja2 templates.
|
||||||
%{_bindir}/jinjaturtle
|
%{_bindir}/jinjaturtle
|
||||||
|
|
||||||
%changelog
|
%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}
|
* 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
|
- 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}
|
* 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
|
- Fix indentation problems with nested dicts
|
||||||
* Fri Jun 19 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
* Fri Jun 19 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
- Empty dicts and lists are now emitted as leaf defaults.
|
- 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
|
- Support ssh configs
|
||||||
* Tue Jan 06 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
* Tue Jan 06 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
|
||||||
- Support converting systemd files and postfix main.cf
|
- Support converting systemd files and postfix main.cf
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from .core import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from .multi import process_directory
|
from .multi import process_directory
|
||||||
|
from .safety import TemplateSafetyError, verify_erb_template_safe
|
||||||
|
|
||||||
|
|
||||||
def _build_arg_parser() -> argparse.ArgumentParser:
|
def _build_arg_parser() -> argparse.ArgumentParser:
|
||||||
|
|
@ -76,6 +77,20 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
||||||
|
|
||||||
|
|
||||||
def _main(argv: list[str] | None = None) -> int:
|
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()
|
defuse_stdlib()
|
||||||
parser = _build_arg_parser()
|
parser = _build_arg_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
@ -107,6 +122,7 @@ def _main(argv: list[str] | None = None) -> int:
|
||||||
role_prefix=args.role_name,
|
role_prefix=args.role_name,
|
||||||
puppet_class=args.puppet_class or 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
|
template_ext = "erb" if args.template_engine == "erb" else j2.TEMPLATE_EXTENSION
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ import yaml
|
||||||
|
|
||||||
from .loop_analyzer import LoopAnalyzer, LoopCandidate
|
from .loop_analyzer import LoopAnalyzer, LoopCandidate
|
||||||
from .erb import puppet_class_name, puppet_local_var_name, translate_jinja2_to_erb
|
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 (
|
from .handlers import (
|
||||||
BaseHandler,
|
BaseHandler,
|
||||||
IniHandler,
|
IniHandler,
|
||||||
|
|
@ -378,15 +382,22 @@ def generate_jinja2_template(
|
||||||
|
|
||||||
# Check if handler supports loop-aware generation
|
# Check if handler supports loop-aware generation
|
||||||
if hasattr(handler, "generate_jinja2_template_with_loops") and loop_candidates:
|
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
|
parsed, role_prefix, original_text, loop_candidates
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
# Fallback to original scalar-only generation
|
# Fallback to original scalar-only generation
|
||||||
return handler.generate_jinja2_template(
|
template = handler.generate_jinja2_template(
|
||||||
parsed, role_prefix, original_text=original_text
|
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(
|
def _template_variable_names(
|
||||||
role_prefix: str,
|
role_prefix: str,
|
||||||
|
|
@ -457,12 +468,15 @@ def generate_erb_template(
|
||||||
loop_candidates=loop_candidates,
|
loop_candidates=loop_candidates,
|
||||||
)
|
)
|
||||||
names = _template_variable_names(role_prefix, flat_items or [], 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,
|
jinja_template,
|
||||||
role_prefix=role_prefix,
|
role_prefix=role_prefix,
|
||||||
puppet_class=puppet_class or role_prefix,
|
puppet_class=puppet_class or role_prefix,
|
||||||
variable_names=names,
|
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:
|
def _stringify_timestamps(obj: Any) -> Any:
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,9 @@ class ErbTranslator:
|
||||||
# JinjaTurtle emits raw blocks only to carry verbatim, security-escaped
|
# JinjaTurtle emits raw blocks only to carry verbatim, security-escaped
|
||||||
# source text (comments and unrecognised lines), so the *contents* must be
|
# source text (comments and unrecognised lines), so the *contents* must be
|
||||||
# treated as literal output, never translated as Jinja tokens.
|
# 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:
|
def translate(self, template_text: str) -> str:
|
||||||
# Split out raw blocks first. Their inner text is literal and must be
|
# Split out raw blocks first. Their inner text is literal and must be
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,14 @@ _JINJA_MARKERS = ("{{", "}}", "{%", "%}", "{#", "#}")
|
||||||
_ERB_OPEN_MARKERS = ("<%=", "<%-", "<%#", "<%")
|
_ERB_OPEN_MARKERS = ("<%=", "<%-", "<%#", "<%")
|
||||||
_ERB_CLOSE_MARKERS = ("-%>", "%>")
|
_ERB_CLOSE_MARKERS = ("-%>", "%>")
|
||||||
|
|
||||||
# Matches a Jinja2 endraw tag in any internal spacing, e.g. "{%endraw%}",
|
# Matches a Jinja2 endraw tag in any internal spacing and with any
|
||||||
# "{% endraw %}", "{%- endraw -%}".
|
# whitespace-control marker on either side. Jinja2 accepts "-", "+", or no
|
||||||
_ENDRAW_RE = re.compile(r"{%-?\s*endraw\s*-?%}")
|
# 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
|
# Sentinel inserted between "end" and "raw" to break the endraw keyword without
|
||||||
# changing the visible characters. We use a Jinja comment-free approach: insert
|
# changing the visible characters. We use a Jinja comment-free approach: insert
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ 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 .escape import escape_jinja_literal
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_SUFFIXES: dict[str, set[str]] = {
|
SUPPORTED_SUFFIXES: dict[str, set[str]] = {
|
||||||
|
|
@ -160,6 +162,11 @@ def _yaml_render_union(
|
||||||
if isinstance(union_obj, dict):
|
if isinstance(union_obj, dict):
|
||||||
for key, val in union_obj.items():
|
for key, val in union_obj.items():
|
||||||
key_path = path + (str(key),)
|
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 = (
|
cond_var = (
|
||||||
defined_var_name(role_prefix, key_path)
|
defined_var_name(role_prefix, key_path)
|
||||||
if key_path in optional_containers
|
if key_path in optional_containers
|
||||||
|
|
@ -170,13 +177,13 @@ def _yaml_render_union(
|
||||||
value = _yaml_scalar_placeholder(role_prefix, key_path, val)
|
value = _yaml_scalar_placeholder(role_prefix, key_path, val)
|
||||||
if cond_var:
|
if cond_var:
|
||||||
lines.append(f"{ind}{j2.if_defined(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:
|
if cond_var:
|
||||||
lines.append(f"{ind}{j2.endif()}")
|
lines.append(f"{ind}{j2.endif()}")
|
||||||
else:
|
else:
|
||||||
if cond_var:
|
if cond_var:
|
||||||
lines.append(f"{ind}{j2.if_defined(cond_var)}")
|
lines.append(f"{ind}{j2.if_defined(cond_var)}")
|
||||||
lines.append(f"{ind}{key}:")
|
lines.append(f"{ind}{safe_key}:")
|
||||||
lines.extend(
|
lines.extend(
|
||||||
_yaml_render_union(
|
_yaml_render_union(
|
||||||
role_prefix,
|
role_prefix,
|
||||||
|
|
@ -214,6 +221,7 @@ def _yaml_render_union(
|
||||||
first = True
|
first = True
|
||||||
for k, v in item.items():
|
for k, v in item.items():
|
||||||
kp = item_path + (str(k),)
|
kp = item_path + (str(k),)
|
||||||
|
safe_k = escape_jinja_literal(str(k))
|
||||||
k_cond = (
|
k_cond = (
|
||||||
defined_var_name(role_prefix, kp)
|
defined_var_name(role_prefix, kp)
|
||||||
if kp in optional_containers
|
if kp in optional_containers
|
||||||
|
|
@ -224,14 +232,14 @@ def _yaml_render_union(
|
||||||
if first:
|
if first:
|
||||||
if k_cond:
|
if k_cond:
|
||||||
lines.append(f"{ind}{j2.if_defined(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:
|
if k_cond:
|
||||||
lines.append(f"{ind}{j2.endif()}")
|
lines.append(f"{ind}{j2.endif()}")
|
||||||
first = False
|
first = False
|
||||||
else:
|
else:
|
||||||
if k_cond:
|
if k_cond:
|
||||||
lines.append(f"{ind} {j2.if_defined(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:
|
if k_cond:
|
||||||
lines.append(f"{ind} {j2.endif()}")
|
lines.append(f"{ind} {j2.endif()}")
|
||||||
else:
|
else:
|
||||||
|
|
@ -239,7 +247,7 @@ def _yaml_render_union(
|
||||||
if first:
|
if first:
|
||||||
if k_cond:
|
if k_cond:
|
||||||
lines.append(f"{ind}{j2.if_defined(k_cond)}")
|
lines.append(f"{ind}{j2.if_defined(k_cond)}")
|
||||||
lines.append(f"{ind}- {k}:")
|
lines.append(f"{ind}- {safe_k}:")
|
||||||
lines.extend(
|
lines.extend(
|
||||||
_yaml_render_union(
|
_yaml_render_union(
|
||||||
role_prefix,
|
role_prefix,
|
||||||
|
|
@ -255,7 +263,7 @@ def _yaml_render_union(
|
||||||
else:
|
else:
|
||||||
if k_cond:
|
if k_cond:
|
||||||
lines.append(f"{ind} {j2.if_defined(k_cond)}")
|
lines.append(f"{ind} {j2.if_defined(k_cond)}")
|
||||||
lines.append(f"{ind} {k}:")
|
lines.append(f"{ind} {safe_k}:")
|
||||||
lines.extend(
|
lines.extend(
|
||||||
_yaml_render_union(
|
_yaml_render_union(
|
||||||
role_prefix,
|
role_prefix,
|
||||||
|
|
@ -301,6 +309,7 @@ def _toml_render_union(
|
||||||
|
|
||||||
def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None:
|
def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None:
|
||||||
var_name = make_var_name(role_prefix, path + (key,))
|
var_name = make_var_name(role_prefix, path + (key,))
|
||||||
|
safe_key = escape_jinja_literal(str(key))
|
||||||
cond = (
|
cond = (
|
||||||
defined_var_name(role_prefix, path + (key,))
|
defined_var_name(role_prefix, path + (key,))
|
||||||
if (path + (key,)) in optional_containers
|
if (path + (key,)) in optional_containers
|
||||||
|
|
@ -309,11 +318,11 @@ def _toml_render_union(
|
||||||
if cond:
|
if cond:
|
||||||
lines.append(f"{j2.if_defined(cond)}")
|
lines.append(f"{j2.if_defined(cond)}")
|
||||||
if isinstance(value, str):
|
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):
|
elif isinstance(value, bool):
|
||||||
lines.append(f"{key} = {j2.lower(var_name)}")
|
lines.append(f"{safe_key} = {j2.lower(var_name)}")
|
||||||
else:
|
else:
|
||||||
lines.append(f"{key} = {j2.variable(var_name)}")
|
lines.append(f"{safe_key} = {j2.variable(var_name)}")
|
||||||
if cond:
|
if cond:
|
||||||
lines.append(j2.endif())
|
lines.append(j2.endif())
|
||||||
|
|
||||||
|
|
@ -326,7 +335,7 @@ def _toml_render_union(
|
||||||
)
|
)
|
||||||
if cond:
|
if cond:
|
||||||
lines.append(f"{j2.if_defined(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)}
|
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)}
|
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:
|
if sec_cond:
|
||||||
lines.append(f"{j2.if_defined(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):
|
for key, raw_val in union.items(section, raw=True):
|
||||||
path = (section, key)
|
path = (section, key)
|
||||||
var = make_var_name(role_prefix, path)
|
var = make_var_name(role_prefix, path)
|
||||||
|
safe_key = escape_jinja_literal(str(key))
|
||||||
key_cond = (
|
key_cond = (
|
||||||
defined_var_name(role_prefix, path) if path in optional_keys else None
|
defined_var_name(role_prefix, path) if path in optional_keys else None
|
||||||
)
|
)
|
||||||
|
|
@ -424,9 +434,9 @@ def _ini_render_union(
|
||||||
if key_cond:
|
if key_cond:
|
||||||
lines.append(f"{j2.if_defined(key_cond)}")
|
lines.append(f"{j2.if_defined(key_cond)}")
|
||||||
if quoted:
|
if quoted:
|
||||||
lines.append(f"{key} = {j2.quoted_variable(var)}")
|
lines.append(f"{safe_key} = {j2.quoted_variable(var)}")
|
||||||
else:
|
else:
|
||||||
lines.append(f"{key} = {j2.variable(var)}")
|
lines.append(f"{safe_key} = {j2.variable(var)}")
|
||||||
if key_cond:
|
if key_cond:
|
||||||
lines.append(j2.endif())
|
lines.append(j2.endif())
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
@ -769,4 +779,10 @@ def process_directory(
|
||||||
defaults_doc[out.list_var] = out.items
|
defaults_doc[out.list_var] = out.items
|
||||||
defaults_yaml = dump_yaml(defaults_doc, sort_keys=True)
|
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
|
return defaults_yaml, outputs
|
||||||
|
|
|
||||||
255
src/jinjaturtle/safety.py
Normal file
255
src/jinjaturtle/safety.py
Normal 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)."
|
||||||
|
)
|
||||||
|
|
@ -165,6 +165,18 @@ SSTI_PAYLOADS = [
|
||||||
"text {% endraw %} breakout {{ evil }}",
|
"text {% endraw %} breakout {{ evil }}",
|
||||||
"nested {% endraw %} spacing {{ evil }}",
|
"nested {% endraw %} spacing {{ evil }}",
|
||||||
"{%- endraw -%}{{ 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 #}",
|
"mixed {{ a }} and {% b %} and {# c #}",
|
||||||
"}}{{ orphan delimiters %}{%",
|
"}}{{ orphan delimiters %}{%",
|
||||||
]
|
]
|
||||||
|
|
@ -196,6 +208,33 @@ def test_escape_jinja_literal_actually_blocks_execution():
|
||||||
assert TRIP in fired
|
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(
|
@pytest.mark.parametrize(
|
||||||
"payload",
|
"payload",
|
||||||
[
|
[
|
||||||
|
|
|
||||||
233
tests/test_output_safety_gate.py
Normal file
233
tests/test_output_safety_gate.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue