Skip to content

Commit b1bb537

Browse files
committed
GHSA-q85w-c766-h5g8 - Advisory for four unprotected routes in price_data_follower, check_proxy and language handling that missing. Reported-by: Mayssare Amakhtari (@cy3erm) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fce2478 commit b1bb537

5 files changed

Lines changed: 95 additions & 35 deletions

File tree

changedetectionio/blueprint/check_proxies/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from functools import wraps
88

99
from flask import Blueprint
10-
from flask_login import login_required
10+
from changedetectionio.auth_decorator import login_optionally_required
1111

1212
STATUS_CHECKING = 0
1313
STATUS_FAILED = 1
@@ -94,14 +94,14 @@ def _recalc_check_status(uuid):
9494

9595
return results
9696

97-
@login_required
9897
@check_proxies_blueprint.route("/<uuid_str:uuid>/status", methods=['GET'])
98+
@login_optionally_required
9999
def get_recheck_status(uuid):
100100
results = _recalc_check_status(uuid=uuid)
101101
return results
102102

103-
@login_required
104103
@check_proxies_blueprint.route("/<uuid_str:uuid>/start", methods=['GET'])
104+
@login_optionally_required
105105
def start_check(uuid):
106106

107107
if not datastore.proxy_list:

changedetectionio/blueprint/price_data_follower/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
from changedetectionio.strtobool import strtobool
33
from flask import Blueprint, flash, redirect, url_for
4-
from flask_login import login_required
4+
from changedetectionio.auth_decorator import login_optionally_required
55
from changedetectionio.store import ChangeDetectionStore
66
from changedetectionio import queuedWatchMetaData
77
from changedetectionio import worker_pool
@@ -14,8 +14,8 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue
1414

1515
price_data_follower_blueprint = Blueprint('price_data_follower', __name__)
1616

17-
@login_required
1817
@price_data_follower_blueprint.route("/<uuid_str:uuid>/accept", methods=['GET'])
18+
@login_optionally_required
1919
def accept(uuid):
2020
datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_ACCEPT
2121
datastore.data['watching'][uuid]['processor'] = 'restock_diff'
@@ -24,8 +24,8 @@ def accept(uuid):
2424
worker_pool.queue_item_async_safe(update_q, queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid}))
2525
return redirect(url_for("watchlist.index"))
2626

27-
@login_required
2827
@price_data_follower_blueprint.route("/<uuid_str:uuid>/reject", methods=['GET'])
28+
@login_optionally_required
2929
def reject(uuid):
3030
datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_REJECT
3131
datastore.data['watching'][uuid].commit()

changedetectionio/flask_app.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -624,8 +624,13 @@ def check_authentication():
624624
# Permitted - static flag icons need to load on login page
625625
elif request.endpoint and request.endpoint == 'static_flags':
626626
return None
627-
# Permitted - language selection should work on login page
628-
elif request.endpoint and request.endpoint == 'set_language':
627+
# Permitted - language selection should work on login page.
628+
# Both halves of the language modal must be exempt: it renders for anonymous
629+
# users (base.html deliberately leaves it outside the is_authenticated guard),
630+
# so exempting only set_language let you pick a language but bounced
631+
# "Auto-detect from browser" to /login without clearing the session locale.
632+
elif request.endpoint and request.endpoint in ('set_language',
633+
'ui.delete_locale_language_session_var_if_it_exists'):
629634
return None
630635
# Permitted
631636
elif request.endpoint and 'login' in request.endpoint:
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env python3
2+
"""
3+
The language modal renders for anonymous users on the login page (base.html keeps it
4+
outside the `current_user.is_authenticated or not has_password` guard that wraps the
5+
search modal). Both of its actions must therefore work while logged out.
6+
7+
Regression: only `set_language` was exempted in check_authentication(), so an anonymous
8+
user at the login screen could pick a specific language but clicking "Auto-detect from
9+
browser" right below it 302'd to /login without clearing the session locale.
10+
"""
11+
12+
from flask import url_for
13+
from .util import live_server_setup, wait_for_all_checks
14+
15+
16+
def test_language_endpoints_work_for_anonymous_users(client, live_server, measure_memory_usage, datastore_path):
17+
# Enable password protection so the global auth wall in check_authentication() is active
18+
res = client.post(
19+
url_for("settings.settings_page"),
20+
data={
21+
"application-password": "hunter2",
22+
"requests-time_between_check-minutes": 180,
23+
"application-fetch_backend": "html_requests",
24+
},
25+
follow_redirects=True)
26+
assert res.status_code == 200
27+
28+
client.get(url_for("logout"), follow_redirects=True)
29+
30+
# Both language links are rendered on the login page, so both must be reachable
31+
res = client.get(url_for("login"))
32+
assert res.status_code == 200
33+
assert b'language-selector' in res.data, "Language modal trigger should render for anonymous users"
34+
35+
# Picking a specific language must not redirect to the login page
36+
res = client.get(url_for("set_language", locale="de"), follow_redirects=False)
37+
assert res.status_code == 302
38+
assert '/login' not in res.headers.get("Location", ""), \
39+
"set_language must not bounce anonymous users to /login"
40+
41+
# ...and neither must clearing it back to auto-detect
42+
res = client.get(url_for("ui.delete_locale_language_session_var_if_it_exists"), follow_redirects=False)
43+
assert res.status_code == 302
44+
assert '/login' not in res.headers.get("Location", ""), \
45+
"Auto-detect must not bounce anonymous users to /login (it renders on the login page)"

