Skip to content

Commit 1ce3bc5

Browse files
committed
fix(controller): check image access when creating builds
Due to the nature of k8s' docker image cache, users can acccess other users' images in some situations (when they land on the same k8s node). That is, once user A has deployed privateregistry.example.com/image-a (with the help of `deis registry:set password=... username=...`), user B can just do `deis pull privateregistry.example.com/image-a` and that will (sometimes) work, since the image is in the k8s cache and thus not pulled again. This commit changes things so that on a `deis pull` (aka `deis build:create`) the controller tries to access the image (with the configured registry credentials, if any) - and then refuses to deploy it to k8s if that fails.
1 parent 26add98 commit 1ce3bc5

12 files changed

Lines changed: 81 additions & 6 deletions

File tree

rootfs/api/models/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,9 @@ def deploy(self, release, force_deploy=False, rollback_on_failure=True): # noqa
520520
image = settings.SLUGRUNNER_IMAGE if release.build.type == 'buildpack' else release.image
521521

522522
try:
523+
# check access to the image, so users can't exploit the k8s image cache
524+
# to gain access to other users' images
525+
release.check_image_access()
523526
# create the application config in k8s (secret in this case) for all deploy objects
524527
self.set_application_config(release)
525528
# only buildpack apps need access to object storage

rootfs/api/models/release.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from django.conf import settings
44
from django.db import models
55

6-
from registry import publish_release, get_port as docker_get_port, RegistryException
6+
from registry import publish_release, get_port as docker_get_port, check_access as docker_check_access, RegistryException # noqa
77
from api.utils import dict_diff
88
from api.models import UuidAuditedModel
99
from api.exceptions import DeisException, AlreadyExists
@@ -136,6 +136,13 @@ def publish(self):
136136
deis_registry = bool(self.build.source_based)
137137
publish_release(source_image, self.image, deis_registry, self.get_registry_auth())
138138

