Skip to content

Commit a142566

Browse files
committed
Fix for plugin auto-approval logic
1 parent 229eb91 commit a142566

File tree

5 files changed

+270
-4
lines changed

5 files changed

+270
-4
lines changed

qgis-app/plugins/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ def plugin_upload(package, **kwargs):
120120
package.len,
121121
"UTF-8",
122122
),
123-
"approved": request.user.has_perm("plugins.can_approve") or plugin.approved,
123+
"approved": request.user.has_perm("plugins.can_approve"),
124124
}
125125

126126
# Optional version metadata

qgis-app/plugins/tests/test_plugin_create_empty.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,5 +291,90 @@ def test_create_empty_plugin_user_without_full_name(self):
291291
# Should use username as author
292292
self.assertEqual(plugin.author, "noname")
293293

294+
@patch("plugins.views.plugin_notify", new=do_nothing)
295+
@patch("plugins.tasks.generate_plugins_xml", new=do_nothing)
296+
@patch("plugins.validator._check_url_link", new=do_nothing)
297+
@patch("plugins.security_utils.run_security_scan", new=do_nothing)
298+
def test_version_create_not_auto_approved_for_untrusted_user_on_approved_plugin(self):
299+
"""
300+
Security: uploading a new version via the web form must NOT be auto-approved
301+
because the plugin already has an approved version. Only the user's
302+
can_approve permission should grant approval.
303+
"""
304+
self.client.login(username="testuser", password="testpassword")
305+
306+
# Create empty plugin then upload first version
307+
create_data = {"package_name": "test_modul", "name": "Test Module Plugin"}
308+
self.client.post(self.url, create_data)
309+
plugin = Plugin.objects.get(package_name="test_modul")
310+
311+
valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin.zip_")
312+
with open(valid_plugin, "rb") as file:
313+
uploaded_file = SimpleUploadedFile(
314+
"valid_plugin.zip_", file.read(), content_type="application/zip"
315+
)
316+
upload_url = reverse("version_create", args=[plugin.package_name])
317+
self.client.post(upload_url, {"package": uploaded_file})
318+
319+
# Simulate staff approval of the first version
320+
first_version = plugin.pluginversion_set.get(version="0.0.1")
321+
first_version.approved = True
322+
first_version.save()
323+
324+
# Upload a second version as the same untrusted user
325+
valid_plugin_v2 = os.path.join(TESTFILE_DIR, "valid_plugin_0.0.2.zip_")
326+
with open(valid_plugin_v2, "rb") as file:
327+
uploaded_file_v2 = SimpleUploadedFile(
328+
"valid_plugin_0.0.2.zip_", file.read(), content_type="application/zip"
329+
)
330+
self.client.post(upload_url, {"package": uploaded_file_v2})
331+
332+
second_version = plugin.pluginversion_set.get(version="0.0.2")
333+
self.assertFalse(
334+
second_version.approved,
335+
"New version must NOT be auto-approved just because the plugin is approved; "
336+
"only user trust (can_approve) should grant approval.",
337+
)
338+
339+
@patch("plugins.views.plugin_notify", new=do_nothing)
340+
@patch("plugins.tasks.generate_plugins_xml", new=do_nothing)
341+
@patch("plugins.validator._check_url_link", new=do_nothing)
342+
@patch("plugins.security_utils.run_security_scan", new=do_nothing)
343+
def test_version_create_auto_approved_for_trusted_user(self):
344+
"""
345+
A user with can_approve permission should have their uploaded version
346+
approved automatically via the version_create view.
347+
"""
348+
from django.contrib.auth.models import Permission
349+
from django.contrib.contenttypes.models import ContentType
350+
from plugins.models import Plugin as PluginModel
351+
352+
ct = ContentType.objects.get_for_model(PluginModel)
353+
perm = Permission.objects.get(codename="can_approve", content_type=ct)
354+
self.user.user_permissions.add(perm)
355+
# Refresh to bust Django's permission cache
356+
self.user = self.user.__class__.objects.get(pk=self.user.pk)
357+
358+
self.client.login(username="testuser", password="testpassword")
359+
360+
# Create empty plugin then upload first version
361+
create_data = {"package_name": "test_modul", "name": "Test Module Plugin"}
362+
self.client.post(self.url, create_data)
363+
plugin = Plugin.objects.get(package_name="test_modul")
364+
365+
valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin.zip_")
366+
with open(valid_plugin, "rb") as file:
367+
uploaded_file = SimpleUploadedFile(
368+
"valid_plugin.zip_", file.read(), content_type="application/zip"
369+
)
370+
upload_url = reverse("version_create", args=[plugin.package_name])
371+
self.client.post(upload_url, {"package": uploaded_file})
372+
373+
version = plugin.pluginversion_set.get(version="0.0.1")
374+
self.assertTrue(
375+
version.approved,
376+
"Version uploaded by a trusted user (can_approve) should be approved automatically.",
377+
)
378+
294379
def tearDown(self):
295380
self.client.logout()

