Skip to content

Commit 14353e1

Browse files
author
Matthew Fisher
committed
fix(controller): disallow unauthorized users from modifying apps
Authenticated users who were not given explicit permission to view or modify an app were still allowed to create new releases, run commands, retrieve logs etc. This makes those requests return a 404 if the user should not be able to see the application.
1 parent 6f00ed5 commit 14353e1

10 files changed

Lines changed: 202 additions & 48 deletions

File tree

controller/api/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
The **api** Django app presents a RESTful web API for interacting with the **deis** system.
33
"""
44

5-
__version__ = '1.1.1'
5+
__version__ = '1.1.2'

controller/api/tests/test_app.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,39 @@ def test_run_without_release_should_error(self):
253253
self.assertEqual(response.data, "No build associated with this release "
254254
"to run this command")
255255

256+
def test_unauthorized_user_cannot_see_app(self):
257+
"""
258+
An unauthorized user should not be able to access an app's resources.
259+
260+
Since an unauthorized user should not know about the application at all, these
261+
requests should return a 404.
262+
"""
263+
app_id = 'autotest'
264+
base_url = '/v1/apps'
265+
body = {'id': app_id}
266+
response = self.client.post(base_url, json.dumps(body), content_type='application/json',
267+
HTTP_AUTHORIZATION='token {}'.format(self.token))
268+
unauthorized_user = User.objects.get(username='autotest2')
269+
unauthorized_token = Token.objects.get(user=unauthorized_user).key
270+
url = '{}/{}/run'.format(base_url, app_id)
271+
body = {'command': 'foo'}
272+
response = self.client.post(url, json.dumps(body), content_type='application/json',
273+
HTTP_AUTHORIZATION='token {}'.format(unauthorized_token))
274+
self.assertEqual(response.status_code, 404)
275+
url = '{}/{}/logs'.format(base_url, app_id)
276+
response = self.client.get(url, HTTP_AUTHORIZATION='token {}'.format(unauthorized_token))
277+
self.assertEqual(response.status_code, 404)
278+
279+
def test_app_info_not_showing_wrong_app(self):
280+
app_id = 'autotest'
281+
base_url = '/v1/apps'
282+
body = {'id': app_id}
283+
response = self.client.post(base_url, json.dumps(body), content_type='application/json',
284+
HTTP_AUTHORIZATION='token {}'.format(self.token))
285+
url = '{}/foo'.format(base_url)
286+
response = self.client.get(url, HTTP_AUTHORIZATION='token {}'.format(self.token))
287+
self.assertEqual(response.status_code, 404)
288+
256289

257290
FAKE_LOG_DATA = """
258291
2013-08-15 12:41:25 [33454] [INFO] Starting gunicorn 17.5

controller/api/tests/test_build.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -208,16 +208,22 @@ def test_admin_can_create_builds_on_other_apps(self):
208208
response.data['app'], response.data['uuid'][:7]))
209209

210210
@mock.patch('requests.post', mock_import_repository_task)
211-
def test_unauthorized_user_cannot_create_build(self):
212-
"""An unauthorized user should not be able to create builds for other apps."""
211+
def test_unauthorized_user_cannot_modify_build(self):
212+
"""
213+
An unauthorized user should not be able to modify other builds.
214+
215+
Since an unauthorized user should not know about the application at all, these
216+
requests should return a 404.
217+
"""
218+
app_id = 'autotest'
213219
url = '/v1/apps'
214-
response = self.client.post(url, HTTP_AUTHORIZATION='token {}'.format(self.token))
215-
app_id = response.data['id']
216-
# attempt to create a build as a malicious user
217-
evil_user = User.objects.get(username='autotest2')
218-
evil_token = Token.objects.get(user=evil_user).key
219-
url = "/v1/apps/{app_id}/builds".format(**locals())
220-
body = {'image': 'eeeeeevillllll'}
220+
body = {'id': app_id}
221+
response = self.client.post(url, json.dumps(body), content_type='application/json',
222+
HTTP_AUTHORIZATION='token {}'.format(self.token))
223+
unauthorized_user = User.objects.get(username='autotest2')
224+
unauthorized_token = Token.objects.get(user=unauthorized_user).key
225+
url = '{}/{}/builds'.format(url, app_id)
226+
body = {'image': 'foo'}
221227
response = self.client.post(url, json.dumps(body), content_type='application/json',
222-
HTTP_AUTHORIZATION='token {}'.format(evil_token))
223-
self.assertEqual(response.status_code, 403)
228+
HTTP_AUTHORIZATION='token {}'.format(unauthorized_token))
229+
self.assertEqual(response.status_code, 404)