changedetectionio/tests/unit/test_auth_decorator_order.py

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
"""
2-
Static analysis test: verify @login_optionally_required is always applied
3-
AFTER (inner to) @blueprint.route(), not before it.
2+
Static analysis test: verify @blueprint.route() is always the outermost
3+
decorator on a view, so nothing sits above it.
44
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).
5+
In Flask, @route() must be outermost because it registers whatever function
6+
it receives and then returns that function unchanged. Any decorator placed
7+
above @route() is applied only to the module-level name, never to the view
8+
the blueprint actually dispatches to — so it is silently dead code. When the
9+
dead decorator is an auth wrapper, the route is left unprotected
10+
(GHSA-jmrh-xmgh-x9j4).
911
1012
Correct order (route outermost, auth inner):
1113
@blueprint.route('/path')
1214
@login_optionally_required
1315
def view(): ...
1416
1517
Wrong order (auth never called):
16-
@login_optionally_required ← registered by route, then discarded
18+
@login_optionally_required ← discarded; route registered the raw fn
1719
@blueprint.route('/path')
1820
def view(): ...
21+
22+
This check is deliberately name-agnostic. An earlier version matched only
23+
the literal name `login_optionally_required`, which missed four routes using
24+
plain flask_login `login_required` (GHSA-q85w-c766-h5g8) — an allowlist of
25+
decorator names only ever catches the names someone remembered to add. We
26+
now flag *any* non-route decorator above @route, which also covers attribute
27+
forms (@flask_login.login_required), aliased imports, and non-auth
28+
decorators that are equally dead up there.
29+
30+
Stacked @route decorators are a legitimate Flask idiom and are exempt.
1931
"""
2032

2133
import ast
@@ -35,11 +47,6 @@ def _is_route_decorator(node: ast.expr) -> bool:
3547
)
3648

3749

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-
4350
def collect_violations() -> list[str]:
4451
violations = []
4552

@@ -54,20 +61,22 @@ def collect_violations() -> list[str]:
5461
continue
5562

5663
decorators = node.decorator_list
57-
auth_indices = [i for i, d in enumerate(decorators) if _is_auth_decorator(d)]
5864
route_indices = [i for i, d in enumerate(decorators) if _is_route_decorator(d)]
65+
if not route_indices:
66+
continue
5967

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-
)
68+
# Everything above the last @route is discarded by the registration.
69+
# Other @route decorators up there are fine — stacking routes is normal.
70+
last_route = max(route_indices)
71+
for i, decorator in enumerate(decorators):
72+
if i < last_route and not _is_route_decorator(decorator):
73+
rel = path.relative_to(REPO_ROOT)
74+
violations.append(
75+
f"{rel}:{node.lineno} — `{node.name}`: "
76+
f"@{ast.unparse(decorator)} (line {decorator.lineno}) is above @route "
77+
f"(line {decorators[last_route].lineno}); it will never be applied "
78+
f"to the registered view"
79+
)
7180

7281
return violations
7382

@@ -76,9 +85,10 @@ def test_auth_decorator_order():
7685
violations = collect_violations()
7786
if violations:
7887
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"
88+
"\n\nFound decorators placed ABOVE @blueprint.route().\n"
89+
"@route() registers the raw function and returns it unchanged, so anything\n"
90+
"above it is never applied to the view Flask dispatches to. If the decorator\n"
91+
"is an auth wrapper, the route is left completely unauthenticated.\n\n"
8292
"Fix: move @blueprint.route() to be the outermost (topmost) decorator.\n\n"
8393
+ "\n".join(f" • {v}" for v in violations)
8494
)

0 commit comments

Comments
 (0)