rootaws: root never reads shared AWS files, even under its own HOME; also drop CA-bundle, web-identity and container-credential selectors (#267)
A root-owned HOME can still hold a .aws the service user can write, so the trusted-HOME exception is gone: root's AWS_CONFIG_FILE and AWS_SHARED_CREDENTIALS_FILE always point at an impossible path. Root's credentials come only from environment keys, the instance profile or the validated off-cloud file. Census: main.go:main re-digested after review (one added ScrubEnvironment call before dispatch).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+75
−81
internal/provision/plan9_b8_census_test.go+1 −1internal/rootaws/environment.go+28 −10internal/rootaws/environment_other.go+0 −5internal/rootaws/environment_test.go+46 −40internal/rootaws/environment_unix.go+0 −25
CI passed — run details
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index e619c2ca9..fa21bf49b 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -532,7 +532,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| // Re-signs only the validated channel pointer; the digest-marker helper only reads. |
| {"internal/release/resign.go", "Signer.claimRawResign", "nonrecord", "sel:Put", "30b9a49890bde1ef0f26886c3dd3eb385de4502bc191e1e9a96cd1b75d4cc8e5"}, |
| // Dispatches CLI verb names including instances and sidecars; record writes remain in reviewed handlers. |
| - {"main.go", "main", "key-site", "lit:instances,lit:sidecars", "ccfb66a13871dca203c7d8bdc7e0781aaeb34736855f64e613bf078335a358e6"}, |
| + {"main.go", "main", "key-site", "lit:instances,lit:sidecars", "81804c8368ee204a8d78b15161fb00bce025c03ae3ee7f920db924ab0cf4fc86"}, |
| {"schedule.go", "renderScheduleList", "key-site", "lit:instances", "af6d666083fbbc753eecc98809d3d307344cc15110362a7e93afab573749ea6e"}, |
| {"internal/activator/layout.go", "LayoutInputs.sockets", "key-site", "lit:instances", "1d11ac5ac5be6a42e676e064771141fe8f4367c78cbb7e5c5cca072f40c27c42"}, |
| {"internal/activator/paths.go", "ExecBackend.pathsFor", "key-site", "lit:instances", "cbc01865e27934cb38baab9329a0867b899149531b4706a533a6956afc7c71b3"}, |
diff --git a/internal/rootaws/environment.go b/internal/rootaws/environment.go| index f1b090fc8..13e0c2037 100644 |
| --- a/internal/rootaws/environment.go |
| +++ b/internal/rootaws/environment.go |
| @@ -8,30 +8,48 @@ import ( |
| // A child of a device cannot exist, even if a service user creates directories. |
| const disabledSharedFile = "/dev/null/carlos-root-aws" |
| +// rootScrubbedEnvironment names every variable that selects a file, a profile |
| +// or a credential endpoint the SDK would read on root's behalf. Root's own |
| +// credentials come from the environment keys, the instance profile, or the |
| +// validated off-cloud file, never from these. |
| +var rootScrubbedEnvironment = []string{ |
| + "AWS_CONFIG_FILE", |
| + "AWS_SHARED_CREDENTIALS_FILE", |
| + "AWS_PROFILE", |
| + "AWS_DEFAULT_PROFILE", |
| + "AWS_SDK_LOAD_CONFIG", |
| + "AWS_CA_BUNDLE", |
| + "AWS_WEB_IDENTITY_TOKEN_FILE", |
| + "AWS_ROLE_ARN", |
| + "AWS_ROLE_SESSION_NAME", |
| + "AWS_CONTAINER_CREDENTIALS_FULL_URI", |
| + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", |
| + "AWS_CONTAINER_AUTHORIZATION_TOKEN", |
| + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", |
| +} |
| + |
| // ScrubEnvironment must run before dispatch or goroutines in each executable. |
| // It also protects SDK loads in dependencies that do not call this package. |
| func ScrubEnvironment() { |
| - scrubEnvironment(os.Geteuid(), rootOwnedHome) |
| + scrubEnvironment(os.Geteuid()) |
| } |
| -func scrubEnvironment(euid int, trustedHome func(string) bool) { |
| +func scrubEnvironment(euid int) { |
| if euid != 0 { |
| return |
| } |
| changed := false |
| - for _, key := range []string{"AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_PROFILE", "AWS_DEFAULT_PROFILE"} { |
| + for _, key := range rootScrubbedEnvironment { |
| if _, set := os.LookupEnv(key); set { |
| changed = true |
| } |
| _ = os.Unsetenv(key) |
| } |
| - // SDK v2 ignores this switch; older SDKs must not opt into shared config. |
| - _ = os.Unsetenv("AWS_SDK_LOAD_CONFIG") |
| - if home := os.Getenv("HOME"); home == "" || !trustedHome(home) { |
| - _ = os.Setenv("AWS_CONFIG_FILE", disabledSharedFile) |
| - _ = os.Setenv("AWS_SHARED_CREDENTIALS_FILE", disabledSharedFile) |
| - } |
| + // Root never reads shared config, not even under its own HOME: a |
| + // root-owned HOME can still hold a .aws the service user can write. |
| + _ = os.Setenv("AWS_CONFIG_FILE", disabledSharedFile) |
| + _ = os.Setenv("AWS_SHARED_CREDENTIALS_FILE", disabledSharedFile) |
| if changed { |
| - log.Print("ALERT: root AWS shared-file and profile environment overrides ignored") |
| + log.Print("ALERT: root AWS file, profile and credential-endpoint environment overrides ignored") |
| } |
| } |
diff --git a/internal/rootaws/environment_other.go b/internal/rootaws/environment_other.go| deleted file mode 100644 |
| index 0d3aebf40..000000000 |
| --- a/internal/rootaws/environment_other.go |
| +++ /dev/null |
| @@ -1,5 +0,0 @@ |
| -//go:build !linux && !darwin |
| - |
| -package rootaws |
| - |
| -func rootOwnedHome(string) bool { return false } |
diff --git a/internal/rootaws/environment_test.go b/internal/rootaws/environment_test.go| index f6c8ad386..574556a81 100644 |
| --- a/internal/rootaws/environment_test.go |
| +++ b/internal/rootaws/environment_test.go |
| @@ -14,23 +14,16 @@ import ( |
| func TestScrubEnvironment(t *testing.T) { |
| for _, tc := range []struct { |
| - name string |
| - euid int |
| - home string |
| - trusted bool |
| + name string |
| + euid int |
| }{ |
| - {"root-home", 0, "/root", true}, |
| - {"service-home", 0, "/home/carlos", false}, |
| - {"missing-home", 0, "", true}, |
| - {"non-root", 1001, "/home/carlos", false}, |
| + {"root", 0}, |
| + {"non-root", 1001}, |
| } { |
| t.Run(tc.name, func(t *testing.T) { |
| - t.Setenv("HOME", tc.home) |
| - keys := []string{"AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_PROFILE", "AWS_DEFAULT_PROFILE"} |
| - for _, key := range keys { |
| + for _, key := range rootScrubbedEnvironment { |
| t.Setenv(key, "untrusted") |
| } |
| - t.Setenv("AWS_SDK_LOAD_CONFIG", "1") |
| t.Setenv("AWS_ACCESS_KEY_ID", "KEEP") |
| t.Setenv("AWS_SECRET_ACCESS_KEY", "KEEP_SECRET") |
| t.Setenv("AWS_SESSION_TOKEN", "KEEP_TOKEN") |
| @@ -38,31 +31,26 @@ func TestScrubEnvironment(t *testing.T) { |
| old := log.Writer() |
| log.SetOutput(&logs) |
| defer log.SetOutput(old) |
| - scrubEnvironment(tc.euid, func(home string) bool { |
| - if home != tc.home { |
| - t.Fatal("wrong home checked") |
| - } |
| - return tc.trusted |
| - }) |
| - for _, key := range keys { |
| + scrubEnvironment(tc.euid) |
| + for _, key := range rootScrubbedEnvironment { |
| got, set := os.LookupEnv(key) |
| - want := "" |
| - if tc.euid != 0 { |
| - want = "untrusted" |
| - } else if (key == "AWS_CONFIG_FILE" || key == "AWS_SHARED_CREDENTIALS_FILE") && (tc.home == "" || !tc.trusted) { |
| - want = disabledSharedFile |
| + want := "untrusted" |
| + if tc.euid == 0 { |
| + want = "" |
| + if key == "AWS_CONFIG_FILE" || key == "AWS_SHARED_CREDENTIALS_FILE" { |
| + want = disabledSharedFile |
| + } |
| } |
| if got != want || set != (want != "") { |
| t.Errorf("%s = %q, set=%v; want %q", key, got, set, want) |
| } |
| } |
| - wantLoad, wantLogs := "", 1 |
| - if tc.euid != 0 { |
| - wantLoad, wantLogs = "1", 0 |
| + wantLogs := 0 |
| + if tc.euid == 0 { |
| + wantLogs = 1 |
| } |
| - load, set := os.LookupEnv("AWS_SDK_LOAD_CONFIG") |
| - if load != wantLoad || set != (tc.euid != 0) || strings.Count(logs.String(), "ALERT:") != wantLogs { |
| - t.Fatal("load-config switch or log count differs") |
| + if strings.Count(logs.String(), "ALERT:") != wantLogs { |
| + t.Fatalf("alert count: %q", logs.String()) |
| } |
| if os.Getenv("AWS_ACCESS_KEY_ID") != "KEEP" || os.Getenv("AWS_SECRET_ACCESS_KEY") != "KEEP_SECRET" || os.Getenv("AWS_SESSION_TOKEN") != "KEEP_TOKEN" { |
| t.Fatal("environment credentials changed") |
| @@ -71,21 +59,47 @@ func TestScrubEnvironment(t *testing.T) { |
| } |
| } |
| +func TestScrubEnvironmentDisablesSharedFilesWithNothingSet(t *testing.T) { |
| + for _, key := range rootScrubbedEnvironment { |
| + t.Setenv(key, "") |
| + os.Unsetenv(key) |
| + } |
| + var logs bytes.Buffer |
| + old := log.Writer() |
| + log.SetOutput(&logs) |
| + defer log.SetOutput(old) |
| + scrubEnvironment(0) |
| + if os.Getenv("AWS_CONFIG_FILE") != disabledSharedFile || os.Getenv("AWS_SHARED_CREDENTIALS_FILE") != disabledSharedFile { |
| + t.Fatal("shared files not disabled for root") |
| + } |
| + if logs.Len() != 0 { |
| + t.Fatalf("alert without overrides: %q", logs.String()) |
| + } |
| +} |
| + |
| func TestScrubEnvironmentBlocksDependencySharedConfig(t *testing.T) { |
| isolatedAWSEnv(t) |
| dir := t.TempDir() |
| marker := filepath.Join(dir, "executed") |
| - path := filepath.Join(dir, "config") |
| + // The planted config sits under HOME, where the SDK looks by default, and |
| + // is also named explicitly. |
| + if err := os.Mkdir(filepath.Join(dir, ".aws"), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(dir, ".aws", "config") |
| if err := os.WriteFile(path, []byte("[default]\ncredential_process = touch "+marker+"\n"), 0600); err != nil { |
| t.Fatal(err) |
| } |
| + if err := os.WriteFile(filepath.Join(dir, ".aws", "credentials"), []byte("[default]\ncredential_process = touch "+marker+"\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| t.Setenv("HOME", dir) |
| t.Setenv("AWS_CONFIG_FILE", path) |
| t.Setenv("AWS_SHARED_CREDENTIALS_FILE", path) |
| t.Setenv("AWS_PROFILE", "default") |
| t.Setenv("AWS_DEFAULT_PROFILE", "default") |
| t.Setenv("AWS_SDK_LOAD_CONFIG", "1") |
| - scrubEnvironment(0, func(string) bool { return false }) |
| + scrubEnvironment(0) |
| // This deliberately bypasses rootaws, as a third-party SDK call would. |
| cfg, err := awsconfig.LoadDefaultConfig(context.Background()) |
| if err != nil { |
| @@ -101,11 +115,3 @@ func TestScrubEnvironmentBlocksDependencySharedConfig(t *testing.T) { |
| t.Fatal("disabled path exists") |
| } |
| } |
| - |
| -func TestRootOwnedHomeRejectsUntrustedPaths(t *testing.T) { |
| - for _, path := range []string{"", ".", filepath.Join(t.TempDir(), "absent"), t.TempDir()} { |
| - if rootOwnedHome(path) { |
| - t.Errorf("trusted replaceable or missing HOME %q", path) |
| - } |
| - } |
| -} |
diff --git a/internal/rootaws/environment_unix.go b/internal/rootaws/environment_unix.go| deleted file mode 100644 |
| index e339df0e8..000000000 |
| --- a/internal/rootaws/environment_unix.go |
| +++ /dev/null |
| @@ -1,25 +0,0 @@ |
| -//go:build linux || darwin |
| - |
| -package rootaws |
| - |
| -import ( |
| - "path/filepath" |
| - |
| - "golang.org/x/sys/unix" |
| -) |
| - |
| -func rootOwnedHome(home string) bool { |
| - if !filepath.IsAbs(home) { |
| - return false |
| - } |
| - // Check the parents too: a service user must not be able to replace HOME. |
| - for path := filepath.Clean(home); ; path = filepath.Dir(path) { |
| - var st unix.Stat_t |
| - if unix.Lstat(path, &st) != nil || st.Mode&unix.S_IFMT != unix.S_IFDIR || st.Uid != 0 || st.Mode&0o022 != 0 { |
| - return false |
| - } |
| - if path == "/" { |
| - return true |
| - } |
| - } |
| -} |