Hardening: use unsafe for ansible vars, ensure API use of JinjaTurtle uses safe XML parsing, avoid symlinks
Some checks failed
CI / test (push) Successful in 48s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m27s
Lint / test (push) Failing after 39s

This commit is contained in:
Miguel Jacq 2026-06-29 08:51:53 +10:00
parent 4a1f2ac15e
commit 70be0f7e33
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
7 changed files with 348 additions and 19 deletions

View file

@ -16,6 +16,7 @@ from .core import (
from .multi import process_directory from .multi import process_directory
from .safety import TemplateSafetyError from .safety import TemplateSafetyError
from .output_safety import OutputPathError, ensure_safe_directory, write_text_safely
def _build_arg_parser() -> argparse.ArgumentParser: def _build_arg_parser() -> argparse.ArgumentParser:
@ -72,6 +73,9 @@ def _main(argv: list[str] | None = None) -> int:
f"jinjaturtle: refusing to generate unsafe template: {exc}", file=sys.stderr f"jinjaturtle: refusing to generate unsafe template: {exc}", file=sys.stderr
) )
return 2 return 2
except OutputPathError as exc:
print(f"jinjaturtle: refusing unsafe output path: {exc}", file=sys.stderr)
return 2
def _run(argv: list[str] | None = None) -> int: def _run(argv: list[str] | None = None) -> int:
@ -89,7 +93,7 @@ def _run(argv: list[str] | None = None) -> int:
# Write defaults # Write defaults
if args.defaults_output: if args.defaults_output:
Path(args.defaults_output).write_text(defaults_yaml, encoding="utf-8") write_text_safely(Path(args.defaults_output), defaults_yaml)
else: else:
print("# defaults/main.yml") print("# defaults/main.yml")
print(defaults_yaml, end="") print(defaults_yaml, end="")
@ -100,12 +104,12 @@ def _run(argv: list[str] | None = None) -> int:
if args.template_output: if args.template_output:
out_path = Path(args.template_output) out_path = Path(args.template_output)
if len(outputs) == 1 and not out_path.is_dir(): if len(outputs) == 1 and not out_path.is_dir():
out_path.write_text(outputs[0].template, encoding="utf-8") write_text_safely(out_path, outputs[0].template)
else: else:
out_path.mkdir(parents=True, exist_ok=True) ensure_safe_directory(out_path)
for o in outputs: for o in outputs:
(out_path / f"config.{o.fmt}.{template_ext}").write_text( write_text_safely(
o.template, encoding="utf-8" out_path / f"config.{o.fmt}.{template_ext}", o.template
) )
else: else:
for o in outputs: for o in outputs:
@ -144,13 +148,13 @@ def _run(argv: list[str] | None = None) -> int:
) )
if args.defaults_output: if args.defaults_output:
Path(args.defaults_output).write_text(ansible_yaml, encoding="utf-8") write_text_safely(Path(args.defaults_output), ansible_yaml)
else: else:
print("# defaults/main.yml") print("# defaults/main.yml")
print(ansible_yaml, end="") print(ansible_yaml, end="")
if args.template_output: if args.template_output:
Path(args.template_output).write_text(template_str, encoding="utf-8") write_text_safely(Path(args.template_output), template_str)
else: else:
print(f"# config.{j2.TEMPLATE_EXTENSION}") print(f"# config.{j2.TEMPLATE_EXTENSION}")
print(template_str, end="") print(template_str, end="")

View file

