mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-05-31 05:51:25 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd56a502c0 | |||
| baae46deed | |||
| d7a1b67c5a | |||
| b7bb67fac4 | |||
| 230fef0f64 | |||
| 08017d66d6 | |||
| 851c054f8b | |||
| 0e3f1941b3 | |||
| 3bff553e4e |
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.7'
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
@@ -99,6 +99,12 @@ def construct_blueprint(datastore: ChangeDetectionStore):
|
||||
|
||||
llm_form_input = dict(form.data.get('llm') or {})
|
||||
|
||||
# Empty IntegerField submissions come back as None from WTForms;
|
||||
# the schema declares those fields as strict `int`, so passing
|
||||
# them through would fail validation. Treat None like the
|
||||
# absent-key case: keep the stored value, don't merge.
|
||||
llm_form_input = {k: v for k, v in llm_form_input.items() if v is not None}
|
||||
|
||||
# PasswordField never re-renders, so a blank submitted value means
|
||||
# "keep stored key" — drop it from the merge.
|
||||
if not (llm_form_input.get('api_key') or '').strip():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -887,7 +887,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 <title>. https://github.com/mozilla/dennis/issues/213
|
||||
use_page_title_in_list = TernaryNoneBooleanField(_l('Use page <title> 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)])
|
||||
@@ -1036,7 +1035,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 <title>. https://github.com/mozilla/dennis/issues/213
|
||||
use_page_title_in_list = BooleanField(_l('Use page <title> in watch overview list')) #BooleanField=True
|
||||
|
||||
# datastore.data['settings']['application']..
|
||||
|
||||
@@ -82,10 +82,23 @@ def _check_input_size(text: str, max_chars: int) -> None:
|
||||
|
||||
def _thinking_extra_body(model: str, budget: int) -> dict | None:
|
||||
"""Return litellm extra_body to control thinking for models that support it.
|
||||
For Gemini 2.5+: passes thinkingConfig with the given budget (0 = disabled).
|
||||
For all other models: returns None (no-op).
|
||||
|
||||
The `thinkingConfig.thinkingBudget` payload is Gemini-specific (Anthropic and
|
||||
OpenAI reasoning models use different parameters), so we gate on the gemini/
|
||||
provider prefix first, then defer to litellm's model registry for the actual
|
||||
"does this model think?" decision. That picks up new Gemini variants and
|
||||
rolling aliases (`gemini-flash-latest`, etc.) as litellm's registry tracks
|
||||
them, without us hardcoding model names here.
|
||||
"""
|
||||
if not model.startswith('gemini/gemini-2.5'):
|
||||
if not model.startswith('gemini/'):
|
||||
return None
|
||||
try:
|
||||
import litellm
|
||||
if not litellm.get_model_info(model).get('supports_reasoning'):
|
||||
return None
|
||||
except Exception:
|
||||
# Unknown model or registry lookup failed — skip the thinking config
|
||||
# rather than guess. Worst case: thinking stays at the provider default.
|
||||
return None
|
||||
return {'generationConfig': {'thinkingConfig': {'thinkingBudget': budget}}}
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>{{ '{{watch_title}}' }}</code></td>
|
||||
{# TRANSLATORS: dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #}
|
||||
<td>{{ _('The page title of the watch, uses <title> if not set, falls back to URL') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -196,6 +196,81 @@ def test_settings_form_preserves_token_counters(
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_settings_form_blank_llm_integer_fields_preserve_stored_values(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Empty IntegerField submissions come back as None from WTForms. LLMSettings
|
||||
declares token_budget_month / max_input_chars / max_tokens_per_count_period /
|
||||
local_token_multiplier as strict `int`, so a None passed through to
|
||||
model_validate raises ValidationError and 500s the settings save.
|
||||
|
||||
Regression for settings/__init__.py — the LLM merge must drop None values
|
||||
(treat them like absent keys) so blank IntegerField submissions preserve
|
||||
the stored value instead of crashing the form.
|
||||
"""
|
||||
ds = client.application.config.get('DATASTORE')
|
||||
ds.data['settings']['application']['llm'] = {
|
||||
'model': 'gpt-4o',
|
||||
'api_key': 'sk-existing',
|
||||
'token_budget_month': 50000,
|
||||
'max_input_chars': 200000,
|
||||
'max_tokens_per_count_period': 1000,
|
||||
'local_token_multiplier': 3,
|
||||
}
|
||||
|
||||
res = client.post(
|
||||
url_for('settings.settings_page'),
|
||||
data={
|
||||
'llm-model': 'gpt-4o',
|
||||
'llm-api_key': '',
|
||||
'llm-api_base': '',
|
||||
# The bug-trigger: every LLM IntegerField submitted blank
|
||||
'llm-token_budget_month': '',
|
||||
'llm-max_input_chars': '',
|
||||
'llm-max_tokens_per_count_period': '',
|
||||
'llm-local_token_multiplier': '',
|
||||
# Minimal required fields for the rest of the form to validate.
|
||||
# 'System default' is popped from notification_format choices for the
|
||||
# global form, so it must be one of the real codes (e.g. 'html').
|
||||
'application-pager_size': '50',
|
||||
'application-notification_format': 'html',
|
||||
'application-fetch_backend': 'html_requests',
|
||||
'application-rss_diff_length': '5',
|
||||
'application-filter_failure_notification_threshold_attempts': '0',
|
||||
'requests-time_between_check-days': '0',
|
||||
'requests-time_between_check-hours': '0',
|
||||
'requests-time_between_check-minutes': '5',
|
||||
'requests-time_between_check-seconds': '0',
|
||||
'requests-time_between_check-weeks': '0',
|
||||
'requests-jitter_seconds': '0',
|
||||
'requests-workers': '10',
|
||||
'requests-timeout': '60',
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert res.status_code == 200, \
|
||||
f"Settings save crashed on blank LLM IntegerField submission (got {res.status_code})"
|
||||
# Sanity: the form must have actually validated and reached the LLM save path
|
||||
# — without this the test would trivially pass because the buggy code never ran.
|
||||
assert b'Settings updated.' in res.data, \
|
||||
"Settings form did not validate — the bug-path was never exercised. Check fixture fields."
|
||||
body = res.data.decode('utf-8', errors='replace')
|
||||
assert 'ValidationError' not in body, \
|
||||
"Pydantic ValidationError leaked into the response — blank IntegerField wasn't filtered"
|
||||
|
||||
llm = ds.data['settings']['application'].get('llm') or {}
|
||||
assert llm.get('token_budget_month') == 50000, \
|
||||
f"Blank submission must preserve stored token_budget_month (got {llm.get('token_budget_month')!r})"
|
||||
assert llm.get('max_input_chars') == 200000, \
|
||||
f"Blank submission must preserve stored max_input_chars (got {llm.get('max_input_chars')!r})"
|
||||
assert llm.get('max_tokens_per_count_period') == 1000, \
|
||||
f"Blank submission must preserve stored max_tokens_per_count_period (got {llm.get('max_tokens_per_count_period')!r})"
|
||||
assert llm.get('local_token_multiplier') == 3, \
|
||||
f"Blank submission must preserve stored local_token_multiplier (got {llm.get('local_token_multiplier')!r})"
|
||||
|
||||
delete_all_watches(client)
|
||||
|
||||
|
||||
def test_settings_form_cannot_inject_fake_token_counts(
|
||||
client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<i>` with locale-conventional quotation marks, tags will no longer match.
|
||||
- **Upstream false positive**: Dennis misinterprets certain HTML tags (e.g., `<title>`) 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 #}
|
||||
<p>{{ _('These settings are <strong><i>added</i></strong> to any existing watch configurations.')|safe }}</p>
|
||||
|
||||
{# TRANSLATORS: dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213 #}
|
||||
<td>{{ _('The page title of the watch, uses <title> if not set, falls back to URL') }}</td>
|
||||
```
|
||||
|
||||
**Example in Python source:**
|
||||
|
||||
```python
|
||||
# dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
use_page_title_in_list = BooleanField(_l('Use page <title> in watch overview list'))
|
||||
# dennis-ignore: W303 - CJK fonts lack native italics; allow substitution with conventional local styling.
|
||||
message = StringField(_l('This is <i>experimental</i> and may change'))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## CI linter
|
||||
|
||||
A GitHub Actions job (`lint-template-i18n`) checks for adjacent `{{ _(...) }}` calls on the same line
|
||||
|
||||
@@ -2959,7 +2959,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 <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "V seznamu použijte stránku <title>"
|
||||
@@ -3059,7 +3058,6 @@ msgstr "Aktualizace UI v reálném čase"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Povolit favikony"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Použijte stránku <title> v přehledu sledování"
|
||||
@@ -3453,7 +3451,6 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "UUID monitoru."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
|
||||
@@ -3011,7 +3011,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 <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Verwenden Sie Seite <title> in der Liste"
|
||||
@@ -3111,7 +3110,6 @@ msgstr "Echtzeit-UI-Updates aktiviert"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Favicons Aktiviert"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Verwenden Sie die Seite <title> in der Übersichtsliste der Beobachtungen"
|
||||
@@ -3507,7 +3505,6 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "Die UUID der Überwachung."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
|
||||
@@ -2953,7 +2953,6 @@ msgstr ""
|
||||
msgid "Match any of the following"
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr ""
|
||||
@@ -3053,7 +3052,6 @@ msgstr ""
|
||||
msgid "Favicons Enabled"
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr ""
|
||||
@@ -3447,7 +3445,6 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
|
||||
@@ -2953,7 +2953,6 @@ msgstr ""
|
||||
msgid "Match any of the following"
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr ""
|
||||
@@ -3053,7 +3052,6 @@ msgstr ""
|
||||
msgid "Favicons Enabled"
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr ""
|
||||
@@ -3447,7 +3445,6 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
|
||||
@@ -3026,7 +3026,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 <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Usar página <title> en la lista"
|
||||
@@ -3126,7 +3125,6 @@ msgstr "Actualizaciones de UI en tiempo real habilitadas"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Favicones habilitados"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Usar <title> de la página en la lista general de monitores"
|
||||
@@ -3520,7 +3518,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 <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "El título de la página del monitor, utiliza <title> si no se establece, vuelve a la URL"
|
||||
|
||||
@@ -2966,7 +2966,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 <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Utiliser la page <title> dans la liste"
|
||||
@@ -3066,7 +3065,6 @@ msgstr "Mises à jour en temps réel hors ligne"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Favicons Activés"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Utiliser la page <title> dans la liste de présentation des moniteurs"
|
||||
@@ -3460,7 +3458,6 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "L'UUID du moniteur."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
|
||||
@@ -2955,7 +2955,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 <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Usa <title> pagina nell'elenco"
|
||||
@@ -3055,7 +3054,6 @@ msgstr "Aggiornamenti UI in tempo reale attivi"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Favicon attive"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Usa <title> pagina nell'elenco osservati"
|
||||
@@ -3449,7 +3447,6 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "L'UUID del monitor."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
|
||||
@@ -2972,7 +2972,6 @@ msgstr "以下のすべてに一致"
|
||||
msgid "Match any of the following"
|
||||
msgstr "以下のいずれかに一致"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "リストでページの <title> を使用"
|
||||
@@ -3072,7 +3071,6 @@ msgstr "リアルタイムUI更新を有効化"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "ファビコンを有効化"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "ウォッチ一覧リストでページの <title> を使用"
|
||||
@@ -3466,7 +3464,6 @@ msgstr "監視中のURL。"
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "ウォッチのUUID。"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "ウォッチのページタイトル。設定されていない場合は <title> を使用し、それもなければURLにフォールバックします。"
|
||||
|
||||
@@ -2963,7 +2963,6 @@ msgstr "다음 모두와 일치"
|
||||
msgid "Match any of the following"
|
||||
msgstr "다음 중 하나와 일치"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "목록에 페이지 <title> 사용"
|
||||
@@ -3063,7 +3062,6 @@ msgstr "실시간 UI 업데이트 활성화"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "파비콘 활성화"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "모니터링 목록에 페이지 <title> 사용"
|
||||
@@ -3457,7 +3455,6 @@ msgstr "모니터링 중인 URL입니다."
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "모니터링 UUID입니다."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "모니터링의 페이지 제목입니다. 설정되지 않았으면 <title> 을 사용하고, 없으면 URL을 사용합니다."
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: changedetection.io 0.55.5\n"
|
||||
"Project-Id-Version: changedetection.io 0.55.7\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-05-25 10:09+0200\n"
|
||||
"POT-Creation-Date: 2026-05-25 18:47+0200\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -2952,7 +2952,6 @@ msgstr ""
|
||||
msgid "Match any of the following"
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr ""
|
||||
@@ -3052,7 +3051,6 @@ msgstr ""
|
||||
msgid "Favicons Enabled"
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr ""
|
||||
@@ -3446,7 +3444,6 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr ""
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
|
||||
@@ -3003,7 +3003,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 <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Usar <title> da página na lista"
|
||||
@@ -3103,7 +3102,6 @@ msgstr "Atualizações de Interface em Tempo Real Ativadas"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Favicons Ativados"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Usar <title> da página na lista de visão geral"
|
||||
@@ -3497,7 +3495,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 <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "O título da página do monitoramento, usa <title> se não definido, ou a URL"
|
||||
|
||||
@@ -3006,7 +3006,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 <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Listede sayfa <title>'ını kullan"
|
||||
@@ -3106,7 +3105,6 @@ msgstr "Gerçek Zamanlı Arayüz Güncellemeleri Etkin"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Favicon'lar Etkin"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "İzleyici genel bakış listesinde sayfa <title>'ını kullan"
|
||||
@@ -3500,7 +3498,6 @@ msgstr "İzlenen URL."
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "İzleyicinin UUID'si."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "İzleyicinin sayfa başlığı, ayarlanmamışsa <title> kullanır, URL'ye geri döner"
|
||||
|
||||
@@ -2985,7 +2985,6 @@ msgstr "Збіг усіх наступних умов"
|
||||
msgid "Match any of the following"
|
||||
msgstr "Збіг будь-якої з наступних умов"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "Використовувати <title> сторінки у списку"
|
||||
@@ -3085,7 +3084,6 @@ msgstr "Оновлення UI в реальному часі увімкнено"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "Фавіконки увімкнено"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "Використовувати <title> сторінки у списку огляду завдань"
|
||||
@@ -3479,7 +3477,6 @@ msgstr "URL, за яким ведеться спостереження."
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "UUID завдання."
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "Заголовок сторінки завдання, використовує <title>, якщо не задано - URL"
|
||||
|
||||
@@ -2958,7 +2958,6 @@ msgstr "匹配以下全部"
|
||||
msgid "Match any of the following"
|
||||
msgstr "匹配以下任意"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "列表中使用页面 <title>"
|
||||
@@ -3058,7 +3057,6 @@ msgstr "启用实时界面更新"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "启用站点图标"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "在监控概览列表中使用页面 <title>"
|
||||
@@ -3452,7 +3450,6 @@ msgstr "被监控的 URL。"
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "监视器的UUID。"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr "监控项的页面标题,未设置时使用 <title>,否则回退为 URL"
|
||||
|
||||
@@ -2957,7 +2957,6 @@ msgstr "符合以下所有條件"
|
||||
msgid "Match any of the following"
|
||||
msgstr "符合以下任一條件"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in list"
|
||||
msgstr "在列表中使用頁面 <title>"
|
||||
@@ -3057,7 +3056,6 @@ msgstr "已啟用即時 UI 更新"
|
||||
msgid "Favicons Enabled"
|
||||
msgstr "啟用網站圖示 (Favicons)"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/forms.py
|
||||
msgid "Use page <title> in watch overview list"
|
||||
msgstr "在監測概覽列表中使用頁面 <title>"
|
||||
@@ -3451,7 +3449,6 @@ msgstr ""
|
||||
msgid "The UUID of the watch."
|
||||
msgstr "監測任務的 UUID。"
|
||||
|
||||
#. dennis-ignore: W303 - False positive caused by <title>. https://github.com/mozilla/dennis/issues/213
|
||||
#: changedetectionio/templates/_common_fields.html
|
||||
msgid "The page title of the watch, uses <title> if not set, falls back to URL"
|
||||
msgstr ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ services:
|
||||
# For complete privacy if you don't want to use the 'check version' / telemetry service
|
||||
# - DISABLE_VERSION_CHECK=true
|
||||
#
|
||||
# Disable all LLM / AI features, prompts etc
|
||||
# - LLM_FEATURES_DISABLED=true
|
||||
#
|
||||
# A valid timezone name to run as (for scheduling watch checking) see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
# - TZ=America/Los_Angeles
|
||||
#
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user