mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-26 07:16:13 +00:00
Merge branch 'master' into restock-value-template-fix
This commit is contained in:
@@ -712,8 +712,29 @@ def changedetection_app(config=None, datastore_o=None):
|
||||
def static_content(group, filename):
|
||||
from flask import make_response
|
||||
import re
|
||||
group = re.sub(r'[^\w.-]+', '', group.lower())
|
||||
filename = re.sub(r'[^\w.-]+', '', filename.lower())
|
||||
def sanitize_filename(filename):
|
||||
filename = filename.lower()
|
||||
|
||||
# Split extension
|
||||
name, ext = os.path.splitext(filename)
|
||||
|
||||
# Remove unwanted chars from name and extension
|
||||
name = re.sub(r'[^a-z0-9_]+', '', name)
|
||||
ext = re.sub(r'[^a-z0-9]+', '', ext.lstrip('.'))
|
||||
|
||||
if not name:
|
||||
raise ValueError("Invalid filename")
|
||||
|
||||
# Rebuild with at most one dot
|
||||
return f"{name}.{ext}" if ext else name
|
||||
|
||||
# Strict sanitization: only allow a-z, 0-9, and underscore (blocks .. and other traversal)
|
||||
group = re.sub(r'[^a-z0-9_]+', '', group.lower())
|
||||
filename = sanitize_filename(filename)
|
||||
|
||||
# Additional safety: reject if sanitization resulted in empty strings
|
||||
if not group or not filename:
|
||||
abort(404)
|
||||
|
||||
if group == 'screenshot':
|
||||
# Could be sensitive, follow password requirements
|
||||
|
||||
@@ -235,6 +235,8 @@ class ChangeDetectionStore(DatastoreUpdatesMixin, FileSavingDataStore):
|
||||
# No datastore yet - check if this is a fresh install or legacy migration
|
||||
self.init_fresh_install(include_default_watches=include_default_watches,
|
||||
version_tag=version_tag)
|
||||
# Maybe they copied a bunch of watch subdirs across too
|
||||
self._load_state()
|
||||
|
||||
def init_fresh_install(self, include_default_watches, version_tag):
|
||||
# Generate app_guid FIRST (required for all operations)
|
||||
|
||||
@@ -478,3 +478,80 @@ def test_logout_with_redirect(client, live_server, measure_memory_usage, datasto
|
||||
# Cleanup
|
||||
del client.application.config['DATASTORE'].data['settings']['application']['password']
|
||||
|
||||
|
||||
def test_static_directory_traversal(client, live_server, measure_memory_usage, datastore_path):
|
||||
"""
|
||||
Test that the static file serving route properly blocks directory traversal attempts.
|
||||
This tests the fix for GHSA-9jj8-v89v-xjvw (CVE pending).
|
||||
|
||||
The vulnerability was in /static/<group>/<filename> where the sanitization regex
|
||||
allowed dots, enabling "../" traversal to read application source files.
|
||||
|
||||
The fix changed the regex from r'[^\w.-]+' to r'[^a-z0-9_]+' which blocks dots.
|
||||
"""
|
||||
|
||||
# Test 1: Direct .. traversal attempt (URL-encoded)
|
||||
res = client.get(
|
||||
"/static/%2e%2e/flask_app.py",
|
||||
follow_redirects=False
|
||||
)
|
||||
# Should be blocked (404 or 403)
|
||||
assert res.status_code in [404, 403], f"Expected 404/403, got {res.status_code}"
|
||||
# Should NOT contain application source code
|
||||
assert b"def static_content" not in res.data
|
||||
assert b"changedetection_app" not in res.data
|
||||
|
||||
# Test 2: Direct .. traversal attempt (unencoded)
|
||||
res = client.get(
|
||||
"/static/../flask_app.py",
|
||||
follow_redirects=False
|
||||
)
|
||||
assert res.status_code in [404, 403], f"Expected 404/403, got {res.status_code}"
|
||||
assert b"def static_content" not in res.data
|
||||
|
||||
# Test 3: Multiple dots traversal
|
||||
res = client.get(
|
||||
"/static/..../flask_app.py",
|
||||
follow_redirects=False
|
||||
)
|
||||
assert res.status_code in [404, 403], f"Expected 404/403, got {res.status_code}"
|
||||
assert b"def static_content" not in res.data
|
||||
|
||||
# Test 4: Try to access other application files
|
||||
for filename in ["__init__.py", "datastore.py", "store.py"]:
|
||||
res = client.get(
|
||||
f"/static/%2e%2e/{filename}",
|
||||
follow_redirects=False
|
||||
)
|
||||
assert res.status_code in [404, 403], f"File {filename} should be blocked"
|
||||
# Should not contain Python code indicators
|
||||
assert b"import" not in res.data or b"# Test" in res.data # Allow "1 Imported" etc
|
||||
|
||||
# Test 5: Verify legitimate static files still work
|
||||
# Note: We can't test actual files without knowing what exists,
|
||||
# but we can verify the sanitization doesn't break valid groups
|
||||
res = client.get(
|
||||
"/static/images/test.png", # Will 404 if file doesn't exist, but won't traverse
|
||||
follow_redirects=False
|
||||
)
|
||||
# Should get 404 (file not found) not 403 (blocked)
|
||||
# This confirms the group name "images" is valid
|
||||
assert res.status_code == 404
|
||||
|
||||
# Test 6: Ensure hyphens and dots are blocked in group names
|
||||
res = client.get(
|
||||
"/static/../../../etc/passwd",
|
||||
follow_redirects=False
|
||||
)
|
||||
assert res.status_code in [404, 403]
|
||||
assert b"root:" not in res.data
|
||||
|
||||
# Test 7: Test that underscores still work (they're allowed)
|
||||
res = client.get(
|
||||
"/static/visual_selector_data/test.json",
|
||||
follow_redirects=False
|
||||
)
|
||||
# visual_selector_data is a real group, but requires auth
|
||||
# Should get 403 (not authenticated) or 404 (file not found), not a path traversal
|
||||
assert res.status_code in [403, 404]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user