controller/api/tests/test_config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,3 +432,23 @@ def test_config_owner_is_requesting_user(self):
432432
"""
433433
response = self.test_admin_can_create_config_on_other_apps()
434434
self.assertEqual(response.data['owner'], self.user.username)
435+
436+
def test_unauthorized_user_cannot_modify_config(self):
437+
"""
438+
An unauthorized user should not be able to modify other config.
439+
440+
Since an unauthorized user should not know about the application at all, these
441+
requests should return a 404.
442+
"""
443+
app_id = 'autotest'
444+
base_url = '/v1/apps'
445+
body = {'id': app_id}
446+
response = self.client.post(base_url, json.dumps(body), content_type='application/json',
447+
HTTP_AUTHORIZATION='token {}'.format(self.token))
448+
unauthorized_user = User.objects.get(username='autotest2')
449+
unauthorized_token = Token.objects.get(user=unauthorized_user).key
450+
url = '{}/{}/config'.format(base_url, app_id)
451+
body = {'values': {'FOO': 'bar'}}
452+
response = self.client.post(url, json.dumps(body), content_type='application/json',
453+
HTTP_AUTHORIZATION='token {}'.format(unauthorized_token))
454+
self.assertEqual(response.status_code, 404)

controller/api/tests/test_domain.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,6 @@ def test_manage_domain_invalid_app(self):
5959
HTTP_AUTHORIZATION='token {}'.format(self.token))
6060
self.assertEqual(response.status_code, 404)
6161

62-
def test_manage_domain_perms_on_app(self):
63-
user = User.objects.get(username='autotest2')
64-
token = Token.objects.get(user=user).key
65-
url = '/v1/apps/{app_id}/domains'.format(app_id=self.app_id)
66-
body = {'domain': 'test-domain2.example.com'}
67-
response = self.client.post(url, json.dumps(body), content_type='application/json',
68-
HTTP_AUTHORIZATION='token {}'.format(token))
69-
self.assertEqual(response.status_code, 201)
70-
7162
def test_manage_domain_invalid_domain(self):
7263
url = '/v1/apps/{app_id}/domains'.format(app_id=self.app_id)
7364
body = {'domain': 'this_is_an.invalid.domain'}
@@ -107,3 +98,23 @@ def test_admin_can_add_domains_to_other_apps(self):
10798
response = self.client.post(url, json.dumps(body), content_type='application/json',
10899
HTTP_AUTHORIZATION='token {}'.format(self.token))
109100
self.assertEqual(response.status_code, 201)
101+
102+
def test_unauthorized_user_cannot_modify_domain(self):
103+
"""
104+
An unauthorized user should not be able to modify other domains.
105+
106+
Since an unauthorized user should not know about the application at all, these
107+
requests should return a 404.
108+
"""
109+
app_id = 'autotest'
110+
url = '/v1/apps'
111+
body = {'id': app_id}
112+
response = self.client.post(url, json.dumps(body), content_type='application/json',
113+
HTTP_AUTHORIZATION='token {}'.format(self.token))
114+
unauthorized_user = User.objects.get(username='autotest2')
115+
unauthorized_token = Token.objects.get(user=unauthorized_user).key
116+
url = '{}/{}/domains'.format(url, app_id)
117+
body = {'domain': 'example.com'}
118+
response = self.client.post(url, json.dumps(body), content_type='application/json',
119+
HTTP_AUTHORIZATION='token {}'.format(unauthorized_token))
120+
self.assertEqual(response.status_code, 404)

controller/api/tests/test_perm.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ def test_create(self):
159159
self.assertEqual(len(response.data['results']), 1)
160160
# check that user 2 can't see any of the app's builds, configs,
161161
# containers, limits, or releases
162-
for model in ['builds', 'config', 'containers', 'limits', 'releases']:
162+
for model in ['builds', 'config', 'containers', 'releases']:
163163
response = self.client.get("/v1/apps/{}/{}/".format(app_id, model),
164164
HTTP_AUTHORIZATION='token {}'.format(self.token2))
165165
self.assertEqual(response.data['detail'], 'Not found')
@@ -192,7 +192,7 @@ def test_create_errors(self):
192192
body = {'username': 'autotest-2'}
193193
response = self.client.post(url, json.dumps(body), content_type='application/json',
194194
HTTP_AUTHORIZATION='token {}'.format(self.token2))
195-
self.assertEqual(response.status_code, 403)
195+
self.assertEqual(response.status_code, 404)
196196

197197
def test_delete(self):
198198
# give user 2 permission to user 1's app
@@ -211,7 +211,7 @@ def test_delete(self):
211211
url = "/v1/apps/{}/perms/{}".format(app_id, 'autotest-2')
212212
response = self.client.delete(url, content_type='application/json',
213213
HTTP_AUTHORIZATION='token {}'.format(self.token2))
214-
self.assertEqual(response.status_code, 403)
214+
self.assertEqual(response.status_code, 404)
215215
self.assertIsNone(response.data)
216216
# delete permission to user 1's app
217217
response = self.client.delete(url, content_type='application/json',
@@ -257,4 +257,24 @@ def test_list_errors(self):
257257
response = self.client.get(
258258
"/v1/apps/{}/perms".format(app_id), content_type='application/json',
259259
HTTP_AUTHORIZATION='token {}'.format(self.token2))
260-
self.assertEqual(response.status_code, 403)
260+
self.assertEqual(response.status_code, 404)
261+
262+
def test_unauthorized_user_cannot_modify_perms(self):
263+
"""
264+
An unauthorized user should not be able to modify other apps' permissions.
265+
266+
Since an unauthorized user should not know about the application at all, these
267+
requests should return a 404.
268+
"""
269+
app_id = 'autotest'
270+
url = '/v1/apps'
271+
body = {'id': app_id}
272+
response = self.client.post(url, json.dumps(body), content_type='application/json',
273+
HTTP_AUTHORIZATION='token {}'.format(self.token))
274+
unauthorized_user = self.user2
275+
unauthorized_token = self.token2
276+
url = '{}/{}/perms'.format(url, app_id)
277+
body = {'username': unauthorized_user.username}
278+
response = self.client.post(url, json.dumps(body), content_type='application/json',
279+
HTTP_AUTHORIZATION='token {}'.format(unauthorized_token))
280+
self.assertEqual(response.status_code, 404)

controller/api/tests/test_release.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,29 @@ def test_admin_can_create_release(self):
244244
self.assertEqual(response.status_code, 200)
245245
# account for the config release as well
246246
self.assertEqual(response.data['count'], 2)
247+
248+
@mock.patch('requests.post', mock_import_repository_task)
249+
def test_unauthorized_user_cannot_modify_release(self):
250+
"""
251+
An unauthorized user should not be able to modify other releases.
252+
253+
Since an unauthorized user should not know about the application at all, these
254+
requests should return a 404.
255+
"""
256+
app_id = 'autotest'
257+
url = '/v1/apps'
258+
body = {'id': app_id}
259+
response = self.client.post(url, json.dumps(body), content_type='application/json',
260+
HTTP_AUTHORIZATION='token {}'.format(self.token))
261+
# update config to roll a new release
262+
url = '/v1/apps/{app_id}/config'.format(**locals())
263+
body = {'values': json.dumps({'NEW_URL1': 'http://localhost:8080/'})}
264+
response = self.client.post(
265+
url, json.dumps(body), content_type='application/json',
266+
HTTP_AUTHORIZATION='token {}'.format(self.token))
267+
unauthorized_user = User.objects.get(username='autotest2')
268+
unauthorized_token = Token.objects.get(user=unauthorized_user).key
269+
# try to rollback
270+
url = '{}/{}/releases/rollback'.format(url, app_id)
271+
response = self.client.post(url, HTTP_AUTHORIZATION='token {}'.format(unauthorized_token))
272+
self.assertEqual(response.status_code, 404)

controller/api/views.py

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,15 @@ def list(self, request, **kwargs):
104104
if request.user != app.owner and \
105105
not request.user.has_perm(perm_name, app) and \
106106
not request.user.is_superuser:
107-
return Response(status=status.HTTP_403_FORBIDDEN)
107+
return Response(status=status.HTTP_404_NOT_FOUND)
108108
usernames = [u.username for u in get_users_with_perms(app)
109109
if u.has_perm(perm_name, app)]
110110
return Response({'users': usernames})
111111

112112
def create(self, request, **kwargs):
113113
app = get_object_or_404(self.model, id=kwargs['id'])
114114
if request.user != app.owner and not request.user.is_superuser:
115-
return Response(status=status.HTTP_403_FORBIDDEN)
115+
return Response(status=status.HTTP_404_NOT_FOUND)
116116
user = get_object_or_404(User, username=request.DATA['username'])
117117
assign_perm(self.perm, user, app)
118118
models.log_event(app, "User {} was granted access to {}".format(user, app))
@@ -121,7 +121,7 @@ def create(self, request, **kwargs):
121121
def destroy(self, request, **kwargs):
122122
app = get_object_or_404(self.model, id=kwargs['id'])
123123
if request.user != app.owner and not request.user.is_superuser:
124-
return Response(status=status.HTTP_403_FORBIDDEN)
124+
return Response(status=status.HTTP_404_NOT_FOUND)
125125
user = get_object_or_404(User, username=kwargs['username'])
126126
if user.has_perm(self.perm, app):
127127
remove_perm(self.perm, user, app)
@@ -138,7 +138,22 @@ class AdminPermsViewSet(viewsets.ModelViewSet):
138138
serializer_class = serializers.AdminUserSerializer
139139
permission_classes = (IsAdmin,)
140140

141+
def check_obj_permissions(self, obj):
142+
"""
143+
Small wrapper around check_object_permissions().
144+
145+
If the user is denied permission to the object, then
146+
it should return a 404 so the user is not aware of the
147+
application resource.
148+
"""
149+
try:
150+
self.check_object_permissions(self.request, obj)
151+
except PermissionDenied:
152+
raise Http404("No {} matches the given query.".format(
153+
self.model._meta.object_name))
154+
141155
def get_queryset(self, **kwargs):
156+
self.check_obj_permissions(self.request.user)
142157
return self.model.objects.filter(is_active=True, is_superuser=True)
143158

144159
def create(self, request, **kwargs):
@@ -225,21 +240,31 @@ class BaseAppViewSet(viewsets.ModelViewSet):
225240

226241
permission_classes = (permissions.IsAuthenticated, IsAppUser)
227242

228-
def pre_save(self, obj):
229-
obj.owner = self.request.user
243+
def check_obj_permissions(self, obj):
244+
"""
245+
Small wrapper around check_object_permissions().
230246
231-
def get_queryset(self, **kwargs):
232-
app = get_object_or_404(models.App, id=self.kwargs['id'])
247+
If the user is denied permission to the object, then
248+
it should return a 404 so the user is not aware of the
249+
application resource.
250+
"""
233251
try:
234-
self.check_object_permissions(self.request, app)
252+
self.check_object_permissions(self.request, obj)
235253
except PermissionDenied:
236254
raise Http404("No {} matches the given query.".format(
237255
self.model._meta.object_name))
256+
257+
def pre_save(self, obj):
258+
obj.owner = self.request.user
259+
260+
def get_queryset(self, **kwargs):
261+
app = get_object_or_404(models.App, id=self.kwargs['id'])
262+
self.check_obj_permissions(app)
238263
return self.model.objects.filter(app=app)
239264

240265
def get_object(self, *args, **kwargs):
241266
obj = self.get_queryset().latest('created')
242-
self.check_object_permissions(self.request, obj)
267+
self.check_obj_permissions(obj)
243268
return obj
244269

245270

@@ -260,7 +285,7 @@ def get_success_headers(self, data):
260285

261286
def create(self, request, *args, **kwargs):
262287
app = get_object_or_404(models.App, id=self.kwargs['id'])
263-
self.check_object_permissions(self.request, app)
288+
self.check_obj_permissions(app)
264289
request._data = request.DATA.copy()
265290
request.DATA['app'] = app
266291
try:
@@ -278,12 +303,8 @@ class AppConfigViewSet(BaseAppViewSet):
278303
def get_object(self, *args, **kwargs):
279304
"""Return the Config associated with the App's latest Release."""
280305
app = get_object_or_404(models.App, id=self.kwargs['id'])
281-
try:
282-
self.check_object_permissions(self.request, app)
283-
return app.release_set.latest().config
284-
except (PermissionDenied, models.Release.DoesNotExist):
285-
raise Http404("No {} matches the given query.".format(
286-
self.model._meta.object_name))
306+
self.check_obj_permissions(app)
307+
return app.release_set.latest().config
287308