139+
def check_image_access(self):
140+
try:
141+
creds = self.get_registry_auth()
142+
docker_check_access(self.image, creds)
143+
except Exception as e:
144+
raise DeisException(str(e)) from e
145+
139146
def get_port(self):
140147
"""
141148
Get application port for a given release. If pulling from private registry

rootfs/api/tests/test_app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def _mock_run(*args, **kwargs):
3535
@requests_mock.Mocker(real_http=True, adapter=adapter)
3636
@mock.patch('api.models.release.publish_release', lambda *args: None)
3737
@mock.patch('api.models.release.docker_get_port', mock_port)
38+
@mock.patch('api.models.release.docker_check_access', lambda *args: None)
3839
class AppTest(DeisTestCase):
3940
"""Tests creation of applications"""
4041

rootfs/api/tests/test_build.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
@requests_mock.Mocker(real_http=True, adapter=adapter)
2424
@mock.patch('api.models.release.publish_release', lambda *args: None)
2525
@mock.patch('api.models.release.docker_get_port', mock_port)
26+
@mock.patch('api.models.release.docker_check_access', lambda *args: None)
2627
class BuildTest(DeisTransactionTestCase):
2728

2829
"""Tests build notification from build system"""
@@ -591,6 +592,44 @@ def test_build_image_in_registry_with_auth(self, mock_requests):
591592
response = self.client.post(url, body)
592593
self.assertEqual(response.status_code, 201, response.data)
593594

595+
def test_build_image_no_registry_password(self, mock_requests):
596+
"""build with image from private registry, but no password given"""
597+
app_id = self.create_app()
598+
599+
# post an image as a build
600+
with mock.patch('api.models.release.docker_check_access') as mock_check_access:
601+
mock_check_access.side_effect = Exception('no no no') # let the image access fail
602+
url = "/v2/apps/{app_id}/builds".format(**locals())
603+
image = 'autotest/example'
604+
response = self.client.post(url, {'image': image})
605+
self.assertEqual(response.status_code, 400, response.data)
606+
607+
def test_build_image_wrong_registry_password(self, mock_requests):
608+
"""build with image from private registry, but wrong password given"""
609+
app_id = self.create_app()
610+
611+
# post an image as a build using registry hostname
612+
url = "/v2/apps/{app_id}/builds".format(**locals())
613+
image = 'autotest/example'
614+
response = self.client.post(url, {'image': image})
615+
self.assertEqual(response.status_code, 201, response.data)
616+
617+
# add the required PORT information
618+
url = '/v2/apps/{app_id}/config'.format(**locals())
619+
body = {'values': json.dumps({'PORT': '80'})}
620+
response = self.client.post(url, body)
621+
self.assertEqual(response.status_code, 201, response.data)
622+
623+
# set some registry information
624+
with mock.patch('api.models.release.docker_check_access') as mock_check_access:
625+
mock_check_access.side_effect = Exception('no no no') # let the image access fail
626+
url = '/v2/apps/{app_id}/config'.format(**locals())
627+
body = {'registry': json.dumps({'username': 'bob', 'password': 'zoomzoom'})}
628+
response = self.client.post(url, body)
629+
self.assertEqual(response.status_code, 400, response.data)
630+
mock_check_access.assert_called_with(
631+
image, {'username': 'bob', 'password': 'zoomzoom', 'email': 'autotest@deis.io'})
632+
594633
def test_build_image_in_registry_with_auth_no_port(self, mock_requests):
595634
"""add authentication to the build but with no PORT config"""
596635
app_id = self.create_app()

rootfs/api/tests/test_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
@requests_mock.Mocker(real_http=True, adapter=adapter)
2222
@mock.patch('api.models.release.publish_release', lambda *args: None)
2323
@mock.patch('api.models.release.docker_get_port', mock_port)
24+
@mock.patch('api.models.release.docker_check_access', lambda *args: None)
2425
class ConfigTest(DeisTransactionTestCase):
2526
"""Tests setting and updating config values"""
2627

rootfs/api/tests/test_healthchecks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
@requests_mock.Mocker(real_http=True, adapter=adapter)
1414
@mock.patch('api.models.release.publish_release', lambda *args: None)
1515
@mock.patch('api.models.release.docker_get_port', mock_port)
16+
@mock.patch('api.models.release.docker_check_access', lambda *args: None)
1617
class TestHealthchecks(DeisTransactionTestCase):
1718
"""Tests setting and updating config values"""
1819

rootfs/api/tests/test_hooks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
@requests_mock.Mocker(real_http=True, adapter=adapter)
5151
@mock.patch('api.models.release.publish_release', lambda *args: None)
5252
@mock.patch('api.models.release.docker_get_port', mock_port)
53+
@mock.patch('api.models.release.docker_check_access', lambda *args: None)
5354
class HookTest(DeisTransactionTestCase):
5455

5556
"""Tests API hooks used to trigger actions from external components"""

rootfs/api/tests/test_pods.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
@requests_mock.Mocker(real_http=True, adapter=adapter)
2222
@mock.patch('api.models.release.publish_release', lambda *args: None)
2323
@mock.patch('api.models.release.docker_get_port', mock_port)
24+
@mock.patch('api.models.release.docker_check_access', lambda *args: None)
2425
class PodTest(DeisTransactionTestCase):
2526
"""Tests creation of pods on nodes"""
2627

rootfs/api/tests/test_registry.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import requests_mock
3+
from unittest import mock
34

45
from django.core.cache import cache
56
from django.contrib.auth.models import User
@@ -136,7 +137,11 @@ def test_registry_deploy(self, mock_requests):
136137
self.assertEqual(response.data['registry']['password'], 's3cur3pw1')
137138

138139
# post a new build
139-
url = "/v2/apps/{app_id}/builds".format(**locals())
140-
body = {'image': 'autotest/example'}
141-
response = self.client.post(url, body)
142-
self.assertEqual(response.status_code, 201, response.data)
140+
with mock.patch('api.models.release.docker_check_access') as mock_check_access:
141+
url = "/v2/apps/{app_id}/builds".format(**locals())
142+
body = {'image': 'autotest/example'}
143+
response = self.client.post(url, body)
144+
self.assertEqual(response.status_code, 201, response.data)
145+
mock_check_access.assert_called_with(
146+
'autotest/example',
147+
{'password': 's3cur3pw1', 'username': 'bob', 'email': 'autotest@deis.io'})

rootfs/api/tests/test_release.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
@requests_mock.Mocker(real_http=True, adapter=adapter)
2222
@mock.patch('api.models.release.publish_release', lambda *args: None)
2323
@mock.patch('api.models.release.docker_get_port', mock_port)
24+
@mock.patch('api.models.release.docker_check_access', lambda *args: None)
2425
class ReleaseTest(DeisTransactionTestCase):
2526

2627
"""Tests push notification from build system"""

0 commit comments

Comments
 (0)