Skip to content

Commit be2891e

Browse files
author
Matthew Fisher
committed
Merge pull request #2826 from bacongobbler/api-loopholes
fix(controller): disallow unauthorized users from modifying apps
2 parents 6f00ed5 + 14353e1 commit be2891e

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)