Skip to content

Commit bdbb9e7

Browse files
committed
Avoid reviewer privilege escalation vulnerability
1 parent c6fba26 commit bdbb9e7

4 files changed

Lines changed: 136 additions & 19 deletions

File tree

src/olympia/addons/views.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,10 @@
4343
AllowAddonAuthor,
4444
AllowAddonOwner,
4545
AllowIfNotMozillaDisabled,
46-
AllowListedViewerOrReviewer,
46+
AllowListedViewerOrReviewerReadOnly,
4747
AllowReadOnlyIfPublic,
4848
AllowRelatedObjectPermissions,
49-
AllowUnlistedViewerOrReviewer,
49+
AllowUnlistedViewerOrReviewerReadOnly,
5050
AnyOf,
5151
APIGatePermission,
5252
GroupPermission,
@@ -253,8 +253,8 @@ class AddonViewSet(
253253
AnyOf(
254254
AllowReadOnlyIfPublic,
255255
AllowAddonAuthor,
256-
AllowListedViewerOrReviewer,
257-
AllowUnlistedViewerOrReviewer,
256+
AllowListedViewerOrReviewerReadOnly,
257+
AllowUnlistedViewerOrReviewerReadOnly,
258258
)
259259
]
260260
authentication_classes = [
@@ -578,8 +578,8 @@ def check_permissions(self, request):
578578
# be add-on author or reviewer.
579579
self.permission_classes = [
580580
AnyOf(
581-
AllowListedViewerOrReviewer,
582-
AllowUnlistedViewerOrReviewer,
581+
AllowListedViewerOrReviewerReadOnly,
582+
AllowUnlistedViewerOrReviewerReadOnly,
583583
AllowAddonAuthor,
584584
)
585585
]
@@ -607,14 +607,16 @@ def check_object_permissions(self, request, obj):
607607
# authors..
608608
self.permission_classes = [
609609
AllowRelatedObjectPermissions(
610-
'addon', [AnyOf(AllowUnlistedViewerOrReviewer, AllowAddonAuthor)]
610+
'addon',
611+
[AnyOf(AllowUnlistedViewerOrReviewerReadOnly, AllowAddonAuthor)],
611612
)
612613
]
613614
elif not obj.is_public():
614615
# If the instance is disabled, only allow reviewers and authors.
615616
self.permission_classes = [
616617
AllowRelatedObjectPermissions(
617-
'addon', [AnyOf(AllowListedViewerOrReviewer, AllowAddonAuthor)]
618+
'addon',
619+
[AnyOf(AllowListedViewerOrReviewerReadOnly, AllowAddonAuthor)],
618620
)
619621
]
620622

@@ -879,8 +881,8 @@ def check_permissions(self, request):
879881
AllowAddonOwner
880882
if self.action in self.owner_actions
881883
else AllowAddonAuthor,
882-
AllowListedViewerOrReviewer,
883-
AllowUnlistedViewerOrReviewer,
884+
AllowListedViewerOrReviewerReadOnly,
885+
AllowUnlistedViewerOrReviewerReadOnly,
884886
),
885887
]
886888
)

src/olympia/api/permissions.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,14 +170,16 @@ class AllowListedViewerOrReviewer(BasePermission):
170170
"""Allow reviewers to access add-ons with listed versions.
171171
172172
The user logged in must either be making a read-only request and have the
173-
'ReviewerTools:View' permission, or simply be a reviewer or admin.
173+
'ReviewerTools:View' permission, or simply be a reviewer.
174174
175175
The definition of an add-on reviewer depends on the object:
176176
- For static themes, it's someone with 'Addons:ThemeReview'
177177
- For the rest of the add-ons, is someone who has either
178178
'Addons:Review' or 'Addons:ContentReview' permission.
179179
"""
180180

181+
disallow_unsafe = False
182+
181183
def has_permission(self, request, view):
182184
return request.user.is_authenticated
183185

@@ -186,13 +188,18 @@ def has_object_permission(self, request, view, obj):
186188
request.method in SAFE_METHODS
187189
and acl.action_allowed_for(request.user, permissions.REVIEWER_TOOLS_VIEW)
188190
)
189-
can_access_because_listed_reviewer = obj.has_listed_versions(
190-
include_deleted=True
191-
) and acl.is_reviewer(request.user, obj)
192-
191+
can_access_because_listed_reviewer = (
192+
obj.has_listed_versions(include_deleted=True)
193+
and acl.is_reviewer(request.user, obj)
194+
and (not self.disallow_unsafe or request.method in SAFE_METHODS)
195+
)
193196
return can_access_because_viewer or can_access_because_listed_reviewer
194197

