carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 337f4ea207e30368a5462f7753515244bd7d563b parent 6fab64c
5 files changed, +75 −81
  • internal/provision/plan9_b8_census_test.go +1 −1
  • internal/rootaws/environment.go +28 −10
  • internal/rootaws/environment_other.go +0 −5
  • internal/rootaws/environment_test.go +46 −40
  • internal/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
- }
- }
-}