mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-05-07 02:01:04 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d427dbc2b2 | |||
| 52b189fc7c | |||
| 866b442576 | |||
| ba20f66cee | |||
| e064bcea13 | |||
| 74a7eb1b11 |
@@ -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.54.10'
|
||||
__version__ = '0.55.1'
|
||||
|
||||
from changedetectionio.strtobool import strtobool
|
||||
from json.decoder import JSONDecodeError
|
||||
|
||||
@@ -981,6 +981,11 @@ def changedetection_app(config=None, datastore_o=None):
|
||||
"queued_data": all_queued
|
||||
})
|
||||
|
||||
if strtobool(os.getenv('HISTORY_SNAPSHOT_FILE_ALLOW_OUTSIDE_WATCH_DATADIR', 'False')):
|
||||
logger.warning("SECURITY WARNING: HISTORY_SNAPSHOT_FILE_ALLOW_OUTSIDE_WATCH_DATADIR is enabled — "
|
||||
"snapshot reads are NOT confined to the watch data directory. "
|
||||
"This disables protection against path traversal via restored backups (GHSA-8757-69j2-hx56).")
|
||||
|
||||
# Start the async workers during app initialization
|
||||
# Can be overridden by ENV or use the default settings
|
||||
n_workers = int(os.getenv("FETCH_WORKERS", datastore.data['settings']['requests']['workers']))
|
||||
|
||||
@@ -282,7 +282,7 @@ def xpath_filter(xpath_filter, html_content, append_pretty_line_formatting=False
|
||||
try:
|
||||
if is_xml:
|
||||
# So that we can keep CDATA for cdata_in_document_to_text() to process
|
||||
parser = etree.XMLParser(strip_cdata=False)
|
||||
parser = etree.XMLParser(strip_cdata=False, resolve_entities=False, no_network=True)
|
||||
# For XML/RSS content, use etree.fromstring to properly handle XML declarations
|
||||
tree = etree.fromstring(html_content.encode('utf-8') if isinstance(html_content, str) else html_content, parser=parser)
|
||||
else:
|
||||
@@ -346,7 +346,7 @@ def xpath1_filter(xpath_filter, html_content, append_pretty_line_formatting=Fals
|
||||
try:
|
||||
if is_xml:
|
||||
# So that we can keep CDATA for cdata_in_document_to_text() to process
|
||||
parser = etree.XMLParser(strip_cdata=False)
|
||||
parser = etree.XMLParser(strip_cdata=False, resolve_entities=False, no_network=True)
|
||||
# For XML/RSS content, use etree.fromstring to properly handle XML declarations
|
||||
tree = etree.fromstring(html_content.encode('utf-8') if isinstance(html_content, str) else html_content, parser=parser)
|
||||
else:
|
||||
|
||||
@@ -465,22 +465,21 @@ class model(EntityPersistenceMixin, watch_base):
|
||||
if ',' in i:
|
||||
k, v = i.strip().split(',', 2)
|
||||
|
||||
# The index history could contain a relative path, so we need to make the fullpath
|
||||
# so that python can read it
|
||||
# Cross-platform: check for any path separator (works on Windows and Unix)
|
||||
if os.sep not in v and '/' not in v and '\\' not in v:
|
||||
# Relative filename only, no path separators
|
||||
v = os.path.join(self.data_dir, v)
|
||||
else:
|
||||
# It's possible that they moved the datadir on older versions
|
||||
# So the snapshot exists but is in a different path
|
||||
# Cross-platform: use os.path.basename instead of split('/')
|
||||
snapshot_fname = os.path.basename(v)
|
||||
proposed_new_path = os.path.join(self.data_dir, snapshot_fname)
|
||||
if not os.path.exists(v) and os.path.exists(proposed_new_path):
|
||||
v = proposed_new_path
|
||||
# Always resolve history entries to within the watch's own data directory.
|
||||
# Entries restored from backup could contain absolute or traversal paths —
|
||||
# never trust them. Use realpath to also block symlink-based escapes.
|
||||
safe_data_dir = os.path.realpath(self.data_dir)
|
||||
snapshot_fname = os.path.basename(v.strip())
|
||||
resolved_path = os.path.realpath(os.path.join(self.data_dir, snapshot_fname))
|
||||
|
||||
tmp_history[k] = v
|
||||
if not resolved_path.startswith(safe_data_dir + os.sep) and resolved_path != safe_data_dir:
|
||||
logger.warning(f"Skipping unsafe history entry for {self.get('uuid')}: {v!r}")
|
||||
continue
|
||||
|
||||
if not os.path.exists(resolved_path):
|
||||
continue
|
||||
|
||||
tmp_history[k] = resolved_path
|
||||
|
||||
if len(tmp_history):
|
||||
self.__newest_history_key = list(tmp_history.keys())[-1]
|
||||
@@ -563,6 +562,15 @@ class model(EntityPersistenceMixin, watch_base):
|
||||
if not filepath:
|
||||
filepath = self.history[timestamp]
|
||||
|
||||
# Confine every read to the watch's own data directory — defence in depth
|
||||
# against any path that bypasses the history parser (e.g. direct filepath= callers).
|
||||
# Set HISTORY_SNAPSHOT_FILE_ALLOW_OUTSIDE_WATCH_DATADIR=true to disable (not recommended).
|
||||
if self.data_dir and not strtobool(os.getenv('HISTORY_SNAPSHOT_FILE_ALLOW_OUTSIDE_WATCH_DATADIR', 'False')):
|
||||
safe_data_dir = os.path.realpath(self.data_dir)
|
||||
resolved = os.path.realpath(filepath)
|
||||
if not (resolved.startswith(safe_data_dir + os.sep) or resolved == safe_data_dir):
|
||||
raise PermissionError(f"Snapshot path {filepath!r} is outside the watch data directory")
|
||||
|
||||
# Check if binary file (image, PDF, etc.)
|
||||
# Binary files are NEVER saved with .br compression, only text files are
|
||||
binary_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.pdf', '.bin', '.jfif')
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
* Provides accessible, animated confirmation dialogs
|
||||
*/
|
||||
|
||||
// Escapes a string for safe insertion via innerHTML
|
||||
function _modalEscapeHTML(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
const ModalDialog = {
|
||||
/**
|
||||
* Show a confirmation dialog
|
||||
@@ -125,9 +132,10 @@ const ModalDialog = {
|
||||
* @param {Function} onConfirm - Callback when confirmed
|
||||
*/
|
||||
confirmDelete: function(itemName, onConfirm) {
|
||||
const safeName = _modalEscapeHTML(itemName);
|
||||
return this.confirm({
|
||||
title: 'Delete ' + itemName + '?',
|
||||
message: `<p>Are you sure you want to delete <strong>${itemName}</strong>?</p><p>This action cannot be undone.</p>`,
|
||||
title: 'Delete ' + safeName + '?',
|
||||
message: `<p>Are you sure you want to delete <strong>${safeName}</strong>?</p><p>This action cannot be undone.</p>`,
|
||||
type: 'danger',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
@@ -141,9 +149,10 @@ const ModalDialog = {
|
||||
* @param {Function} onConfirm - Callback when confirmed
|
||||
*/
|
||||
confirmUnlink: function(itemName, onConfirm) {
|
||||
const safeName = _modalEscapeHTML(itemName);
|
||||
return this.confirm({
|
||||
title: 'Unlink ' + itemName + '?',
|
||||
message: `<p>Are you sure you want to unlink all watches from <strong>${itemName}</strong>?</p><p>The tag will be kept but watches will be removed from it.</p>`,
|
||||
title: 'Unlink ' + safeName + '?',
|
||||
message: `<p>Are you sure you want to unlink all watches from <strong>${safeName}</strong>?</p><p>The tag will be kept but watches will be removed from it.</p>`,
|
||||
type: 'warning',
|
||||
confirmText: 'Unlink',
|
||||
cancelText: 'Cancel',
|
||||
@@ -172,11 +181,11 @@ $(document).ready(function() {
|
||||
const url = $element.attr('href');
|
||||
|
||||
const config = {
|
||||
type: $element.data('confirm-type') || 'danger',
|
||||
title: $element.data('confirm-title') || 'Confirm Action',
|
||||
message: $element.data('confirm-message') || '<p>Are you sure you want to proceed?</p>',
|
||||
confirmText: $element.data('confirm-button') || 'Confirm',
|
||||
cancelText: $element.data('cancel-button') || 'Cancel',
|
||||
type: $element.attr('data-confirm-type') || 'danger',
|
||||
title: $element.attr('data-confirm-title') || 'Confirm Action',
|
||||
message: $element.attr('data-confirm-message') || '<p>Are you sure you want to proceed?</p>',
|
||||
confirmText: $element.attr('data-confirm-button') || 'Confirm',
|
||||
cancelText: $element.attr('data-cancel-button') || 'Cancel',
|
||||
onConfirm: function() {
|
||||
// If it's a link, navigate to the URL
|
||||
if ($element.is('a')) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import pytest
|
||||
|
||||
from flask import url_for
|
||||
from zipfile import ZipFile, ZIP_DEFLATED
|
||||
|
||||
from changedetectionio.tests.util import set_modified_response
|
||||
from .util import live_server_setup, wait_for_all_checks, delete_all_watches
|
||||
@@ -823,3 +827,75 @@ def test_unresolvable_hostname_is_allowed(client, live_server, monkeypatch):
|
||||
res = client.get(url_for('watchlist.index'))
|
||||
assert b'this-host-does-not-exist-xyz987.invalid' in res.data, \
|
||||
"Unresolvable hostname watch should appear in the watch overview list"
|
||||
|
||||
|
||||
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
|
||||
expose arbitrary local files through the preview or API endpoints.
|
||||
|
||||
Attack chain:
|
||||
1. Attacker creates a backup ZIP with a malicious history.txt containing an absolute
|
||||
path (e.g. /etc/passwd) as a snapshot reference.
|
||||
2. Victim restores the backup.
|
||||
3. Attacker reads the targeted file via the Preview page.
|
||||
|
||||
The fix ensures history entries are always resolved to os.path.basename() joined with
|
||||
the watch's data_dir, and rejects entries that escape that directory.
|
||||
"""
|
||||
set_original_response(datastore_path=datastore_path)
|
||||
|
||||
datastore = live_server.app.config['DATASTORE']
|
||||
watch_url = url_for('test_endpoint', _external=True)
|
||||
|
||||
# Create a real watch and trigger a check so we have a valid backup structure
|
||||
uuid = datastore.add_watch(url=watch_url)
|
||||
client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
|
||||
wait_for_all_checks(client)
|
||||
|
||||
# Download a legitimate backup to use as a template
|
||||
client.get(url_for("backups.request_backup"), follow_redirects=True)
|
||||
time.sleep(4)
|
||||
res = client.get(url_for("backups.download_backup", filename="latest"), follow_redirects=True)
|
||||
assert res.content_type == "application/zip"
|
||||
|
||||
# Tamper: replace the history.txt inside the backup with a malicious entry
|
||||
# that points at /etc/passwd (a file that exists on any Unix system)
|
||||
original_zip = ZipFile(io.BytesIO(res.data))
|
||||
tampered_buf = io.BytesIO()
|
||||
with ZipFile(tampered_buf, 'w', ZIP_DEFLATED) as new_zip:
|
||||
for item in original_zip.infolist():
|
||||
data = original_zip.read(item.filename)
|
||||
# Replace the watch's history.txt with a malicious absolute path entry
|
||||
if item.filename.endswith('history.txt') and uuid in item.filename:
|
||||
data = b'1776969105,/etc/passwd\n'
|
||||
new_zip.writestr(item, data)
|
||||
|
||||
tampered_buf.seek(0)
|
||||
tampered_zip_data = tampered_buf.read()
|
||||
|
||||
# Restore the tampered backup
|
||||
res = client.post(
|
||||
url_for("backups.restore.backups_restore_start"),
|
||||
data={
|
||||
'zip_file': (io.BytesIO(tampered_zip_data), 'malicious_backup.zip'),
|
||||
'include_watches': 'y',
|
||||
'include_watches_replace_existing': 'y',
|
||||
},
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True
|
||||
)
|
||||
assert res.status_code == 200
|
||||
time.sleep(2)
|
||||
|
||||
# Now try to read the /etc/passwd contents via the Preview page using the injected timestamp
|
||||
res = client.get(
|
||||
url_for("ui.ui_preview.preview_page", uuid=uuid) + "?timestamp=1776969105",
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
# The preview must NOT contain typical /etc/passwd content
|
||||
assert b'root:' not in res.data, \
|
||||
"Preview must not expose /etc/passwd — history path traversal not blocked"
|
||||
assert b'/bin/' not in res.data or b'No history' in res.data or res.status_code in [404, 500], \
|
||||
"Preview must not serve arbitrary local files from a malicious history entry"
|
||||
|
||||
@@ -311,5 +311,72 @@ class TestLLMDiffSummaryCache(unittest.TestCase):
|
||||
assert watch.get_llm_diff_summary('1000', '2000', prompt=self.PROMPT) == 'Updated summary'
|
||||
|
||||
|
||||
class TestHistoryPathTraversal(unittest.TestCase):
|
||||
"""GHSA-8757-69j2-hx56: history.txt must not allow reads outside the watch data dir."""
|
||||
|
||||
def _make_watch(self):
|
||||
mock_datastore = {'settings': {'application': {}}, 'watching': {}}
|
||||
watch = Watch.model(datastore_path='/tmp', __datastore=mock_datastore, default={})
|
||||
watch.ensure_data_dir_exists()
|
||||
return watch
|
||||
|
||||
def _write_history_txt(self, watch, lines):
|
||||
"""Directly write raw lines to history.txt to simulate a restored backup."""
|
||||
fname = os.path.join(watch.data_dir, watch.history_index_filename)
|
||||
with open(fname, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
def test_absolute_path_in_history_is_rejected(self):
|
||||
"""An absolute path like /etc/passwd must not appear in history."""
|
||||
watch = self._make_watch()
|
||||
self._write_history_txt(watch, ['1000000000,/etc/passwd\n'])
|
||||
history = watch.history
|
||||
self.assertEqual(history, {}, "Absolute path entry must be rejected")
|
||||
|
||||
def test_traversal_path_in_history_is_rejected(self):
|
||||
"""A relative traversal path like ../../etc/passwd must not appear in history."""
|
||||
watch = self._make_watch()
|
||||
self._write_history_txt(watch, ['1000000000,../../etc/passwd\n'])
|
||||
history = watch.history
|
||||
self.assertEqual(history, {}, "Path traversal entry must be rejected")
|
||||
|
||||
def test_normal_snapshot_entry_is_accepted(self):
|
||||
"""A bare filename written by save_history_blob must still load correctly."""
|
||||
import uuid as uuid_builder
|
||||
watch = self._make_watch()
|
||||
watch.save_history_blob(contents="hello world", timestamp=1000000000, snapshot_id=str(uuid_builder.uuid4()))
|
||||
history = watch.history
|
||||
self.assertEqual(len(history), 1, "Normal snapshot entry must be accepted")
|
||||
self.assertTrue(
|
||||
list(history.values())[0].startswith(watch.data_dir),
|
||||
"Resolved path must be inside the watch data directory"
|
||||
)
|
||||
|
||||
def test_get_history_snapshot_blocks_outside_path_directly(self):
|
||||
"""get_history_snapshot(filepath=...) must raise if the path escapes data_dir."""
|
||||
watch = self._make_watch()
|
||||
with self.assertRaises(PermissionError):
|
||||
watch.get_history_snapshot(filepath='/etc/passwd')
|
||||
|
||||
def test_get_history_snapshot_blocks_traversal_directly(self):
|
||||
"""get_history_snapshot(filepath=...) must raise on ../../ traversal paths."""
|
||||
watch = self._make_watch()
|
||||
with self.assertRaises(PermissionError):
|
||||
watch.get_history_snapshot(filepath=os.path.join(watch.data_dir, '../../etc/passwd'))
|
||||
|
||||
def test_resolved_path_stays_inside_data_dir(self):
|
||||
"""All resolved history paths must reside within the watch's data_dir."""
|
||||
import uuid as uuid_builder
|
||||
watch = self._make_watch()
|
||||
for ts in [1000000001, 1000000002, 1000000003]:
|
||||
watch.save_history_blob(contents=f"content {ts}", timestamp=ts, snapshot_id=str(uuid_builder.uuid4()))
|
||||
safe_dir = os.path.realpath(watch.data_dir)
|
||||
for path in watch.history.values():
|
||||
self.assertTrue(
|
||||
os.path.realpath(path).startswith(safe_dir),
|
||||
f"Path {path!r} escapes the watch data directory"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# run from dir above changedetectionio/ dir
|
||||
# python3 -m pytest changedetectionio/tests/unit/test_xml_security.py
|
||||
|
||||
import pytest
|
||||
from changedetectionio import html_tools
|
||||
|
||||
|
||||
def _xxe_payload(file_path: str) -> str:
|
||||
return f"""<?xml version="1.0"?>
|
||||
<!DOCTYPE root [
|
||||
<!ENTITY xxe SYSTEM "file://{file_path}">
|
||||
]>
|
||||
<root><item>&xxe;</item></root>"""
|
||||
|
||||
|
||||
def test_xxe_not_expanded_xpath_filter(tmp_path):
|
||||
"""xpath_filter must not expand external entities (CVE-2026-41895)."""
|
||||
sentinel_file = tmp_path / "sentinel.txt"
|
||||
sentinel = "xxe_sentinel_should_never_appear_in_output"
|
||||
sentinel_file.write_text(sentinel)
|
||||
|
||||
result = html_tools.xpath_filter("//item", _xxe_payload(sentinel_file), is_xml=True)
|
||||
assert sentinel not in result
|
||||
|
||||
|
||||
def test_xxe_not_expanded_xpath1_filter(tmp_path):
|
||||
"""xpath1_filter must not expand external entities (CVE-2026-41895)."""
|
||||
sentinel_file = tmp_path / "sentinel.txt"
|
||||
sentinel = "xxe_sentinel_should_never_appear_in_output"
|
||||
sentinel_file.write_text(sentinel)
|
||||
|
||||
result = html_tools.xpath1_filter("//item", _xxe_payload(sentinel_file), is_xml=True)
|
||||
assert sentinel not in result
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1429,7 +1429,7 @@ msgstr "已將 1 個監測任務排入複查佇列。"
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
msgid "Queued {} watches for rechecking ({} already queued or running)."
|
||||
msgstr "已將 {} 個監測任務排入複查佇列。"
|
||||
msgstr "已將 {} 個監測任務排入複查佇列({} 個已在佇列中或正在執行)。"
|
||||
|
||||
#: changedetectionio/blueprint/ui/__init__.py
|
||||
#, python-brace-format
|
||||
@@ -4154,4 +4154,3 @@ msgstr "否"
|
||||
#: changedetectionio/widgets/ternary_boolean.py
|
||||
msgid "Main settings"
|
||||
msgstr "主設定"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user