diff --git a/.github/workflows/test-only.yml b/.github/workflows/test-only.yml
index 4833ad9c..1c08b893 100644
--- a/.github/workflows/test-only.yml
+++ b/.github/workflows/test-only.yml
@@ -31,33 +31,15 @@ jobs:
echo "Checking $f"
msgfmt --check-format -o /dev/null "$f"
done
- - name: Lint .po/.pot files with dennis (errors only)
+ - name: Lint .pot template with dennis
run: |
pip install "$(grep -E '^dennis ?>=' requirements.txt)"
- dennis-cmd lint --errorsonly changedetectionio/translations/
- - name: Lint .pot template with dennis (warnings)
+ dennis-cmd lint --strict changedetectionio/translations/messages.pot
+ - name: Lint .po files with dennis
run: |
- output=$(dennis-cmd lint changedetectionio/translations/messages.pot)
- echo "$output"
- warnings=$(echo "$output" | awk '/Warnings:/ {print $NF; exit}')
- if (( ${warnings:-0} > 0 )); then
- echo "ERROR: ${warnings} dennis warning(s) detected in messages.pot"
- echo "Fix the warning(s)."
- exit 1
- fi
- - name: Lint .po files with dennis (warnings)
+ dennis-cmd lint --strict --excluderules=W302 changedetectionio/translations/*/LC_MESSAGES/messages.po
# W302 (unchanged) is excluded due to high false-positive rate in this codebase:
# many msgstrs intentionally match msgid (units like "AI", "LLM", and proper nouns).
- run: |
- output=$(dennis-cmd lint --excluderules=W302 \
- changedetectionio/translations/*/LC_MESSAGES/messages.po)
- echo "$output"
- warnings=$(echo "$output" | awk '/Total number of warnings:/ {print $NF; exit}')
- if (( ${warnings:-0} > 0 )); then
- echo "ERROR: ${warnings} dennis warning(s) detected in .po files"
- echo "Fix the warning(s)."
- exit 1
- fi
- name: Check translation catalog is up-to-date
run: |
pip install "$(grep -E '^babel==' requirements.txt)"
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index cb387e44..0dcb3c85 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -7,3 +7,19 @@ repos:
args: [--fix]
# Fomrat
- id: ruff-format
+
+ - repo: local
+ hooks:
+ - id: dennis-lint-pot
+ name: dennis lint pot
+ language: system
+ entry: dennis-cmd lint --strict
+ files: ^changedetectionio/translations/messages\.pot$
+ pass_filenames: true
+
+ - id: dennis-lint-po
+ name: dennis lint po
+ language: system
+ entry: dennis-cmd lint --strict --excluderules=W302
+ files: ^changedetectionio/translations/\w+/LC_MESSAGES/messages\.po$
+ pass_filenames: true
diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py
index 9a210943..9b010ba1 100644
--- a/changedetectionio/__init__.py
+++ b/changedetectionio/__init__.py
@@ -2,7 +2,7 @@
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
# Semver means never use .01, or 00. Should be .1.
-__version__ = '0.55.5'
+__version__ = '0.55.6'
from changedetectionio.strtobool import strtobool
from json.decoder import JSONDecodeError
diff --git a/changedetectionio/content_fetchers/requests.py b/changedetectionio/content_fetchers/requests.py
index a97d1674..08e8b327 100644
--- a/changedetectionio/content_fetchers/requests.py
+++ b/changedetectionio/content_fetchers/requests.py
@@ -9,7 +9,7 @@ import asyncio
from changedetectionio import strtobool
from changedetectionio.content_fetchers.exceptions import BrowserStepsInUnsupportedFetcher, EmptyReply, Non200ErrorCodeReceived
from changedetectionio.content_fetchers.base import Fetcher
-from changedetectionio.validate_url import is_private_hostname
+from changedetectionio.validate_url import is_private_hostname, is_url_private_or_parser_confused
# "html_requests" is listed as the default fetcher in store.py!
@@ -87,10 +87,12 @@ class fetcher(Fetcher):
try:
# Fresh DNS check at fetch time — catches DNS rebinding regardless of add-time cache.
+ # Validates every hostname both urlparse and urllib3 see, so parser-differential
+ # payloads (GHSA-rph4-96w6-q594) cannot smuggle an internal target past the gate.
if not allow_iana_restricted:
- parsed_initial = urlparse(url)
- if parsed_initial.hostname and is_private_hostname(parsed_initial.hostname):
- raise Exception(f"Fetch blocked: '{url}' resolves to a private/reserved IP address. "
+ if is_url_private_or_parser_confused(url):
+ raise Exception(f"Fetch blocked: '{url}' resolves to a private/reserved IP address "
+ f"or contains a parser-differential payload. "
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow.")
r = session.request(method=request_method,
@@ -111,9 +113,9 @@ class fetcher(Fetcher):
location = r.headers.get('Location', '')
redirect_url = urljoin(current_url, location)
if not allow_iana_restricted:
- parsed_redirect = urlparse(redirect_url)
- if parsed_redirect.hostname and is_private_hostname(parsed_redirect.hostname):
- raise Exception(f"Redirect blocked: '{redirect_url}' resolves to a private/reserved IP address.")
+ if is_url_private_or_parser_confused(redirect_url):
+ raise Exception(f"Redirect blocked: '{redirect_url}' resolves to a private/reserved IP address "
+ f"or contains a parser-differential payload.")
current_url = redirect_url
r = session.request('GET', redirect_url,
headers=request_headers,
diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py
index 78cdc661..bc95beca 100644
--- a/changedetectionio/forms.py
+++ b/changedetectionio/forms.py
@@ -889,7 +889,6 @@ class processor_text_json_diff_form(commonSettingsForm):
conditions_match_logic = RadioField(_l('Match'), choices=[('ALL', _l('Match all of the following')),('ANY', _l('Match any of the following'))], default='ALL')
conditions = FieldList(FormField(ConditionFormRow), min_entries=1) # Add rule logic here
- # dennis-ignore: W303 - False positive caused by
. https://github.com/mozilla/dennis/issues/213
use_page_title_in_list = TernaryNoneBooleanField(_l('Use page in list'), default=None)
history_snapshot_max_length = IntegerField(_l('Number of history items per watch to keep'), render_kw={"style": "width: 5em;"}, validators=[validators.Optional(), validators.NumberRange(min=2)])
@@ -1038,7 +1037,6 @@ class globalSettingsApplicationUIForm(Form):
open_diff_in_new_tab = BooleanField(_l("Open 'History' page in a new tab"), default=True, validators=[validators.Optional()])
socket_io_enabled = BooleanField(_l('Realtime UI Updates Enabled'), default=True, validators=[validators.Optional()])
favicons_enabled = BooleanField(_l('Favicons Enabled'), default=True, validators=[validators.Optional()])
- # dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
use_page_title_in_list = BooleanField(_l('Use page in watch overview list')) #BooleanField=True
# datastore.data['settings']['application']..
diff --git a/changedetectionio/notification/apprise_plugin/custom_handlers.py b/changedetectionio/notification/apprise_plugin/custom_handlers.py
index c6270a13..023f661c 100644
--- a/changedetectionio/notification/apprise_plugin/custom_handlers.py
+++ b/changedetectionio/notification/apprise_plugin/custom_handlers.py
@@ -60,7 +60,7 @@ from apprise.utils.logic import dict_full_update
from loguru import logger
from requests.structures import CaseInsensitiveDict
-from changedetectionio.validate_url import is_private_hostname
+from changedetectionio.validate_url import is_private_hostname, is_url_private_or_parser_confused
SUPPORTED_HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head"}
@@ -198,12 +198,14 @@ def apprise_http_custom_handler(
url = re.sub(rf"^{schema}", "https" if schema.endswith("s") else "http", parsed_url.get("url"))
- # SSRF protection — block private/loopback addresses unless explicitly allowed
+ # SSRF protection — block private/loopback addresses unless explicitly allowed.
+ # Uses parser-agnostic check so urlparse/urllib3 differentials (GHSA-rph4-96w6-q594)
+ # can't smuggle an internal target past the gate.
if not os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', '').lower() in ('true', '1', 'yes'):
- hostname = urlparse(url).hostname or ''
- if hostname and is_private_hostname(hostname):
+ if is_url_private_or_parser_confused(url):
raise ValueError(
- f"Notification target '{hostname}' is a private/reserved address. "
+ f"Notification target '{url}' is a private/reserved address "
+ f"or contains a parser-differential payload. "
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow."
)
diff --git a/changedetectionio/processors/base.py b/changedetectionio/processors/base.py
index 062c5604..26d38f73 100644
--- a/changedetectionio/processors/base.py
+++ b/changedetectionio/processors/base.py
@@ -5,7 +5,7 @@ import hashlib
from changedetectionio.browser_steps.browser_steps import browser_steps_get_valid_steps
from changedetectionio.content_fetchers.base import Fetcher
from changedetectionio.strtobool import strtobool
-from changedetectionio.validate_url import is_private_hostname
+from changedetectionio.validate_url import is_private_hostname, is_url_private_or_parser_confused
from copy import deepcopy
from abc import abstractmethod
import os
@@ -104,13 +104,13 @@ class difference_detection_processor():
"""
if strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')):
return
- parsed = urlparse(self.watch.link)
- if not parsed.hostname:
- return
loop = asyncio.get_running_loop()
- if await loop.run_in_executor(None, is_private_hostname, parsed.hostname):
+ # Use the parser-agnostic check so urlparse/urllib3 differentials (GHSA-rph4-96w6-q594)
+ # can't slip a private/internal hostname past this pre-flight gate.
+ if await loop.run_in_executor(None, is_url_private_or_parser_confused, self.watch.link):
raise Exception(
- f"Fetch blocked: '{self.watch.link}' resolves to a private/reserved IP address. "
+ f"Fetch blocked: '{self.watch.link}' resolves to a private/reserved IP address "
+ f"or contains a parser-differential payload. "
f"Set ALLOW_IANA_RESTRICTED_ADDRESSES=true to allow."
)
diff --git a/changedetectionio/templates/_common_fields.html b/changedetectionio/templates/_common_fields.html
index 829ca1c4..d067f792 100644
--- a/changedetectionio/templates/_common_fields.html
+++ b/changedetectionio/templates/_common_fields.html
@@ -34,7 +34,6 @@
{{ '{{watch_title}}' }} |
- {# TRANSLATORS: dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213 #}
{{ _('The page title of the watch, uses if not set, falls back to URL') }} |
diff --git a/changedetectionio/tests/test_security.py b/changedetectionio/tests/test_security.py
index 24956a2e..ab393609 100644
--- a/changedetectionio/tests/test_security.py
+++ b/changedetectionio/tests/test_security.py
@@ -760,7 +760,9 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
f = RequestsFetcher()
- with patch('changedetectionio.content_fetchers.requests.is_private_hostname', return_value=True):
+ # Patch the underlying is_private_hostname in validate_url — the fetcher now goes through
+ # is_url_private_or_parser_confused() (GHSA-rph4-96w6-q594), which calls it transitively.
+ with patch('changedetectionio.validate_url.is_private_hostname', return_value=True):
with pytest.raises(Exception, match='private/reserved'):
f._run_sync(
url='http://example.com/',
@@ -784,7 +786,7 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
return hostname in {'169.254.169.254', '10.0.0.1', '172.16.0.1',
'192.168.0.1', '127.0.0.1', '::1'}
- with patch('changedetectionio.content_fetchers.requests.is_private_hostname',
+ with patch('changedetectionio.validate_url.is_private_hostname',
side_effect=_private_only_for_redirect):
with patch('requests.Session.request', return_value=mock_redirect):
with pytest.raises(Exception, match='Redirect blocked'):
@@ -829,6 +831,113 @@ def test_unresolvable_hostname_is_allowed(client, live_server, monkeypatch):
"Unresolvable hostname watch should appear in the watch overview list"
+def test_ghsa_rph4_96w6_q594_urlparse_urllib3_parser_differential_ssrf(client, live_server, monkeypatch, measure_memory_usage, datastore_path):
+ """
+ GHSA-rph4-96w6-q594: SSRF via urlparse/urllib3 parser differential.
+
+ A URL like http://INTERNAL:8888\\@PUBLIC/ is parsed two different ways:
+ - urlparse() treats \\@ as a credential separator → hostname = PUBLIC
+ - urllib3 treats \\ as a path character → hostname = INTERNAL
+ The pre-fetch SSRF check used urlparse(), but requests/urllib3 actually connected
+ to INTERNAL. Fix: parser-agnostic gate that (a) blocks any URL containing a
+ backslash and (b) validates every hostname both parsers produce.
+
+ Covers:
+ 1. extract_url_hostnames() reveals BOTH hostnames for the payload
+ 2. is_url_private_or_parser_confused() blocks backslash payloads outright
+ 3. is_safe_valid_url() rejects backslash payloads at add-time
+ 4. The /api/v1/watch add endpoint rejects the payload
+ 5. The requests fetcher refuses the payload at fetch-time
+ 6. The redirect-following loop refuses a backslash payload in Location
+ """
+ from unittest.mock import patch, MagicMock
+ from changedetectionio.validate_url import (
+ extract_url_hostnames,
+ is_safe_valid_url,
+ is_url_private_or_parser_confused,
+ )
+
+ monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
+
+ # The published proof-of-concept payload — backslash splits the two parsers' views.
+ payload = "http://169.254.169.254:8888" + chr(92) + "@httpbin.org/latest/meta-data/"
+
+ # ---------------------------------------------------------------
+ # 1. extract_url_hostnames() returns BOTH parsers' hostnames
+ # ---------------------------------------------------------------
+ hosts = extract_url_hostnames(payload)
+ assert '169.254.169.254' in hosts, \
+ f"urllib3 sees 169.254.169.254 as the connect target; extract_url_hostnames must surface it. Got {hosts!r}"
+ assert 'httpbin.org' in hosts, \
+ f"urlparse sees httpbin.org; extract_url_hostnames must surface it too. Got {hosts!r}"
+
+ # ---------------------------------------------------------------
+ # 2. Parser-agnostic gate blocks the payload
+ # ---------------------------------------------------------------
+ assert is_url_private_or_parser_confused(payload), \
+ "Parser-differential payload must be blocked by the SSRF gate"
+
+ # And a plain backslash anywhere in the URL is enough to block, even without a private IP
+ assert is_url_private_or_parser_confused("http://example.com" + chr(92) + "@evil.com/"), \
+ "Any backslash in a URL must trigger the parser-differential block"
+
+ # Sanity: a regular public URL is not blocked
+ assert not is_url_private_or_parser_confused("http://example.com/path"), \
+ "Plain public URLs must continue to pass the gate"
+
+ # ---------------------------------------------------------------
+ # 3. is_safe_valid_url() rejects backslash payloads at add-time
+ # ---------------------------------------------------------------
+ assert not is_safe_valid_url(payload), \
+ "is_safe_valid_url must reject URLs containing a backslash (parser-differential vector)"
+
+ # ---------------------------------------------------------------
+ # 4. The watch-add API endpoint rejects the payload
+ # ---------------------------------------------------------------
+ api_key = live_server.app.config['DATASTORE'].data['settings']['application'].get('api_access_token')
+ res = client.post(
+ url_for('createwatch'),
+ data='{"url": "%s", "fetch_backend": "html_requests"}' % payload,
+ headers={'x-api-key': api_key, 'Content-Type': 'application/json'},
+ )
+ assert res.status_code >= 400, \
+ f"API must refuse to create a watch for parser-differential URL; got status {res.status_code} body {res.data!r}"
+
+ # ---------------------------------------------------------------
+ # 5. Requests fetcher refuses the payload at fetch-time
+ # ---------------------------------------------------------------
+ from changedetectionio.content_fetchers.requests import fetcher as RequestsFetcher
+
+ f = RequestsFetcher()
+ with pytest.raises(Exception, match='private/reserved|parser-differential'):
+ f._run_sync(
+ url=payload,
+ timeout=5,
+ request_headers={},
+ request_body=None,
+ request_method='GET',
+ )
+
+ # ---------------------------------------------------------------
+ # 6. A 302 Location header pointing at a backslash payload is blocked
+ # (open-redirect → SSRF via parser differential)
+ # ---------------------------------------------------------------
+ mock_redirect = MagicMock()
+ mock_redirect.is_redirect = True
+ mock_redirect.status_code = 302
+ mock_redirect.headers = {'Location': payload}
+
+ with patch('requests.Session.request', return_value=mock_redirect):
+ with pytest.raises(Exception, match='Redirect blocked'):
+ f._run_sync(
+ url='http://example.com/',
+ timeout=5,
+ request_headers={},
+ request_body=None,
+ request_method='GET',
+ )
+
+
def test_ghsa_8757_69j2_hx56_backup_restore_history_path_traversal(client, live_server, measure_memory_usage, datastore_path):
"""
GHSA-8757-69j2-hx56: Crafted backup ZIP with absolute path in history.txt must not
diff --git a/changedetectionio/translations/README.md b/changedetectionio/translations/README.md
index 66718ccf..efd61df5 100644
--- a/changedetectionio/translations/README.md
+++ b/changedetectionio/translations/README.md
@@ -247,34 +247,31 @@ dennis-cmd lint --excluderules=W302 changedetectionio/translations/
The `W303` rule ensures that HTML tags in the `msgstr` match the `msgid`. This is crucial for catching broken markup (e.g., missing closing tags).
-##### Handling intentional deviations and false positives
+##### Handling intentional deviations
-Some W303 warnings are intentional or result from upstream false positives.
+Some W303 warnings are intentional.
Use the `dennis-ignore: W303` comment in the source files (templates or Python code) within a `TRANSLATORS` comment to suppress these warnings.
This ensures the ignore instruction is extracted into the `.po` files.
- **CJK italic policy**: When replacing `` with locale-conventional quotation marks, tags will no longer match.
-- **Upstream false positive**: Dennis misinterprets certain HTML tags (e.g., ``) within `msgstr`. See https://github.com/mozilla/dennis/issues/213.
**Examples in Jinja2 templates:**
```jinja
{# TRANSLATORS: CJK fonts lack native italics; allow substitution with conventional local styling. dennis-ignore: W303 #}
{{ _('These settings are added to any existing watch configurations.')|safe }}
-
-{# TRANSLATORS: dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213 #}
-{{ _('The page title of the watch, uses if not set, falls back to URL') }} |
```
**Example in Python source:**
```python
-# dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
-use_page_title_in_list = BooleanField(_l('Use page in watch overview list'))
+# dennis-ignore: W303 - CJK fonts lack native italics; allow substitution with conventional local styling.
+message = StringField(_l('This is experimental and may change'))
```
---
+
## CI linter
A GitHub Actions job (`lint-template-i18n`) checks for adjacent `{{ _(...) }}` calls on the same line
diff --git a/changedetectionio/translations/cs/LC_MESSAGES/messages.po b/changedetectionio/translations/cs/LC_MESSAGES/messages.po
index f97e0ad9..ad82fba5 100644
--- a/changedetectionio/translations/cs/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/cs/LC_MESSAGES/messages.po
@@ -3075,7 +3075,6 @@ msgstr "Spojit všechny následující položky"
msgid "Match any of the following"
msgstr "Přiřaďte kteroukoli z následujících možností"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in list"
msgstr "V seznamu použijte stránku "
@@ -3175,7 +3174,6 @@ msgstr "Aktualizace UI v reálném čase"
msgid "Favicons Enabled"
msgstr "Povolit favikony"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in watch overview list"
msgstr "Použijte stránku v přehledu sledování"
@@ -3569,7 +3567,6 @@ msgstr ""
msgid "The UUID of the watch."
msgstr "UUID monitoru."
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/templates/_common_fields.html
msgid "The page title of the watch, uses if not set, falls back to URL"
msgstr ""
diff --git a/changedetectionio/translations/de/LC_MESSAGES/messages.po b/changedetectionio/translations/de/LC_MESSAGES/messages.po
index 89a1e1ff..f13602ed 100644
--- a/changedetectionio/translations/de/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/de/LC_MESSAGES/messages.po
@@ -3127,7 +3127,6 @@ msgstr "Passen Sie alle folgenden Punkte an"
msgid "Match any of the following"
msgstr "Entspricht einer der folgenden Bedingungen"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in list"
msgstr "Verwenden Sie Seite in der Liste"
@@ -3227,7 +3226,6 @@ msgstr "Echtzeit-UI-Updates aktiviert"
msgid "Favicons Enabled"
msgstr "Favicons Aktiviert"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in watch overview list"
msgstr "Verwenden Sie die Seite in der Übersichtsliste der Beobachtungen"
@@ -3623,7 +3621,6 @@ msgstr ""
msgid "The UUID of the watch."
msgstr "Die UUID der Überwachung."
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/templates/_common_fields.html
msgid "The page title of the watch, uses if not set, falls back to URL"
msgstr ""
diff --git a/changedetectionio/translations/en_GB/LC_MESSAGES/messages.po b/changedetectionio/translations/en_GB/LC_MESSAGES/messages.po
index b3e6f8c7..11aa0910 100644
--- a/changedetectionio/translations/en_GB/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/en_GB/LC_MESSAGES/messages.po
@@ -3069,7 +3069,6 @@ msgstr ""
msgid "Match any of the following"
msgstr ""
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in list"
msgstr ""
@@ -3169,7 +3168,6 @@ msgstr ""
msgid "Favicons Enabled"
msgstr ""
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in watch overview list"
msgstr ""
@@ -3563,7 +3561,6 @@ msgstr ""
msgid "The UUID of the watch."
msgstr ""
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/templates/_common_fields.html
msgid "The page title of the watch, uses if not set, falls back to URL"
msgstr ""
diff --git a/changedetectionio/translations/en_US/LC_MESSAGES/messages.po b/changedetectionio/translations/en_US/LC_MESSAGES/messages.po
index cf284952..342deefd 100644
--- a/changedetectionio/translations/en_US/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/en_US/LC_MESSAGES/messages.po
@@ -3069,7 +3069,6 @@ msgstr ""
msgid "Match any of the following"
msgstr ""
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in list"
msgstr ""
@@ -3169,7 +3168,6 @@ msgstr ""
msgid "Favicons Enabled"
msgstr ""
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in watch overview list"
msgstr ""
@@ -3563,7 +3561,6 @@ msgstr ""
msgid "The UUID of the watch."
msgstr ""
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/templates/_common_fields.html
msgid "The page title of the watch, uses if not set, falls back to URL"
msgstr ""
diff --git a/changedetectionio/translations/es/LC_MESSAGES/messages.po b/changedetectionio/translations/es/LC_MESSAGES/messages.po
index 455f65b5..442f5c29 100644
--- a/changedetectionio/translations/es/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/es/LC_MESSAGES/messages.po
@@ -3142,7 +3142,6 @@ msgstr "Coincide con todo lo siguiente"
msgid "Match any of the following"
msgstr "Coincide con cualquiera de los siguientes"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in list"
msgstr "Usar página en la lista"
@@ -3242,7 +3241,6 @@ msgstr "Actualizaciones de UI en tiempo real habilitadas"
msgid "Favicons Enabled"
msgstr "Favicones habilitados"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in watch overview list"
msgstr "Usar de la página en la lista general de monitores"
@@ -3636,7 +3634,6 @@ msgstr "La URL que se está viendo."
msgid "The UUID of the watch."
msgstr "El UUID del monitor."
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/templates/_common_fields.html
msgid "The page title of the watch, uses if not set, falls back to URL"
msgstr "El título de la página del monitor, utiliza si no se establece, vuelve a la URL"
diff --git a/changedetectionio/translations/fr/LC_MESSAGES/messages.po b/changedetectionio/translations/fr/LC_MESSAGES/messages.po
index 8dde048a..6204b319 100644
--- a/changedetectionio/translations/fr/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/fr/LC_MESSAGES/messages.po
@@ -3082,7 +3082,6 @@ msgstr "Faites correspondre tous les éléments suivants"
msgid "Match any of the following"
msgstr "Faites correspondre l'un des éléments suivants"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in list"
msgstr "Utiliser la page dans la liste"
@@ -3182,7 +3181,6 @@ msgstr "Mises à jour en temps réel hors ligne"
msgid "Favicons Enabled"
msgstr "Favicons Activés"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in watch overview list"
msgstr "Utiliser la page dans la liste de présentation des moniteurs"
@@ -3576,7 +3574,6 @@ msgstr ""
msgid "The UUID of the watch."
msgstr "L'UUID du moniteur."
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/templates/_common_fields.html
msgid "The page title of the watch, uses if not set, falls back to URL"
msgstr ""
diff --git a/changedetectionio/translations/it/LC_MESSAGES/messages.po b/changedetectionio/translations/it/LC_MESSAGES/messages.po
index 7c7e053f..a4493812 100644
--- a/changedetectionio/translations/it/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/it/LC_MESSAGES/messages.po
@@ -3071,7 +3071,6 @@ msgstr "Corrisponde a tutti i seguenti"
msgid "Match any of the following"
msgstr "Corrisponde a uno qualsiasi dei seguenti"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in list"
msgstr "Usa pagina nell'elenco"
@@ -3171,7 +3170,6 @@ msgstr "Aggiornamenti UI in tempo reale attivi"
msgid "Favicons Enabled"
msgstr "Favicon attive"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in watch overview list"
msgstr "Usa pagina nell'elenco osservati"
@@ -3565,7 +3563,6 @@ msgstr ""
msgid "The UUID of the watch."
msgstr "L'UUID del monitor."
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/templates/_common_fields.html
msgid "The page title of the watch, uses if not set, falls back to URL"
msgstr ""
diff --git a/changedetectionio/translations/ja/LC_MESSAGES/messages.po b/changedetectionio/translations/ja/LC_MESSAGES/messages.po
index a4f7b9df..8ac43b6d 100644
--- a/changedetectionio/translations/ja/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/ja/LC_MESSAGES/messages.po
@@ -3088,7 +3088,6 @@ msgstr "以下のすべてに一致"
msgid "Match any of the following"
msgstr "以下のいずれかに一致"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in list"
msgstr "リストでページの を使用"
@@ -3188,7 +3187,6 @@ msgstr "リアルタイムUI更新を有効化"
msgid "Favicons Enabled"
msgstr "ファビコンを有効化"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in watch overview list"
msgstr "ウォッチ一覧リストでページの を使用"
@@ -3582,7 +3580,6 @@ msgstr "監視中のURL。"
msgid "The UUID of the watch."
msgstr "ウォッチのUUID。"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/templates/_common_fields.html
msgid "The page title of the watch, uses if not set, falls back to URL"
msgstr "ウォッチのページタイトル。設定されていない場合は を使用し、それもなければURLにフォールバックします。"
diff --git a/changedetectionio/translations/ko/LC_MESSAGES/messages.po b/changedetectionio/translations/ko/LC_MESSAGES/messages.po
index 283a03a0..b6b3ce7c 100644
--- a/changedetectionio/translations/ko/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/ko/LC_MESSAGES/messages.po
@@ -3079,7 +3079,6 @@ msgstr "다음 모두와 일치"
msgid "Match any of the following"
msgstr "다음 중 하나와 일치"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in list"
msgstr "목록에 페이지 사용"
@@ -3179,7 +3178,6 @@ msgstr "실시간 UI 업데이트 활성화"
msgid "Favicons Enabled"
msgstr "파비콘 활성화"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in watch overview list"
msgstr "모니터링 목록에 페이지 사용"
@@ -3573,7 +3571,6 @@ msgstr "모니터링 중인 URL입니다."
msgid "The UUID of the watch."
msgstr "모니터링 UUID입니다."
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/templates/_common_fields.html
msgid "The page title of the watch, uses if not set, falls back to URL"
msgstr "모니터링의 페이지 제목입니다. 설정되지 않았으면 을 사용하고, 없으면 URL을 사용합니다."
diff --git a/changedetectionio/translations/messages.pot b/changedetectionio/translations/messages.pot
index d506a6b4..e9710fd1 100644
--- a/changedetectionio/translations/messages.pot
+++ b/changedetectionio/translations/messages.pot
@@ -6,9 +6,9 @@
#, fuzzy
msgid ""
msgstr ""
-"Project-Id-Version: changedetection.io 0.55.5\n"
+"Project-Id-Version: changedetection.io 0.55.6\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2026-05-25 14:28+0200\n"
+"POT-Creation-Date: 2026-05-25 18:00+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -3068,7 +3068,6 @@ msgstr ""
msgid "Match any of the following"
msgstr ""
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in list"
msgstr ""
@@ -3168,7 +3167,6 @@ msgstr ""
msgid "Favicons Enabled"
msgstr ""
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in watch overview list"
msgstr ""
@@ -3562,7 +3560,6 @@ msgstr ""
msgid "The UUID of the watch."
msgstr ""
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/templates/_common_fields.html
msgid "The page title of the watch, uses if not set, falls back to URL"
msgstr ""
diff --git a/changedetectionio/translations/pt_BR/LC_MESSAGES/messages.po b/changedetectionio/translations/pt_BR/LC_MESSAGES/messages.po
index dbc71095..34cb556d 100644
--- a/changedetectionio/translations/pt_BR/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/pt_BR/LC_MESSAGES/messages.po
@@ -3119,7 +3119,6 @@ msgstr "Corresponder a TODOS os seguintes"
msgid "Match any of the following"
msgstr "Corresponder a QUALQUER um dos seguintes"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in list"
msgstr "Usar da página na lista"
@@ -3219,7 +3218,6 @@ msgstr "Atualizações de Interface em Tempo Real Ativadas"
msgid "Favicons Enabled"
msgstr "Favicons Ativados"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in watch overview list"
msgstr "Usar da página na lista de visão geral"
@@ -3613,7 +3611,6 @@ msgstr "A URL que está sendo monitorada."
msgid "The UUID of the watch."
msgstr "O UUID do monitoramento."
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/templates/_common_fields.html
msgid "The page title of the watch, uses if not set, falls back to URL"
msgstr "O título da página do monitoramento, usa se não definido, ou a URL"
diff --git a/changedetectionio/translations/tr/LC_MESSAGES/messages.po b/changedetectionio/translations/tr/LC_MESSAGES/messages.po
index d47b46c5..ca237e62 100644
--- a/changedetectionio/translations/tr/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/tr/LC_MESSAGES/messages.po
@@ -3122,7 +3122,6 @@ msgstr "Aşağıdakilerin tümünü eşleştir"
msgid "Match any of the following"
msgstr "Aşağıdakilerden herhangi birini eşleştir"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in list"
msgstr "Listede sayfa 'ını kullan"
@@ -3222,7 +3221,6 @@ msgstr "Gerçek Zamanlı Arayüz Güncellemeleri Etkin"
msgid "Favicons Enabled"
msgstr "Favicon'lar Etkin"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in watch overview list"
msgstr "İzleyici genel bakış listesinde sayfa 'ını kullan"
@@ -3616,7 +3614,6 @@ msgstr "İzlenen URL."
msgid "The UUID of the watch."
msgstr "İzleyicinin UUID'si."
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/templates/_common_fields.html
msgid "The page title of the watch, uses if not set, falls back to URL"
msgstr "İzleyicinin sayfa başlığı, ayarlanmamışsa kullanır, URL'ye geri döner"
diff --git a/changedetectionio/translations/uk/LC_MESSAGES/messages.po b/changedetectionio/translations/uk/LC_MESSAGES/messages.po
index dc009cf0..d28ca82e 100644
--- a/changedetectionio/translations/uk/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/uk/LC_MESSAGES/messages.po
@@ -3101,7 +3101,6 @@ msgstr "Збіг усіх наступних умов"
msgid "Match any of the following"
msgstr "Збіг будь-якої з наступних умов"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in list"
msgstr "Використовувати сторінки у списку"
@@ -3201,7 +3200,6 @@ msgstr "Оновлення UI в реальному часі увімкнено"
msgid "Favicons Enabled"
msgstr "Фавіконки увімкнено"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in watch overview list"
msgstr "Використовувати сторінки у списку огляду завдань"
@@ -3595,7 +3593,6 @@ msgstr "URL, за яким ведеться спостереження."
msgid "The UUID of the watch."
msgstr "UUID завдання."
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/templates/_common_fields.html
msgid "The page title of the watch, uses if not set, falls back to URL"
msgstr "Заголовок сторінки завдання, використовує , якщо не задано - URL"
diff --git a/changedetectionio/translations/zh/LC_MESSAGES/messages.po b/changedetectionio/translations/zh/LC_MESSAGES/messages.po
index 3b0a1a6a..69755bbc 100644
--- a/changedetectionio/translations/zh/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/zh/LC_MESSAGES/messages.po
@@ -3074,7 +3074,6 @@ msgstr "匹配以下全部"
msgid "Match any of the following"
msgstr "匹配以下任意"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in list"
msgstr "列表中使用页面 "
@@ -3174,7 +3173,6 @@ msgstr "启用实时界面更新"
msgid "Favicons Enabled"
msgstr "启用站点图标"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in watch overview list"
msgstr "在监控概览列表中使用页面 "
@@ -3568,7 +3566,6 @@ msgstr "被监控的 URL。"
msgid "The UUID of the watch."
msgstr "监视器的UUID。"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/templates/_common_fields.html
msgid "The page title of the watch, uses if not set, falls back to URL"
msgstr "监控项的页面标题,未设置时使用 ,否则回退为 URL"
diff --git a/changedetectionio/translations/zh_Hant_TW/LC_MESSAGES/messages.po b/changedetectionio/translations/zh_Hant_TW/LC_MESSAGES/messages.po
index bf3332c5..64b636f5 100644
--- a/changedetectionio/translations/zh_Hant_TW/LC_MESSAGES/messages.po
+++ b/changedetectionio/translations/zh_Hant_TW/LC_MESSAGES/messages.po
@@ -3073,7 +3073,6 @@ msgstr "符合以下所有條件"
msgid "Match any of the following"
msgstr "符合以下任一條件"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in list"
msgstr "在列表中使用頁面 "
@@ -3173,7 +3172,6 @@ msgstr "已啟用即時 UI 更新"
msgid "Favicons Enabled"
msgstr "啟用網站圖示 (Favicons)"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/forms.py
msgid "Use page in watch overview list"
msgstr "在監測概覽列表中使用頁面 "
@@ -3567,7 +3565,6 @@ msgstr ""
msgid "The UUID of the watch."
msgstr "監測任務的 UUID。"
-#. dennis-ignore: W303 - False positive caused by . https://github.com/mozilla/dennis/issues/213
#: changedetectionio/templates/_common_fields.html
msgid "The page title of the watch, uses if not set, falls back to URL"
msgstr ""
diff --git a/changedetectionio/validate_url.py b/changedetectionio/validate_url.py
index 8bbfd5d2..85dfd25e 100644
--- a/changedetectionio/validate_url.py
+++ b/changedetectionio/validate_url.py
@@ -80,6 +80,52 @@ def is_private_hostname(hostname):
return False
+def extract_url_hostnames(url):
+ """Return every hostname this URL could resolve to under different URL parsers.
+
+ Why: urllib's urlparse() and urllib3's parse_url() disagree on URLs containing
+ a backslash (e.g. http://INTERNAL:8888\\@PUBLIC/ — urlparse extracts PUBLIC, but
+ urllib3/requests will actually connect to INTERNAL). Any SSRF check that trusts
+ only one parser can be bypassed by the other. Callers should reject the fetch
+ if ANY hostname returned here is private/reserved.
+
+ See GHSA-rph4-96w6-q594.
+ """
+ hostnames = set()
+ try:
+ h = urlparse(url).hostname
+ if h:
+ hostnames.add(h)
+ except Exception:
+ pass
+ try:
+ from urllib3.util.url import parse_url as _u3_parse_url
+ u3 = _u3_parse_url(url)
+ if u3.host:
+ # urllib3 keeps IPv6 brackets in `.host`; strip them so socket.getaddrinfo() accepts the literal.
+ hostnames.add(u3.host.strip('[]'))
+ except Exception:
+ pass
+ return hostnames
+
+
+def is_url_private_or_parser_confused(url):
+ """SSRF gate that defends against urlparse/urllib3 parser-differential attacks.
+
+ Returns True (block the fetch) when:
+ * the URL contains a backslash — no legitimate URL needs one, and it is the
+ established vector for the parser-differential bypass (GHSA-rph4-96w6-q594), OR
+ * any hostname produced by urlparse OR urllib3 resolves to a private/reserved IP.
+ """
+ if '\\' in url:
+ logger.warning(f"URL '{url}' contains a backslash — rejected to prevent urlparse/urllib3 parser-differential SSRF.")
+ return True
+ for hostname in extract_url_hostnames(url):
+ if is_private_hostname(hostname):
+ return True
+ return False
+
+
def is_llm_api_base_safe(api_base):
"""SSRF guard for the LLM `api_base` setting (GHSA-jrxm-qjfh-g54f).
@@ -178,6 +224,13 @@ def is_safe_valid_url(test_url):
logger.warning(f'URL "{test_url}" contains suspicious characters')
return False
+ # Reject backslashes — urllib's urlparse and urllib3's parse_url disagree on URLs containing
+ # a backslash (e.g. http://INTERNAL:8888\@PUBLIC/), which is the documented SSRF bypass in
+ # GHSA-rph4-96w6-q594. A backslash has no legitimate use in an HTTP URL, so block at add-time.
+ if '\\' in test_url:
+ logger.warning(f'URL "{test_url}" contains a backslash — rejected (parser-differential SSRF vector).')
+ return False
+
# Normalize URL encoding - handle both encoded and unencoded query parameters
test_url = normalize_url_encoding(test_url)
diff --git a/requirements.txt b/requirements.txt
index 5fa90d1e..5aa090e8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -159,7 +159,7 @@ psutil==7.2.2
ruff >= 0.11.2
pre_commit >= 4.2.0
-dennis >= 1.2.0
+dennis >= 1.3.0
# For events between checking and socketio updates
blinker