From bd304729cb3fbd67d68aa86398de1d21032f60d0 Mon Sep 17 00:00:00 2001 From: justinsb Date: Mon, 1 Apr 2024 08:25:33 -0400 Subject: [PATCH] Inline dependency on (Apache licensed) auth challenge This code was made internal in https://github.com/distribution/distribution/pull/4126, because it was not intended to be consumed externally. As it is Apache-licensed, start by inlining the dependency; we can then enhance test coverage and address any shortcomings. --- go.mod | 2 +- pkg/v1/remote/transport/bearer.go | 3 +- pkg/v1/remote/transport/ping.go | 5 +- pkg/v1/remote/transport/response_challenge.go | 169 ++++++++++++++++++ .../transport/response_challenge_test.go | 40 +++++ 5 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 pkg/v1/remote/transport/response_challenge.go create mode 100644 pkg/v1/remote/transport/response_challenge_test.go diff --git a/go.mod b/go.mod index 180a50012..bce1ae86b 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.18 require ( github.com/containerd/stargz-snapshotter/estargz v0.14.3 github.com/docker/cli v24.0.0+incompatible - github.com/docker/distribution v2.8.2+incompatible github.com/docker/docker v24.0.0+incompatible github.com/google/go-cmp v0.5.9 github.com/klauspost/compress v1.16.5 @@ -23,6 +22,7 @@ require ( cloud.google.com/go/compute/metadata v0.2.3 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/docker/distribution v2.8.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.7.0 // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.5.0 // indirect diff --git a/pkg/v1/remote/transport/bearer.go b/pkg/v1/remote/transport/bearer.go index cb1567496..4f300a30d 100644 --- a/pkg/v1/remote/transport/bearer.go +++ b/pkg/v1/remote/transport/bearer.go @@ -25,7 +25,6 @@ import ( "net/url" "strings" - authchallenge "github.com/docker/distribution/registry/client/auth/challenge" "github.com/google/go-containerregistry/internal/redact" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/logs" @@ -151,7 +150,7 @@ func (bt *bearerTransport) RoundTrip(in *http.Request) (*http.Response, error) { } // If we hit a WWW-Authenticate challenge, it might be due to expired tokens or insufficient scope. - if challenges := authchallenge.ResponseChallenges(res); len(challenges) != 0 { + if challenges := authResponseChallenges(res); len(challenges) != 0 { // close out old response, since we will not return it. res.Body.Close() diff --git a/pkg/v1/remote/transport/ping.go b/pkg/v1/remote/transport/ping.go index 799c7ea08..9d39da728 100644 --- a/pkg/v1/remote/transport/ping.go +++ b/pkg/v1/remote/transport/ping.go @@ -23,7 +23,6 @@ import ( "strings" "time" - authchallenge "github.com/docker/distribution/registry/client/auth/challenge" "github.com/google/go-containerregistry/pkg/logs" "github.com/google/go-containerregistry/pkg/name" ) @@ -84,7 +83,7 @@ func pingSingle(ctx context.Context, reg name.Registry, t http.RoundTripper, sch Insecure: insecure, }, nil case http.StatusUnauthorized: - if challenges := authchallenge.ResponseChallenges(resp); len(challenges) != 0 { + if challenges := authResponseChallenges(resp); len(challenges) != 0 { // If we hit more than one, let's try to find one that we know how to handle. wac := pickFromMultipleChallenges(challenges) return &Challenge{ @@ -165,7 +164,7 @@ func pingParallel(ctx context.Context, reg name.Registry, t http.RoundTripper, s } } -func pickFromMultipleChallenges(challenges []authchallenge.Challenge) authchallenge.Challenge { +func pickFromMultipleChallenges(challenges []authChallenge) authChallenge { // It might happen there are multiple www-authenticate headers, e.g. `Negotiate` and `Basic`. // Picking simply the first one could result eventually in `unrecognized challenge` error, // that's why we're looping through the challenges in search for one that can be handled. diff --git a/pkg/v1/remote/transport/response_challenge.go b/pkg/v1/remote/transport/response_challenge.go new file mode 100644 index 000000000..654198f4d --- /dev/null +++ b/pkg/v1/remote/transport/response_challenge.go @@ -0,0 +1,169 @@ +package transport + +// This code is copy-paste imported from the Apache-licensed https://github.com/distribution/distribution, +// as the dependency has been made internal upstream. +// There is an alternative implementation in https://fuchsia.googlesource.com/tools/+/efc566f8f0dcc061dac3d57989b24f496b109ecb/net/digest/digest.go + +import ( + "net/http" + "strings" +) + +// Octet types from RFC 2616. +type octetType byte + +var octetTypes [256]octetType + +const ( + isToken octetType = 1 << iota + isSpace +) + +func init() { + // OCTET = + // CHAR = + // CTL = + // CR = + // LF = + // SP = + // HT = + // <"> = + // CRLF = CR LF + // LWS = [CRLF] 1*( SP | HT ) + // TEXT = + // separators = "(" | ")" | "<" | ">" | "@" | "," | ";" | ":" | "\" | <"> + // | "/" | "[" | "]" | "?" | "=" | "{" | "}" | SP | HT + // token = 1* + // qdtext = > + + for c := 0; c < 256; c++ { + var t octetType + isCtl := c <= 31 || c == 127 + isChar := 0 <= c && c <= 127 + isSeparator := strings.ContainsRune(" \t\"(),/:;<=>?@[]\\{}", rune(c)) + if strings.ContainsRune(" \t\r\n", rune(c)) { + t |= isSpace + } + if isChar && !isCtl && !isSeparator { + t |= isToken + } + octetTypes[c] = t + } +} + +// authResponseChallenges returns a list of authorization challenges +// for the given http Response. Challenges are only checked if +// the response status code was a 401. +func authResponseChallenges(resp *http.Response) []authChallenge { + if resp.StatusCode == http.StatusUnauthorized { + // Parse the WWW-Authenticate Header and store the challenges + // on this endpoint object. + return parseAuthHeader(resp.Header) + } + + return nil +} + +func parseAuthHeader(header http.Header) []authChallenge { + challenges := []authChallenge{} + for _, h := range header[http.CanonicalHeaderKey("WWW-Authenticate")] { + v, p := parseValueAndParams(h) + if v != "" { + challenges = append(challenges, authChallenge{Scheme: v, Parameters: p}) + } + } + return challenges +} + +// Note: we may be able to combine with Challenge here +type authChallenge struct { + Scheme string + + // Following the challenge there are often key/value pairs + // e.g. Bearer service="gcr.io",realm="https://auth.gcr.io/v36/tokenz" + Parameters map[string]string +} + +func parseValueAndParams(header string) (value string, params map[string]string) { + params = make(map[string]string) + value, s := expectToken(header) + if value == "" { + return + } + value = strings.ToLower(value) + s = "," + skipSpace(s) + for strings.HasPrefix(s, ",") { + var pkey string + pkey, s = expectToken(skipSpace(s[1:])) + if pkey == "" { + return + } + if !strings.HasPrefix(s, "=") { + return + } + var pvalue string + pvalue, s = expectTokenOrQuoted(s[1:]) + if pvalue == "" { + return + } + pkey = strings.ToLower(pkey) + params[pkey] = pvalue + s = skipSpace(s) + } + return +} + +func skipSpace(s string) (rest string) { + i := 0 + for ; i < len(s); i++ { + if octetTypes[s[i]]&isSpace == 0 { + break + } + } + return s[i:] +} + +func expectToken(s string) (token, rest string) { + i := 0 + for ; i < len(s); i++ { + if octetTypes[s[i]]&isToken == 0 { + break + } + } + return s[:i], s[i:] +} + +func expectTokenOrQuoted(s string) (value string, rest string) { + if !strings.HasPrefix(s, "\"") { + return expectToken(s) + } + s = s[1:] + for i := 0; i < len(s); i++ { + switch s[i] { + case '"': + return s[:i], s[i+1:] + case '\\': + p := make([]byte, len(s)-1) + j := copy(p, s[:i]) + escape := true + for i = i + 1; i < len(s); i++ { + b := s[i] + switch { + case escape: + escape = false + p[j] = b + j++ + case b == '\\': + escape = true + case b == '"': + return string(p[:j]), s[i+1:] + default: + p[j] = b + j++ + } + } + return "", "" + } + } + return "", "" +} diff --git a/pkg/v1/remote/transport/response_challenge_test.go b/pkg/v1/remote/transport/response_challenge_test.go new file mode 100644 index 000000000..2453f4b6a --- /dev/null +++ b/pkg/v1/remote/transport/response_challenge_test.go @@ -0,0 +1,40 @@ +package transport + +// This code is copy-paste imported from the Apache-licensed https://github.com/distribution/distribution, +// as the dependency has been made internal upstream. + +import ( + "net/http" + "testing" +) + +func TestAuthChallengeParse(t *testing.T) { + header := http.Header{} + header.Add("WWW-Authenticate", `Bearer realm="https://auth.example.com/token",service="registry.example.com",other=fun,slashed="he\"\l\lo"`) + + challenges := parseAuthHeader(header) + if len(challenges) != 1 { + t.Fatalf("Unexpected number of auth challenges: %d, expected 1", len(challenges)) + } + challenge := challenges[0] + + if expected := "bearer"; challenge.Scheme != expected { + t.Fatalf("Unexpected scheme: %s, expected: %s", challenge.Scheme, expected) + } + + if expected := "https://auth.example.com/token"; challenge.Parameters["realm"] != expected { + t.Fatalf("Unexpected param: %s, expected: %s", challenge.Parameters["realm"], expected) + } + + if expected := "registry.example.com"; challenge.Parameters["service"] != expected { + t.Fatalf("Unexpected param: %s, expected: %s", challenge.Parameters["service"], expected) + } + + if expected := "fun"; challenge.Parameters["other"] != expected { + t.Fatalf("Unexpected param: %s, expected: %s", challenge.Parameters["other"], expected) + } + + if expected := "he\"llo"; challenge.Parameters["slashed"] != expected { + t.Fatalf("Unexpected param: %s, expected: %s", challenge.Parameters["slashed"], expected) + } +}