Add ability for specific geographic regions to be blocked from pulling images within a namespace

This commit is contained in:
Joseph Schorr 2018-12-05 15:19:37 -05:00
parent c71a43a06c
commit c3710a6a5e
20 changed files with 257 additions and 37 deletions

View file

@ -15,7 +15,7 @@ from flask_principal import Identity
from app import storage
from data.database import (close_db_filter, configure, DerivedStorageForImage, QueueItem, Image,
TagManifest, TagManifestToManifest, Manifest, ManifestLegacyImage,
ManifestBlob)
ManifestBlob, NamespaceGeoRestriction, User)
from data import model
from data.registry_model import registry_model
from endpoints.csrf import generate_csrf_token
@ -116,6 +116,13 @@ def registry_server_executor(app):
TagManifest.delete().execute()
return 'OK'
def set_geo_block_for_namespace(namespace_name, iso_country_code):
NamespaceGeoRestriction.create(namespace=User.get(username=namespace_name),
description='',
unstructured_json={},
restricted_region_iso_code=iso_country_code)
return 'OK'
executor = LiveServerExecutor()
executor.register('generate_csrf', generate_csrf)
executor.register('set_supports_direct_download', set_supports_direct_download)
@ -130,6 +137,7 @@ def registry_server_executor(app):
executor.register('create_app_repository', create_app_repository)
executor.register('disable_namespace', disable_namespace)
executor.register('delete_manifests', delete_manifests)
executor.register('set_geo_block_for_namespace', set_geo_block_for_namespace)
return executor

View file

@ -15,6 +15,7 @@ class V1ProtocolSteps(Enum):
PUT_TAG = 'put-tag'
PUT_IMAGE_JSON = 'put-image-json'
DELETE_TAG = 'delete-tag'
GET_LAYER = 'get-layer'
class V1Protocol(RegistryProtocol):
@ -45,6 +46,9 @@ class V1Protocol(RegistryProtocol):
Failures.INVALID_IMAGES: 400,
Failures.NAMESPACE_DISABLED: 400,
},
V1ProtocolSteps.GET_LAYER: {
Failures.GEO_BLOCKED: 403,
},
}
def __init__(self, jwk):
@ -118,8 +122,11 @@ class V1Protocol(RegistryProtocol):
self.conduct(session, 'HEAD', image_prefix + 'layer', headers=headers)
# And retrieve the layer data.
result = self.conduct(session, 'GET', image_prefix + 'layer', headers=headers)
assert result.content == images[index].bytes
result = self.conduct(session, 'GET', image_prefix + 'layer', headers=headers,
expected_status=(200, expected_failure, V1ProtocolSteps.GET_LAYER),
options=options)
if result.status_code == 200:
assert result.content == images[index].bytes
return PullResult(manifests=None, image_ids=image_ids)

View file

@ -27,6 +27,7 @@ class V2ProtocolSteps(Enum):
CATALOG = 'catalog'
LIST_TAGS = 'list-tags'
START_UPLOAD = 'start-upload'
GET_BLOB = 'get-blob'
class V2Protocol(RegistryProtocol):
@ -48,6 +49,9 @@ class V2Protocol(RegistryProtocol):
Failures.UNAUTHORIZED: 401,
Failures.DISALLOWED_LIBRARY_NAMESPACE: 400,
},
V2ProtocolSteps.GET_BLOB: {
Failures.GEO_BLOCKED: 403,
},
V2ProtocolSteps.BLOB_HEAD_CHECK: {
Failures.DISALLOWED_LIBRARY_NAMESPACE: 400,
},
@ -466,10 +470,11 @@ class V2Protocol(RegistryProtocol):
assert response.headers['Content-Length'] == str(len(blob_bytes))
# And retrieve the blob data.
result = self.conduct(session, 'GET',
'/v2/%s/blobs/%s' % (self.repo_name(namespace, repo_name), blob_digest),
headers=headers, expected_status=200)
assert result.content == blob_bytes
if not options.skip_blob_push_checks:
result = self.conduct(session, 'GET',
'/v2/%s/blobs/%s' % (self.repo_name(namespace, repo_name), blob_digest),
headers=headers, expected_status=200)
assert result.content == blob_bytes
return True
@ -558,8 +563,10 @@ class V2Protocol(RegistryProtocol):
result = self.conduct(session, 'GET',
'/v2/%s/blobs/%s' % (self.repo_name(namespace, repo_name),
blob_digest),
expected_status=expected_status,
headers=headers)
expected_status=(expected_status, expected_failure,
V2ProtocolSteps.GET_BLOB),
headers=headers,
options=options)
if expected_status == 200:
assert result.content == image.bytes

View file

@ -65,6 +65,7 @@ class Failures(Enum):
INVALID_BLOB = 'invalid-blob'
NAMESPACE_DISABLED = 'namespace-disabled'
UNAUTHORIZED_FOR_MOUNT = 'unauthorized-for-mount'
GEO_BLOCKED = 'geo-blocked'
class ProtocolOptions(object):
@ -78,6 +79,8 @@ class ProtocolOptions(object):
self.accept_mimetypes = None
self.mount_blobs = None
self.push_by_manifest_digest = False
self.request_addr = None
self.skip_blob_push_checks = False
@add_metaclass(ABCMeta)
@ -115,12 +118,16 @@ class RegistryProtocol(object):
return repo_name
def conduct(self, session, method, url, expected_status=200, params=None, data=None,
json_data=None, headers=None, auth=None):
json_data=None, headers=None, auth=None, options=None):
if json_data is not None:
data = json.dumps(json_data)
headers = headers or {}
headers['Content-Type'] = 'application/json'
if options and options.request_addr:
headers = headers or {}
headers['X-Override-Remote-Addr-For-Testing'] = options.request_addr
if isinstance(expected_status, tuple):
expected_status, expected_failure, protocol_step = expected_status
if expected_failure is not None:

View file

@ -1706,3 +1706,25 @@ def test_verify_schema2(v22_protocol, basic_images, liveserver_session, liveserv
credentials=credentials)
manifest = result.manifests['latest']
assert manifest.schema_version == 2
def test_geo_blocking(pusher, puller, basic_images, liveserver_session,
liveserver, registry_server_executor, app_reloader):
""" Test: Attempt to pull an image from a geoblocked IP address. """
credentials = ('devtable', 'password')
options = ProtocolOptions()
options.skip_blob_push_checks = True # Otherwise, cache gets established.
# Push a new repository.
pusher.push(liveserver_session, 'devtable', 'newrepo', 'latest', basic_images,
credentials=credentials, options=options)
registry_server_executor.on(liveserver).set_geo_block_for_namespace('devtable', 'US')
# Attempt to pull the repository to verify. This should fail with a 403 due to
# the geoblocking of the IP being using.
options = ProtocolOptions()
options.request_addr = '6.0.0.0'
puller.pull(liveserver_session, 'devtable', 'newrepo', 'latest', basic_images,
credentials=credentials, options=options,
expected_failure=Failures.GEO_BLOCKED)