195198

199+
class AllowListedViewerOrReviewerReadOnly(AllowListedViewerOrReviewer):
200+
disallow_unsafe = True
201+
202+
196203
class AllowUnlistedViewerOrReviewer(AllowListedViewerOrReviewer):
197204
"""Allow unlisted reviewers to access add-ons with unlisted versions, or
198205
add-ons with no listed versions at all.
@@ -219,7 +226,7 @@ def has_object_permission(self, request, view, obj):
219226
)
220227
can_access_because_unlisted_reviewer = acl.is_unlisted_addons_reviewer(
221228
request.user
222-
)
229+
) and (not self.disallow_unsafe or request.method in SAFE_METHODS)
223230
has_unlisted_or_no_listed = obj.has_unlisted_versions(
224231
include_deleted=True
225232
) or not obj.has_listed_versions(include_deleted=True)
@@ -229,6 +236,10 @@ def has_object_permission(self, request, view, obj):
229236
)
230237

231238

239+
class AllowUnlistedViewerOrReviewerReadOnly(AllowUnlistedViewerOrReviewer):
240+
disallow_unsafe = True
241+
242+
232243
class AllowAnyKindOfReviewer(BasePermission):
233244
"""Allow access to any kind of reviewer. Use only for views that don't
234245
alter add-on data.

src/olympia/api/tests/test_permissions.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@
2525
AllowIfNotMozillaDisabled,
2626
AllowIfPublic,
2727
AllowListedViewerOrReviewer,
28+
AllowListedViewerOrReviewerReadOnly,
2829
AllowNone,
2930
AllowOwner,
3031
AllowReadOnlyIfPublic,
3132
AllowRelatedObjectPermissions,
3233
AllowUnlistedViewerOrReviewer,
34+
AllowUnlistedViewerOrReviewerReadOnly,
3335
AnyOf,
3436
ByHttpMethod,
3537
GroupPermission,
@@ -415,6 +417,67 @@ def test_no_listed_version_reviewer(self):
415417
assert not self.permission.has_object_permission(request, myview, obj)
416418

417419

420+
class TestAllowListedViewerOrReviewerReadOnly(TestAllowListedViewerOrReviewer):
421+
"""Similar to AllowListedViewerOrReviewer, but restricted to safe methods."""
422+
423+
def setUp(self):
424+
super().setUp()
425+
self.permission = AllowListedViewerOrReviewerReadOnly()
426+
427+
def test_admin(self):
428+
user = user_factory()
429+
self.grant_permission(user, '*:*')
430+
obj = Mock(spec=[])
431+
obj.type = amo.ADDON_EXTENSION
432+
obj.has_listed_versions = lambda include_deleted=False: True
433+
434+
for method in self.safe_methods:
435+
request = getattr(self.request_factory, method)('/')
436+
request.user = user
437+
assert self.permission.has_permission(request, myview)
438+
assert self.permission.has_object_permission(request, myview, obj)
439+
440+
for method in self.unsafe_methods:
441+
request = getattr(self.request_factory, method)('/')
442+
request.user = user
443+
assert self.permission.has_permission(request, myview)
444+
assert not self.permission.has_object_permission(request, myview, obj)
445+
446+
def test_addon_reviewer(self):
447+
user = user_factory()
448+
self.grant_permission(user, 'Addons:Review')
449+
obj = Mock(spec=[])
450+
obj.type = amo.ADDON_EXTENSION
451+
obj.has_listed_versions = lambda include_deleted=False: True
452+
453+
for method in self.safe_methods:
454+
request = getattr(self.request_factory, method)('/')
455+
request.user = user
456+
assert self.permission.has_object_permission(request, myview, obj)
457+
458+
for method in self.unsafe_methods:
459+
request = getattr(self.request_factory, method)('/')
460+
request.user = user
461+
assert not self.permission.has_object_permission(request, myview, obj)
462+
463+
def test_theme_reviewer(self):
464+
user = user_factory()
465+
self.grant_permission(user, 'Addons:ThemeReview')
466+
obj = Mock(spec=[])
467+
obj.type = amo.ADDON_STATICTHEME
468+
obj.has_listed_versions = lambda include_deleted=False: True
469+
470+
for method in self.safe_methods:
471+
request = getattr(self.request_factory, method)('/')
472+
request.user = user
473+
assert self.permission.has_object_permission(request, myview, obj)
474+
475+
for method in self.unsafe_methods:
476+
request = getattr(self.request_factory, method)('/')
477+
request.user = user
478+
assert not self.permission.has_object_permission(request, myview, obj)
479+
480+
418481
class TestAllowAnyKindOfReviewer(TestCase):
419482
# Note: be careful when testing, under the hood we're using a method that
420483
# relies on UserProfile.groups_list, which is cached on the UserProfile
@@ -587,6 +650,47 @@ def test_object_with_no_unlisted_versions_and_no_listed_versions_viewer(self):
587650
assert self.permission.has_object_permission(self.request, myview, obj)
588651

