More hardening measures
Some checks failed
CI / test (push) Successful in 1m20s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m20s
Lint / test (push) Failing after 40s

This commit is contained in:
Miguel Jacq 2026-06-24 16:17:41 +10:00
parent a9d56b66c5
commit be1f9940a5
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
22 changed files with 902 additions and 162 deletions

View file

@ -196,6 +196,16 @@ def test_escape_jinja_literal_actually_blocks_execution():
assert TRIP in fired
def test_escape_jinja_literal_wraps_erb_markup_for_translated_erb_safety():
payload = "<%= boom.run('erb-through-jinja') %>"
escaped = escape_jinja_literal(payload)
assert escaped != payload
env = jinja2.Environment(undefined=jinja2.ChainableUndefined)
rendered = env.from_string(escaped).render(boom=_Boom())
assert rendered == payload
assert TRIP not in rendered
@pytest.mark.parametrize(
"payload",
[
@ -234,3 +244,306 @@ def test_escape_literal_jinja_output_is_inert():
env = jinja2.Environment(undefined=jinja2.ChainableUndefined)
rendered = env.from_string(escaped).render(boom=_Boom())
assert TRIP not in rendered
def test_json_source_keys_are_escaped_and_do_not_execute(tmp_path):
body = '{"evil_{{ boom.run(\'json-key\') }}": "ok"}\n'
template_text, defaults = _run_jinjaturtle(tmp_path, "evil.json", body, "json")
rendered = _render_jinja(template_text, {**defaults, "boom": _Boom()})
assert TRIP not in rendered
assert "evil_{{ boom.run('json-key') }}" in rendered
def test_xml_source_comments_cannot_spoof_internal_markers(tmp_path):
body = (
"<root>\n"
" <!--IF:boom.run('xml-marker')-->\n"
" <item>ok</item>\n"
" <!--ENDIF:any-->\n"
"</root>\n"
)
template_text, defaults = _run_jinjaturtle(tmp_path, "evil.xml", body, "xml")
assert "{% if boom.run" not in template_text
rendered = _render_jinja(template_text, {**defaults, "boom": _Boom()})
assert TRIP not in rendered
assert "IF:boom.run('xml-marker')" in rendered
def test_folder_yaml_keys_are_escaped_and_do_not_execute(tmp_path):
from jinjaturtle.multi import process_directory
(tmp_path / "one.yaml").write_text(
'evil_{{ boom.run("folder-yaml") }}: ok\n', encoding="utf-8"
)
defaults_yaml, outputs = process_directory(
tmp_path, recursive=False, role_prefix="role"
)
defaults = pyyaml.safe_load(defaults_yaml) or {}
assert len(outputs) == 1
template_text = outputs[0].template
rendered = _render_jinja(
template_text, {**defaults["role_items"][0], "boom": _Boom()}
)
assert TRIP not in rendered
assert 'evil_{{ boom.run("folder-yaml") }}' in rendered
def test_erb_source_comment_payload_is_literal_not_executed(tmp_path):
import shutil
marker = tmp_path / "erb-owned"
src = tmp_path / "evil.ini"
src.write_text(
"[s]\n" "good = ok\n" f"# <%= File.write({str(marker)!r}, 'owned') %>\n",
encoding="utf-8",
)
tpl = tmp_path / "out.erb"
dfl = tmp_path / "hiera.yml"
res = subprocess.run(
[
sys.executable,
"-m",
"jinjaturtle.cli",
str(src),
"-f",
"ini",
"--template-engine",
"erb",
"--role-name",
"role",
"-t",
str(tpl),
"-d",
str(dfl),
],
capture_output=True,
text=True,
)
assert res.returncode == 0, res.stderr
erb_text = tpl.read_text(encoding="utf-8")
live_chunks = re.findall(r"<%[-=#]?(.*?)-?%>", erb_text, re.S)
assert not any("File.write" in chunk for chunk in live_chunks)
ruby = shutil.which("ruby")
if ruby is None:
pytest.skip("ruby is not installed")
run = subprocess.run(
[
ruby,
"-rerb",
"-e",
"puts ERB.new(File.read(ARGV[0])).result(binding)",
str(tpl),
],
capture_output=True,
text=True,
)
assert run.returncode == 0, run.stderr
assert not marker.exists()
def test_ansible_defaults_tag_dangerous_strings_as_unsafe_and_still_load():
from jinjaturtle.core import generate_ansible_yaml
out = generate_ansible_yaml(
"role",
[
(("jinja",), "{{ boom.run('unsafe') }}"),
(("erb",), "<%= File.write('/tmp/nope', 'x') %>"),
(("plain",), "ordinary"),
],
)
assert "role_jinja: !unsafe" in out
assert "role_erb: !unsafe" in out
assert "role_plain: ordinary" in out
loaded = pyyaml.safe_load(out)
assert loaded["role_jinja"] == "{{ boom.run('unsafe') }}"
def test_cli_rejects_template_unsafe_role_name(tmp_path):
from jinjaturtle import cli
src = tmp_path / "a.ini"
src.write_text("[s]\nkey=value\n", encoding="utf-8")
with pytest.raises(SystemExit) as exc:
cli._main([str(src), "--role-name", "bad-{{ role }}"])
assert exc.value.code == 2
def test_yaml_loop_source_key_cannot_become_jinja_expression(tmp_path):
from jinjaturtle.core import (
analyze_loops,
flatten_config,
generate_ansible_yaml,
generate_jinja2_template,
parse_config,
)
malicious_key = (
"__class__.__mro__[1].__subclasses__()[166].__init__"
".__globals__['__builtins__']['__import__']('os')"
".popen('echo JT_RCE').read()"
)
src = tmp_path / "evil.yaml"
src.write_text(
"servers:\n" f" - {malicious_key!r}: a\n" f" - {malicious_key!r}: b\n",
encoding="utf-8",
)
fmt, parsed = parse_config(src, "yaml")
loops = analyze_loops(fmt, parsed)
flat = flatten_config(fmt, parsed, loops)
template = generate_jinja2_template(fmt, parsed, "role", src.read_text(), loops)
defaults = pyyaml.safe_load(generate_ansible_yaml("role", flat, loops)) or {}
assert f"server.{malicious_key}" not in template
assert "server.jt_" in template
rendered = _render_jinja(template, defaults)
assert TRIP not in rendered
assert "JT_RCE" in rendered # literal source key only, not command output
def test_toml_array_table_loop_source_key_cannot_become_jinja_expression(tmp_path):
from jinjaturtle.core import (
analyze_loops,
flatten_config,
generate_ansible_yaml,
generate_jinja2_template,
parse_config,
)
malicious_key = (
"__class__.__mro__[1].__subclasses__()[166].__init__"
".__globals__['__builtins__']['__import__']('os')"
".popen('echo JT_TOML_RCE').read()"
)
src = tmp_path / "evil.toml"
src.write_text(
f'[[servers]]\n"{malicious_key}" = "a"\n\n'
f'[[servers]]\n"{malicious_key}" = "b"\n',
encoding="utf-8",
)
fmt, parsed = parse_config(src, "toml")
loops = analyze_loops(fmt, parsed)
flat = flatten_config(fmt, parsed, loops)
template = generate_jinja2_template(fmt, parsed, "role", src.read_text(), loops)
defaults = pyyaml.safe_load(generate_ansible_yaml("role", flat, loops)) or {}
assert f"server.{malicious_key}" not in template
assert "server.jt_" in template
rendered = _render_jinja(template, defaults)
assert TRIP not in rendered
assert "JT_TOML_RCE" in rendered
def test_erb_loop_source_key_cannot_become_ruby_method_call(tmp_path):
import shutil
from jinjaturtle.core import (
analyze_loops,
flatten_config,
generate_erb_template,
generate_puppet_hiera_yaml,
parse_config,
)
marker = tmp_path / "erb-loop-owned"
key = f"instance_eval(%q{{File.write({str(marker)!r}, 'owned')}})"
src = tmp_path / "evil.yaml"
src.write_text(
"servers:\n" f" - {key!r}: a\n" f" - {key!r}: b\n",
encoding="utf-8",
)
fmt, parsed = parse_config(src, "yaml")
loops = analyze_loops(fmt, parsed)
flat = flatten_config(fmt, parsed, loops)
erb = generate_erb_template(
fmt,
parsed,
"role",
original_text=src.read_text(),
loop_candidates=loops,
flat_items=flat,
)
assert "server.instance_eval" not in erb
assert "server['jt_" in erb
ruby = shutil.which("ruby")
if ruby is None:
pytest.skip("ruby is not installed")
hiera_path = tmp_path / "hiera.yml"
hiera_path.write_text(
generate_puppet_hiera_yaml("role", flat, loops), encoding="utf-8"
)
tpl = tmp_path / "safe.erb"
tpl.write_text(erb, encoding="utf-8")
run = subprocess.run(
[
ruby,
"-rerb",
"-ryaml",
"-e",
"@servers = YAML.load_file(ARGV[1])['role::servers']; "
"puts ERB.new(File.read(ARGV[0]), trim_mode: '-').result(binding)",
str(tpl),
str(hiera_path),
],
capture_output=True,
text=True,
)
assert run.returncode == 0, run.stderr
assert not marker.exists()
def test_canonical_ini_and_postfix_escape_source_keys():
from configparser import ConfigParser
from jinjaturtle.core import generate_jinja2_template
parser = ConfigParser()
parser.optionxform = str
parser.add_section('s_{{ boom.run("ini") }}')
parser.set('s_{{ boom.run("ini") }}', "key", "ok")
ini_template = generate_jinja2_template("ini", parser, "role", original_text=None)
rendered = _render_jinja(
ini_template, {"role_s_boom_run_ini_key": "ok", "boom": _Boom()}
)
assert TRIP not in rendered
assert 's_{{ boom.run("ini") }}' in rendered
postfix_template = generate_jinja2_template(
"postfix",
{'evil_{{ boom.run("postfix") }}': "ok"},
"role",
original_text=None,
)
rendered = _render_jinja(
postfix_template,
{"role_evil_boom_run_postfix": "ok", "boom": _Boom()},
)
assert TRIP not in rendered
assert 'evil_{{ boom.run("postfix") }}' in rendered
def test_template_safety_validator_rejects_future_expression_injection():
from jinjaturtle.template_safety import (
UnsafeTemplateError,
validate_generated_template,
)
with pytest.raises(UnsafeTemplateError):
validate_generated_template("{{ item.__class__.__mro__[1] }}")
with pytest.raises(UnsafeTemplateError):
validate_generated_template("{{ boom.run('x') }}")
# Generated field names and loop.last remain allowed.
validate_generated_template(
"{% for item in role_items %}{{ item.jt_name_82a3537f }}"
"{% if not loop.last %},{% endif %}{% endfor %}"
)
def test_defaults_generation_rejects_colliding_variable_names():
from jinjaturtle.core import generate_ansible_yaml
with pytest.raises(ValueError, match="same variable name"):
generate_ansible_yaml("role", [(("a-b",), 1), (("a_b",), 2)])