Skip to content

Commit a7add8b

Browse files
committed
Merge branch 'master' into dev
2 parents caf8891 + 746e213 commit a7add8b

File tree

15 files changed

+3526
-23
lines changed

15 files changed

+3526
-23
lines changed

.github/workflows/test-stack-reusable-workflow.yml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,7 @@ jobs:
9999
100100
- name: Run Unit Tests
101101
run: |
102-
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_notification_diff'
103-
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_watch_model'
104-
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_jinja2_security'
105-
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_semver'
106-
docker run test-changedetectionio bash -c 'python3 -m unittest changedetectionio.tests.unit.test_html_to_text'
102+
docker run test-changedetectionio bash -c 'cd changedetectionio;pytest tests/unit/'
107103
108104
# Basic pytest tests with ancillary services
109105
basic-tests:

changedetectionio/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# Read more https://github.com/dgtlmoon/changedetection.io/wiki
44
# Semver means never use .01, or 00. Should be .1.
5-
__version__ = '0.54.7'
5+
__version__ = '0.54.8'
66

77
from changedetectionio.strtobool import strtobool
88
from json.decoder import JSONDecodeError

changedetectionio/blueprint/backups/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ def construct_blueprint(datastore: ChangeDetectionStore):
9898
backups_blueprint.register_blueprint(construct_restore_blueprint(datastore))
9999
backup_threads = []
100100

101-
@login_optionally_required
102101
@backups_blueprint.route("/request-backup", methods=['GET'])
102+
@login_optionally_required
103103
def request_backup():
104104
if any(thread.is_alive() for thread in backup_threads):
105105
flash(gettext("A backup is already running, check back in a few minutes"), "error")
@@ -141,8 +141,8 @@ def find_backups():
141141

142142
return backup_info
143143

144-
@login_optionally_required
145144
@backups_blueprint.route("/download/<string:filename>", methods=['GET'])
145+
@login_optionally_required
146146
def download_backup(filename):
147147
import re
148148
filename = filename.strip()
@@ -165,9 +165,9 @@ def download_backup(filename):
165165
logger.debug(f"Backup download request for '{full_path}'")
166166
return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True)
167167

168-
@login_optionally_required
169168
@backups_blueprint.route("/", methods=['GET'])
170169
@backups_blueprint.route("/create", methods=['GET'])
170+
@login_optionally_required
171171
def create():
172172
backups = find_backups()
173173
output = render_template("backup_create.html",
@@ -176,8 +176,8 @@ def create():
176176
)
177177
return output
178178

179-
@login_optionally_required
180179
@backups_blueprint.route("/remove-backups", methods=['GET'])
180+
@login_optionally_required
181181
def remove_backups():
182182

183183
backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*"))

changedetectionio/blueprint/backups/restore.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,8 @@ def construct_restore_blueprint(datastore):
174174
restore_blueprint = Blueprint('restore', __name__, template_folder="templates")
175175
restore_threads = []
176176

