From d68af81f32e2626f9a7ae2191eefaa6c16ffe088 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Thu, 8 Dec 2022 11:22:33 -0500 Subject: [PATCH] WIP: Add a `/etc/containers/auth.json` A long-running tension in the docker/podman land is around running as a system service versus being executed by a user. (Specifically a "login user", i.e. a Unix user that can be logged into via `ssh` etc.) For login users, it makes total sense to configure the container runtime in `$HOME`. But for system services (e.g. code executed by systemd) it is generally a bad idea to access or read the `/root` home directory. On image based systems, `/root` may be dynamically mutable state in contrast to `/etc` which may be managed by OS upgrades, or even be read-only. For these reasons, let's introduce `/etc/contaners/auth.json`. If it is present, and the current process is executing in systemd, it will be preferred. (There's some further logic around this that is explained in the manpage; please see that for details) cc https://github.com/coreos/rpm-ostree/issues/4180 Signed-off-by: Colin Walters --- docs/containers-auth.json.5.md | 9 ++++-- pkg/docker/config/config.go | 51 +++++++++++++++++++++++++++----- pkg/docker/config/config_test.go | 4 +-- 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/docs/containers-auth.json.5.md b/docs/containers-auth.json.5.md index 4030a06c05..b7d6c1785a 100644 --- a/docs/containers-auth.json.5.md +++ b/docs/containers-auth.json.5.md @@ -6,15 +6,20 @@ containers-auth.json - syntax for the registry authentication file # DESCRIPTION A credentials file in JSON format used to authenticate against container image registries. -The primary (read/write) file is stored at `${XDG_RUNTIME_DIR}/containers/auth.json` on Linux; +The primary (read/write) per-user file is stored at `${XDG_RUNTIME_DIR}/containers/auth.json` on Linux; on Windows and macOS, at `$HOME/.config/containers/auth.json`. -When searching for the credential for a registry, the following files will be read in sequence until the valid credential is found: +There is also a system-global `/etc/containers/auth.json` path. When the current process is executing inside systemd as root, this path will be preferred. + +When running as a user and searching for the credential for a registry, the following files will be read in sequence until the valid credential is found: first reading the primary (read/write) file, or the explicit override using an option of the calling application. If credentials are not present, search in `${XDG_CONFIG_HOME}/containers/auth.json` (usually `~/.config/containers/auth.json`), `$HOME/.docker/config.json`, `$HOME/.dockercfg`. +If the current process is not running in systemd, but is running as root, the system global path will be read last. + Except the primary (read/write) file, other files are read-only, unless the user use an option of the calling application explicitly points at it as an override. +Note that the `/etc/containers/auth.json` file must have mode `0600` i.e. only readable by root, or it will be ignored. ## FORMAT diff --git a/pkg/docker/config/config.go b/pkg/docker/config/config.go index 0790a47f24..0c56b7f33e 100644 --- a/pkg/docker/config/config.go +++ b/pkg/docker/config/config.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -35,9 +36,12 @@ type dockerConfigFile struct { type authPath struct { path string legacyFormat bool + // requireUserOnly will cause the file to be ignored if it is readable by group or other + requireUserOnly bool } var ( + systemPath = filepath.FromSlash("/etc/containers/auth.json") defaultPerUIDPathFormat = filepath.FromSlash("/run/containers/%d/auth.json") xdgConfigHomePath = filepath.FromSlash("containers/auth.json") xdgRuntimeDirPath = filepath.FromSlash("containers/auth.json") @@ -147,7 +151,7 @@ func GetAllCredentials(sys *types.SystemContext) (map[string]types.DockerAuthCon case sysregistriesv2.AuthenticationFileHelper: for _, path := range getAuthFilePaths(sys, homedir.Get()) { // readJSONFile returns an empty map in case the path doesn't exist. - auths, err := readJSONFile(path.path, path.legacyFormat) + auths, err := readJSONFile(path.path, path.legacyFormat, path.requireUserOnly) if err != nil { return nil, fmt.Errorf("reading JSON file %q: %w", path.path, err) } @@ -204,7 +208,17 @@ func GetAllCredentials(sys *types.SystemContext) (map[string]types.DockerAuthCon // The homeDir parameter should always be homedir.Get(), and is only intended to be overridden // by tests. func getAuthFilePaths(sys *types.SystemContext, homeDir string) []authPath { + runningInSystemd := os.Getenv("INVOCATION_ID") != "" + runningAsRoot := os.Getuid() == 0 + runningSystemdPrivileged := runningInSystemd && runningAsRoot + paths := []authPath{} + + // If we're in systemd, prefer the global auth path first. + if runningSystemdPrivileged { + paths = append(paths, authPath{path: systemPath, legacyFormat: false}) + } + pathToAuth, lf, err := getPathToAuth(sys) if err == nil { paths = append(paths, authPath{path: pathToAuth, legacyFormat: lf}) @@ -214,6 +228,7 @@ func getAuthFilePaths(sys *types.SystemContext, homeDir string) []authPath { // Logging the error as a warning instead and moving on to pulling the image logrus.Warnf("%v: Trying to pull image in the event that it is a public image.", err) } + xdgCfgHome := os.Getenv("XDG_CONFIG_HOME") if xdgCfgHome == "" { xdgCfgHome = filepath.Join(homeDir, ".config") @@ -231,6 +246,13 @@ func getAuthFilePaths(sys *types.SystemContext, homeDir string) []authPath { paths = append(paths, authPath{path: filepath.Join(homeDir, dockerLegacyHomePath), legacyFormat: true}, ) + + // If we're not in systemd, we prefer the per-user paths first. However, as + // a convenience we do read the global auth path if we're running as root. + if !runningSystemdPrivileged && runningAsRoot { + paths = append(paths, authPath{path: systemPath, legacyFormat: false}) + } + return paths } @@ -276,7 +298,7 @@ func getCredentialsWithHomeDir(sys *types.SystemContext, key, homeDir string) (t // Anonymous function to query credentials from auth files. getCredentialsFromAuthFiles := func() (types.DockerAuthConfig, string, error) { for _, path := range getAuthFilePaths(sys, homeDir) { - authConfig, err := findCredentialsInFile(key, registry, path.path, path.legacyFormat) + authConfig, err := findCredentialsInFile(key, registry, path.path, path.legacyFormat, path.requireUserOnly) if err != nil { return types.DockerAuthConfig{}, "", err } @@ -538,10 +560,10 @@ func getPathToAuthWithOS(sys *types.SystemContext, goOS string) (string, bool, e // readJSONFile unmarshals the authentications stored in the auth.json file and returns it // or returns an empty dockerConfigFile data structure if auth.json does not exist // if the file exists and is empty, readJSONFile returns an error -func readJSONFile(path string, legacyFormat bool) (dockerConfigFile, error) { +func readJSONFile(path string, legacyFormat, requireUserOnly bool) (dockerConfigFile, error) { var auths dockerConfigFile - raw, err := os.ReadFile(path) + f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { auths.AuthConfigs = map[string]dockerAuthConfig{} @@ -549,6 +571,21 @@ func readJSONFile(path string, legacyFormat bool) (dockerConfigFile, error) { } return dockerConfigFile{}, err } + defer f.Close() + if requireUserOnly { + st, err := f.Stat() + if err != nil { + return dockerConfigFile{}, fmt.Errorf("stat %s: %w", path, err) + } + perms := st.Mode().Perm() + if (perms & 04) > 0 { + return dockerConfigFile{}, fmt.Errorf("refusing to process %s with world read permissions", path) + } + } + raw, err := io.ReadAll(f) + if err != nil { + return dockerConfigFile{}, fmt.Errorf("reading %s: %w", path, err) + } if legacyFormat { if err = json.Unmarshal(raw, &auths.AuthConfigs); err != nil { @@ -588,7 +625,7 @@ func modifyJSON(sys *types.SystemContext, editor func(auths *dockerConfigFile) ( return "", err } - auths, err := readJSONFile(path, false) + auths, err := readJSONFile(path, false, false) if err != nil { return "", fmt.Errorf("reading JSON file %q: %w", path, err) } @@ -655,8 +692,8 @@ func deleteAuthFromCredHelper(credHelper, registry string) error { // findCredentialsInFile looks for credentials matching "key" // (which is "registry" or a namespace in "registry") in "path". -func findCredentialsInFile(key, registry, path string, legacyFormat bool) (types.DockerAuthConfig, error) { - auths, err := readJSONFile(path, legacyFormat) +func findCredentialsInFile(key, registry, path string, legacyFormat, requireUserOnly bool) (types.DockerAuthConfig, error) { + auths, err := readJSONFile(path, legacyFormat, requireUserOnly) if err != nil { return types.DockerAuthConfig{}, fmt.Errorf("reading JSON file %q: %w", path, err) } diff --git a/pkg/docker/config/config_test.go b/pkg/docker/config/config_test.go index 4277bba824..0553997517 100644 --- a/pkg/docker/config/config_test.go +++ b/pkg/docker/config/config_test.go @@ -652,7 +652,7 @@ func TestSetCredentials(t *testing.T) { } // Read the resulting file and verify it contains the expected keys - auth, err := readJSONFile(tmpFile.Name(), false) + auth, err := readJSONFile(tmpFile.Name(), false, false) require.NoError(t, err) assert.Len(t, auth.AuthConfigs, len(writtenCredentials)) // auth.AuthConfigs and writtenCredentials are both maps, i.e. their keys are unique; @@ -772,7 +772,7 @@ func TestRemoveAuthentication(t *testing.T) { } } - auth, err := readJSONFile(tmpFile.Name(), false) + auth, err := readJSONFile(tmpFile.Name(), false, false) require.NoError(t, err) tc.assert(auth)