Skip to content

Commit 21b1f7b

Browse files
EljeesTerryHowe
andauthored
fix(credentials): use the host's credential helper for a namespaced key (#1457)
* fix(credentials): use the host's credential helper for a namespaced key DynamicStore picked the backing store by an exact credHelpers lookup, so a namespaced key such as example.com/team/app never matched the helper configured for example.com. Put then failed with plaintext puts disabled, or wrote the secret in cleartext to config.json with them enabled. Walk up the key's path to the nearest configured helper instead. Fixes #1453 Signed-off-by: Eljees <3.14hell@gmail.com> * docs(credentials): describe helper selection for namespaced keys Co-authored-by: Terry Howe <terrylhowe@gmail.com> Signed-off-by: Eljees <3.14hell@gmail.com> * fix(credentials): treat an inherited helper's Get error as a miss A helper found by walking up to a parent namespace was never configured for the key it is probed with. A helper that reports an unknown server URL with its own message would otherwise fail the first iteration of the namespace walk instead of letting it reach the host. Co-authored-by: Terry Howe <terrylhowe@gmail.com> Signed-off-by: Eljees <3.14hell@gmail.com> --------- Signed-off-by: Eljees <3.14hell@gmail.com> Co-authored-by: Terry Howe <terrylhowe@gmail.com>
1 parent cab9028 commit 21b1f7b

2 files changed

Lines changed: 110 additions & 11 deletions

File tree

‎registry/remote/credentials/store.go‎

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"fmt"
2626
"os"
2727
"path/filepath"
28+
"strings"
2829

2930
"github.com/oras-project/oras-go/v3/registry/remote/internal/configfile"
3031
)
@@ -116,8 +117,10 @@ type StoreOptions struct {
116117
// containers-auth.json (Podman/Buildah). When false (default), exact
117118
// hostname matching is used, as in Docker config.json.
118119
//
119-
// This only affects the plaintext file store; credential helpers and
120-
// native stores are always keyed by the exact server address.
120+
// This only affects credential lookup in the plaintext file store. A
121+
// credential helper is always selected by nearest configured parent, and
122+
// the selected helper and native stores are always keyed by the exact
123+
// server address, regardless of this option.
121124
//
122125
// It also makes the store authoritative for namespace matching, reported
123126
// through [NamespaceMatcher]: a key that is present but holds no
@@ -179,6 +182,9 @@ func NewStoreFromDocker(opt StoreOptions) (*DynamicStore, error) {
179182
}
180183

181184
// Get retrieves credentials from the store for the given server address.
185+
//
186+
// Callers that walk namespaces call this once per path segment, so a helper
187+
// inherited from a parent namespace may be executed several times per lookup.
182188
func (ds *DynamicStore) Get(ctx context.Context, serverAddress string) (Credential, error) {
183189
return ds.getStore(serverAddress).Get(ctx, serverAddress)
184190
}
@@ -231,23 +237,43 @@ func (ds *DynamicStore) ConfigPath() string {
231237
}
232238

233239
// getHelperSuffix returns the credential helper suffix for the given server
234-
// address.
235-
func (ds *DynamicStore) getHelperSuffix(serverAddress string) string {
236-
// 1. Look for a server-specific credential helper first
237-
if helper := ds.config.GetCredentialHelper(serverAddress); helper != "" {
238-
return helper
240+
// address, and whether it was configured for that address itself rather than
241+
// inherited from a parent namespace.
242+
//
243+
// "credHelpers" is keyed by registry host, so a namespaced address
244+
// ("host/path") resolves to the helper configured for its nearest parent,
245+
// down to the bare host. The resolved helper is queried with the full address,
246+
// path included. Note that containers/image queries the helper with the host
247+
// alone, so a credential stored here under a namespaced key is not visible to
248+
// Podman or the Docker CLI.
249+
func (ds *DynamicStore) getHelperSuffix(serverAddress string) (suffix string, exact bool) {
250+
// 1. Look for a server-specific credential helper, then for the helper of
251+
// each parent namespace.
252+
key := serverAddress
253+
for {
254+
if helper := ds.config.GetCredentialHelper(key); helper != "" {
255+
return helper, key == serverAddress
256+
}
257+
i := strings.LastIndex(key, "/")
258+
if i <= 0 {
259+
break
260+
}
261+
key = key[:i]
239262
}
240263
// 2. Then look for the configured native store
241264
if credsStore := ds.config.CredentialsStore(); credsStore != "" {
242-
return credsStore
265+
return credsStore, true
243266
}
244267
// 3. Use the detected default store
245-
return ds.detectedCredsStore
268+
return ds.detectedCredsStore, true
246269
}
247270

248271
// getStore returns a store for the given server address.
249272
func (ds *DynamicStore) getStore(serverAddress string) Store {
250-
if helper := ds.getHelperSuffix(serverAddress); helper != "" {
273+
if helper, exact := ds.getHelperSuffix(serverAddress); helper != "" {
274+
if !exact {
275+
return inheritedHelperStore{NewNativeStore(helper)}
276+
}
251277
return NewNativeStore(helper)
252278
}
253279

@@ -257,6 +283,23 @@ func (ds *DynamicStore) getStore(serverAddress string) Store {
257283
return fs
258284
}
259285

286+
// inheritedHelperStore is a credential helper configured for a parent
287+
// namespace. Get probes it with a key it was never configured for, so an error
288+
// there is a miss rather than a failure.
289+
type inheritedHelperStore struct {
290+
Store
291+
}
292+
293+
// Get retrieves credentials from the helper, reporting a failed probe as a miss
294+
// so that the caller falls back to the parent namespace.
295+
func (s inheritedHelperStore) Get(ctx context.Context, serverAddress string) (Credential, error) {
296+
cred, err := s.Store.Get(ctx, serverAddress)
297+
if err != nil {
298+
return EmptyCredential, nil
299+
}
300+
return cred, nil
301+
}
302+
260303
// getDockerConfigPath returns the path to the default docker config file.
261304
func getDockerConfigPath() (string, error) {
262305
// first try the environment variable

‎registry/remote/credentials/store_test.go‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -629,14 +629,44 @@ func Test_DynamicStore_getHelperSuffix(t *testing.T) {
629629
serverAddress: "whatever.example.com",
630630
want: "teststore",
631631
},
632+
{
633+
name: "Namespaced address uses the cred helper of its host",
634+
configPath: "testdata/credHelpers_config.json",
635+
serverAddress: "registry1.example.com/team/app",
636+
want: "registry1-helper",
637+
},
638+
{
639+
name: "Namespaced address with an empty cred helper for its host",
640+
configPath: "testdata/credHelpers_config.json",
641+
serverAddress: "registry3.example.com/team/app",
642+
want: "",
643+
},
644+
{
645+
name: "Namespaced address without a cred helper falls back to creds store",
646+
configPath: "testdata/credsStore_config.json",
647+
serverAddress: "whatever.example.com/team/app",
648+
want: "teststore",
649+
},
650+
{
651+
name: "Namespaced address prefers its host's cred helper over creds store",
652+
configPath: "testdata/credsStore_config.json",
653+
serverAddress: "test.example.com/team/app",
654+
want: "test-helper",
655+
},
656+
{
657+
name: "Host is not matched by prefix",
658+
configPath: "testdata/credHelpers_config.json",
659+
serverAddress: "registry1.example.com.evil/team",
660+
want: "",
661+
},
632662
}
633663
for _, tt := range tests {
634664
t.Run(tt.name, func(t *testing.T) {
635665
ds, err := NewStore(tt.configPath, StoreOptions{})
636666
if err != nil {
637667
t.Fatal("NewStore() error =", err)
638668
}
639-
if got := ds.getHelperSuffix(tt.serverAddress); got != tt.want {
669+
if got, _ := ds.getHelperSuffix(tt.serverAddress); got != tt.want {
640670
t.Errorf("DynamicStore.getHelperSuffix() = %v, want %v", got, tt.want)
641671
}
642672
})
@@ -682,6 +712,11 @@ func Test_DynamicStore_getStore_nativeStore(t *testing.T) {
682712
configPath: "testdata/credsStore_config.json",
683713
serverAddress: "whaterver.example.com",
684714
},
715+
{
716+
name: "Cred helper configured for the host of a namespaced address",
717+
configPath: "testdata/credHelpers_config.json",
718+
serverAddress: "registry1.example.com/team/app",
719+
},
685720
}
686721
for _, tt := range tests {
687722
t.Run(tt.name, func(t *testing.T) {
@@ -690,6 +725,9 @@ func Test_DynamicStore_getStore_nativeStore(t *testing.T) {
690725
t.Fatal("NewStore() error =", err)
691726
}
692727
gotStore := ds.getStore(tt.serverAddress)
728+
if inherited, ok := gotStore.(inheritedHelperStore); ok {
729+
gotStore = inherited.Store
730+
}
693731
if _, ok := gotStore.(*nativeStore); !ok {
694732
t.Errorf("gotStore is not a native store")
695733
}
@@ -1129,3 +1167,21 @@ func TestStoreWithFallbacks_MatchesNamespace(t *testing.T) {
11291167
})
11301168
}
11311169
}
1170+
1171+
func Test_inheritedHelperStore_Get_helperError(t *testing.T) {
1172+
store := inheritedHelperStore{&nativeStore{&testExecuter{}}}
1173+
got, err := store.Get(context.Background(), exeErrorHost)
1174+
if err != nil {
1175+
t.Fatal("inheritedHelperStore.Get() error =", err)
1176+
}
1177+
if got != EmptyCredential {
1178+
t.Errorf("inheritedHelperStore.Get() = %v, want %v", got, EmptyCredential)
1179+
}
1180+
}
1181+
1182+
func Test_inheritedHelperStore_Put_helperError(t *testing.T) {
1183+
store := inheritedHelperStore{&nativeStore{&testExecuter{}}}
1184+
if err := store.Put(context.Background(), "localhost:500/unknown", Credential{}); err == nil {
1185+
t.Error("inheritedHelperStore.Put() error = nil, want error")
1186+
}
1187+
}

0 commit comments

Comments
 (0)