@ -33,6 +33,22 @@ class QuotedString(str):
pass pass
class AnsibleUnsafeString(str):
"""Marker type emitted with Ansible's !unsafe YAML tag.
Ansible recursively templates string values by default. Source-derived
config values that contain Jinja delimiters must therefore be marked
unsafe in defaults/main.yml, otherwise a harvested value such as
``{{ lookup('pipe', 'id') }}`` becomes executable on the Ansible
controller when the generated role is applied.
"""
pass
_JINJA_STARTS = ("{{", "{%", "{#")
def _fallback_str_representer(dumper: yaml.SafeDumper, data: Any): def _fallback_str_representer(dumper: yaml.SafeDumper, data: Any):
""" """
Fallback for objects the dumper doesn't know about. Fallback for objects the dumper doesn't know about.
@ -52,7 +68,33 @@ def _quoted_str_representer(dumper: yaml.SafeDumper, data: QuotedString):
return dumper.represent_scalar("tag:yaml.org,2002:str", str(data), style='"') return dumper.represent_scalar("tag:yaml.org,2002:str", str(data), style='"')
def _ansible_unsafe_str_representer(dumper: yaml.SafeDumper, data: AnsibleUnsafeString):
return dumper.represent_scalar("!unsafe", str(data), style="'")
def _needs_ansible_unsafe(value: str) -> bool:
return any(marker in value for marker in _JINJA_STARTS)
def _mark_ansible_unsafe_values(obj: Any) -> Any:
"""Recursively mark mapping/list values containing Jinja as !unsafe.
Mapping keys are intentionally left alone: they are variable names or YAML
structure, not Ansible-templated values. Values nested in folder-mode item
lists, including source-derived ``id`` values, are protected.
"""
if isinstance(obj, dict):
return {k: _mark_ansible_unsafe_values(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_mark_ansible_unsafe_values(v) for v in obj]
if isinstance(obj, str) and _needs_ansible_unsafe(obj):
return AnsibleUnsafeString(obj)
return obj
_TurtleDumper.add_representer(QuotedString, _quoted_str_representer) _TurtleDumper.add_representer(QuotedString, _quoted_str_representer)
_TurtleDumper.add_representer(AnsibleUnsafeString, _ansible_unsafe_str_representer)
# Use our fallback for any unknown object types # Use our fallback for any unknown object types
_TurtleDumper.add_representer(None, _fallback_str_representer) _TurtleDumper.add_representer(None, _fallback_str_representer)
@ -84,8 +126,9 @@ def dump_yaml(data: Any, *, sort_keys: bool = True) -> str:
This is used by both the single-file and multi-file code paths. This is used by both the single-file and multi-file code paths.
""" """
safe_data = _mark_ansible_unsafe_values(data)
return yaml.dump( return yaml.dump(
data, safe_data,
Dumper=_TurtleDumper, Dumper=_TurtleDumper,
sort_keys=sort_keys, sort_keys=sort_keys,
default_flow_style=False, default_flow_style=False,

View file

@ -3,7 +3,8 @@ from __future__ import annotations
from collections import Counter, defaultdict from collections import Counter, defaultdict
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import xml.etree.ElementTree as ET # nosec import xml.etree.ElementTree as ET # nosec B405 - safe trees only; parsing uses defusedxml
import defusedxml.ElementTree as DET
from .base import BaseHandler from .base import BaseHandler
from .. import j2 from .. import j2
@ -20,11 +21,11 @@ class XmlHandler(BaseHandler):
def parse(self, path: Path) -> ET.Element: def parse(self, path: Path) -> ET.Element:
text = path.read_text(encoding="utf-8") text = path.read_text(encoding="utf-8")
parser = ET.XMLParser( # Security must live in the handler, not only in the CLI entry point:
target=ET.TreeBuilder(insert_comments=False) # callers may import JinjaTurtle as a library and invoke parse_config()
) # nosec B314 # directly. defusedxml rejects DTD/entity abuse and also discards
parser.feed(text) # comments by default, matching the previous TreeBuilder behaviour.
root = parser.close() root = DET.fromstring(text)
return root return root
def flatten(self, parsed: Any) -> list[tuple[tuple[str, ...], Any]]: def flatten(self, parsed: Any) -> list[tuple[tuple[str, ...], Any]]:

View file

@ -22,7 +22,9 @@ Notes:
from collections import Counter, defaultdict from collections import Counter, defaultdict
from copy import deepcopy from copy import deepcopy
import os
import configparser import configparser
import stat
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
@ -44,8 +46,23 @@ SUPPORTED_SUFFIXES: dict[str, set[str]] = {
} }
def _lstat(path: Path) -> os.stat_result:
return path.lstat()
def is_supported_file(path: Path) -> bool: def is_supported_file(path: Path) -> bool:
if not path.is_file(): """Return True only for real regular files with supported suffixes.
pathlib.Path.is_file() follows symlinks. Folder mode must not follow
attacker-controlled symlinks when run over an untrusted tree, especially if
an administrator accidentally runs the CLI as root.
"""
try:
st = _lstat(path)
except FileNotFoundError:
return False
if not stat.S_ISREG(st.st_mode):
return False return False
suffix = path.suffix.lower() suffix = path.suffix.lower()
for exts in SUPPORTED_SUFFIXES.values(): for exts in SUPPORTED_SUFFIXES.values():
@ -55,11 +72,16 @@ def is_supported_file(path: Path) -> bool:
def iter_supported_files(root: Path, recursive: bool) -> list[Path]: def iter_supported_files(root: Path, recursive: bool) -> list[Path]:
if not root.exists(): try:
st = _lstat(root)
except FileNotFoundError:
raise FileNotFoundError(str(root)) raise FileNotFoundError(str(root))
if root.is_file():
if stat.S_ISLNK(st.st_mode):
raise ValueError(f"refusing to follow symlink: {root}")
if stat.S_ISREG(st.st_mode):
return [root] if is_supported_file(root) else [] return [root] if is_supported_file(root) else []
if not root.is_dir(): if not stat.S_ISDIR(st.st_mode):
return [] return []
it = root.rglob("*") if recursive else root.glob("*") it = root.rglob("*") if recursive else root.glob("*")

View file

@ -0,0 +1,124 @@
from __future__ import annotations
"""Safer file-output helpers for the JinjaTurtle CLI.
The CLI is often used by administrators. A plain Path.write_text() follows a
final-path symlink and can therefore be dangerous when a root-run invocation
writes into an attacker-writable tree. These helpers validate path components,
write through a private temporary file in the target directory, and replace the
final path atomically. Existing final-path symlinks are refused rather than
followed.
"""
import os
from pathlib import Path
import stat
import tempfile
class OutputPathError(OSError):
"""Raised when a requested output path is unsafe."""
def _absolute(path: Path) -> Path:
return path if path.is_absolute() else Path.cwd() / path
def _check_existing_path_not_symlink(path: Path) -> None:
try:
st = path.lstat()
except FileNotFoundError:
return
if stat.S_ISLNK(st.st_mode):
raise OutputPathError(f"refusing to use symlink path: {path}")
def _check_existing_output_file(path: Path) -> None:
try:
st = path.lstat()
except FileNotFoundError:
return
if stat.S_ISLNK(st.st_mode):
raise OutputPathError(f"refusing to write through symlink: {path}")
if not stat.S_ISREG(st.st_mode):
raise OutputPathError(f"refusing to replace non-regular file: {path}")
def _check_parent_components(parent: Path) -> None:
"""Require every existing parent component to be a real directory."""
parent = _absolute(parent)
parts = parent.parts
if not parts:
return
cur = Path(parts[0])
for part in parts[1:]:
cur = cur / part
try:
st = cur.lstat()
except FileNotFoundError as exc:
raise OutputPathError(f"output parent does not exist: {cur}") from exc
if stat.S_ISLNK(st.st_mode):
raise OutputPathError(f"refusing to use symlink parent: {cur}")
if not stat.S_ISDIR(st.st_mode):
raise OutputPathError(f"output parent is not a directory: {cur}")
def ensure_safe_directory(path: Path) -> None:
"""Create or validate a directory tree without accepting symlinks."""
path = _absolute(path)
parts = path.parts
if not parts:
return
cur = Path(parts[0])
for part in parts[1:]:
cur = cur / part
try:
st = cur.lstat()
except FileNotFoundError:
cur.mkdir(mode=0o700)
st = cur.lstat()
if stat.S_ISLNK(st.st_mode):
raise OutputPathError(f"refusing to use symlink directory: {cur}")
if not stat.S_ISDIR(st.st_mode):
raise OutputPathError(f"output path is not a directory: {cur}")
def write_text_safely(path: Path, text: str, *, encoding: str = "utf-8") -> None:
"""Write text without following a final-path symlink.
The target's parent must already exist and every parent component must be a
real directory. The write is completed with os.replace(), which atomically
swaps the final directory entry and does not dereference a final symlink.
"""
path = _absolute(path)
_check_parent_components(path.parent)
_check_existing_output_file(path)
fd = -1
tmp_name: str | None = None
try:
fd, tmp_name = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent), text=True
)
with os.fdopen(fd, "w", encoding=encoding) as f:
fd = -1
f.write(text)
f.flush()
os.fsync(f.fileno())
os.chmod(tmp_name, 0o600)
_check_existing_output_file(path)
os.replace(tmp_name, path)
tmp_name = None
finally:
if fd >= 0:
os.close(fd)
if tmp_name is not None:
try:
os.unlink(tmp_name)
except FileNotFoundError:
pass