qgis-app/plugins/tests/test_plugin_upload.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,5 +86,91 @@ def test_plugin_upload_form(self):
8686
mail.outbox[0].from_email,
8787
settings.DEFAULT_FROM_EMAIL
8888
)
89+
90+
@patch("plugins.tasks.generate_plugins_xml", new=do_nothing)
91+
@patch("plugins.validator._check_url_link", new=do_nothing)
92+
def test_new_version_not_auto_approved_for_untrusted_user_on_approved_plugin(self):
93+
"""
94+
Security: a new version must NOT be auto-approved because the plugin is
95+
approved. Only user trust (can_approve permission) should grant approval.
96+
"""
97+
self.client.login(username='testuser', password='testpassword')
98+
99+
# Upload the initial plugin
100+
valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin.zip_")
101+
with open(valid_plugin, "rb") as file:
102+
uploaded_file = SimpleUploadedFile(
103+
"valid_plugin.zip_", file.read(), content_type="application/zip"
104+
)
105+
self.client.post(self.url, {'package': uploaded_file})
106+
107+
plugin = Plugin.objects.get(name='Test Plugin')
108+
109+
# Simulate staff approval of the existing version
110+
version = PluginVersion.objects.get(plugin=plugin, version='0.0.1')
111+
version.approved = True
112+
version.save()
113+
self.assertTrue(version.approved)
114+
115+
# Upload a new version as the same untrusted user
116+
valid_plugin_v2 = os.path.join(TESTFILE_DIR, "valid_plugin_0.0.2.zip_")
117+
with open(valid_plugin_v2, "rb") as file:
118+
uploaded_file_v2 = SimpleUploadedFile(
119+
"valid_plugin_0.0.2.zip_", file.read(), content_type="application/zip"
120+
)
121+
response = self.client.post(self.url, {'package': uploaded_file_v2})
122+
123+
self.assertEqual(response.status_code, 302)
124+
new_version = PluginVersion.objects.get(plugin=plugin, version='0.0.2')
125+
self.assertFalse(
126+
new_version.approved,
127+
"New version must NOT be auto-approved just because the plugin is approved; "
128+
"only user trust (can_approve) should grant approval.",
129+
)
130+
131+
@patch("plugins.tasks.generate_plugins_xml", new=do_nothing)
132+
@patch("plugins.validator._check_url_link", new=do_nothing)
133+
def test_new_version_auto_approved_for_trusted_user(self):
134+
"""
135+
A user with can_approve permission should have their uploaded version
136+
approved automatically via the plugin_upload view.
137+
"""
138+
from django.contrib.auth.models import Permission
139+
from django.contrib.contenttypes.models import ContentType
140+
from plugins.models import Plugin as PluginModel
141+
142+
ct = ContentType.objects.get_for_model(PluginModel)
143+
perm = Permission.objects.get(codename="can_approve", content_type=ct)
144+
self.user.user_permissions.add(perm)
145+
# Refresh to bust Django's permission cache
146+
self.user = self.user.__class__.objects.get(pk=self.user.pk)
147+
148+
self.client.login(username="testuser", password="testpassword")
149+
150+
# Upload the initial plugin
151+
valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin.zip_")
152+
with open(valid_plugin, "rb") as file:
153+
uploaded_file = SimpleUploadedFile(
154+
"valid_plugin.zip_", file.read(), content_type="application/zip"
155+
)
156+
self.client.post(self.url, {"package": uploaded_file})
157+
158+
plugin = Plugin.objects.get(name="Test Plugin")
159+
160+
# Upload a second version as the trusted user
161+
valid_plugin_v2 = os.path.join(TESTFILE_DIR, "valid_plugin_0.0.2.zip_")
162+
with open(valid_plugin_v2, "rb") as file:
163+
uploaded_file_v2 = SimpleUploadedFile(
164+
"valid_plugin_0.0.2.zip_", file.read(), content_type="application/zip"
165+
)
166+
response = self.client.post(self.url, {"package": uploaded_file_v2})
167+
168+
self.assertEqual(response.status_code, 302)
169+
new_version = PluginVersion.objects.get(plugin=plugin, version="0.0.2")
170+
self.assertTrue(
171+
new_version.approved,
172+
"Version uploaded by a trusted user (can_approve) should be approved automatically.",
173+
)
174+
89175
def tearDown(self):
90176
self.client.logout()

qgis-app/plugins/tests/test_token_auth.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,95 @@ def test_update_version_with_invalid_token(self):
238238
).exists()
239239
)
240240

