mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-04-01 08:37:57 +00:00
Compare commits
8 Commits
0.54.2
...
python-314
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fecd181e07 | ||
|
|
525e390523 | ||
|
|
7fe332ad95 | ||
|
|
b65a01ec02 | ||
|
|
b984426666 | ||
|
|
1889a10ef6 | ||
|
|
f66ae4fceb | ||
|
|
fb14229888 |
11
.github/workflows/test-only.yml
vendored
11
.github/workflows/test-only.yml
vendored
@@ -52,4 +52,13 @@ jobs:
|
|||||||
uses: ./.github/workflows/test-stack-reusable-workflow.yml
|
uses: ./.github/workflows/test-stack-reusable-workflow.yml
|
||||||
with:
|
with:
|
||||||
python-version: '3.13'
|
python-version: '3.13'
|
||||||
skip-pypuppeteer: true
|
skip-pypuppeteer: true
|
||||||
|
|
||||||
|
|
||||||
|
test-application-3-14:
|
||||||
|
#if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||||
|
needs: lint-code
|
||||||
|
uses: ./.github/workflows/test-stack-reusable-workflow.yml
|
||||||
|
with:
|
||||||
|
python-version: '3.14'
|
||||||
|
skip-pypuppeteer: false
|
||||||
|
|||||||
@@ -706,7 +706,19 @@ jobs:
|
|||||||
- name: Check upgrade works without error
|
- name: Check upgrade works without error
|
||||||
run: |
|
run: |
|
||||||
echo "=== Testing upgrade path from 0.49.1 to ${{ github.ref_name }} (${{ github.sha }}) ==="
|
echo "=== Testing upgrade path from 0.49.1 to ${{ github.ref_name }} (${{ github.sha }}) ==="
|
||||||
|
sudo apt-get update && sudo apt-get install -y --no-install-recommends \
|
||||||
|
g++ \
|
||||||
|
gcc \
|
||||||
|
libc-dev \
|
||||||
|
libffi-dev \
|
||||||
|
libjpeg-dev \
|
||||||
|
libssl-dev \
|
||||||
|
libxslt-dev \
|
||||||
|
make \
|
||||||
|
patch \
|
||||||
|
pkg-config \
|
||||||
|
zlib1g-dev
|
||||||
|
|
||||||
# Checkout old version and create datastore
|
# Checkout old version and create datastore
|
||||||
git checkout 0.49.1
|
git checkout 0.49.1
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
|
||||||
# Semver means never use .01, or 00. Should be .1.
|
# Semver means never use .01, or 00. Should be .1.
|
||||||
__version__ = '0.54.2'
|
__version__ = '0.54.3'
|
||||||
|
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ def get_timeago_locale(flask_locale):
|
|||||||
'no': 'nb_NO', # Norwegian Bokmål
|
'no': 'nb_NO', # Norwegian Bokmål
|
||||||
'hi': 'in_HI', # Hindi
|
'hi': 'in_HI', # Hindi
|
||||||
'cs': 'en', # Czech not supported by timeago, fallback to English
|
'cs': 'en', # Czech not supported by timeago, fallback to English
|
||||||
|
'uk': 'uk', # Ukrainian
|
||||||
'en_GB': 'en', # British English - timeago uses 'en'
|
'en_GB': 'en', # British English - timeago uses 'en'
|
||||||
'en_US': 'en', # American English - timeago uses 'en'
|
'en_US': 'en', # American English - timeago uses 'en'
|
||||||
}
|
}
|
||||||
@@ -67,6 +68,7 @@ LANGUAGE_DATA = {
|
|||||||
'tr': {'flag': 'fi fi-tr fis', 'name': 'Türkçe'},
|
'tr': {'flag': 'fi fi-tr fis', 'name': 'Türkçe'},
|
||||||
'ar': {'flag': 'fi fi-sa fis', 'name': 'العربية'},
|
'ar': {'flag': 'fi fi-sa fis', 'name': 'العربية'},
|
||||||
'hi': {'flag': 'fi fi-in fis', 'name': 'हिन्दी'},
|
'hi': {'flag': 'fi fi-in fis', 'name': 'हिन्दी'},
|
||||||
|
'uk': {'flag': 'fi fi-ua fis', 'name': 'Українська'},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import psutil
|
import psutil
|
||||||
import time
|
import time
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
import multiprocessing
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import arrow
|
import arrow
|
||||||
@@ -191,6 +192,34 @@ def cleanup(datastore_path):
|
|||||||
if os.path.isfile(f):
|
if os.path.isfile(f):
|
||||||
os.unlink(f)
|
os.unlink(f)
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
"""Configure pytest environment before tests run.
|
||||||
|
|
||||||
|
CRITICAL: Set multiprocessing start method to 'fork' for Python 3.14+ compatibility.
|
||||||
|
|
||||||
|
Python 3.14 changed the default start method from 'fork' to 'forkserver' on Linux.
|
||||||
|
The forkserver method requires all objects to be picklable, but pytest-flask's
|
||||||
|
LiveServer uses nested functions that can't be pickled.
|
||||||
|
|
||||||
|
Setting 'fork' explicitly:
|
||||||
|
- Maintains compatibility with Python 3.10-3.13 (where 'fork' was already default)
|
||||||
|
- Fixes Python 3.14 pickling errors
|
||||||
|
- Only affects Unix-like systems (Windows uses 'spawn' regardless)
|
||||||
|
|
||||||
|
See: https://github.com/python/cpython/issues/126831
|
||||||
|
See: https://docs.python.org/3/whatsnew/3.14.html
|
||||||
|
"""
|
||||||
|
# Only set if not already set (respects existing configuration)
|
||||||
|
if multiprocessing.get_start_method(allow_none=True) is None:
|
||||||
|
try:
|
||||||
|
# 'fork' is available on Unix-like systems (Linux, macOS)
|
||||||
|
# On Windows, this will have no effect as 'spawn' is the only option
|
||||||
|
multiprocessing.set_start_method('fork', force=False)
|
||||||
|
logger.debug("Set multiprocessing start method to 'fork' for Python 3.14+ compatibility")
|
||||||
|
except (ValueError, RuntimeError):
|
||||||
|
# Already set, not available on this platform, or context already created
|
||||||
|
pass
|
||||||
|
|
||||||
def pytest_addoption(parser):
|
def pytest_addoption(parser):
|
||||||
"""Add custom command-line options for pytest.
|
"""Add custom command-line options for pytest.
|
||||||
|
|
||||||
|
|||||||
@@ -584,13 +584,16 @@ def test_static_directory_traversal(client, live_server, measure_memory_usage, d
|
|||||||
|
|
||||||
def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memory_usage, datastore_path):
|
def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memory_usage, datastore_path):
|
||||||
"""
|
"""
|
||||||
SSRF protection: IANA-reserved/private IP addresses must be blocked by default.
|
SSRF protection: IANA-reserved/private IP addresses are blocked at fetch-time, not add-time.
|
||||||
|
|
||||||
|
Watches targeting private/reserved IPs can be *added* freely; the block happens when the
|
||||||
|
fetcher actually tries to reach the URL (via validate_iana_url() in call_browser()).
|
||||||
|
|
||||||
Covers:
|
Covers:
|
||||||
1. is_private_hostname() correctly classifies all reserved ranges
|
1. is_private_hostname() correctly classifies all reserved ranges
|
||||||
2. is_safe_valid_url() rejects private-IP URLs at add-time (env var off)
|
2. is_safe_valid_url() ALLOWS private-IP URLs at add-time (IANA check moved to fetch-time)
|
||||||
3. is_safe_valid_url() allows private-IP URLs when ALLOW_IANA_RESTRICTED_ADDRESSES=true
|
3. ALLOW_IANA_RESTRICTED_ADDRESSES has no effect on add-time; it only controls fetch-time
|
||||||
4. UI form rejects private-IP URLs and shows the standard error message
|
4. UI form accepts private-IP URLs at add-time without error
|
||||||
5. Requests fetcher blocks fetch-time DNS rebinding (fresh check on every fetch)
|
5. Requests fetcher blocks fetch-time DNS rebinding (fresh check on every fetch)
|
||||||
6. Requests fetcher blocks redirects that lead to a private IP (open-redirect bypass)
|
6. Requests fetcher blocks redirects that lead to a private IP (open-redirect bypass)
|
||||||
|
|
||||||
@@ -623,9 +626,10 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
|
|||||||
assert not is_private_hostname(host), f"{host} should be identified as public"
|
assert not is_private_hostname(host), f"{host} should be identified as public"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 2. is_safe_valid_url() blocks private-IP URLs (env var off)
|
# 2. is_safe_valid_url() ALLOWS private-IP URLs at add-time
|
||||||
|
# IANA check is no longer done here — it moved to fetch-time validate_iana_url()
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
blocked_urls = [
|
private_ip_urls = [
|
||||||
'http://127.0.0.1/',
|
'http://127.0.0.1/',
|
||||||
'http://10.0.0.1/',
|
'http://10.0.0.1/',
|
||||||
'http://172.16.0.1/',
|
'http://172.16.0.1/',
|
||||||
@@ -636,21 +640,24 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
|
|||||||
'http://[fc00::1]/',
|
'http://[fc00::1]/',
|
||||||
'http://[fe80::1]/',
|
'http://[fe80::1]/',
|
||||||
]
|
]
|
||||||
for url in blocked_urls:
|
for url in private_ip_urls:
|
||||||
assert not is_safe_valid_url(url), f"{url} should be blocked by is_safe_valid_url"
|
assert is_safe_valid_url(url), f"{url} should be allowed by is_safe_valid_url (IANA check is at fetch-time)"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 3. ALLOW_IANA_RESTRICTED_ADDRESSES=true bypasses the block
|
# 3. ALLOW_IANA_RESTRICTED_ADDRESSES does not affect add-time validation
|
||||||
|
# It only controls fetch-time blocking inside validate_iana_url()
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'true')
|
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'true')
|
||||||
assert is_safe_valid_url('http://127.0.0.1/'), \
|
assert is_safe_valid_url('http://127.0.0.1/'), \
|
||||||
"Private IP should be allowed when ALLOW_IANA_RESTRICTED_ADDRESSES=true"
|
"Private IP should be allowed at add-time regardless of ALLOW_IANA_RESTRICTED_ADDRESSES"
|
||||||
|
|
||||||
# Restore the block for the remaining assertions
|
|
||||||
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
|
monkeypatch.setenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')
|
||||||
|
assert is_safe_valid_url('http://127.0.0.1/'), \
|
||||||
|
"Private IP should be allowed at add-time regardless of ALLOW_IANA_RESTRICTED_ADDRESSES"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 4. UI form rejects private-IP URLs
|
# 4. UI form accepts private-IP URLs at add-time
|
||||||
|
# The watch is created; the SSRF block fires later at fetch-time
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
for url in ['http://127.0.0.1/', 'http://169.254.169.254/latest/meta-data/']:
|
for url in ['http://127.0.0.1/', 'http://169.254.169.254/latest/meta-data/']:
|
||||||
res = client.post(
|
res = client.post(
|
||||||
@@ -658,8 +665,8 @@ def test_ssrf_private_ip_blocked(client, live_server, monkeypatch, measure_memor
|
|||||||
data={'url': url, 'tags': ''},
|
data={'url': url, 'tags': ''},
|
||||||
follow_redirects=True
|
follow_redirects=True
|
||||||
)
|
)
|
||||||
assert b'Watch protocol is not permitted or invalid URL format' in res.data, \
|
assert b'Watch protocol is not permitted or invalid URL format' not in res.data, \
|
||||||
f"UI should reject {url}"
|
f"UI should accept {url} at add-time (SSRF is blocked at fetch-time)"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 5. Fetch-time DNS-rebinding check in the requests fetcher
|
# 5. Fetch-time DNS-rebinding check in the requests fetcher
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1978,7 +1978,7 @@ msgstr "Format d'heure invalide. Utilisez HH:MM."
|
|||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "Not a valid timezone name"
|
msgid "Not a valid timezone name"
|
||||||
msgstr "Ce n'est pas un nom de fuseau horaire valide"
|
msgstr "Nom de fuseau horaire invalide"
|
||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "not set"
|
msgid "not set"
|
||||||
@@ -2054,9 +2054,7 @@ msgstr "secondes"
|
|||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "Notification Body and Title is required when a Notification URL is used"
|
msgid "Notification Body and Title is required when a Notification URL is used"
|
||||||
msgstr ""
|
msgstr "Le corps et le titre de la notification sont requis lorsqu'une URL de notification est utilisée"
|
||||||
"Le corps et le titre de la notification sont requis lorsqu'une URL de notification est utiliséeLe corps et le titre "
|
|
||||||
"de la notification sont requis lorsqu'une URL de notification est utilisée"
|
|
||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
#, python-format
|
#, python-format
|
||||||
@@ -2185,11 +2183,11 @@ msgstr "Utilisez les paramètres globaux pour le temps entre la vérification et
|
|||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "CSS/JSONPath/JQ/XPath Filters"
|
msgid "CSS/JSONPath/JQ/XPath Filters"
|
||||||
msgstr "Filtre CSS/xPath"
|
msgstr "Filtre CSS/JSONPath/JQ/XPath"
|
||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "Remove elements"
|
msgid "Remove elements"
|
||||||
msgstr "Sélectionner par élément"
|
msgstr "Supprimer par élément"
|
||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "Extract text"
|
msgid "Extract text"
|
||||||
@@ -2337,7 +2335,7 @@ msgstr "URL du proxy"
|
|||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "Proxy URLs must start with http://, https:// or socks5://"
|
msgid "Proxy URLs must start with http://, https:// or socks5://"
|
||||||
msgstr "Les URL proxy doivent commencer par http://, https:// ou chaussettes5://"
|
msgstr "Les URL proxy doivent commencer par http://, https:// ou socks5://"
|
||||||
|
|
||||||
#: changedetectionio/forms.py
|
#: changedetectionio/forms.py
|
||||||
msgid "Browser connection URL"
|
msgid "Browser connection URL"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
BIN
changedetectionio/translations/uk/LC_MESSAGES/messages.mo
Normal file
BIN
changedetectionio/translations/uk/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
3026
changedetectionio/translations/uk/LC_MESSAGES/messages.po
Normal file
3026
changedetectionio/translations/uk/LC_MESSAGES/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -71,12 +71,15 @@ def is_private_hostname(hostname):
|
|||||||
for info in socket.getaddrinfo(hostname, None):
|
for info in socket.getaddrinfo(hostname, None):
|
||||||
ip = ipaddress.ip_address(info[4][0])
|
ip = ipaddress.ip_address(info[4][0])
|
||||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||||
|
logger.warning(f"Hostname '{hostname} - {ip} - ip.is_private = {ip.is_private}, ip.is_loopback = {ip.is_loopback}, ip.is_link_local = {ip.is_link_local}, ip.is_reserved = {ip.is_reserved}")
|
||||||
return True
|
return True
|
||||||
except socket.gaierror as e:
|
except socket.gaierror as e:
|
||||||
logger.warning(f"{hostname} error checking {str(e)}")
|
logger.warning(f"{hostname} error checking {str(e)}")
|
||||||
return False
|
return False
|
||||||
|
logger.info(f"Hostname '{hostname}' is NOT private/IANA restricted.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def is_safe_valid_url(test_url):
|
def is_safe_valid_url(test_url):
|
||||||
from changedetectionio import strtobool
|
from changedetectionio import strtobool
|
||||||
from changedetectionio.jinja2_custom import render as jinja_render
|
from changedetectionio.jinja2_custom import render as jinja_render
|
||||||
@@ -139,12 +142,4 @@ def is_safe_valid_url(test_url):
|
|||||||
logger.warning(f'URL f"{test_url}" failed validation, aborting.')
|
logger.warning(f'URL f"{test_url}" failed validation, aborting.')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Block IANA-restricted (private/reserved) IP addresses unless explicitly allowed.
|
|
||||||
# This is an add-time check; fetch-time re-validation in requests.py handles DNS rebinding.
|
|
||||||
if not strtobool(os.getenv('ALLOW_IANA_RESTRICTED_ADDRESSES', 'false')):
|
|
||||||
parsed = urlparse(test_url)
|
|
||||||
if parsed.hostname and is_private_hostname(parsed.hostname):
|
|
||||||
logger.warning(f'URL "{test_url}" resolves to a private/reserved IP address, aborting.')
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
Reference in New Issue
Block a user