View file

@ -29,6 +29,21 @@ from jinjaturtle.escape import (
TRIP = "__TRIPWIRE_FIRED__" TRIP = "__TRIPWIRE_FIRED__"
class _UnsafeAwareLoader(pyyaml.SafeLoader):
pass
def _construct_unsafe(loader: _UnsafeAwareLoader, node: pyyaml.Node):
return loader.construct_scalar(node)
_UnsafeAwareLoader.add_constructor("!unsafe", _construct_unsafe)
def _safe_load_defaults(text: str):
return pyyaml.load(text, Loader=_UnsafeAwareLoader)
class _Boom: class _Boom:
"""Returns the tripwire sentinel for any access/call an SSTI payload makes.""" """Returns the tripwire sentinel for any access/call an SSTI payload makes."""
@ -82,7 +97,7 @@ def _run_jinjaturtle(tmp_path: Path, source_name: str, body: str, fmt: str):
text=True, text=True,
) )
assert res.returncode == 0, f"generation failed: {res.stderr}" assert res.returncode == 0, f"generation failed: {res.stderr}"
defaults = pyyaml.safe_load(dfl.read_text()) or {} defaults = _safe_load_defaults(dfl.read_text()) or {}
return tpl.read_text(), defaults return tpl.read_text(), defaults

