Files
changedetection.io/changedetectionio/tests/unit/test_jinja2_security.py
f20c82fb15 Fix Jinja2 false-positive when URL contains {% (#4208)
* Add tests for Jinja2 template detection in URL validation (RED)

* Fix Jinja2 false-positive when URL contains `{%` (GREEN)

* Consolidate Jinja2 template detection across URL/body/headers validation

* Propagate `FORCE_COLOR` to test containers

* Apply suggestion from @dgtlmoon

* Apply suggestion from @dgtlmoon

* Only render request headers/body at fetch time when they contain Jinja2, drop FORCE_COLOR changes

The edit form now skips Jinja2 validation for text like {%22 that is not a template,
but processors/base.py still rendered every header and the body unconditionally, so
a body such as q={%22key%22:1} was accepted on save and then failed every check with
a TemplateSyntaxError. Use JINJA2_MARKER_PATTERN there too, so it is sent as-is.

Also reset the CI workflow and run_*_tests.sh scripts to master, the FORCE_COLOR
changes are unrelated to this fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: dgtlmoon <dgtlmoon@gmail.com>
Co-authored-by: dgtlmoon <leigh@morresi.net>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:32:32 +02:00

84 lines
3.0 KiB
Python

#!/usr/bin/env python3
# run from dir above changedetectionio/ dir
# python3 -m unittest changedetectionio.tests.unit.test_jinja2_security
import unittest
import pytest
from changedetectionio import jinja2_custom as safe_jinja
# mostly
class TestJinja2SSTI(unittest.TestCase):
def test_exception(self):
import jinja2
# Where sandbox should kick in
attempt_list = [
"My name is {{ self.__init__.__globals__.__builtins__.__import__('os').system('id') }}",
"{{ self._TemplateReference__context.cycler.__init__.__globals__.os }}",
"{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}",
"{{cycler.__init__.__globals__.os.popen('id').read()}}",
"{{joiner.__init__.__globals__.os.popen('id').read()}}",
"{{namespace.__init__.__globals__.os.popen('id').read()}}",
"{{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/hello.txt', 'w').write('Hello here !') }}",
"My name is {{ self.__init__.__globals__ }}",
"{{ dict.__base__.__subclasses__() }}"
]
for attempt in attempt_list:
with self.assertRaises(jinja2.exceptions.SecurityError):
safe_jinja.render(attempt)
def test_exception_debug_calls(self):
import jinja2
# Where sandbox should kick in - configs and debug calls
attempt_list = [
"{% debug %}",
]
for attempt in attempt_list:
# Usually should be something like 'Encountered unknown tag 'debug'.'
with self.assertRaises(jinja2.exceptions.TemplateSyntaxError):
safe_jinja.render(attempt)
# https://book.hacktricks.xyz/pentesting-web/ssti-server-side-template-injection/jinja2-ssti#accessing-global-objects
def test_exception_empty_calls(self):
import jinja2
attempt_list = [
"{{config}}",
"{{ debug }}"
"{{[].__class__}}",
]
for attempt in attempt_list:
self.assertEqual(len(safe_jinja.render(attempt)), 0, f"string test '{attempt}' is correctly empty")
def test_jinja2_escaped_html(self):
x = safe_jinja.render_fully_escaped('woo <a href="https://google.com">dfdfd</a>')
self.assertEqual(x, "woo &lt;a href=&#34;https://google.com&#34;&gt;dfdfd&lt;/a&gt;")
@pytest.mark.parametrize("text", [
'https://example.com/{{watch_url}}',
'https://example.com/{% if foo %}bar{% endif %}',
"https://example.com/{% now 'Europe/Berlin', '%Y' %}",
'{%- if foo -%}',
'{%+ if foo +%}',
])
def test_jinja2_marker_pattern_true_positives(text):
from changedetectionio.jinja2_custom import JINJA2_MARKER_PATTERN
assert JINJA2_MARKER_PATTERN.search(text)
@pytest.mark.parametrize("text", [
'',
'https://example.com/api?q={%22key%22:1}',
])
def test_jinja2_marker_pattern_false_positives(text):
from changedetectionio.jinja2_custom import JINJA2_MARKER_PATTERN
assert not JINJA2_MARKER_PATTERN.search(text)
if __name__ == '__main__':
unittest.main()