177-
@login_optionally_required
178177
@restore_blueprint.route("/restore", methods=['GET'])
178+
@login_optionally_required
179179
def restore():
180180
form = RestoreForm()
181181
return render_template("backup_restore.html",
@@ -184,8 +184,8 @@ def restore():
184184
max_upload_mb=_MAX_UPLOAD_BYTES // (1024 * 1024),
185185
max_decompressed_mb=_MAX_DECOMPRESSED_BYTES // (1024 * 1024))
186186

187-
@login_optionally_required
188187
@restore_blueprint.route("/restore/start", methods=['POST'])
188+
@login_optionally_required
189189
def backups_restore_start():
190190
if any(t.is_alive() for t in restore_threads):
191191
flash(gettext("A restore is already running, check back in a few minutes"), "error")

changedetectionio/blueprint/browser_steps/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,8 @@ async def start_browsersteps_session(watch_uuid):
263263
return browsersteps_start_session
264264

265265

266-
@login_optionally_required
267266
@browser_steps_blueprint.route("/browsersteps_start_session", methods=['GET'])
267+
@login_optionally_required
268268
def browsersteps_start_session():
269269
# A new session was requested, return sessionID
270270
import uuid
@@ -299,8 +299,8 @@ def browsersteps_start_session():
299299
logger.debug("Starting connection with playwright - done")
300300
return {'browsersteps_session_id': browsersteps_session_id}
301301

302-
@login_optionally_required
303302
@browser_steps_blueprint.route("/browsersteps_image", methods=['GET'])
303+
@login_optionally_required
304304
def browser_steps_fetch_screenshot_image():
305305
from flask import (
306306
make_response,
@@ -325,8 +325,8 @@ def browser_steps_fetch_screenshot_image():
325325
return make_response('Unable to fetch image, is the URL correct? does the watch exist? does the step_type-n.jpeg exist?', 401)
326326

327327
# A request for an action was received
328-
@login_optionally_required
329328
@browser_steps_blueprint.route("/browsersteps_update", methods=['POST'])
329+
@login_optionally_required
330330
def browsersteps_ui_update():
331331
import base64
332332

changedetectionio/content_fetchers/webdriver_selenium.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,17 @@ def _run_sync():
9999

100100
from selenium.webdriver.remote.remote_connection import RemoteConnection
101101
from selenium.webdriver.remote.webdriver import WebDriver as RemoteWebDriver
102+
from selenium.webdriver.remote.client_config import ClientConfig
103+
from urllib3.util import Timeout
102104
driver = None
103105
try:
104-
# Create the RemoteConnection and set timeout (e.g., 30 seconds)
105-
remote_connection = RemoteConnection(
106-
self.browser_connection_url,
106+
connection_timeout = int(os.getenv("WEBDRIVER_CONNECTION_TIMEOUT", 90))
107+
client_config = ClientConfig(
108+
remote_server_addr=self.browser_connection_url,
109+
timeout=Timeout(connect=connection_timeout, total=connection_timeout)
107110
)
108-
remote_connection.set_timeout(30) # seconds
111+
remote_connection = RemoteConnection(client_config=client_config)
109112

110-
# Now create the driver with the RemoteConnection
111113
driver = RemoteWebDriver(
112114
command_executor=remote_connection,
113115
options=options

changedetectionio/languages.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def get_timeago_locale(flask_locale):
3737
'no': 'nb_NO', # Norwegian Bokmål
3838
'hi': 'in_HI', # Hindi
3939
'cs': 'en', # Czech not supported by timeago, fallback to English
40+
'ja': 'ja', # Japanese
4041
'uk': 'uk', # Ukrainian
4142
'en_GB': 'en', # British English - timeago uses 'en'
4243
'en_US': 'en', # American English - timeago uses 'en'
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""
2+
Static analysis test: verify @login_optionally_required is always applied
3+
AFTER (inner to) @blueprint.route(), not before it.
4+
5+
In Flask, @route() must be the outermost decorator because it registers
6+
whatever function it receives. If @login_optionally_required is placed
7+
above @route(), the raw unprotected function gets registered and auth is
8+
silently bypassed (GHSA-jmrh-xmgh-x9j4).
9+
10+
Correct order (route outermost, auth inner):
11+
@blueprint.route('/path')
12+
@login_optionally_required
13+
def view(): ...
14+
15+
Wrong order (auth never called):
16+
@login_optionally_required ← registered by route, then discarded
17+
@blueprint.route('/path')
18+
def view(): ...
19+
"""
20+
21+
import ast
22+
import pathlib
23+
import pytest
24+
25+
REPO_ROOT = pathlib.Path(__file__).parents[3] # …/changedetection.io/
26+
SOURCE_ROOT = REPO_ROOT / "changedetectionio"
27+
28+
29+
def _is_route_decorator(node: ast.expr) -> bool:
30+
"""Return True if the decorator looks like @something.route(...)."""
31+
return (
32+
isinstance(node, ast.Call)
33+
and isinstance(node.func, ast.Attribute)
34+
and node.func.attr == "route"
35+
)
36+
37+
38+
def _is_auth_decorator(node: ast.expr) -> bool:
39+
"""Return True if the decorator is @login_optionally_required."""
40+
return isinstance(node, ast.Name) and node.id == "login_optionally_required"
41+
42+
43+
def collect_violations() -> list[str]:
44+
violations = []
45+
46+
for path in SOURCE_ROOT.rglob("*.py"):
47+
try:
48+
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
49+
except SyntaxError:
50+
continue
51+
52+
for node in ast.walk(tree):
53+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
54+
continue
55+
56+
decorators = node.decorator_list
57+
auth_indices = [i for i, d in enumerate(decorators) if _is_auth_decorator(d)]
58+
route_indices = [i for i, d in enumerate(decorators) if _is_route_decorator(d)]
59+
60+
# Bad order: auth decorator appears at a lower index (higher up) than a route decorator
61+
for auth_idx in auth_indices:
62+
for route_idx in route_indices:
63+
if auth_idx < route_idx:
64+
rel = path.relative_to(REPO_ROOT)
65+
violations.append(
66+
f"{rel}:{node.lineno} — `{node.name}`: "
67+
f"@login_optionally_required (line {decorators[auth_idx].lineno}) "
68+
f"is above @route (line {decorators[route_idx].lineno}); "
69+
f"auth wrapper will never be called"
70+
)
71+
72+
return violations
73+
74+
75+
def test_auth_decorator_order():
76+
violations = collect_violations()
77+
if violations:
78+
msg = (
79+
"\n\nFound routes where @login_optionally_required is placed ABOVE @blueprint.route().\n"
80+
"This silently disables authentication — @route() registers the raw function\n"
81+
"and the auth wrapper is never called.\n\n"
82+
"Fix: move @blueprint.route() to be the outermost (topmost) decorator.\n\n"
83+
+ "\n".join(f" • {v}" for v in violations)
84+
)
85+
pytest.fail(msg)

changedetectionio/tests/unit/test_conditions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def test_conditions_execution_pass(self):
6464
"conditions": [
6565
{"operator": ">=", "field": "extracted_number", "value": "10"},
6666
{"operator": "<=", "field": "extracted_number", "value": "5000"},
67-
{"operator": "in", "field": "page_text", "value": "rock"},
67+
{"operator": "in", "field": "page_filtered_text", "value": "rock"},
6868
#{"operator": "starts_with", "field": "page_text", "value": "I saw"},
6969
]
7070
}

changedetectionio/translations/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ These commands read settings from `../../setup.cfg` automatically.
7676
- `en_US` - English (US)
7777
- `fr` - French (Français)
7878
- `it` - Italian (Italiano)
79+
- `ja` - Japanese (日本語)
7980
- `ko` - Korean (한국어)
8081
- `zh` - Chinese Simplified (中文简体)
8182
- `zh_Hant_TW` - Chinese Traditional (繁體中文)

0 commit comments

Comments
 (0)