Add ability for specific geographic regions to be blocked from pulling images within a namespace
This commit is contained in:
parent
c71a43a06c
commit
c3710a6a5e
20 changed files with 257 additions and 37 deletions
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
Reference in a new issue