Skip to content

Commit c2ad883

Browse files
elazarclaude
andcommitted
Fix check_login decorator order so Flask registers the auth wrapper
@check_login was placed above @ui_bp.route on all protected routes. Because Flask's Blueprint captures the view function reference at route registration time (before @check_login wraps it), the auth wrapper was never invoked — protected routes were accessible without credentials. Flask's own documentation states: "When applying further decorators, always remember that the route() decorator is the outermost." https://flask.palletsprojects.com/en/stable/patterns/viewdecorators/ Swap decorator order to @ui_bp.route above @check_login on download_log, series_images, movies_images, backup_download, and proxy so Flask registers the check_login wrapper as the view function. Also guard the pkg_resources import in tests/conftest.py, which was removed from setuptools 82+ and broke the test suite on Python 3.14. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a21300b commit c2ad883

3 files changed

Lines changed: 94 additions & 6 deletions

File tree

bazarr/app/ui.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,14 +118,14 @@ def catch_all(path):
118118
return render_template("index.html", BAZARR_SERVER_INJECT=inject, baseUrl=template_url)
119119

120120

121-
@check_login
122121
@ui_bp.route('/' + FILE_LOG)
122+
@check_login
123123
def download_log():
124124
return send_file(get_log_file_path(), max_age=0, as_attachment=True)
125125

126126

127-
@check_login
128127
@ui_bp.route('/images/series/<path:url>', methods=['GET'])
128+
@check_login
129129
def series_images(url):
130130
url = url.strip("/")
131131
apikey = settings.sonarr.apikey
@@ -139,8 +139,8 @@ def series_images(url):
139139
return Response(stream_with_context(req.iter_content(2048)), content_type=req.headers['content-type'])
140140

141141

142-
@check_login
143142
@ui_bp.route('/images/movies/<path:url>', methods=['GET'])
143+
@check_login
144144
def movies_images(url):
145145
apikey = settings.radarr.apikey
146146
baseUrl = settings.radarr.base_url
@@ -153,8 +153,8 @@ def movies_images(url):
153153
return Response(stream_with_context(req.iter_content(2048)), content_type=req.headers['content-type'])
154154

155155

156-
@check_login
157156
@ui_bp.route('/system/backup/download/<path:filename>', methods=['GET'])
157+
@check_login
158158
def backup_download(filename):
159159
fullpath = os.path.normpath(os.path.join(settings.backup.folder, filename))
160160
if not fullpath.startswith(settings.backup.folder):
@@ -175,9 +175,9 @@ def swaggerui_static(filename):
175175
return send_file(fullpath)
176176

177177

178-
@check_login
179178
@ui_bp.route('/test', methods=['GET'])
180179
@ui_bp.route('/test/<protocol>/<path:url>', methods=['GET'])
180+
@check_login
181181
def proxy(protocol, url):
182182
if protocol.lower() not in ['http', 'https']:
183183
return dict(status=False, error='Unsupported protocol', code=0)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""
2+
Tests that verify check_login is invoked through Flask's routing layer.
3+
4+
The bug: @check_login is placed ABOVE @ui_bp.route in ui.py. Flask captures
5+
the original function reference before check_login wraps it, making check_login
6+
dead code — auth is never enforced for these routes regardless of configuration.
7+
8+
# broken: Flask registers original series_images, ignoring the wrapper
9+
@check_login
10+
@ui_bp.route('/images/series/<path:url>', methods=['GET'])
11+
def series_images(url): ...
12+
13+
Fix: @ui_bp.route must be the outermost decorator so Flask registers the
14+
check_login wrapper as the view function.
15+
16+
# correct: Flask registers the check_login wrapper
17+
@ui_bp.route('/images/series/<path:url>', methods=['GET'])
18+
@check_login
19+
def series_images(url): ...
20+
"""
21+
import pytest
22+
from unittest.mock import patch
23+
from flask import Flask
24+
25+
from bazarr.app.ui import ui_bp
26+
27+
28+
@pytest.fixture
29+
def app():
30+
application = Flask(__name__)
31+
application.register_blueprint(ui_bp)
32+
application.config['TESTING'] = True
33+
return application
34+
35+
36+
def test_series_images_requires_basic_auth(app):
37+
"""
38+
GET /images/series/* must return 401 when basic auth is configured and no
39+
credentials are supplied. Fails before fix: Flask dispatches directly to
40+
the original series_images function, bypassing check_login entirely.
41+
"""
42+
with patch('bazarr.app.ui.settings') as mock_settings:
43+
mock_settings.auth.type = 'basic'
44+
with app.test_client() as client:
45+
response = client.get('/images/series/MediaCover/123/poster.jpg')
46+
assert response.status_code == 401
47+
48+
49+
def test_movies_images_requires_basic_auth(app):
50+
"""
51+
GET /images/movies/* must return 401 when basic auth is configured and no
52+
credentials are supplied.
53+
"""
54+
with patch('bazarr.app.ui.settings') as mock_settings:
55+
mock_settings.auth.type = 'basic'
56+
with app.test_client() as client:
57+
response = client.get('/images/movies/MediaCover/456/poster.jpg')
58+
assert response.status_code == 401
59+
60+
61+
def test_backup_download_requires_basic_auth(app):
62+
"""
63+
GET /system/backup/download/* must return 401 when basic auth is configured
64+
and no credentials are supplied.
65+
"""
66+
with patch('bazarr.app.ui.settings') as mock_settings:
67+
mock_settings.auth.type = 'basic'
68+
with app.test_client() as client:
69+
response = client.get('/system/backup/download/backup.zip')
70+
assert response.status_code == 401
71+
72+
73+
def test_download_log_requires_basic_auth(app):
74+
"""
75+
GET /bazarr.log must return 401 when basic auth is configured and no
76+
credentials are supplied.
77+
"""
78+
with patch('bazarr.app.ui.settings') as mock_settings:
79+
mock_settings.auth.type = 'basic'
80+
with app.test_client() as client:
81+
response = client.get('/bazarr.log')
82+
assert response.status_code == 401

tests/conftest.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
import pkgutil
44
import sys
55

6-
import pkg_resources
6+
try:
7+
import pkg_resources
8+
except ImportError:
9+
pkg_resources = None
710

811
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../libs/"))
912
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../bazarr/"))
@@ -20,6 +23,9 @@ def _get_conflicting(path):
2023
for _, package_name, _ in pkgutil.iter_modules([path]):
2124
libs_packages.append(package_name)
2225

26+
if pkg_resources is None:
27+
return []
28+
2329
installed_packages = pkg_resources.working_set
2430
package_names = [package.key for package in installed_packages]
2531
unique_package_names = set(package_names)

0 commit comments

Comments
 (0)