241+
def test_new_version_not_auto_approved_for_untrusted_user_on_approved_plugin(self):
242+
"""
243+
Security: uploading a new version via token must NOT be auto-approved
244+
because the plugin already has an approved version. Only the user's
245+
can_approve permission should grant approval.
246+
"""
247+
# Approve the existing version to simulate an approved plugin
248+
version = PluginVersion.objects.get(plugin__name="Test Plugin", version="0.0.1")
249+
version.approved = True
250+
version.save()
251+
252+
# Generate a token for the untrusted user
253+
self.client.post(self.url_token_create, {})
254+
outstanding_token = OutstandingToken.objects.last().token
255+
refresh = RefreshToken(outstanding_token)
256+
refresh["plugin_id"] = self.plugin.pk
257+
refresh["refresh_jti"] = refresh["jti"]
258+
access_token = str(refresh.access_token)
259+
260+
self.client.logout()
261+
262+
valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin_0.0.2.zip_")
263+
with open(valid_plugin, "rb") as file:
264+
uploaded_file = SimpleUploadedFile(
265+
"valid_plugin_0.0.2.zip_", file.read(), content_type="application/zip_"
266+
)
267+
268+
c = Client(HTTP_AUTHORIZATION=f"Bearer {access_token}")
269+
response = c.post(self.url_add_version, {"package": uploaded_file})
270+
271+
self.assertEqual(response.status_code, 201)
272+
data = response.json()
273+
self.assertTrue(data.get("success"))
274+
275+
new_version = PluginVersion.objects.get(plugin__name="Test Plugin", version="0.0.2")
276+
self.assertFalse(
277+
new_version.approved,
278+
"New version must NOT be auto-approved just because the plugin is approved; "
279+
"only user trust (can_approve) should grant approval.",
280+
)
281+
self.assertFalse(data.get("approved"))
282+
self.assertIn("approval_message", data)
283+
284+
def test_new_version_auto_approved_for_trusted_token_user(self):
285+
"""
286+
A token whose owner holds can_approve should result in an approved version,
287+
regardless of whether request.user is AnonymousUser (token path).
288+
"""
289+
from django.contrib.auth.models import Permission
290+
from django.contrib.contenttypes.models import ContentType
291+
from plugins.models import Plugin as PluginModel
292+
293+
# Grant can_approve to the token user
294+
ct = ContentType.objects.get_for_model(PluginModel)
295+
perm = Permission.objects.get(codename="can_approve", content_type=ct)
296+
self.user.user_permissions.add(perm)
297+
# Refresh to bust the permission cache
298+
self.user = self.user.__class__.objects.get(pk=self.user.pk)
299+
300+
# Generate a token for the now-trusted user
301+
self.client.post(self.url_token_create, {})
302+
outstanding_token = OutstandingToken.objects.last().token
303+
refresh = RefreshToken(outstanding_token)
304+
refresh["plugin_id"] = self.plugin.pk
305+
refresh["refresh_jti"] = refresh["jti"]
306+
access_token = str(refresh.access_token)
307+
308+
self.client.logout()
309+
310+
valid_plugin = os.path.join(TESTFILE_DIR, "valid_plugin_0.0.2.zip_")
311+
with open(valid_plugin, "rb") as file:
312+
uploaded_file = SimpleUploadedFile(
313+
"valid_plugin_0.0.2.zip_", file.read(), content_type="application/zip_"
314+
)
315+
316+
c = Client(HTTP_AUTHORIZATION=f"Bearer {access_token}")
317+
response = c.post(self.url_add_version, {"package": uploaded_file})
318+
319+
self.assertEqual(response.status_code, 201)
320+
data = response.json()
321+
self.assertTrue(data.get("success"))
322+
323+
new_version = PluginVersion.objects.get(plugin__name="Test Plugin", version="0.0.2")
324+
self.assertTrue(
325+
new_version.approved,
326+
"Version uploaded via token by a trusted user (can_approve) should be approved.",
327+
)
328+
self.assertTrue(data.get("approved"))
329+
241330

242331
class APIResponseTestCase(TestCase):
243332
"""Test cases for API response improvements"""

qgis-app/plugins/views.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -557,8 +557,7 @@ def plugin_upload(request):
557557
"version": form.cleaned_data.get("version"),
558558
"created_by": request.user,
559559
"package": form.cleaned_data.get("package"),
560-
"approved": request.user.has_perm("plugins.can_approve")
561-
or plugin.approved,
560+
"approved": request.user.has_perm("plugins.can_approve"),
562561
"experimental": form.cleaned_data.get("experimental", False),
563562
"changelog": form.cleaned_data.get("changelog", ""),
564563
"external_deps": form.cleaned_data.get("external_deps", ""),
@@ -1563,7 +1562,14 @@ def _version_create(request, plugin, version):
15631562
contained in the package metadata
15641563
"""
15651564
is_api_request = getattr(version, "is_from_token", False)
1566-
is_trusted = request.user.has_perm("plugins.can_approve") or plugin.approved
1565+
if is_api_request:
1566+
# For token-based uploads, resolve the token owner and check their permissions.
1567+
# request.user is AnonymousUser in this path since authentication is via JWT token,
1568+
# not Django session.
1569+
token_user = request.plugin_token.token.user
1570+
is_trusted = token_user.has_perm("plugins.can_approve")
1571+
else:
1572+
is_trusted = request.user.has_perm("plugins.can_approve")
15671573
if request.method == "POST":
15681574

15691575
form = PluginVersionForm(

0 commit comments

Comments
 (0)