From 766d60493f86a36f3cb3a3e34163950d702b316a Mon Sep 17 00:00:00 2001 From: Jake Moshenko Date: Tue, 15 Dec 2015 16:21:06 -0500 Subject: [PATCH] Add the ability to blacklist v2 for specific versions --- config.py | 4 ++++ endpoints/v2/__init__.py | 11 ++++++++++ requirements-nover.txt | 3 ++- requirements.txt | 1 + test/registry_tests.py | 10 ++++++++- test/test_util.py | 43 ++++++++++++++++++++++++++++++++++++++ util/registry/dockerver.py | 27 ++++++++++++++++++++++++ 7 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 util/registry/dockerver.py diff --git a/config.py b/config.py index 6fa139e7d..d3a53cff8 100644 --- a/config.py +++ b/config.py @@ -193,6 +193,10 @@ class DefaultConfig(object): # Feature Flag: Whether the v2/ endpoint is visible FEATURE_ADVERTISE_V2 = True + # Semver spec for which Docker versions we will blacklist + # Documentation: http://pythonhosted.org/semantic_version/reference.html#semantic_version.Spec + BLACKLIST_V2_SPEC = '<1.6.0' + BUILD_MANAGER = ('enterprise', {}) DISTRIBUTED_STORAGE_CONFIG = { diff --git a/endpoints/v2/__init__.py b/endpoints/v2/__init__.py index 06b400f20..434248ed4 100644 --- a/endpoints/v2/__init__.py +++ b/endpoints/v2/__init__.py @@ -3,6 +3,7 @@ import logging from flask import Blueprint, make_response, url_for, request, jsonify from functools import wraps from urlparse import urlparse +from semantic_version import Spec import features @@ -13,8 +14,10 @@ from auth.auth_context import get_grant_context from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission, AdministerRepositoryPermission) from data import model +from app import app from util.http import abort from util.saas.metricqueue import time_blueprint +from util.registry.dockerver import docker_version from auth.registry_jwt_auth import process_registry_jwt_auth, get_auth_headers logger = logging.getLogger(__name__) @@ -75,6 +78,14 @@ def route_show_if(value): @process_registry_jwt_auth @anon_allowed def v2_support_enabled(): + docker_ver = docker_version(request.user_agent.string) + + # Check if our version is one of the blacklisted versions, if we can't + # identify the version (None) we will fail open and assume that it is + # newer and therefore should not be blacklisted. + if Spec(app.config['BLACKLIST_V2_SPEC']).match(docker_ver) and docker_ver is not None: + abort(404) + response = make_response('true', 200) if get_grant_context() is None: diff --git a/requirements-nover.txt b/requirements-nover.txt index 1f1450103..eaf0ced3a 100644 --- a/requirements-nover.txt +++ b/requirements-nover.txt @@ -57,4 +57,5 @@ pyjwkest rfc3987 jsonpath-rw bintrees -redlock \ No newline at end of file +redlock +semantic-version diff --git a/requirements.txt b/requirements.txt index 062a73a1f..cc98dad31 100644 --- a/requirements.txt +++ b/requirements.txt @@ -78,6 +78,7 @@ reportlab==2.7 requests==2.7.0 requests-oauthlib==0.5.0 rfc3987==1.3.4 +semantic-version==2.4.2 simplejson==3.7.3 six==1.9.0 SQLAlchemy==1.0.6 diff --git a/test/registry_tests.py b/test/registry_tests.py index 6ad43d140..21fc6c883 100644 --- a/test/registry_tests.py +++ b/test/registry_tests.py @@ -181,13 +181,18 @@ class RegistryTestCaseMixin(LiveServerTestCase): class BaseRegistryMixin(object): def conduct(self, method, url, headers=None, data=None, auth=None, params=None, expected_code=200, - json_data=None): + json_data=None, user_agent=None): params = params or {} params['_csrf_token'] = self.csrf_token headers = headers or {} auth_tuple = None + if user_agent is not None: + headers['User-Agent'] = user_agent + else: + headers['User-Agent'] = 'docker/1.9.1' + if self.docker_token: headers['X-Docker-Token'] = self.docker_token @@ -1026,6 +1031,9 @@ class V2RegistryTests(V2RegistryPullMixin, V2RegistryPushMixin, RegistryTestsMix # Try to get tags before a repo exists. self.conduct('GET', '/v2/devtable/doesnotexist/tags/list', auth='jwt', expected_code=401) + def test_one_five_blacklist(self): + self.conduct('GET', '/v2/', expected_code=404, user_agent='Go 1.1 package http') + class V1PushV2PullRegistryTests(V2RegistryPullMixin, V1RegistryPushMixin, RegistryTestsMixin, RegistryTestCaseMixin, LiveServerTestCase): diff --git a/test/test_util.py b/test/test_util.py index 2cfe2f5cd..a04b25bfb 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -1,9 +1,11 @@ import unittest from itertools import islice +from semantic_version import Version, Spec from util.validation import generate_valid_usernames from util.registry.generatorfile import GeneratorFile +from util.registry.dockerver import docker_version class TestGeneratorFile(unittest.TestCase): def sample_generator(self): @@ -131,6 +133,47 @@ class TestUsernameGenerator(unittest.TestCase): self.assertEquals('a003', generated_output[3]) +class TestDockerVersionParsing(unittest.TestCase): + def test_parsing(self): + tests_cases = [ + ('docker/1.6.0 go/go1.4.2 git-commit/1234567 kernel/4.2.0-18-generic os/linux arch/amd64', + Version('1.6.0')), + ('docker/1.7.1 go/go1.4.2 kernel/4.1.7-15.23.amzn1.x86_64 os/linux arch/amd64', + Version('1.7.1')), + ('docker/1.6.2 go/go1.4.2 git-commit/7c8fca2-dirty kernel/4.0.5 os/linux arch/amd64', + Version('1.6.2')), + ('docker/1.9.0 go/go1.4.2 git-commit/76d6bc9 kernel/3.16.0-4-amd64 os/linux arch/amd64', + Version('1.9.0')), + ('docker/1.9.1 go/go1.4.2 git-commit/a34a1d5 kernel/3.10.0-229.20.1.el7.x86_64 os/linux arch/amd64', + Version('1.9.1')), + ('docker/1.8.2-circleci go/go1.4.2 git-commit/a8b52f5 kernel/3.13.0-71-generic os/linux arch/amd64', + Version('1.8.2')), + ('Go 1.1 package http', Version('1.5.0')), + ('curl', None), + ('docker/1.8 stuff', Version('1.8.0')), + ] + + for ua_string, ver_info in tests_cases: + parsed_ver = docker_version(ua_string) + self.assertEquals(ver_info, parsed_ver) + + def test_specs(self): + test_cases = [ + # (Spec, no_match_case_list, matching_case_list) + (Spec('<1.6.0'), ['1.6.0', '1.6.1', '1.9.0', '100.5.2'], ['0.0.0', '1.5.99']), + (Spec('<1.9.0'), ['1.9.0', '100.5.2'], ['0.0.0', '1.5.99', '1.6.0', '1.6.1']), + (Spec('<1.6.0,>0.0.1'), ['1.6.0', '1.6.1', '1.9.0', '0.0.0'], ['1.5.99']), + ] + + for spec, no_match_cases, match_cases in test_cases: + for no_match_case in no_match_cases: + self.assertFalse(spec.match(Version(no_match_case)), + 'Spec: %s Case: %s' % (spec, no_match_case)) + + for match_case in match_cases: + self.assertTrue(spec.match(Version(match_case)), + 'Spec: %s Case: %s' % (spec, match_case)) + if __name__ == '__main__': unittest.main() diff --git a/util/registry/dockerver.py b/util/registry/dockerver.py new file mode 100644 index 000000000..2156d6377 --- /dev/null +++ b/util/registry/dockerver.py @@ -0,0 +1,27 @@ +import re + +from semantic_version import Version + +_USER_AGENT_SEARCH_REGEX = re.compile(r'docker\/([0-9]+(?:\.[0-9]+){1,2})') +_EXACT_1_5_USER_AGENT = re.compile(r'^Go 1\.1 package http$') +_ONE_FIVE_ZERO = '1.5.0' + +def docker_version(user_agent_string): + """ Extract the Docker version from the user agent, taking special care to + handle the case of a 1.5 client requesting an auth token, which sends + a broken user agent. If we can not positively identify a version, return + None. + """ + + # First search for a well defined semver portion in the UA header. + found_semver = _USER_AGENT_SEARCH_REGEX.search(user_agent_string) + if found_semver: + return Version(found_semver.group(1), partial=True) + + # Check if we received the very specific header which represents a 1.5 request + # to the auth endpoints. + elif _EXACT_1_5_USER_AGENT.match(user_agent_string): + return Version(_ONE_FIVE_ZERO) + + else: + return None