Skip to content

Commit 82cc428

Browse files
authored
remote: resolve push-check credentials against the repository (#2411)
PR #510 narrowed credential resolution from the registry to the repository level. CheckPushPermission was never updated. Therefore, a config.json holding both a registry-level entry and a more specific repository-level one made the check reject a push that Write then performed successfully.
1 parent 97815aa commit 82cc428

2 files changed

Lines changed: 132 additions & 2 deletions

File tree

‎pkg/v1/remote/check.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ import (
3232
//
3333
// TODO(#412): Remove the need for this method.
3434
func CheckPushPermission(ref name.Reference, kc authn.Keychain, t http.RoundTripper) error {
35-
auth, err := kc.Resolve(ref.Context().Registry)
35+
auth, err := authn.Resolve(context.TODO(), kc, ref.Context())
3636
if err != nil {
37-
return fmt.Errorf("resolving authorization for %v failed: %w", ref.Context().Registry, err)
37+
return fmt.Errorf("resolving authorization for %v failed: %w", ref.Context(), err)
3838
}
3939

4040
scopes := []string{ref.Scope(transport.PushScope)}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright 2026 Google LLC All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package remote
16+
17+
import (
18+
"encoding/base64"
19+
"encoding/json"
20+
"fmt"
21+
"net/http"
22+
"net/http/httptest"
23+
"net/url"
24+
"os"
25+
"path/filepath"
26+
"testing"
27+
28+
"github.com/google/go-containerregistry/pkg/authn"
29+
"github.com/google/go-containerregistry/pkg/registry"
30+
"github.com/google/go-containerregistry/pkg/v1/random"
31+
)
32+
33+
type dockerConfig struct {
34+
Auths map[string]dockerAuth `json:"auths"`
35+
}
36+
37+
type dockerAuth struct {
38+
Auth string `json:"auth"`
39+
}
40+
41+
func basicAuth(username, password string) dockerAuth {
42+
return dockerAuth{Auth: base64.StdEncoding.EncodeToString([]byte(username + ":" + password))}
43+
}
44+
45+
// TestCheckAgreesWithWrite asserts that CheckPushPermission and Write always agree.
46+
func TestCheckAgreesWithWrite(t *testing.T) {
47+
const (
48+
repo = "write/time"
49+
user = "user"
50+
pass = "pass"
51+
)
52+
53+
reg := registry.New()
54+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
55+
gotUser, gotPass, ok := r.BasicAuth()
56+
if r.URL.Path != "/v2/" && (!ok || gotUser != user || gotPass != pass) {
57+
http.Error(w, "unauthorized", http.StatusUnauthorized)
58+
return
59+
}
60+
reg.ServeHTTP(w, r)
61+
}))
62+
defer server.Close()
63+
64+
u, err := url.Parse(server.URL)
65+
if err != nil {
66+
t.Fatalf("url.Parse(%v) = %v", server.URL, err)
67+
}
68+
host := u.Host
69+
70+
img, err := random.Image(256, 1)
71+
if err != nil {
72+
t.Fatalf("random.Image() = %v", err)
73+
}
74+
75+
ref := mustNewTag(t, fmt.Sprintf("%s/%s:latest", host, repo))
76+
77+
cases := []struct {
78+
name string
79+
auths map[string]dockerAuth
80+
}{{
81+
"registry credential",
82+
map[string]dockerAuth{
83+
host: basicAuth(user, pass),
84+
},
85+
}, {
86+
"repository credential",
87+
map[string]dockerAuth{
88+
host + "/" + repo: basicAuth(user, pass),
89+
},
90+
}, {
91+
"repository credential alongside a registry credential",
92+
map[string]dockerAuth{
93+
host: basicAuth("wrong", "wrong"),
94+
host + "/" + repo: basicAuth(user, pass),
95+
},
96+
}, {
97+
"unusable repository credential alongside a registry credential",
98+
map[string]dockerAuth{
99+
host: basicAuth(user, pass),
100+
host + "/" + repo: basicAuth("wrong", "wrong"),
101+
},
102+
}, {
103+
"no usable credential",
104+
map[string]dockerAuth{
105+
host: basicAuth("wrong", "wrong"),
106+
},
107+
}}
108+
109+
for _, c := range cases {
110+
t.Run(c.name, func(t *testing.T) {
111+
config, err := json.Marshal(dockerConfig{Auths: c.auths})
112+
if err != nil {
113+
t.Fatalf("json.Marshal() = %v", err)
114+
}
115+
116+
dir := t.TempDir()
117+
t.Setenv("DOCKER_CONFIG", dir)
118+
err = os.WriteFile(filepath.Join(dir, "config.json"), config, 0600)
119+
if err != nil {
120+
t.Fatalf("writing config.json: %v", err)
121+
}
122+
123+
checkErr := CheckPushPermission(ref, authn.DefaultKeychain, http.DefaultTransport)
124+
writeErr := Write(ref, img, WithAuthFromKeychain(authn.DefaultKeychain))
125+
if (checkErr != nil) != (writeErr != nil) {
126+
t.Errorf("CheckPushPermission and Write disagree: check = %v, write = %v", checkErr, writeErr)
127+
}
128+
})
129+
}
130+
}

0 commit comments

Comments
 (0)