589652

653+
class TestAllowUnlistedViewerOrReviewerReadOnly(TestAllowUnlistedViewerOrReviewer):
654+
"""Similar to AllowUnlistedViewerOrReviewer, but restricted to safe methods."""
655+
656+
def setUp(self):
657+
super().setUp()
658+
self.permission = AllowUnlistedViewerOrReviewerReadOnly()
659+
660+
def test_admin(self):
661+
self.request.user = user_factory()
662+
self.grant_permission(self.request.user, '*:*')
663+
obj = Mock(spec=[])
664+
obj.has_unlisted_versions = lambda include_deleted=False: True
665+
666+
assert self.permission.has_permission(self.request, myview)
667+
assert not self.permission.has_object_permission(self.request, myview, obj)
668+
self.request.method = 'GET'
669+
assert self.permission.has_object_permission(self.request, myview, obj)
670+
671+
def test_unlisted_reviewer(self):
672+
self.request.user = user_factory()
673+
self.grant_permission(self.request.user, 'Addons:ReviewUnlisted')
674+
obj = Mock(spec=[])
675+
obj.has_unlisted_versions = lambda include_deleted=False: True
676+
677+
assert self.permission.has_permission(self.request, myview)
678+
assert not self.permission.has_object_permission(self.request, myview, obj)
679+
self.request.method = 'GET'
680+
assert self.permission.has_object_permission(self.request, myview, obj)
681+
682+
def test_object_with_no_unlisted_versions_and_no_listed_versions(self):
683+
self.request.user = user_factory()
684+
self.grant_permission(self.request.user, 'Addons:ReviewUnlisted')
685+
obj = Mock(spec=[])
686+
obj.has_unlisted_versions = lambda include_deleted=False: False
687+
obj.has_listed_versions = lambda include_deleted=False: False
688+
assert self.permission.has_permission(self.request, myview)
689+
assert not self.permission.has_object_permission(self.request, myview, obj)
690+
self.request.method = 'GET'
691+
assert self.permission.has_object_permission(self.request, myview, obj)
692+
693+
590694
class TestAllowIfPublic(TestCase):
591695
def setUp(self):
592696
self.permission = AllowIfPublic()

src/olympia/reviewers/views.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
)
4747
from olympia.api.permissions import (
4848
AllowAnyKindOfReviewer,
49-
AllowUnlistedViewerOrReviewer,
49+
AllowUnlistedViewerOrReviewerReadOnly,
5050
GroupPermission,
5151
)
5252
from olympia.constants.abuse import DECISION_ACTIONS
@@ -1057,7 +1057,7 @@ def unsubscribe_listed(self, request, **kwargs):
10571057
@drf_action(
10581058
detail=True,
10591059
methods=['post'],
1060-
permission_classes=[AllowUnlistedViewerOrReviewer],
1060+
permission_classes=[AllowUnlistedViewerOrReviewerReadOnly],
10611061
)
10621062
def subscribe_unlisted(self, request, **kwargs):
10631063
addon = get_object_or_404(Addon, pk=kwargs['pk'])
@@ -1069,7 +1069,7 @@ def subscribe_unlisted(self, request, **kwargs):
10691069
@drf_action(
10701070
detail=True,
10711071
methods=['post'],
1072-
permission_classes=[AllowUnlistedViewerOrReviewer],
1072+
permission_classes=[AllowUnlistedViewerOrReviewerReadOnly],
10731073
)
10741074
def unsubscribe_unlisted(self, request, **kwargs):
10751075
addon = get_object_or_404(Addon, pk=kwargs['pk'])

0 commit comments

Comments
 (0)