Try to prevent what could lead to execution of embedded jinja in original files when converting
This commit is contained in:
parent
f5de32b778
commit
a5ca5f2974
13 changed files with 640 additions and 101 deletions
57
README.md
57
README.md
|
|
@ -115,9 +115,8 @@ ERB mode the file is intended to be Puppet Hiera data rather than Ansible role
|
||||||
defaults.
|
defaults.
|
||||||
|
|
||||||
JinjaTurtle does **not** generate Puppet classes or `file` resources. A Puppet
|
JinjaTurtle does **not** generate Puppet classes or `file` resources. A Puppet
|
||||||
module, or another tool such as Enroll, should still declare the class
|
module, should still declare the class parameters and call the template, for
|
||||||
parameters and call the template, for example with Puppet's `template()`
|
example with Puppet's `template()` function.
|
||||||
function.
|
|
||||||
|
|
||||||
## Using `--puppet-class`
|
## Using `--puppet-class`
|
||||||
|
|
||||||
|
|
@ -340,15 +339,53 @@ JinjaTurtle also templates some common bespoke config formats:
|
||||||
For ambiguous extensions like `*.conf`, JinjaTurtle uses lightweight content
|
For ambiguous extensions like `*.conf`, JinjaTurtle uses lightweight content
|
||||||
sniffing. You can always force a specific handler with `--format`.
|
sniffing. You can always force a specific handler with `--format`.
|
||||||
|
|
||||||
## Relationship with Enroll
|
## Security model
|
||||||
|
|
||||||
JinjaTurtle can be used directly, but it is also useful as a helper for tools
|
JinjaTurtle is frequently pointed at config files that were *harvested* from
|
||||||
that generate configuration-management code.
|
real systems, where some content may be influenced by an untrusted party (a
|
||||||
|
hostname, a login banner, a `GECOS` comment, a "Managed by ..." note). It is
|
||||||
|
therefore designed so that source content cannot turn into executable template
|
||||||
|
code.
|
||||||
|
|
||||||
|
Two guarantees matter:
|
||||||
|
|
||||||
|
1. **Values are data, never code.** Every config *value* is replaced with a
|
||||||
|
`{{ variable }}` placeholder in the template, and the original value is stored
|
||||||
|
separately in the defaults/Hiera data. When the template is later rendered,
|
||||||
|
the placeholder prints the value as a literal string; Jinja2 does not
|
||||||
|
recursively render the *contents* of a variable, so a payload sitting inside
|
||||||
|
a value (for example `motd = {{ salt['cmd.run']('id') }}`) is inert.
|
||||||
|
|
||||||
|
2. **Verbatim text is neutralised.** To preserve formatting, JinjaTurtle copies
|
||||||
|
comments, blank lines, headers and any unrecognised lines from the source
|
||||||
|
into the template. Any template metacharacters in that copied text
|
||||||
|
(`{{ }}`, `{% %}`, `{# #}` for Jinja2; `<% %>` for ERB) are escaped so they
|
||||||
|
render as the literal characters the author wrote, rather than executing.
|
||||||
|
|
||||||
|
### Consumer responsibilities
|
||||||
|
|
||||||
|
The value guarantee above relies on the downstream renderer being single-pass,
|
||||||
|
which is the normal case:
|
||||||
|
|
||||||
|
- **Ansible**: rendering a template with `template:`/`ansible.builtin.template`
|
||||||
|
is single-pass. For defence in depth, treat the generated defaults as
|
||||||
|
untrusted input — Ansible already does not re-template variable *contents* by
|
||||||
|
default. If you build your own var structures from this data and pass them
|
||||||
|
through additional templating, mark untrusted values with the `!unsafe` tag so
|
||||||
|
they are never re-evaluated.
|
||||||
|
- **Salt**: use the generated file as a `file.managed` template
|
||||||
|
(`template: jinja`). Do **not** place JinjaTurtle output where Salt would
|
||||||
|
render it a second time as part of SLS/pillar rendering, which is a separate
|
||||||
|
Jinja pass and would re-evaluate any embedded expressions.
|
||||||
|
- **Puppet/ERB**: render with the standard `template()`/`epp()` flow.
|
||||||
|
|
||||||
|
In short: render JinjaTurtle output exactly once. Do not feed it back through
|
||||||
|
another templating pass.
|
||||||
|
|
||||||
|
**IMPORTANT**: Always review both the original config files, then the resulting
|
||||||
|
templates generated by JinjaTurtle, before integrating them into your config
|
||||||
|
management system!
|
||||||
|
|
||||||
Enroll can call JinjaTurtle when it is available on the `PATH`. In that setup,
|
|
||||||
Enroll decides where Puppet, Ansible, or Salt files belong, while JinjaTurtle
|
|
||||||
concentrates on converting harvested config files into templates plus variable
|
|
||||||
data.
|
|
||||||
|
|
||||||
## Found a bug, have a suggestion?
|
## Found a bug, have a suggestion?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -199,4 +199,8 @@ def main() -> None:
|
||||||
"""
|
"""
|
||||||
Console-script entry point.
|
Console-script entry point.
|
||||||
"""
|
"""
|
||||||
_main(sys.argv[1:])
|
sys.exit(_main(sys.argv[1:]))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
|
||||||
|
|
@ -412,8 +412,6 @@ def generate_puppet_hiera_yaml(
|
||||||
``role_prefix`` remains the source variable prefix used by JinjaTurtle while
|
``role_prefix`` remains the source variable prefix used by JinjaTurtle while
|
||||||
``puppet_class`` is the Puppet class/Hiera namespace. In the normal case
|
``puppet_class`` is the Puppet class/Hiera namespace. In the normal case
|
||||||
they are the same, so ``php_memory_limit`` becomes ``php::memory_limit``.
|
they are the same, so ``php_memory_limit`` becomes ``php::memory_limit``.
|
||||||
Enroll may pass a file-specific role prefix and a separate Puppet class to
|
|
||||||
avoid parameter-name collisions inside one generated Puppet module.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
klass = puppet_class_name(puppet_class or role_prefix)
|
klass = puppet_class_name(puppet_class or role_prefix)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from .escape import escape_erb_literal
|
||||||
|
|
||||||
|
|
||||||
def _safe_name(raw: str, *, fallback: str = "var") -> str:
|
def _safe_name(raw: str, *, fallback: str = "var") -> str:
|
||||||
text = re.sub(r"[^A-Za-z0-9_]+", "_", str(raw or fallback)).strip("_").lower()
|
text = re.sub(r"[^A-Za-z0-9_]+", "_", str(raw or fallback)).strip("_").lower()
|
||||||
|
|
@ -38,10 +40,9 @@ def puppet_local_var_name(
|
||||||
generated Jinja variable such as ``php_memory_limit`` becomes Puppet local
|
generated Jinja variable such as ``php_memory_limit`` becomes Puppet local
|
||||||
parameter ``memory_limit`` and Hiera key ``php::memory_limit``.
|
parameter ``memory_limit`` and Hiera key ``php::memory_limit``.
|
||||||
|
|
||||||
Enroll sometimes needs a file-specific variable prefix to avoid collisions
|
When ``puppet_class`` differs from ``role_prefix`` we keep the full
|
||||||
inside a generated module. When ``puppet_class`` differs from
|
generated variable name as the local parameter and only use ``puppet_class``
|
||||||
``role_prefix`` we keep the full generated variable name as the local
|
as the Hiera namespace.
|
||||||
parameter and only use ``puppet_class`` as the Hiera namespace.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
var_name = _safe_name(jinja_var_name, fallback="value")
|
var_name = _safe_name(jinja_var_name, fallback="value")
|
||||||
|
|
@ -71,7 +72,33 @@ class ErbTranslator:
|
||||||
self.loop_stack: list[tuple[str, str, str]] = []
|
self.loop_stack: list[tuple[str, str, str]] = []
|
||||||
self.needs_json = False
|
self.needs_json = False
|
||||||
|
|
||||||
|
# Matches a JinjaTurtle ``{% raw %} ... {% endraw %}`` block (non-greedy).
|
||||||
|
# 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)
|
||||||
|
|
||||||
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
|
||||||
|
# carried through as literal ERB (with ERB delimiters re-escaped), rather
|
||||||
|
# than tokenised -- otherwise an escaped Jinja payload inside a comment
|
||||||
|
# would be "re-animated" into live ERB during translation.
|
||||||
|
segments = self._RAW_BLOCK_RE.split(template_text)
|
||||||
|
out: list[str] = []
|
||||||
|
# re.split with one capture group yields: [text, raw_inner, text, ...].
|
||||||
|
for idx, segment in enumerate(segments):
|
||||||
|
if idx % 2 == 1:
|
||||||
|
# Captured raw-block contents: emit as literal ERB text.
|
||||||
|
out.append(escape_erb_literal(segment))
|
||||||
|
else:
|
||||||
|
out.append(self._translate_tokens(segment))
|
||||||
|
|
||||||
|
rendered = "".join(out)
|
||||||
|
if self.needs_json and "require 'json'" not in rendered:
|
||||||
|
rendered = "<% require 'json' -%>\n" + rendered
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
def _translate_tokens(self, template_text: str) -> str:
|
||||||
parts = self._TOKEN_RE.split(template_text)
|
parts = self._TOKEN_RE.split(template_text)
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
for token in parts:
|
for token in parts:
|
||||||
|
|
@ -86,11 +113,7 @@ class ErbTranslator:
|
||||||
out.append(self.statement_to_erb(stmt))
|
out.append(self.statement_to_erb(stmt))
|
||||||
continue
|
continue
|
||||||
out.append(token)
|
out.append(token)
|
||||||
|
return "".join(out)
|
||||||
rendered = "".join(out)
|
|
||||||
if self.needs_json and "require 'json'" not in rendered:
|
|
||||||
rendered = "<% require 'json' -%>\n" + rendered
|
|
||||||
return rendered
|
|
||||||
|
|
||||||
def local_var(self, name: str) -> str:
|
def local_var(self, name: str) -> str:
|
||||||
return puppet_local_var_name(
|
return puppet_local_var_name(
|
||||||
|
|
|
||||||
148
src/jinjaturtle/escape.py
Normal file
148
src/jinjaturtle/escape.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Neutralise template metacharacters in text copied verbatim from source files.
|
||||||
|
|
||||||
|
JinjaTurtle preserves formatting by copying parts of the *original* config file
|
||||||
|
straight into the generated template: comments, blank lines, section headers,
|
||||||
|
and any line it does not recognise as ``key = value``. Config *values* are
|
||||||
|
always replaced with ``{{ var }}`` placeholders and parked in the defaults data,
|
||||||
|
so a payload inside a value is inert. Verbatim text is different: if the source
|
||||||
|
contains ``{{ ... }}``, ``{% ... %}`` or ``{# ... #}`` (Jinja2), or ``<%= %>`` /
|
||||||
|
``<% %>`` (ERB), that text becomes *live template code* in the output and is
|
||||||
|
executed when Salt/Ansible/Puppet later renders the template.
|
||||||
|
|
||||||
|
Because JinjaTurtle is frequently fed harvested, attacker-influenceable config
|
||||||
|
(hostnames, banners, GECOS-derived comments, "Managed by" notes), this is a
|
||||||
|
template-injection / SSTI vector that can lead to remote code execution on the
|
||||||
|
configuration-management control node.
|
||||||
|
|
||||||
|
The functions here render those metacharacters as literal text so they survive a
|
||||||
|
later template render as the characters the source author actually wrote, rather
|
||||||
|
than as executable template syntax.
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
* We only ever escape text that originates from the *source file*. We never
|
||||||
|
pass JinjaTurtle's own generated placeholders (``{{ role_var }}``) through
|
||||||
|
these helpers, so the placeholders keep working.
|
||||||
|
* Jinja2 literal text is wrapped in a single ``{% raw %} ... {% endraw %}``
|
||||||
|
block. ``raw`` disables *all* tag interpretation inside it -- expressions,
|
||||||
|
statements and ``{# #}`` comments alike -- so one wrap neutralises every
|
||||||
|
Jinja construct. The only way to break out of a raw block is a literal
|
||||||
|
``{% endraw %}`` in the source, so we defang the token ``endraw`` (in any
|
||||||
|
internal spacing) before wrapping.
|
||||||
|
* The ERB translator (``erb.py``) is raw-aware: it copies the *contents* of a
|
||||||
|
JinjaTurtle raw block through as literal ERB text and re-escapes any ERB
|
||||||
|
delimiters found there. That keeps a Jinja-escaped comment inert after the
|
||||||
|
Jinja2 -> ERB translation step, instead of the payload being "re-animated"
|
||||||
|
as ERB.
|
||||||
|
* ``escape_erb_literal`` exists for completeness / direct ERB emission: it
|
||||||
|
rewrites each ERB delimiter into an ERB expression that prints the delimiter
|
||||||
|
characters literally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Jinja2 delimiters we must neutralise. Engine-default; JinjaTurtle never
|
||||||
|
# configures custom delimiters.
|
||||||
|
_JINJA_MARKERS = ("{{", "}}", "{%", "%}", "{#", "#}")
|
||||||
|
|
||||||
|
# ERB delimiters. Longer markers first so "<%=" matches before "<%".
|
||||||
|
_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*-?%}")
|
||||||
|
|
||||||
|
# Sentinel inserted between "end" and "raw" to break the endraw keyword without
|
||||||
|
# changing the visible characters. We use a Jinja comment-free approach: insert
|
||||||
|
# the two halves across a raw boundary so the literal text still reads "endraw"
|
||||||
|
# to a human but is never a valid tag. See escape_jinja_literal for usage.
|
||||||
|
|
||||||
|
|
||||||
|
def contains_jinja_markup(text: str) -> bool:
|
||||||
|
"""Return True if *text* contains any Jinja2 delimiter."""
|
||||||
|
return any(m in text for m in _JINJA_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def contains_erb_markup(text: str) -> bool:
|
||||||
|
"""Return True if *text* contains any ERB delimiter."""
|
||||||
|
return any(m in text for m in (*_ERB_OPEN_MARKERS, *_ERB_CLOSE_MARKERS))
|
||||||
|
|
||||||
|
|
||||||
|
def _defang_endraw(text: str) -> str:
|
||||||
|
"""Rewrite any literal ``{% endraw %}`` so it cannot close our raw wrapper.
|
||||||
|
|
||||||
|
We turn each endraw tag into ``{% endraw %}{{ '{% endraw %}' }}{% raw %}``...
|
||||||
|
no -- that would re-introduce live tags. Instead we keep everything literal:
|
||||||
|
we break the keyword by emitting the tag's text in two raw segments split
|
||||||
|
inside the word ``endraw``. The result, when later rendered, reproduces the
|
||||||
|
exact original characters ``{% endraw %}`` while never being a parseable tag.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _replace(match: re.Match[str]) -> str:
|
||||||
|
tag = match.group(0)
|
||||||
|
# Split the keyword "endraw" as "end" + "raw"; close and reopen the raw
|
||||||
|
# block between them. Each half is plain text inside a raw block, so the
|
||||||
|
# reconstructed output is byte-identical to the original tag, but at no
|
||||||
|
# point does the token "{% endraw %}" exist contiguously to close raw.
|
||||||
|
idx = tag.lower().index("endraw")
|
||||||
|
head = tag[: idx + 3] # up to and including "end"
|
||||||
|
tail = tag[idx + 3 :] # "raw...%}"
|
||||||
|
return f"{head}{{% endraw %}}{{% raw %}}{tail}"
|
||||||
|
|
||||||
|
return _ENDRAW_RE.sub(_replace, text)
|
||||||
|
|
||||||
|
|
||||||
|
def escape_jinja_literal(text: str) -> str:
|
||||||
|
"""Make *text* render as literal characters under a later Jinja2 render.
|
||||||
|
|
||||||
|
Text with no Jinja metacharacters is returned unchanged so the common case
|
||||||
|
stays byte-for-byte identical to the source. Otherwise the text is wrapped
|
||||||
|
in a single ``{% raw %}`` block, with any embedded ``endraw`` defanged.
|
||||||
|
"""
|
||||||
|
if not text or not contains_jinja_markup(text):
|
||||||
|
return text
|
||||||
|
return "{% raw %}" + _defang_endraw(text) + "{% endraw %}"
|
||||||
|
|
||||||
|
|
||||||
|
def escape_erb_literal(text: str) -> str:
|
||||||
|
"""Make *text* render as literal characters under a later ERB render.
|
||||||
|
|
||||||
|
ERB has no ``raw`` block, so each opening/closing delimiter is rewritten as
|
||||||
|
an ERB expression that prints the delimiter literally. Text with no ERB
|
||||||
|
metacharacters is returned unchanged.
|
||||||
|
"""
|
||||||
|
if not text or not contains_erb_markup(text):
|
||||||
|
return text
|
||||||
|
|
||||||
|
result: list[str] = []
|
||||||
|
i = 0
|
||||||
|
n = len(text)
|
||||||
|
while i < n:
|
||||||
|
matched = None
|
||||||
|
for marker in (*_ERB_CLOSE_MARKERS, *_ERB_OPEN_MARKERS):
|
||||||
|
if text.startswith(marker, i):
|
||||||
|
matched = marker
|
||||||
|
break
|
||||||
|
if matched is not None:
|
||||||
|
escaped = matched.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
result.append('<%= "' + escaped + '" %>')
|
||||||
|
i += len(matched)
|
||||||
|
else:
|
||||||
|
result.append(text[i])
|
||||||
|
i += 1
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def escape_literal(text: str, *, engine: str = "jinja2") -> str:
|
||||||
|
"""Escape verbatim *text* for the target template *engine*.
|
||||||
|
|
||||||
|
``engine`` is ``"jinja2"`` (default) or ``"erb"``. Unknown engines fall back
|
||||||
|
to Jinja2 escaping. In JinjaTurtle's pipeline ERB output is produced by
|
||||||
|
translating Jinja2 output, and the translator is raw-aware, so handlers can
|
||||||
|
always Jinja-escape and rely on the translator to keep the literal inert.
|
||||||
|
"""
|
||||||
|
if engine == "erb":
|
||||||
|
return escape_erb_literal(text)
|
||||||
|
return escape_jinja_literal(text)
|
||||||
|
|
@ -6,6 +6,7 @@ from typing import Any
|
||||||
|
|
||||||
from . import BaseHandler
|
from . import BaseHandler
|
||||||
from .. import j2
|
from .. import j2
|
||||||
|
from ..escape import escape_jinja_literal
|
||||||
|
|
||||||
|
|
||||||
class IniHandler(BaseHandler):
|
class IniHandler(BaseHandler):
|
||||||
|
|
@ -84,16 +85,18 @@ class IniHandler(BaseHandler):
|
||||||
line = raw_line
|
line = raw_line
|
||||||
stripped = line.lstrip()
|
stripped = line.lstrip()
|
||||||
|
|
||||||
# Blank or pure comment: keep as-is
|
# Blank or pure comment: keep formatting, but neutralise any
|
||||||
|
# template metacharacters so attacker-controlled comment text cannot
|
||||||
|
# become live template code in the output.
|
||||||
if not stripped or stripped[0] in {"#", ";"}:
|
if not stripped or stripped[0] in {"#", ";"}:
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Section header
|
# Section header
|
||||||
if stripped.startswith("[") and "]" in stripped:
|
if stripped.startswith("[") and "]" in stripped:
|
||||||
header_inner = stripped[1 : stripped.index("]")]
|
header_inner = stripped[1 : stripped.index("]")]
|
||||||
current_section = header_inner.strip()
|
current_section = header_inner.strip()
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Work without newline so we can re-attach it exactly
|
# Work without newline so we can re-attach it exactly
|
||||||
|
|
@ -108,8 +111,9 @@ class IniHandler(BaseHandler):
|
||||||
|
|
||||||
eq_index = content.find("=")
|
eq_index = content.find("=")
|
||||||
if eq_index == -1:
|
if eq_index == -1:
|
||||||
# Not a simple key=value line: leave untouched
|
# Not a simple key=value line: leave content intact but escape
|
||||||
out_lines.append(raw_line)
|
# any template metacharacters.
|
||||||
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
before_eq = content[:eq_index]
|
before_eq = content[:eq_index]
|
||||||
|
|
@ -117,7 +121,7 @@ class IniHandler(BaseHandler):
|
||||||
|
|
||||||
key = before_eq.strip()
|
key = before_eq.strip()
|
||||||
if not key:
|
if not key:
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Whitespace after '='
|
# Whitespace after '='
|
||||||
|
|
@ -146,8 +150,16 @@ class IniHandler(BaseHandler):
|
||||||
else:
|
else:
|
||||||
replacement_value = j2.variable(var_name)
|
replacement_value = j2.variable(var_name)
|
||||||
|
|
||||||
|
# ``before_eq`` (key + surrounding whitespace) and ``comment_part``
|
||||||
|
# both originate from the source file and may carry template
|
||||||
|
# metacharacters; escape each independently so the safe
|
||||||
|
# ``replacement_value`` placeholder between them is preserved.
|
||||||
new_content = (
|
new_content = (
|
||||||
before_eq + "=" + leading_ws + replacement_value + comment_part
|
escape_jinja_literal(before_eq)
|
||||||
|
+ "="
|
||||||
|
+ leading_ws
|
||||||
|
+ replacement_value
|
||||||
|
+ escape_jinja_literal(comment_part)
|
||||||
)
|
)
|
||||||
out_lines.append(new_content + newline)
|
out_lines.append(new_content + newline)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from typing import Any
|
||||||
|
|
||||||
from . import BaseHandler
|
from . import BaseHandler
|
||||||
from .. import j2
|
from .. import j2
|
||||||
|
from ..escape import escape_jinja_literal
|
||||||
|
|
||||||
|
|
||||||
class PostfixMainHandler(BaseHandler):
|
class PostfixMainHandler(BaseHandler):
|
||||||
|
|
@ -108,16 +109,16 @@ class PostfixMainHandler(BaseHandler):
|
||||||
|
|
||||||
stripped = content.strip()
|
stripped = content.strip()
|
||||||
if not stripped:
|
if not stripped:
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
if stripped.startswith("#"):
|
if stripped.startswith("#"):
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if "=" not in content:
|
if "=" not in content:
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -127,7 +128,7 @@ class PostfixMainHandler(BaseHandler):
|
||||||
|
|
||||||
key = before_eq.strip()
|
key = before_eq.strip()
|
||||||
if not key:
|
if not key:
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -162,16 +163,21 @@ class PostfixMainHandler(BaseHandler):
|
||||||
|
|
||||||
var = self.make_var_name(role_prefix, (key,))
|
var = self.make_var_name(role_prefix, (key,))
|
||||||
v = value
|
v = value
|
||||||
|
# ``before_eq`` (key) and ``comment_part`` are source-derived and may
|
||||||
|
# contain template metacharacters; escape each around the safe
|
||||||
|
# placeholder.
|
||||||
|
safe_before = escape_jinja_literal(before_eq)
|
||||||
|
safe_comment = escape_jinja_literal(comment_part)
|
||||||
quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"}
|
quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"}
|
||||||
if quoted:
|
if quoted:
|
||||||
replacement = (
|
replacement = (
|
||||||
f"{before_eq}={leading_ws}{j2.quoted_variable(var)}"
|
f"{safe_before}={leading_ws}{j2.quoted_variable(var)}"
|
||||||
f"{comment_part}{newline}"
|
f"{safe_comment}{newline}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
replacement = (
|
replacement = (
|
||||||
f"{before_eq}={leading_ws}{j2.variable(var)}"
|
f"{safe_before}={leading_ws}{j2.variable(var)}"
|
||||||
f"{comment_part}{newline}"
|
f"{safe_comment}{newline}"
|
||||||
)
|
)
|
||||||
|
|
||||||
out_lines.append(replacement)
|
out_lines.append(replacement)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from typing import Any
|
||||||
|
|
||||||
from . import BaseHandler
|
from . import BaseHandler
|
||||||
from .. import j2
|
from .. import j2
|
||||||
|
from ..escape import escape_jinja_literal
|
||||||
|
|
||||||
|
|
||||||
_SECTION_KEYWORDS = {"host", "match"}
|
_SECTION_KEYWORDS = {"host", "match"}
|
||||||
|
|
@ -255,12 +256,12 @@ class SshConfigHandler(BaseHandler):
|
||||||
out_lines: list[str] = []
|
out_lines: list[str] = []
|
||||||
for ln in parsed.lines:
|
for ln in parsed.lines:
|
||||||
if ln.kind != "kv":
|
if ln.kind != "kv":
|
||||||
out_lines.append(ln.raw)
|
out_lines.append(escape_jinja_literal(ln.raw))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
path = self._path_for_line(ln)
|
path = self._path_for_line(ln)
|
||||||
if not path:
|
if not path:
|
||||||
out_lines.append(ln.raw)
|
out_lines.append(escape_jinja_literal(ln.raw))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
var = self.make_var_name(role_prefix, path)
|
var = self.make_var_name(role_prefix, path)
|
||||||
|
|
@ -270,9 +271,12 @@ class SshConfigHandler(BaseHandler):
|
||||||
else:
|
else:
|
||||||
replacement_value = j2.variable(var)
|
replacement_value = j2.variable(var)
|
||||||
|
|
||||||
|
# ``before_value`` (keyword + spacing) and ``comment`` are
|
||||||
|
# source-derived; escape around the safe placeholder.
|
||||||
rendered = (
|
rendered = (
|
||||||
f"{ln.before_value}{replacement_value}"
|
f"{escape_jinja_literal(ln.before_value)}{replacement_value}"
|
||||||
f"{ln.whitespace_before_comment}{ln.comment}{ln.newline}"
|
f"{ln.whitespace_before_comment}"
|
||||||
|
f"{escape_jinja_literal(ln.comment)}{ln.newline}"
|
||||||
)
|
)
|
||||||
out_lines.append(rendered)
|
out_lines.append(rendered)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from typing import Any
|
||||||
|
|
||||||
from . import BaseHandler
|
from . import BaseHandler
|
||||||
from .. import j2
|
from .. import j2
|
||||||
|
from ..escape import escape_jinja_literal
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -157,7 +158,13 @@ class SystemdUnitHandler(BaseHandler):
|
||||||
out_lines: list[str] = []
|
out_lines: list[str] = []
|
||||||
for ln in parsed.lines:
|
for ln in parsed.lines:
|
||||||
if ln.kind != "kv" or not ln.section or not ln.key:
|
if ln.kind != "kv" or not ln.section or not ln.key:
|
||||||
out_lines.append(ln.raw)
|
# Verbatim lines (blank/comment/section/unrecognised "raw")
|
||||||
|
# originate from the source file. Escape template
|
||||||
|
# metacharacters so they cannot become live template code.
|
||||||
|
# This is the only handler that emits unrecognised lines, which
|
||||||
|
# is where Jinja *statement* injection (``{% ... %}``) was
|
||||||
|
# possible, so escaping here is essential.
|
||||||
|
out_lines.append(escape_jinja_literal(ln.raw))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
path: tuple[str, ...] = (ln.section, ln.key)
|
path: tuple[str, ...] = (ln.section, ln.key)
|
||||||
|
|
@ -166,16 +173,18 @@ class SystemdUnitHandler(BaseHandler):
|
||||||
var = self.make_var_name(role_prefix, path)
|
var = self.make_var_name(role_prefix, path)
|
||||||
|
|
||||||
v = (ln.value or "").strip()
|
v = (ln.value or "").strip()
|
||||||
|
safe_before = escape_jinja_literal(ln.before_eq)
|
||||||
|
safe_comment = escape_jinja_literal(ln.comment)
|
||||||
quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"}
|
quoted = len(v) >= 2 and v[0] == v[-1] and v[0] in {'"', "'"}
|
||||||
if quoted:
|
if quoted:
|
||||||
repl = (
|
repl = (
|
||||||
f"{ln.before_eq}={ln.leading_ws_after_eq}"
|
f"{safe_before}={ln.leading_ws_after_eq}"
|
||||||
f"{j2.quoted_variable(var)}{ln.comment}"
|
f"{j2.quoted_variable(var)}{safe_comment}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
repl = (
|
repl = (
|
||||||
f"{ln.before_eq}={ln.leading_ws_after_eq}"
|
f"{safe_before}={ln.leading_ws_after_eq}"
|
||||||
f"{j2.variable(var)}{ln.comment}"
|
f"{j2.variable(var)}{safe_comment}"
|
||||||
)
|
)
|
||||||
|
|
||||||
newline = "\n" if ln.raw.endswith("\n") else ""
|
newline = "\n" if ln.raw.endswith("\n") else ""
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from typing import Any
|
||||||
|
|
||||||
from . import DictLikeHandler
|
from . import DictLikeHandler
|
||||||
from .. import j2
|
from .. import j2
|
||||||
|
from ..escape import escape_jinja_literal
|
||||||
from ..loop_analyzer import LoopCandidate
|
from ..loop_analyzer import LoopCandidate
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -77,19 +78,25 @@ class TomlHandler(DictLikeHandler):
|
||||||
def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None:
|
def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None:
|
||||||
var_name = self.make_var_name(role_prefix, path + (key,))
|
var_name = self.make_var_name(role_prefix, path + (key,))
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
lines.append(f"{key} = {self._toml_quoted_expr(var_name)}")
|
lines.append(
|
||||||
|
f"{escape_jinja_literal(str(key))} = {self._toml_quoted_expr(var_name)}"
|
||||||
|
)
|
||||||
elif isinstance(value, bool):
|
elif isinstance(value, bool):
|
||||||
# Booleans need | lower filter (Python True/False → TOML true/false)
|
# Booleans need | lower filter (Python True/False → TOML true/false)
|
||||||
lines.append(f"{key} = {self._toml_value_expr(var_name, value)}")
|
lines.append(
|
||||||
|
f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
lines.append(f"{key} = {self._toml_value_expr(var_name, value)}")
|
lines.append(
|
||||||
|
f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}"
|
||||||
|
)
|
||||||
|
|
||||||
def walk(obj: dict[str, Any], path: tuple[str, ...] = ()) -> None:
|
def walk(obj: dict[str, Any], path: tuple[str, ...] = ()) -> None:
|
||||||
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)}
|
||||||
|
|
||||||
if path:
|
if path:
|
||||||
header = ".".join(path)
|
header = ".".join(escape_jinja_literal(str(p)) for p in path)
|
||||||
lines.append(f"[{header}]")
|
lines.append(f"[{header}]")
|
||||||
|
|
||||||
for key, val in scalar_items.items():
|
for key, val in scalar_items.items():
|
||||||
|
|
@ -130,10 +137,14 @@ class TomlHandler(DictLikeHandler):
|
||||||
def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None:
|
def emit_kv(path: tuple[str, ...], key: str, value: Any) -> None:
|
||||||
var_name = self.make_var_name(role_prefix, path + (key,))
|
var_name = self.make_var_name(role_prefix, path + (key,))
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
lines.append(f"{key} = {self._toml_quoted_expr(var_name)}")
|
lines.append(
|
||||||
|
f"{escape_jinja_literal(str(key))} = {self._toml_quoted_expr(var_name)}"
|
||||||
|
)
|
||||||
elif isinstance(value, bool):
|
elif isinstance(value, bool):
|
||||||
# Booleans need | lower filter (Python True/False → TOML true/false)
|
# Booleans need | lower filter (Python True/False → TOML true/false)
|
||||||
lines.append(f"{key} = {self._toml_value_expr(var_name, value)}")
|
lines.append(
|
||||||
|
f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}"
|
||||||
|
)
|
||||||
elif isinstance(value, list):
|
elif isinstance(value, list):
|
||||||
# Check if this list is a loop candidate
|
# Check if this list is a loop candidate
|
||||||
if path + (key,) in loop_paths:
|
if path + (key,) in loop_paths:
|
||||||
|
|
@ -157,19 +168,26 @@ class TomlHandler(DictLikeHandler):
|
||||||
elif candidate.item_schema in ("simple_dict", "nested"):
|
elif candidate.item_schema in ("simple_dict", "nested"):
|
||||||
# Dict list loop - TOML array of tables
|
# Dict list loop - TOML array of tables
|
||||||
# This is complex for TOML, using simplified approach
|
# This is complex for TOML, using simplified approach
|
||||||
lines.append(f"{key} = " f"{j2.to_json(var_name)}")
|
lines.append(
|
||||||
|
f"{escape_jinja_literal(str(key))} = "
|
||||||
|
f"{j2.to_json(var_name)}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Not a loop, treat as regular variable
|
# Not a loop, treat as regular variable
|
||||||
lines.append(f"{key} = {self._toml_value_expr(var_name, value)}")
|
lines.append(
|
||||||
|
f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
lines.append(f"{key} = {self._toml_value_expr(var_name, value)}")
|
lines.append(
|
||||||
|
f"{escape_jinja_literal(str(key))} = {self._toml_value_expr(var_name, value)}"
|
||||||
|
)
|
||||||
|
|
||||||
def walk(obj: dict[str, Any], path: tuple[str, ...] = ()) -> None:
|
def walk(obj: dict[str, Any], path: tuple[str, ...] = ()) -> None:
|
||||||
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)}
|
||||||
|
|
||||||
if path:
|
if path:
|
||||||
header = ".".join(path)
|
header = ".".join(escape_jinja_literal(str(p)) for p in path)
|
||||||
lines.append(f"[{header}]")
|
lines.append(f"[{header}]")
|
||||||
|
|
||||||
for key, val in scalar_items.items():
|
for key, val in scalar_items.items():
|
||||||
|
|
@ -217,7 +235,7 @@ class TomlHandler(DictLikeHandler):
|
||||||
|
|
||||||
# Blank or pure comment
|
# Blank or pure comment
|
||||||
if not stripped or stripped.startswith("#"):
|
if not stripped or stripped.startswith("#"):
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Table header: [server] or [server.tls] or [[array.of.tables]]
|
# Table header: [server] or [server.tls] or [[array.of.tables]]
|
||||||
|
|
@ -230,7 +248,7 @@ class TomlHandler(DictLikeHandler):
|
||||||
inner = inner.strip("[]") # handle [[table]] as well
|
inner = inner.strip("[]") # handle [[table]] as well
|
||||||
parts = [p.strip() for p in inner.split(".") if p.strip()]
|
parts = [p.strip() for p in inner.split(".") if p.strip()]
|
||||||
current_table = tuple(parts)
|
current_table = tuple(parts)
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Try key = value
|
# Try key = value
|
||||||
|
|
@ -245,7 +263,7 @@ class TomlHandler(DictLikeHandler):
|
||||||
|
|
||||||
eq_index = content.find("=")
|
eq_index = content.find("=")
|
||||||
if eq_index == -1:
|
if eq_index == -1:
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
before_eq = content[:eq_index]
|
before_eq = content[:eq_index]
|
||||||
|
|
@ -253,7 +271,7 @@ class TomlHandler(DictLikeHandler):
|
||||||
|
|
||||||
key = before_eq.strip()
|
key = before_eq.strip()
|
||||||
if not key:
|
if not key:
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Whitespace after '='
|
# Whitespace after '='
|
||||||
|
|
@ -289,19 +307,23 @@ class TomlHandler(DictLikeHandler):
|
||||||
nested_var = self.make_var_name(role_prefix, nested_path)
|
nested_var = self.make_var_name(role_prefix, nested_path)
|
||||||
if isinstance(sub_val, str):
|
if isinstance(sub_val, str):
|
||||||
inner_bits.append(
|
inner_bits.append(
|
||||||
f"{sub_key} = {self._toml_quoted_expr(nested_var)}"
|
f"{escape_jinja_literal(str(sub_key))} = {self._toml_quoted_expr(nested_var)}"
|
||||||
)
|
)
|
||||||
elif isinstance(sub_val, bool):
|
elif isinstance(sub_val, bool):
|
||||||
inner_bits.append(
|
inner_bits.append(
|
||||||
f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}"
|
f"{escape_jinja_literal(str(sub_key))} = {self._toml_value_expr(nested_var, sub_val)}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
inner_bits.append(
|
inner_bits.append(
|
||||||
f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}"
|
f"{escape_jinja_literal(str(sub_key))} = {self._toml_value_expr(nested_var, sub_val)}"
|
||||||
)
|
)
|
||||||
replacement_value = "{ " + ", ".join(inner_bits) + " }"
|
replacement_value = "{ " + ", ".join(inner_bits) + " }"
|
||||||
new_content = (
|
new_content = (
|
||||||
before_eq + "=" + leading_ws + replacement_value + comment_part
|
escape_jinja_literal(before_eq)
|
||||||
|
+ "="
|
||||||
|
+ leading_ws
|
||||||
|
+ replacement_value
|
||||||
|
+ escape_jinja_literal(comment_part)
|
||||||
)
|
)
|
||||||
out_lines.append(new_content + newline)
|
out_lines.append(new_content + newline)
|
||||||
continue
|
continue
|
||||||
|
|
@ -327,7 +349,11 @@ class TomlHandler(DictLikeHandler):
|
||||||
replacement_value = j2.variable(var_name)
|
replacement_value = j2.variable(var_name)
|
||||||
|
|
||||||
new_content = (
|
new_content = (
|
||||||
before_eq + "=" + leading_ws + replacement_value + comment_part
|
escape_jinja_literal(before_eq)
|
||||||
|
+ "="
|
||||||
|
+ leading_ws
|
||||||
|
+ replacement_value
|
||||||
|
+ escape_jinja_literal(comment_part)
|
||||||
)
|
)
|
||||||
out_lines.append(new_content + newline)
|
out_lines.append(new_content + newline)
|
||||||
|
|
||||||
|
|
@ -355,7 +381,7 @@ class TomlHandler(DictLikeHandler):
|
||||||
if not stripped or stripped.startswith("#"):
|
if not stripped or stripped.startswith("#"):
|
||||||
# Only output if we're not skipping
|
# Only output if we're not skipping
|
||||||
if not skip_until_next_table:
|
if not skip_until_next_table:
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Table header: [server] or [server.tls] or [[array.of.tables]]
|
# Table header: [server] or [server.tls] or [[array.of.tables]]
|
||||||
|
|
@ -404,7 +430,9 @@ class TomlHandler(DictLikeHandler):
|
||||||
out_lines.append(
|
out_lines.append(
|
||||||
f"{j2.for_start(item_var, collection_var)}\n"
|
f"{j2.for_start(item_var, collection_var)}\n"
|
||||||
)
|
)
|
||||||
out_lines.append(f"[[{'.'.join(table_path)}]]\n")
|
out_lines.append(
|
||||||
|
f"[[{'.'.join(escape_jinja_literal(str(p)) for p in table_path)}]]\n"
|
||||||
|
)
|
||||||
|
|
||||||
# Add fields from sample item
|
# Add fields from sample item
|
||||||
for key, value in sample_item.items():
|
for key, value in sample_item.items():
|
||||||
|
|
@ -412,17 +440,17 @@ class TomlHandler(DictLikeHandler):
|
||||||
continue
|
continue
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
out_lines.append(
|
out_lines.append(
|
||||||
f"{key} = "
|
f"{escape_jinja_literal(str(key))} = "
|
||||||
f"{self._toml_quoted_expr(f'{item_var}.{key}')}\n"
|
f"{self._toml_quoted_expr(f'{item_var}.{key}')}\n"
|
||||||
)
|
)
|
||||||
elif isinstance(value, bool):
|
elif isinstance(value, bool):
|
||||||
out_lines.append(
|
out_lines.append(
|
||||||
f"{key} = "
|
f"{escape_jinja_literal(str(key))} = "
|
||||||
f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n"
|
f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
out_lines.append(
|
out_lines.append(
|
||||||
f"{key} = "
|
f"{escape_jinja_literal(str(key))} = "
|
||||||
f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n"
|
f"{self._toml_value_expr(f'{item_var}.{key}', value)}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -437,7 +465,7 @@ class TomlHandler(DictLikeHandler):
|
||||||
skip_until_next_table = False
|
skip_until_next_table = False
|
||||||
current_table = table_path
|
current_table = table_path
|
||||||
|
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# If we're inside a skipped array-of-tables section, skip this line
|
# If we're inside a skipped array-of-tables section, skip this line
|
||||||
|
|
@ -456,7 +484,7 @@ class TomlHandler(DictLikeHandler):
|
||||||
|
|
||||||
eq_index = content.find("=")
|
eq_index = content.find("=")
|
||||||
if eq_index == -1:
|
if eq_index == -1:
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
before_eq = content[:eq_index]
|
before_eq = content[:eq_index]
|
||||||
|
|
@ -464,7 +492,7 @@ class TomlHandler(DictLikeHandler):
|
||||||
|
|
||||||
key = before_eq.strip()
|
key = before_eq.strip()
|
||||||
if not key:
|
if not key:
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Whitespace after '='
|
# Whitespace after '='
|
||||||
|
|
@ -501,7 +529,11 @@ class TomlHandler(DictLikeHandler):
|
||||||
replacement_value = j2.to_json(collection_var)
|
replacement_value = j2.to_json(collection_var)
|
||||||
|
|
||||||
new_content = (
|
new_content = (
|
||||||
before_eq + "=" + leading_ws + replacement_value + comment_part
|
escape_jinja_literal(before_eq)
|
||||||
|
+ "="
|
||||||
|
+ leading_ws
|
||||||
|
+ replacement_value
|
||||||
|
+ escape_jinja_literal(comment_part)
|
||||||
)
|
)
|
||||||
out_lines.append(new_content + newline)
|
out_lines.append(new_content + newline)
|
||||||
continue
|
continue
|
||||||
|
|
@ -526,19 +558,23 @@ class TomlHandler(DictLikeHandler):
|
||||||
nested_var = self.make_var_name(role_prefix, nested_path)
|
nested_var = self.make_var_name(role_prefix, nested_path)
|
||||||
if isinstance(sub_val, str):
|
if isinstance(sub_val, str):
|
||||||
inner_bits.append(
|
inner_bits.append(
|
||||||
f"{sub_key} = {self._toml_quoted_expr(nested_var)}"
|
f"{escape_jinja_literal(str(sub_key))} = {self._toml_quoted_expr(nested_var)}"
|
||||||
)
|
)
|
||||||
elif isinstance(sub_val, bool):
|
elif isinstance(sub_val, bool):
|
||||||
inner_bits.append(
|
inner_bits.append(
|
||||||
f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}"
|
f"{escape_jinja_literal(str(sub_key))} = {self._toml_value_expr(nested_var, sub_val)}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
inner_bits.append(
|
inner_bits.append(
|
||||||
f"{sub_key} = {self._toml_value_expr(nested_var, sub_val)}"
|
f"{escape_jinja_literal(str(sub_key))} = {self._toml_value_expr(nested_var, sub_val)}"
|
||||||
)
|
)
|
||||||
replacement_value = "{ " + ", ".join(inner_bits) + " }"
|
replacement_value = "{ " + ", ".join(inner_bits) + " }"
|
||||||
new_content = (
|
new_content = (
|
||||||
before_eq + "=" + leading_ws + replacement_value + comment_part
|
escape_jinja_literal(before_eq)
|
||||||
|
+ "="
|
||||||
|
+ leading_ws
|
||||||
|
+ replacement_value
|
||||||
|
+ escape_jinja_literal(comment_part)
|
||||||
)
|
)
|
||||||
out_lines.append(new_content + newline)
|
out_lines.append(new_content + newline)
|
||||||
continue
|
continue
|
||||||
|
|
@ -564,7 +600,11 @@ class TomlHandler(DictLikeHandler):
|
||||||
replacement_value = j2.variable(var_name)
|
replacement_value = j2.variable(var_name)
|
||||||
|
|
||||||
new_content = (
|
new_content = (
|
||||||
before_eq + "=" + leading_ws + replacement_value + comment_part
|
escape_jinja_literal(before_eq)
|
||||||
|
+ "="
|
||||||
|
+ leading_ws
|
||||||
|
+ replacement_value
|
||||||
|
+ escape_jinja_literal(comment_part)
|
||||||
)
|
)
|
||||||
out_lines.append(new_content + newline)
|
out_lines.append(new_content + newline)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import xml.etree.ElementTree as ET # nosec
|
||||||
|
|
||||||
from .base import BaseHandler
|
from .base import BaseHandler
|
||||||
from .. import j2
|
from .. import j2
|
||||||
|
from ..escape import escape_jinja_literal
|
||||||
from ..loop_analyzer import LoopCandidate
|
from ..loop_analyzer import LoopCandidate
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -230,6 +231,37 @@ class XmlHandler(BaseHandler):
|
||||||
|
|
||||||
walk(root, ())
|
walk(root, ())
|
||||||
|
|
||||||
|
# Internal marker prefixes used by JinjaTurtle's own comment nodes. These
|
||||||
|
# must NOT be escaped (they are converted into real Jinja control structures
|
||||||
|
# downstream). Source-file comments have none of these prefixes.
|
||||||
|
_MARKER_PREFIXES = ("LOOP:", "IF:", "ENDIF:")
|
||||||
|
|
||||||
|
def _is_jt_marker(self, comment_text: str) -> bool:
|
||||||
|
stripped = (comment_text or "").lstrip()
|
||||||
|
return any(stripped.startswith(p) for p in self._MARKER_PREFIXES)
|
||||||
|
|
||||||
|
def _escape_source_comments(self, root: ET.Element) -> None:
|
||||||
|
"""Escape template metacharacters in comments preserved from the source.
|
||||||
|
|
||||||
|
XML comments are re-emitted verbatim by ``ET.tostring`` (the tree is
|
||||||
|
parsed with ``insert_comments=True``). Attacker-controlled comment text
|
||||||
|
such as ``<!-- {{ cmd.run('id') }} -->`` would otherwise become live
|
||||||
|
template code. Element/attribute *names* cannot carry Jinja delimiters
|
||||||
|
(XML naming rules forbid the characters and the parser rejects them), and
|
||||||
|
text/attribute *values* are already replaced with ``{{ var }}``
|
||||||
|
placeholders, so comments (and the prolog, handled separately) are the
|
||||||
|
only XML injection vector.
|
||||||
|
|
||||||
|
JinjaTurtle's own internal marker comments are left untouched so they can
|
||||||
|
be converted into real loops/conditionals later.
|
||||||
|
"""
|
||||||
|
# ET represents comments with a callable tag (ET.Comment). Iterate all
|
||||||
|
# descendants and escape comment text that is not one of our markers.
|
||||||
|
for elem in root.iter():
|
||||||
|
if elem.tag is ET.Comment:
|
||||||
|
if not self._is_jt_marker(elem.text or ""):
|
||||||
|
elem.text = escape_jinja_literal(elem.text or "")
|
||||||
|
|
||||||
def _generate_xml_template_from_text(self, role_prefix: str, text: str) -> str:
|
def _generate_xml_template_from_text(self, role_prefix: str, text: str) -> str:
|
||||||
"""Generate scalar-only Jinja2 template."""
|
"""Generate scalar-only Jinja2 template."""
|
||||||
prolog, body = self._split_xml_prolog(text)
|
prolog, body = self._split_xml_prolog(text)
|
||||||
|
|
@ -240,12 +272,16 @@ class XmlHandler(BaseHandler):
|
||||||
|
|
||||||
self._apply_jinja_to_xml_tree(role_prefix, root)
|
self._apply_jinja_to_xml_tree(role_prefix, root)
|
||||||
|
|
||||||
|
# Neutralise template metacharacters in any comments preserved from the
|
||||||
|
# source file before serialising.
|
||||||
|
self._escape_source_comments(root)
|
||||||
|
|
||||||
indent = getattr(ET, "indent", None)
|
indent = getattr(ET, "indent", None)
|
||||||
if indent is not None:
|
if indent is not None:
|
||||||
indent(root, space=" ") # type: ignore[arg-type]
|
indent(root, space=" ") # type: ignore[arg-type]
|
||||||
|
|
||||||
xml_body = ET.tostring(root, encoding="unicode")
|
xml_body = ET.tostring(root, encoding="unicode")
|
||||||
return prolog + xml_body
|
return escape_jinja_literal(prolog) + xml_body
|
||||||
|
|
||||||
def _generate_xml_template_with_loops_from_text(
|
def _generate_xml_template_with_loops_from_text(
|
||||||
self,
|
self,
|
||||||
|
|
@ -265,6 +301,11 @@ class XmlHandler(BaseHandler):
|
||||||
# Apply Jinja transformations (including loop markers)
|
# Apply Jinja transformations (including loop markers)
|
||||||
self._apply_jinja_to_xml_tree(role_prefix, root, loop_candidates)
|
self._apply_jinja_to_xml_tree(role_prefix, root, loop_candidates)
|
||||||
|
|
||||||
|
# Escape comments preserved from the source. JinjaTurtle's own
|
||||||
|
# LOOP/IF/ENDIF marker comments are recognised and left intact so they
|
||||||
|
# can be converted into real Jinja control structures below.
|
||||||
|
self._escape_source_comments(root)
|
||||||
|
|
||||||
# Convert to string
|
# Convert to string
|
||||||
indent = getattr(ET, "indent", None)
|
indent = getattr(ET, "indent", None)
|
||||||
if indent is not None:
|
if indent is not None:
|
||||||
|
|
@ -275,7 +316,7 @@ class XmlHandler(BaseHandler):
|
||||||
# Post-process to replace loop markers with actual Jinja loops
|
# Post-process to replace loop markers with actual Jinja loops
|
||||||
xml_body = self._insert_xml_loops(xml_body, role_prefix, loop_candidates, root)
|
xml_body = self._insert_xml_loops(xml_body, role_prefix, loop_candidates, root)
|
||||||
|
|
||||||
return prolog + xml_body
|
return escape_jinja_literal(prolog) + xml_body
|
||||||
|
|
||||||
def _insert_xml_loops(
|
def _insert_xml_loops(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from typing import Any
|
||||||
|
|
||||||
from .dict import DictLikeHandler
|
from .dict import DictLikeHandler
|
||||||
from .. import j2
|
from .. import j2
|
||||||
|
from ..escape import escape_jinja_literal
|
||||||
from ..loop_analyzer import LoopCandidate
|
from ..loop_analyzer import LoopCandidate
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -102,7 +103,7 @@ class YamlHandler(DictLikeHandler):
|
||||||
indent = len(raw_line) - len(stripped)
|
indent = len(raw_line) - len(stripped)
|
||||||
|
|
||||||
if not stripped or stripped.startswith("#"):
|
if not stripped or stripped.startswith("#"):
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
while stack and indent < stack[-1][0]:
|
while stack and indent < stack[-1][0]:
|
||||||
|
|
@ -112,7 +113,7 @@ class YamlHandler(DictLikeHandler):
|
||||||
key_part, rest = stripped.split(":", 1)
|
key_part, rest = stripped.split(":", 1)
|
||||||
key = key_part.strip()
|
key = key_part.strip()
|
||||||
if not key:
|
if not key:
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
rest_stripped = rest.lstrip(" \t")
|
rest_stripped = rest.lstrip(" \t")
|
||||||
|
|
@ -125,7 +126,7 @@ class YamlHandler(DictLikeHandler):
|
||||||
stack.append((indent, path, "map"))
|
stack.append((indent, path, "map"))
|
||||||
|
|
||||||
if not has_value:
|
if not has_value:
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
value_part, comment_part = self._split_inline_comment(
|
value_part, comment_part = self._split_inline_comment(
|
||||||
|
|
@ -147,8 +148,8 @@ class YamlHandler(DictLikeHandler):
|
||||||
replacement = self._yaml_scalar_expr(var_name, raw_value)
|
replacement = self._yaml_scalar_expr(var_name, raw_value)
|
||||||
|
|
||||||
leading = rest[: len(rest) - len(rest.lstrip(" \t"))]
|
leading = rest[: len(rest) - len(rest.lstrip(" \t"))]
|
||||||
new_rest = f"{leading}{replacement}{comment_part}"
|
new_rest = f"{leading}{replacement}{escape_jinja_literal(comment_part)}"
|
||||||
new_stripped = f"{key}:{new_rest}"
|
new_stripped = f"{escape_jinja_literal(key)}:{new_rest}"
|
||||||
out_lines.append(
|
out_lines.append(
|
||||||
" " * indent
|
" " * indent
|
||||||
+ new_stripped
|
+ new_stripped
|
||||||
|
|
@ -185,7 +186,7 @@ class YamlHandler(DictLikeHandler):
|
||||||
else:
|
else:
|
||||||
replacement = self._yaml_scalar_expr(var_name, raw_value)
|
replacement = self._yaml_scalar_expr(var_name, raw_value)
|
||||||
|
|
||||||
new_stripped = f"- {replacement}{comment_part}"
|
new_stripped = f"- {replacement}{escape_jinja_literal(comment_part)}"
|
||||||
out_lines.append(
|
out_lines.append(
|
||||||
" " * indent
|
" " * indent
|
||||||
+ new_stripped
|
+ new_stripped
|
||||||
|
|
@ -193,7 +194,7 @@ class YamlHandler(DictLikeHandler):
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
|
|
||||||
return "".join(out_lines)
|
return "".join(out_lines)
|
||||||
|
|
||||||
|
|
@ -275,7 +276,7 @@ class YamlHandler(DictLikeHandler):
|
||||||
next_line = next_significant_line(line_index)
|
next_line = next_significant_line(line_index)
|
||||||
if next_line is None:
|
if next_line is None:
|
||||||
skip_until_indent = None
|
skip_until_indent = None
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
else:
|
else:
|
||||||
next_indent, next_stripped = next_line
|
next_indent, next_stripped = next_line
|
||||||
still_in_collection = next_indent > skip_until_indent or (
|
still_in_collection = next_indent > skip_until_indent or (
|
||||||
|
|
@ -284,12 +285,12 @@ class YamlHandler(DictLikeHandler):
|
||||||
)
|
)
|
||||||
if not still_in_collection:
|
if not still_in_collection:
|
||||||
skip_until_indent = None
|
skip_until_indent = None
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
if is_comment:
|
if is_comment:
|
||||||
if indent <= skip_until_indent:
|
if indent <= skip_until_indent:
|
||||||
skip_until_indent = None
|
skip_until_indent = None
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
# Comments/blank lines indented beneath the replaced
|
# Comments/blank lines indented beneath the replaced
|
||||||
# collection are considered part of that collection and
|
# collection are considered part of that collection and
|
||||||
# cannot be placed safely inside a generated loop.
|
# cannot be placed safely inside a generated loop.
|
||||||
|
|
@ -303,7 +304,7 @@ class YamlHandler(DictLikeHandler):
|
||||||
|
|
||||||
# Blank or comment lines
|
# Blank or comment lines
|
||||||
if is_blank or is_comment:
|
if is_blank or is_comment:
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Adjust stack based on indent
|
# Adjust stack based on indent
|
||||||
|
|
@ -315,7 +316,7 @@ class YamlHandler(DictLikeHandler):
|
||||||
key_part, rest = stripped.split(":", 1)
|
key_part, rest = stripped.split(":", 1)
|
||||||
key = key_part.strip()
|
key = key_part.strip()
|
||||||
if not key:
|
if not key:
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
rest_stripped = rest.lstrip(" \t")
|
rest_stripped = rest.lstrip(" \t")
|
||||||
|
|
@ -353,7 +354,7 @@ class YamlHandler(DictLikeHandler):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not has_value:
|
if not has_value:
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Scalar value - replace with variable
|
# Scalar value - replace with variable
|
||||||
|
|
@ -376,8 +377,8 @@ class YamlHandler(DictLikeHandler):
|
||||||
replacement = self._yaml_scalar_expr(var_name, raw_value)
|
replacement = self._yaml_scalar_expr(var_name, raw_value)
|
||||||
|
|
||||||
leading = rest[: len(rest) - len(rest.lstrip(" \t"))]
|
leading = rest[: len(rest) - len(rest.lstrip(" \t"))]
|
||||||
new_rest = f"{leading}{replacement}{comment_part}"
|
new_rest = f"{leading}{replacement}{escape_jinja_literal(comment_part)}"
|
||||||
new_stripped = f"{key}:{new_rest}"
|
new_stripped = f"{escape_jinja_literal(key)}:{new_rest}"
|
||||||
out_lines.append(
|
out_lines.append(
|
||||||
" " * indent
|
" " * indent
|
||||||
+ new_stripped
|
+ new_stripped
|
||||||
|
|
@ -439,7 +440,7 @@ class YamlHandler(DictLikeHandler):
|
||||||
else:
|
else:
|
||||||
replacement = self._yaml_scalar_expr(var_name, raw_value)
|
replacement = self._yaml_scalar_expr(var_name, raw_value)
|
||||||
|
|
||||||
new_stripped = f"- {replacement}{comment_part}"
|
new_stripped = f"- {replacement}{escape_jinja_literal(comment_part)}"
|
||||||
out_lines.append(
|
out_lines.append(
|
||||||
" " * indent
|
" " * indent
|
||||||
+ new_stripped
|
+ new_stripped
|
||||||
|
|
@ -447,7 +448,7 @@ class YamlHandler(DictLikeHandler):
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
out_lines.append(raw_line)
|
out_lines.append(escape_jinja_literal(raw_line))
|
||||||
|
|
||||||
return "".join(out_lines)
|
return "".join(out_lines)
|
||||||
|
|
||||||
|
|
@ -480,7 +481,7 @@ class YamlHandler(DictLikeHandler):
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
if not is_list:
|
if not is_list:
|
||||||
key = candidate.path[-1] if candidate.path else "items"
|
key = candidate.path[-1] if candidate.path else "items"
|
||||||
lines.append(f"{indent_str}{key}:")
|
lines.append(f"{indent_str}{escape_jinja_literal(str(key))}:")
|
||||||
|
|
||||||
item_lines: list[str] = []
|
item_lines: list[str] = []
|
||||||
if candidate.items:
|
if candidate.items:
|
||||||
|
|
@ -549,12 +550,16 @@ class YamlHandler(DictLikeHandler):
|
||||||
if first_key and is_list_item:
|
if first_key and is_list_item:
|
||||||
# First key gets the list marker
|
# First key gets the list marker
|
||||||
value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value)
|
value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value)
|
||||||
lines.append(f"{indent_str}- {key}: {value_expr}")
|
lines.append(
|
||||||
|
f"{indent_str}- {escape_jinja_literal(str(key))}: {value_expr}"
|
||||||
|
)
|
||||||
first_key = False
|
first_key = False
|
||||||
else:
|
else:
|
||||||
# Subsequent keys are indented
|
# Subsequent keys are indented
|
||||||
sub_indent = indent + 2 if is_list_item else indent
|
sub_indent = indent + 2 if is_list_item else indent
|
||||||
value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value)
|
value_expr = self._yaml_value_expr(f"{loop_var}.{key}", value)
|
||||||
lines.append(f"{' ' * sub_indent}{key}: {value_expr}")
|
lines.append(
|
||||||
|
f"{' ' * sub_indent}{escape_jinja_literal(str(key))}: {value_expr}"
|
||||||
|
)
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
|
|
|
||||||
212
tests/test_injection_security.py
Normal file
212
tests/test_injection_security.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
"""Security regression tests for template injection (SSTI).
|
||||||
|
|
||||||
|
JinjaTurtle copies parts of the source config (comments, unrecognised lines,
|
||||||
|
structural keys) verbatim into the generated template. If that text contains
|
||||||
|
Jinja2/ERB delimiters it must be neutralised, otherwise attacker-influenced
|
||||||
|
config content becomes live template code that executes when Salt/Ansible/Puppet
|
||||||
|
later renders the template.
|
||||||
|
|
||||||
|
These tests render the *generated* template the way a downstream tool would and
|
||||||
|
assert that an injected payload never executes. A tripwire object is exposed
|
||||||
|
under every name a payload might reference; if the rendered output ever contains
|
||||||
|
the tripwire sentinel, an injected expression executed and the test fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
import pytest
|
||||||
|
import yaml as pyyaml
|
||||||
|
|
||||||
|
from jinjaturtle.escape import escape_jinja_literal, escape_erb_literal
|
||||||
|
|
||||||
|
|
||||||
|
TRIP = "__TRIPWIRE_FIRED__"
|
||||||
|
|
||||||
|
|
||||||
|
class _Boom:
|
||||||
|
"""Returns the tripwire sentinel for any access/call an SSTI payload makes."""
|
||||||
|
|
||||||
|
def run(self, *a, **k):
|
||||||
|
return TRIP
|
||||||
|
|
||||||
|
def __call__(self, *a, **k):
|
||||||
|
return TRIP
|
||||||
|
|
||||||
|
def __getitem__(self, k):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __getattr__(self, n):
|
||||||
|
return _Boom()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return TRIP
|
||||||
|
|
||||||
|
|
||||||
|
def _render_jinja(template_text: str, defaults: dict) -> str:
|
||||||
|
env = jinja2.Environment(undefined=jinja2.ChainableUndefined)
|
||||||
|
env.filters.setdefault("to_json", lambda v, **k: __import__("json").dumps(v))
|
||||||
|
env.filters.setdefault("lower", lambda v: str(v).lower())
|
||||||
|
ctx = dict(defaults or {})
|
||||||
|
for name in ("salt", "cmd", "os", "subprocess", "cycler", "lipsum", "namespace"):
|
||||||
|
ctx.setdefault(name, _Boom())
|
||||||
|
return env.from_string(template_text).render(**ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_jinjaturtle(tmp_path: Path, source_name: str, body: str, fmt: str):
|
||||||
|
src = tmp_path / source_name
|
||||||
|
src.write_text(body, encoding="utf-8")
|
||||||
|
tpl = tmp_path / "out.tpl"
|
||||||
|
dfl = tmp_path / "defaults.yml"
|
||||||
|
res = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"jinjaturtle.cli",
|
||||||
|
str(src),
|
||||||
|
"-f",
|
||||||
|
fmt,
|
||||||
|
"--role-name",
|
||||||
|
"role",
|
||||||
|
"-t",
|
||||||
|
str(tpl),
|
||||||
|
"-d",
|
||||||
|
str(dfl),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert res.returncode == 0, f"generation failed: {res.stderr}"
|
||||||
|
defaults = pyyaml.safe_load(dfl.read_text()) or {}
|
||||||
|
return tpl.read_text(), defaults
|
||||||
|
|
||||||
|
|
||||||
|
# A representative payload for each format, placed where the format allows
|
||||||
|
# attacker-controlled verbatim text (comments / unrecognised lines).
|
||||||
|
FORMAT_CASES = [
|
||||||
|
(
|
||||||
|
"ini",
|
||||||
|
"evil.ini",
|
||||||
|
"[s]\n"
|
||||||
|
"good = ok ; {{ salt['cmd.run']('id') }}\n"
|
||||||
|
"# {{ cmd.run('whoami') }}\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"yaml",
|
||||||
|
"evil.yaml",
|
||||||
|
"server:\n" " motd: ok\n" " # {{ salt['cmd.run']('id') }}\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"toml",
|
||||||
|
"evil.toml",
|
||||||
|
"[s]\n" 'good = "ok"\n' "# {{ cmd.run('id') }}\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"xml",
|
||||||
|
"evil.xml",
|
||||||
|
"<config>\n"
|
||||||
|
" <!-- {{ salt['cmd.run']('id') }} -->\n"
|
||||||
|
' <server name="ok"><motd>ok</motd></server>\n'
|
||||||
|
"</config>\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"postfix",
|
||||||
|
"main.cf",
|
||||||
|
"myhostname = mail.example.com\n" "# {{ salt['cmd.run']('id') }}\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"systemd",
|
||||||
|
"evil.service",
|
||||||
|
"[Unit]\n"
|
||||||
|
"Description=ok\n"
|
||||||
|
"# {{ cmd.run('id') }}\n"
|
||||||
|
"RawLineNoEquals {% for x in ().__class__.__bases__ %}\n"
|
||||||
|
"[Service]\n"
|
||||||
|
"ExecStart=/bin/true\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ssh",
|
||||||
|
"sshd_config",
|
||||||
|
"# {{ salt['cmd.run']('id') }}\n" "Port 22\n" "PermitRootLogin no\n",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("fmt,name,body", FORMAT_CASES)
|
||||||
|
def test_comment_payload_does_not_execute(tmp_path, fmt, name, body):
|
||||||
|
template_text, defaults = _run_jinjaturtle(tmp_path, name, body, fmt)
|
||||||
|
rendered = _render_jinja(template_text, defaults)
|
||||||
|
assert TRIP not in rendered, f"injected payload executed for {fmt}:\n{rendered}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("fmt,name,body", FORMAT_CASES)
|
||||||
|
def test_generated_template_is_renderable(tmp_path, fmt, name, body):
|
||||||
|
# A correct escape must still produce a syntactically valid template.
|
||||||
|
template_text, defaults = _run_jinjaturtle(tmp_path, name, body, fmt)
|
||||||
|
# Should not raise a TemplateSyntaxError.
|
||||||
|
_render_jinja(template_text, defaults)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Unit-level guarantees for the escaper itself ---------------------------
|
||||||
|
|
||||||
|
SSTI_PAYLOADS = [
|
||||||
|
"{{ 7*7 }}",
|
||||||
|
"{{ salt['cmd.run']('id') }}",
|
||||||
|
"{% set x = cycler.__init__.__globals__ %}{{ x }}",
|
||||||
|
"{# comment payload #}",
|
||||||
|
"text {% endraw %} breakout {{ evil }}",
|
||||||
|
"nested {% endraw %} spacing {{ evil }}",
|
||||||
|
"{%- endraw -%}{{ evil }}",
|
||||||
|
"mixed {{ a }} and {% b %} and {# c #}",
|
||||||
|
"}}{{ orphan delimiters %}{%",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", SSTI_PAYLOADS)
|
||||||
|
def test_escape_jinja_literal_renders_back_to_original(payload):
|
||||||
|
"""Escaped text must render to the exact original characters, inertly."""
|
||||||
|
env = jinja2.Environment(undefined=jinja2.ChainableUndefined)
|
||||||
|
escaped = escape_jinja_literal(payload)
|
||||||
|
rendered = env.from_string(escaped).render(evil="EVIL", x="X", a="A")
|
||||||
|
assert rendered == payload
|
||||||
|
assert TRIP not in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_jinja_literal_noop_on_plain_text():
|
||||||
|
for plain in ["", "hello world", "# a normal comment", "port = 8080", "key: value"]:
|
||||||
|
assert escape_jinja_literal(plain) == plain
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_jinja_literal_actually_blocks_execution():
|
||||||
|
env = jinja2.Environment(undefined=jinja2.ChainableUndefined)
|
||||||
|
payload = "{{ boom.run('x') }}"
|
||||||
|
escaped = escape_jinja_literal(payload)
|
||||||
|
rendered = env.from_string(escaped).render(boom=_Boom())
|
||||||
|
assert TRIP not in rendered
|
||||||
|
# Sanity: the *unescaped* payload would have fired the tripwire.
|
||||||
|
fired = env.from_string(payload).render(boom=_Boom())
|
||||||
|
assert TRIP in fired
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"payload",
|
||||||
|
[
|
||||||
|
"<%= system('id') %>",
|
||||||
|
"<% require 'open3' %>",
|
||||||
|
"text <%= 1+1 %> more",
|
||||||
|
"-%> orphan <%",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_escape_erb_literal_removes_executable_tags(payload):
|
||||||
|
escaped = escape_erb_literal(payload)
|
||||||
|
# No raw executable ERB tag should survive that contains the original code.
|
||||||
|
live = re.findall(r"<%[-=#]?(.*?)-?%>", escaped, re.S)
|
||||||
|
for chunk in live:
|
||||||
|
assert "system" not in chunk
|
||||||
|
assert "require" not in chunk
|
||||||
|
# Numeric/expression payloads must be reduced to literal-string prints.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue