From 4bfce3c4b0decff08e83dfbb7e7d1a50342cb2a2 Mon Sep 17 00:00:00 2001 From: Northern Monkey <42392870+pongoe@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:09:13 +0100 Subject: [PATCH] Security: hide the running version from anonymous visitors when a password is set (#2190) (#4306) --- changedetectionio/__init__.py | 8 ++++++-- changedetectionio/flask_app.py | 12 +++++++++++- changedetectionio/tests/test_access_control.py | 4 ++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index 1263e763..a01fa939 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -628,9 +628,13 @@ def main(): @app.context_processor def inject_template_globals(): from changedetectionio.llm.evaluator import get_llm_config as _get_llm_config - return dict(right_sticky="v"+__version__, + from flask_login import current_user + has_password = datastore.data['settings']['application']['password'] != False + # Don't reveal the running version to anonymous visitors when password protection is enabled (#2190) + show_version = current_user.is_authenticated or not has_password + return dict(right_sticky="v"+__version__ if show_version else None, new_version_available=app.config['NEW_VERSION_AVAILABLE'], - has_password=datastore.data['settings']['application']['password'] != False, + has_password=has_password, socket_io_enabled=datastore.data['settings']['application'].get('ui', {}).get('socket_io_enabled', True), all_paused=datastore.data['settings']['application'].get('all_paused', False), all_muted=datastore.data['settings']['application'].get('all_muted', False), diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py index ffa17b45..2d52b695 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import flask_login +import hashlib import locale import os import queue @@ -194,7 +195,16 @@ def get_darkmode_state(): @app.template_global() def get_css_version(): - return __version__ + """Cache-busting token for static assets. + + Changes on every upgrade (so browsers refetch CSS/JS) but is not the raw + version string - the raw version was leaking to anonymous visitors on the + login page via `?v=x.y.z`, which allows exposed instances to be fingerprinted + for known-vulnerable releases (#2190). Salted with the per-installation + app_guid so it can't be reversed to the version. + """ + salt = datastore.data.get('app_guid', '') if datastore else '' + return hashlib.sha256(f"{salt}{__version__}".encode('utf-8')).hexdigest()[:10] @app.template_global('filtered_action_url') def _filtered_action_url(endpoint, **overrides): diff --git a/changedetectionio/tests/test_access_control.py b/changedetectionio/tests/test_access_control.py index 9ef91a99..b21d44ec 100644 --- a/changedetectionio/tests/test_access_control.py +++ b/changedetectionio/tests/test_access_control.py @@ -1,5 +1,7 @@ from .util import live_server_setup, wait_for_all_checks from flask import url_for +from changedetectionio import __version__ +import re import time def test_check_access_control(app, client, live_server, measure_memory_usage, datastore_path): @@ -43,6 +45,8 @@ def test_check_access_control(app, client, live_server, measure_memory_usage, da res = c.get(url_for("watchlist.index"), follow_redirects=True) # Should be logged out assert b"Login" in res.data + # The login page must not leak the running version via the static asset cache-busters (#2190) + assert not re.search(rb'\?v(?:er)?=' + re.escape(__version__.encode()), res.data) # The diff page should return something valid when logged out res = c.get(url_for("ui.ui_diff.diff_history_page", uuid="first"))