Compare commits

...

4 Commits

Author SHA1 Message Date
dgtlmoon
0e9f01639a Fix docs 2025-10-24 18:38:17 +02:00
dgtlmoon
b236c8b24b WIP 2025-10-24 18:30:21 +02:00
dgtlmoon
27cedc9fa4 Template - Adding |regex_replace Re #3501 2025-10-24 18:24:35 +02:00
dgtlmoon
a51614f83d Be sure that default namespaces are registered (#3535) 2025-10-24 18:04:47 +02:00
8 changed files with 455 additions and 6 deletions

View File

@@ -185,8 +185,21 @@ def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False
tree = html.fromstring(bytes(html_content, encoding='utf-8'), parser=parser)
html_block = ""
r = elementpath.select(tree, xpath_filter.strip(), namespaces={'re': 'http://exslt.org/regular-expressions'}, parser=XPath3Parser)
#@note: //title/text() wont work where <title>CDATA..
# Build namespace map for XPath queries
namespaces = {'re': 'http://exslt.org/regular-expressions'}
# Handle default namespace in documents (common in RSS/Atom feeds, but can occur in any XML)
# XPath spec: unprefixed element names have no namespace, not the default namespace
# Solution: Register the default namespace with empty string prefix in elementpath
# This is primarily for RSS/Atom feeds but works for any XML with default namespace
if hasattr(tree, 'nsmap') and tree.nsmap and None in tree.nsmap:
# Register the default namespace with empty string prefix for elementpath
# This allows //title to match elements in the default namespace
namespaces[''] = tree.nsmap[None]
r = elementpath.select(tree, xpath_filter.strip(), namespaces=namespaces, parser=XPath3Parser)
#@note: //title/text() now works with default namespaces (fixed by registering '' prefix)
#@note: //title/text() wont work where <title>CDATA.. (use cdata_in_document_to_text first)
if type(r) != list:
r = [r]
@@ -221,8 +234,19 @@ def xpath1_filter(xpath_filter, html_content, append_pretty_line_formatting=Fals
tree = html.fromstring(bytes(html_content, encoding='utf-8'), parser=parser)
html_block = ""
r = tree.xpath(xpath_filter.strip(), namespaces={'re': 'http://exslt.org/regular-expressions'})
#@note: //title/text() wont work where <title>CDATA..
# Build namespace map for XPath queries
namespaces = {'re': 'http://exslt.org/regular-expressions'}
# NOTE: lxml's native xpath() does NOT support empty string prefix for default namespace
# For documents with default namespace (RSS/Atom feeds), users must use:
# - local-name(): //*[local-name()='title']/text()
# - Or use xpath_filter (not xpath1_filter) which supports default namespaces
# XPath spec: unprefixed element names have no namespace, not the default namespace
r = tree.xpath(xpath_filter.strip(), namespaces=namespaces)
#@note: xpath1 (lxml) does NOT automatically handle default namespaces
#@note: Use //*[local-name()='element'] or switch to xpath_filter for default namespace support
#@note: //title/text() wont work where <title>CDATA.. (use cdata_in_document_to_text first)
for element in r:
# When there's more than 1 match, then add the suffix to separate each line

View File

@@ -9,6 +9,7 @@ from .safe_jinja import (
JINJA2_MAX_RETURN_PAYLOAD_SIZE,
DEFAULT_JINJA2_EXTENSIONS,
)
from .plugins.regex import regex_replace
__all__ = [
'TimeExtension',
@@ -17,4 +18,5 @@ __all__ = [
'create_jinja_env',
'JINJA2_MAX_RETURN_PAYLOAD_SIZE',
'DEFAULT_JINJA2_EXTENSIONS',
'regex_replace',
]

View File

@@ -0,0 +1,6 @@
"""
Jinja2 custom filter plugins for changedetection.io
"""
from .regex import regex_replace
__all__ = ['regex_replace']

View File

@@ -0,0 +1,98 @@
"""
Regex filter plugin for Jinja2 templates.
Provides regex_replace filter for pattern-based string replacements in templates.
"""
import re
import signal
from loguru import logger
def regex_replace(value: str, pattern: str, replacement: str = '', count: int = 0) -> str:
"""
Replace occurrences of a regex pattern in a string.
Security: Protected against ReDoS (Regular Expression Denial of Service) attacks:
- Limits input value size to prevent excessive processing
- Uses timeout mechanism to prevent runaway regex operations
- Validates pattern complexity to prevent catastrophic backtracking
Args:
value: The input string to perform replacements on
pattern: The regex pattern to search for
replacement: The replacement string (default: '')
count: Maximum number of replacements (0 = replace all, default: 0)
Returns:
String with replacements applied, or original value on error
Example:
{{ "hello world" | regex_replace("world", "universe") }}
{{ diff | regex_replace("<td>([^<]+)</td><td>([^<]+)</td>", "Label1: \\1\\nLabel2: \\2") }}
Security limits:
- Maximum input size: 10MB
- Maximum pattern length: 500 characters
- Operation timeout: 10 seconds
- Dangerous nested quantifier patterns are rejected
"""
# Security limits
MAX_INPUT_SIZE = 1024 * 1024 * 10 # 10MB max input size
MAX_PATTERN_LENGTH = 500 # Maximum regex pattern length
REGEX_TIMEOUT_SECONDS = 10 # Maximum time for regex operation
# Validate input sizes
value_str = str(value)
if len(value_str) > MAX_INPUT_SIZE:
logger.warning(f"regex_replace: Input too large ({len(value_str)} bytes), truncating")
value_str = value_str[:MAX_INPUT_SIZE]
if len(pattern) > MAX_PATTERN_LENGTH:
logger.warning(f"regex_replace: Pattern too long ({len(pattern)} chars), rejecting")
return value_str
# Check for potentially dangerous patterns (basic checks)
# Nested quantifiers like (a+)+ can cause catastrophic backtracking
dangerous_patterns = [
r'\([^)]*\+[^)]*\)\+', # (x+)+
r'\([^)]*\*[^)]*\)\+', # (x*)+
r'\([^)]*\+[^)]*\)\*', # (x+)*
r'\([^)]*\*[^)]*\)\*', # (x*)*
]
for dangerous in dangerous_patterns:
if re.search(dangerous, pattern):
logger.warning(f"regex_replace: Potentially dangerous pattern detected: {pattern}")
return value_str
def timeout_handler(signum, frame):
raise TimeoutError("Regex operation timed out")
try:
# Set up timeout for regex operation (Unix-like systems only)
# This prevents ReDoS attacks
old_handler = None
if hasattr(signal, 'SIGALRM'):
old_handler = signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(REGEX_TIMEOUT_SECONDS)
try:
result = re.sub(pattern, replacement, value_str, count=count)
finally:
# Cancel the alarm
if hasattr(signal, 'SIGALRM'):
signal.alarm(0)
if old_handler is not None:
signal.signal(signal.SIGALRM, old_handler)
return result
except TimeoutError:
logger.error(f"regex_replace: Regex operation timed out - possible ReDoS attack. Pattern: {pattern}")
return value_str
except re.error as e:
logger.warning(f"regex_replace: Invalid regex pattern: {e}")
return value_str
except Exception as e:
logger.error(f"regex_replace: Unexpected error: {e}")
return value_str

View File

@@ -8,13 +8,13 @@ import jinja2.sandbox
import typing as t
import os
from .extensions.TimeExtension import TimeExtension
from .plugins import regex_replace
JINJA2_MAX_RETURN_PAYLOAD_SIZE = 1024 * int(os.getenv("JINJA2_MAX_RETURN_PAYLOAD_SIZE_KB", 1024 * 10))
# Default extensions - can be overridden in create_jinja_env()
DEFAULT_JINJA2_EXTENSIONS = [TimeExtension]
def create_jinja_env(extensions=None, **kwargs) -> jinja2.sandbox.ImmutableSandboxedEnvironment:
"""
Create a sandboxed Jinja2 environment with our custom extensions and default timezone.
@@ -38,6 +38,9 @@ def create_jinja_env(extensions=None, **kwargs) -> jinja2.sandbox.ImmutableSandb
default_timezone = os.getenv('TZ', 'UTC').strip()
jinja2_env.default_timezone = default_timezone
# Register custom filters
jinja2_env.filters['regex_replace'] = regex_replace
return jinja2_env

View File

@@ -134,6 +134,12 @@
<p>
URL encoding, use <strong>|urlencode</strong>, for example - <code>gets://hook-website.com/test.php?title={{ '{{ watch_title|urlencode }}' }}</code>
</p>
<p>
Regular-expression replace, use <strong>|regex_replace</strong>, for example - <code>{{ "{{ \"hello world 123\" | regex_replace('[0-9]+', 'no-more-numbers') }}" }}</code>
</p>
<p>
For a complete reference of all Jinja2 built-in filters, users can refer to the <a href="https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters">https://jinja.palletsprojects.com/en/3.1.x/templates/#builtin-filters</a>
</p>
</div>
</div>
<div class="pure-control-group">

View File

@@ -169,4 +169,161 @@ def test_default_timezone_subtraction(environment):
finalRender = render("{% now '' - 'minutes=11' %}")
assert finalRender == "Wed, 09 Dec 2015 23:22:01"
assert finalRender == "Wed, 09 Dec 2015 23:22:01"
def test_regex_replace_basic():
"""Test basic regex_replace functionality."""
# Simple word replacement
finalRender = render("{{ 'hello world' | regex_replace('world', 'universe') }}")
assert finalRender == "hello universe"
def test_regex_replace_with_groups():
"""Test regex_replace with capture groups (issue #3501 use case)."""
# Transform HTML table data as described in the issue
template = "{{ '<td>thing</td><td>other</td>' | regex_replace('<td>([^<]+)</td><td>([^<]+)</td>', 'ThingLabel: \\\\1\\nOtherLabel: \\\\2') }}"
finalRender = render(template)
assert "ThingLabel: thing" in finalRender
assert "OtherLabel: other" in finalRender
def test_regex_replace_multiple_matches():
"""Test regex_replace replacing multiple occurrences."""
finalRender = render("{{ 'foo bar foo baz' | regex_replace('foo', 'qux') }}")
assert finalRender == "qux bar qux baz"
def test_regex_replace_count_parameter():
"""Test regex_replace with count parameter to limit replacements."""
finalRender = render("{{ 'foo bar foo baz' | regex_replace('foo', 'qux', 1) }}")
assert finalRender == "qux bar foo baz"
def test_regex_replace_empty_replacement():
"""Test regex_replace with empty replacement (removal)."""
finalRender = render("{{ 'hello world 123' | regex_replace('[0-9]+', '') }}")
assert finalRender == "hello world "
def test_regex_replace_no_match():
"""Test regex_replace when pattern doesn't match."""
finalRender = render("{{ 'hello world' | regex_replace('xyz', 'abc') }}")
assert finalRender == "hello world"
def test_regex_replace_invalid_regex():
"""Test regex_replace with invalid regex pattern returns original value."""
# Invalid regex (unmatched parenthesis)
finalRender = render("{{ 'hello world' | regex_replace('(invalid', 'replacement') }}")
assert finalRender == "hello world"
def test_regex_replace_special_characters():
"""Test regex_replace with special regex characters."""
finalRender = render("{{ 'Price: $50.00' | regex_replace('\\\\$([0-9.]+)', 'USD \\\\1') }}")
assert finalRender == "Price: USD 50.00"
def test_regex_replace_multiline():
"""Test regex_replace on multiline text."""
template = "{{ 'line1\\nline2\\nline3' | regex_replace('^line', 'row') }}"
finalRender = render(template)
# By default re.sub doesn't use MULTILINE flag, so only first line matches with ^
assert finalRender == "row1\nline2\nline3"
def test_regex_replace_with_notification_context():
"""Test regex_replace with notification diff variable."""
# Simulate how it would be used in notifications with diff variable
from changedetectionio.notification_service import NotificationContextData
context = NotificationContextData()
context['diff'] = '<td>value1</td><td>value2</td>'
template = "{{ diff | regex_replace('<td>([^<]+)</td>', '\\\\1 ') }}"
from changedetectionio.jinja2_custom import create_jinja_env
from jinja2 import BaseLoader
jinja2_env = create_jinja_env(loader=BaseLoader)
jinja2_env.globals.update(context)
finalRender = jinja2_env.from_string(template).render()
assert "value1 value2 " in finalRender
def test_regex_replace_security_large_input():
"""Test regex_replace handles large input safely."""
# Create a large input string (over 10MB)
large_input = "x" * (1024 * 1024 * 10 + 1000)
template = "{{ large_input | regex_replace('x', 'y') }}"
from changedetectionio.jinja2_custom import create_jinja_env
from jinja2 import BaseLoader
jinja2_env = create_jinja_env(loader=BaseLoader)
jinja2_env.globals['large_input'] = large_input
finalRender = jinja2_env.from_string(template).render()
# Should be truncated to 10MB
assert len(finalRender) == 1024 * 1024 * 10
def test_regex_replace_security_long_pattern():
"""Test regex_replace rejects very long patterns."""
# Pattern longer than 500 chars should be rejected
long_pattern = "a" * 501
finalRender = render("{{ 'test' | regex_replace('" + long_pattern + "', 'replacement') }}")
# Should return original value when pattern is too long
assert finalRender == "test"
def test_regex_replace_security_dangerous_pattern():
"""Test regex_replace detects and rejects dangerous nested quantifiers."""
# Patterns that could cause catastrophic backtracking
dangerous_patterns = [
"(a+)+",
"(a*)+",
"(a+)*",
"(a*)*",
]
for dangerous in dangerous_patterns:
# Create a template with the dangerous pattern
# Using single quotes to avoid escaping issues
from changedetectionio.jinja2_custom import create_jinja_env
from jinja2 import BaseLoader
jinja2_env = create_jinja_env(loader=BaseLoader)
jinja2_env.globals['pattern'] = dangerous
template = "{{ 'aaaaaaaaaa' | regex_replace(pattern, 'x') }}"
finalRender = jinja2_env.from_string(template).render()
# Should return original value when dangerous pattern is detected
assert finalRender == "aaaaaaaaaa"
def test_regex_replace_security_timeout_protection():
"""Test that regex_replace has timeout protection (if SIGALRM available)."""
import signal
# Only test on systems that support SIGALRM
if not hasattr(signal, 'SIGALRM'):
# Skip test on Windows and other systems without SIGALRM
return
# This pattern is known to cause exponential backtracking on certain inputs
# but should be caught by our dangerous pattern detector
# We're mainly testing that the timeout mechanism works
from changedetectionio.jinja2_custom import regex_replace
# Create input that could trigger slow regex
test_input = "a" * 50 + "b"
# This shouldn't take long due to our protections
result = regex_replace(test_input, "a+b", "x")
# Should complete and return a result
assert result is not None

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""
Unit tests for XPath default namespace handling in RSS/Atom feeds.
Tests the fix for issue where //title/text() returns empty on feeds with default namespaces.
Real-world test data from https://github.com/microsoft/PowerToys/releases.atom
"""
import sys
import os
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import html_tools
# Real-world Atom feed with default namespace from GitHub PowerToys releases
# This is the actual format that was failing before the fix
atom_feed_with_default_ns = """<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xml:lang="en-US">
<id>tag:github.com,2008:https://github.com/microsoft/PowerToys/releases</id>
<link type="text/html" rel="alternate" href="https://github.com/microsoft/PowerToys/releases"/>
<link type="application/atom+xml" rel="self" href="https://github.com/microsoft/PowerToys/releases.atom"/>
<title>Release notes from PowerToys</title>
<updated>2025-10-23T08:53:12Z</updated>
<entry>
<id>tag:github.com,2008:Repository/184456251/v0.95.1</id>
<updated>2025-10-24T14:20:14Z</updated>
<link rel="alternate" type="text/html" href="https://github.com/microsoft/PowerToys/releases/tag/v0.95.1"/>
<title>Release 0.95.1</title>
<content type="html">&lt;p&gt;This patch release fixes several important stability issues.&lt;/p&gt;</content>
<author>
<name>Jaylyn-Barbee</name>
</author>
</entry>
<entry>
<id>tag:github.com,2008:Repository/184456251/v0.95.0</id>
<updated>2025-10-17T12:51:21Z</updated>
<link rel="alternate" type="text/html" href="https://github.com/microsoft/PowerToys/releases/tag/v0.95.0"/>
<title>Release v0.95.0</title>
<content type="html">&lt;p&gt;New features, stability, optimization improvements.&lt;/p&gt;</content>
<author>
<name>Jaylyn-Barbee</name>
</author>
</entry>
</feed>"""
# RSS feed without default namespace
rss_feed_no_default_ns = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Channel Title</title>
<description>Channel Description</description>
<item>
<title>Item 1 Title</title>
<description>Item 1 Description</description>
</item>
<item>
<title>Item 2 Title</title>
<description>Item 2 Description</description>
</item>
</channel>
</rss>"""
# RSS 2.0 feed with namespace prefix (not default)
rss_feed_with_ns_prefix = """<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:atom="http://www.w3.org/2005/Atom"
version="2.0">
<channel>
<title>Channel Title</title>
<atom:link href="http://example.com/feed" rel="self" type="application/rss+xml"/>
<item>
<title>Item Title</title>
<dc:creator>Author Name</dc:creator>
</item>
</channel>
</rss>"""
class TestXPathDefaultNamespace:
"""Test XPath queries on feeds with and without default namespaces."""
def test_atom_feed_simple_xpath_with_xpath_filter(self):
"""Test that //title/text() works on Atom feed with default namespace using xpath_filter."""
result = html_tools.xpath_filter('//title/text()', atom_feed_with_default_ns, is_rss=True)
assert 'Release notes from PowerToys' in result
assert 'Release 0.95.1' in result
assert 'Release v0.95.0' in result
def test_atom_feed_nested_xpath_with_xpath_filter(self):
"""Test nested XPath like //entry/title/text() on Atom feed."""
result = html_tools.xpath_filter('//entry/title/text()', atom_feed_with_default_ns, is_rss=True)
assert 'Release 0.95.1' in result
assert 'Release v0.95.0' in result
# Should NOT include the feed title
assert 'Release notes from PowerToys' not in result
def test_atom_feed_other_elements_with_xpath_filter(self):
"""Test that other elements like //updated/text() work on Atom feed."""
result = html_tools.xpath_filter('//updated/text()', atom_feed_with_default_ns, is_rss=True)
assert '2025-10-23T08:53:12Z' in result
assert '2025-10-24T14:20:14Z' in result
def test_rss_feed_without_namespace(self):
"""Test that //title/text() works on RSS feed without default namespace."""
result = html_tools.xpath_filter('//title/text()', rss_feed_no_default_ns, is_rss=True)
assert 'Channel Title' in result
assert 'Item 1 Title' in result
assert 'Item 2 Title' in result
def test_rss_feed_nested_xpath(self):
"""Test nested XPath on RSS feed without default namespace."""
result = html_tools.xpath_filter('//item/title/text()', rss_feed_no_default_ns, is_rss=True)
assert 'Item 1 Title' in result
assert 'Item 2 Title' in result
# Should NOT include channel title
assert 'Channel Title' not in result
def test_rss_feed_with_prefixed_namespaces(self):
"""Test that feeds with namespace prefixes (not default) still work."""
result = html_tools.xpath_filter('//title/text()', rss_feed_with_ns_prefix, is_rss=True)
assert 'Channel Title' in result
assert 'Item Title' in result
def test_local_name_workaround_still_works(self):
"""Test that local-name() workaround still works for Atom feeds."""
result = html_tools.xpath_filter('//*[local-name()="title"]/text()', atom_feed_with_default_ns, is_rss=True)
assert 'Release notes from PowerToys' in result
assert 'Release 0.95.1' in result
def test_xpath1_filter_without_default_namespace(self):
"""Test xpath1_filter works on RSS without default namespace."""
result = html_tools.xpath1_filter('//title/text()', rss_feed_no_default_ns, is_rss=True)
assert 'Channel Title' in result
assert 'Item 1 Title' in result
def test_xpath1_filter_with_default_namespace_returns_empty(self):
"""Test that xpath1_filter returns empty on Atom with default namespace (known limitation)."""
result = html_tools.xpath1_filter('//title/text()', atom_feed_with_default_ns, is_rss=True)
# xpath1_filter (lxml) doesn't support default namespaces, so this returns empty
assert result == ''
def test_xpath1_filter_local_name_workaround(self):
"""Test that xpath1_filter works with local-name() workaround on Atom feeds."""
result = html_tools.xpath1_filter('//*[local-name()="title"]/text()', atom_feed_with_default_ns, is_rss=True)
assert 'Release notes from PowerToys' in result
assert 'Release 0.95.1' in result
if __name__ == '__main__':
pytest.main([__file__, '-v'])