diff --git a/CHANGES/+unquoted-auth-challenge.bugfix b/CHANGES/+unquoted-auth-challenge.bugfix new file mode 100644 index 000000000..8531b1c19 --- /dev/null +++ b/CHANGES/+unquoted-auth-challenge.bugfix @@ -0,0 +1 @@ +Fixed pull-through signature lookups failing with a 500 when the upstream registry (e.g. gcr.io) sends unquoted values in its bearer auth challenge. diff --git a/pulp_container/app/downloaders.py b/pulp_container/app/downloaders.py index d24d7c601..4c06b339a 100644 --- a/pulp_container/app/downloaders.py +++ b/pulp_container/app/downloaders.py @@ -115,14 +115,7 @@ async def update_token(self, response_auth_header, used_token, repo_name): ): return log.info("Updating bearer token") - bearer_info_string = response_auth_header[len("Bearer ") :] - bearer_info_list = re.split(",(?=[^=,]+=)", bearer_info_string) - - # The remaining string consists of comma seperated key=value pairs - auth_query_dict = {} - for key, value in (item.split("=") for item in bearer_info_list): - # The value is a string within a string, ex: '"value"' - auth_query_dict[key] = json.loads(value) + auth_query_dict = self.parse_auth_challenge(response_auth_header) try: token_base_url = auth_query_dict.pop("realm") except KeyError: @@ -156,6 +149,24 @@ async def update_token(self, response_auth_header, used_token, repo_name): token = token_js.get("token") or token_js.get("access_token") self.factory.registry_auth["bearer"] = token + @staticmethod + def parse_auth_challenge(response_auth_header): + """ + Parse the parameters of a Bearer auth challenge into a dictionary. + + Values may be quoted strings or plain tokens (RFC 7235), e.g. gcr.io responds with + 'Bearer realm="https://gcr.io/v2/token",service=gcr.io'. + """ + bearer_info_string = response_auth_header[len("Bearer ") :] + auth_query_dict = {} + for match in re.finditer(r'([^=,\s]+)=(?:"((?:[^"\\]|\\.)*)"|([^,]*))', bearer_info_string): + key, quoted, plain = match.groups() + if quoted is not None: + auth_query_dict[key] = re.sub(r"\\(.)", r"\1", quoted) + else: + auth_query_dict[key] = plain.strip() + return auth_query_dict + @staticmethod def auth_header(token, basic_auth): """ diff --git a/pulp_container/tests/unit/test_downloaders.py b/pulp_container/tests/unit/test_downloaders.py new file mode 100644 index 000000000..1befbf77d --- /dev/null +++ b/pulp_container/tests/unit/test_downloaders.py @@ -0,0 +1,33 @@ +import pytest + +from pulp_container.app.downloaders import RegistryAuthHttpDownloader + + +@pytest.mark.parametrize( + "header,expected", + [ + ( + 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io"', + {"realm": "https://auth.docker.io/token", "service": "registry.docker.io"}, + ), + ( + 'Bearer realm="https://gcr.io/v2/token",service=gcr.io', + {"realm": "https://gcr.io/v2/token", "service": "gcr.io"}, + ), + ( + 'Bearer realm="https://quay.io/v2/auth",service="quay.io",' + 'scope="repository:foo/bar:pull,push"', + { + "realm": "https://quay.io/v2/auth", + "service": "quay.io", + "scope": "repository:foo/bar:pull,push", + }, + ), + ( + 'Bearer realm="https://example.com/token", service=example.com, error="a \\"b\\""', + {"realm": "https://example.com/token", "service": "example.com", "error": 'a "b"'}, + ), + ], +) +def test_parse_auth_challenge(header, expected): + assert RegistryAuthHttpDownloader.parse_auth_challenge(header) == expected