View file

@ -0,0 +1,120 @@
from __future__ import annotations
from pathlib import Path
import os
import pytest
import yaml
from defusedxml.common import EntitiesForbidden
from jinjaturtle import cli
from jinjaturtle.core import generate_ansible_yaml, parse_config, flatten_config
from jinjaturtle.multi import is_supported_file, iter_supported_files, process_directory
from jinjaturtle.output_safety import OutputPathError, write_text_safely
class UnsafeAwareLoader(yaml.SafeLoader):
pass
def _unsafe(loader: UnsafeAwareLoader, node: yaml.Node):
return loader.construct_scalar(node)
UnsafeAwareLoader.add_constructor("!unsafe", _unsafe)
def test_jinja_values_are_emitted_as_ansible_unsafe(tmp_path: Path):
src = tmp_path / "app.ini"
src.write_text("[main]\ncmd = {{ lookup('pipe','id') }}\n", encoding="utf-8")
fmt, parsed = parse_config(src)
defaults_yaml = generate_ansible_yaml("role", flatten_config(fmt, parsed))
assert "role_main_cmd: !unsafe" in defaults_yaml
assert "{{ lookup(''pipe'',''id'') }}" in defaults_yaml
loaded = yaml.load(defaults_yaml, Loader=UnsafeAwareLoader)
assert loaded["role_main_cmd"] == "{{ lookup('pipe','id') }}"
def test_folder_mode_marks_nested_jinja_values_and_ids_unsafe(tmp_path: Path):
src = tmp_path / "src"
src.mkdir()
# Filename ids are source-derived values too.
(src / "{{ bad }}.yaml").write_text(
"message: \"{{ lookup('pipe','id') }}\"\n", encoding="utf-8"
)
defaults_yaml, _outputs = process_directory(
src, recursive=False, role_prefix="role"
)
assert "id: !unsafe" in defaults_yaml
assert "role_message: !unsafe" in defaults_yaml
loaded = yaml.load(defaults_yaml, Loader=UnsafeAwareLoader)
assert loaded["role_items"][0]["id"] == "{{ bad }}.yaml"
assert loaded["role_items"][0]["role_message"] == "{{ lookup('pipe','id') }}"
def test_xml_parser_rejects_entities_when_called_as_library(tmp_path: Path):
src = tmp_path / "bad.xml"
src.write_text(
"<!DOCTYPE root [<!ENTITY xxe SYSTEM 'file:///etc/passwd'>]><root>&xxe;</root>",
encoding="utf-8",
)
with pytest.raises(EntitiesForbidden):
parse_config(src, "xml")
def test_folder_mode_does_not_follow_symlinked_files(tmp_path: Path):
real = tmp_path / "secret.ini"
real.write_text("[main]\nsecret=yes\n", encoding="utf-8")
root = tmp_path / "root"
root.mkdir()
link = root / "link.ini"
link.symlink_to(real)
assert not is_supported_file(link)
assert iter_supported_files(root, recursive=False) == []
assert iter_supported_files(root, recursive=True) == []
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks unavailable")
def test_cli_refuses_to_write_through_final_symlink(tmp_path: Path):
target = tmp_path / "target.txt"
target.write_text("keep\n", encoding="utf-8")
link = tmp_path / "out.yml"
link.symlink_to(target)
with pytest.raises(OutputPathError):
write_text_safely(link, "replace\n")
assert target.read_text(encoding="utf-8") == "keep\n"
assert link.is_symlink()
def test_cli_refuses_symlinked_output_parent(tmp_path: Path):
real_dir = tmp_path / "real"
real_dir.mkdir()
link_dir = tmp_path / "linkdir"
link_dir.symlink_to(real_dir, target_is_directory=True)
with pytest.raises(OutputPathError):
write_text_safely(link_dir / "out.yml", "data\n")
assert not (real_dir / "out.yml").exists()
def test_cli_reports_unsafe_output_path_without_overwriting_symlink(tmp_path: Path):
cfg = tmp_path / "app.ini"
cfg.write_text("[main]\nname = ok\n", encoding="utf-8")
target = tmp_path / "target.yml"
target.write_text("keep\n", encoding="utf-8")
link = tmp_path / "defaults.yml"
link.symlink_to(target)
exit_code = cli._main([str(cfg), "--defaults-output", str(link)])
assert exit_code == 2
assert target.read_text(encoding="utf-8") == "keep\n"