288309
def pre_save(self, config):
289310
"""merge the old config with the new"""
@@ -396,20 +417,35 @@ class DomainViewSet(OwnerViewSet):
396417
model = models.Domain
397418
serializer_class = serializers.DomainSerializer
398419

420+
def check_obj_permissions(self, obj):
421+
"""
422+
Small wrapper around check_object_permissions().
423+
424+
If the user is denied permission to the object, then
425+
it should return a 404 so the user is not aware of the
426+
application resource.
427+
"""
428+
try:
429+
self.check_object_permissions(self.request, obj)
430+
except PermissionDenied:
431+
raise Http404("No {} matches the given query.".format(
432+
self.model._meta.object_name))
433+
399434
def create(self, request, *args, **kwargs):
400435
app = get_object_or_404(models.App, id=self.kwargs['id'])
436+
self.check_obj_permissions(app)
401437
request._data = request.DATA.copy()
402438
request.DATA['app'] = app
403439
return super(DomainViewSet, self).create(request, *args, **kwargs)
404440

405441
def get_queryset(self, **kwargs):
406442
app = get_object_or_404(models.App, id=self.kwargs['id'])
407-
qs = self.model.objects.filter(app=app)
408-
return qs
443+
self.check_obj_permissions(app)
444+
return self.model.objects.filter(app=app)
409445

410446
def get_object(self, *args, **kwargs):
411-
qs = self.get_queryset(**kwargs)
412-
obj = qs.get(domain=self.kwargs['domain'])
447+
obj = self.get_queryset().get(domain=self.kwargs['domain'])
448+
self.check_obj_permissions(obj)
413449
return obj
414450

415451

docs/reference/api-v1.1.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
Controller API v1.1
77
===================
88

9-
This is the v1.0 REST API for the :ref:`Controller`.
9+
This is the v1.1 REST API for the :ref:`Controller`.
1010

1111

1212
What's New
@@ -16,6 +16,8 @@ What's New
1616

1717
**New!** All controller responses now return the ``X_DEIS_PLATFORM_VERSION`` header.
1818

19+
**New!** Users see a 404 response when modifying applications they are unauthorized to see.
20+
1921

2022
Authentication
2123
--------------

0 commit comments

Comments
 (0)