diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py index 9f0cebb85..8246b0d01 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -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 diff --git a/changedetectionio/store/__init__.py b/changedetectionio/store/__init__.py index 52383658c..a35fca966 100644 --- a/changedetectionio/store/__init__.py +++ b/changedetectionio/store/__init__.py @@ -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) diff --git a/changedetectionio/tests/test_security.py b/changedetectionio/tests/test_security.py index c75b8d325..afbf99fe3 100644 --- a/changedetectionio/tests/test_security.py +++ b/changedetectionio/tests/test_security.py @@ -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// 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] +