rootaws, host, release: root never lets the AWS SDK read shared config or credential files; off-cloud static keys via a strict parser and a fresh-inode repair; the registry child drops privilege in-process before input (#267)
Root AWS loads go through internal/rootaws: empty shared-file lists, no profile (AWS_PROFILE/AWS_DEFAULT_PROFILE refused), and /etc/carlos/credentials parsed as [default] static keys only (credential_process refused) when it is root-owned, single-link and not group- or other-writable. Repair writes a new root:carlos 0640 inode with the validated keys, skips already-correct files, and alerts rather than stopping adopt. The registry child execs as root and runs setresgid/setresuid then PR_SET_DUMPABLE 0 before any input. Non-root callers are unchanged.
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
30 files changed,
+738
−42
internal/alerting/alerting.go+2 −1internal/awsinv/awsinv.go+2 −2internal/awsinv/exact_instance.go+2 −1internal/console/cepull.go+2 −2internal/dnsrec/route53.go+2 −1internal/edge/route53dns.go+2 −1internal/host/agent.go+1 −2internal/host/claimproduction_marker.go+2 −1internal/host/memwatch.go+2 −2internal/host/offcloudcredentials_linux.go+47 −11internal/host/offcloudcredentials_linux_test.go+128 −0internal/host/registrychild_exec_linux.go+44 −2internal/host/registrychild_exec_linux_test.go+45 −1internal/host/registrychild_unix.go+1 −2internal/host/registrychild_unix_test.go+3 −3internal/lease/endpoint.go+3 −2internal/lease/staging.go+2 −1internal/mail/aws.go+2 −1internal/objstore/aws.go+3 −2internal/release/store.go+4 −3internal/rootaws/config.go+62 −0internal/rootaws/config_test.go+155 −0internal/rootaws/credentials.go+53 −0internal/rootaws/credentials_linux_test.go+97 −0internal/rootaws/credentials_other.go+13 −0internal/rootaws/credentials_unix.go+31 −0internal/rootaws/open_darwin.go+11 −0internal/rootaws/open_linux.go+13 −0internal/safefile/reap_linux.go+2 −0internal/steering/route53.go+2 −1
CI passed — run details
diff --git a/internal/alerting/alerting.go b/internal/alerting/alerting.go| index 782cc052b..8956e2fec 100644 |
| --- a/internal/alerting/alerting.go |
| +++ b/internal/alerting/alerting.go |
| @@ -26,6 +26,7 @@ import ( |
| awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| "github.com/aws/aws-sdk-go-v2/service/sns" |
| snstypes "github.com/aws/aws-sdk-go-v2/service/sns/types" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| ) |
| // Page levels (moves spec §13): an incident wants someone now, a warning |
| @@ -237,7 +238,7 @@ func ParseTopicARN(s string) (region string, err error) { |
| // (a box's instance role, the console's task role), aimed at the topic's |
| // own region. |
| func NewSNSClient(ctx context.Context, region string) (Publisher, error) { |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| if err != nil { |
| return nil, err |
| } |
diff --git a/internal/awsinv/awsinv.go b/internal/awsinv/awsinv.go| index ab25d647c..20492fbd2 100644 |
| --- a/internal/awsinv/awsinv.go |
| +++ b/internal/awsinv/awsinv.go |
| @@ -7,11 +7,11 @@ import ( |
| "strings" |
| "time" |
| - awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| "github.com/aws/aws-sdk-go-v2/service/ec2" |
| "github.com/aws/aws-sdk-go-v2/service/sts" |
| "github.com/aws/smithy-go" |
| "github.com/carlosframework/platform/internal/infraview" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| ) |
| // credentialProbe is how long New waits for the credential chain to answer |
| @@ -49,7 +49,7 @@ type Provider struct { |
| // has no inventory grant", latching the pass off rather than retrying |
| // hourly forever. |
| func New(ctx context.Context) (*Provider, error) { |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx) |
| if err != nil { |
| return nil, fmt.Errorf("awsinv: aws config: %w", err) |
| } |
diff --git a/internal/awsinv/exact_instance.go b/internal/awsinv/exact_instance.go| index ff854f0f0..61a725c46 100644 |
| --- a/internal/awsinv/exact_instance.go |
| +++ b/internal/awsinv/exact_instance.go |
| @@ -10,6 +10,7 @@ import ( |
| awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" |
| awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| "github.com/aws/aws-sdk-go-v2/service/ec2" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| ) |
| // ErrExactInstance marks every result that fails to prove that the exact |
| @@ -42,7 +43,7 @@ func NewEC2Reader(ctx context.Context, region string) (ExactEC2API, error) { |
| if strings.TrimSpace(region) == "" { |
| return nil, fmt.Errorf("%w: enrolled region is empty", ErrExactInstance) |
| } |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| if err != nil { |
| return nil, fmt.Errorf("%w: AWS config: %v", ErrExactInstance, err) |
| } |
diff --git a/internal/console/cepull.go b/internal/console/cepull.go| index 52e5510d4..52c4c08b1 100644 |
| --- a/internal/console/cepull.go |
| +++ b/internal/console/cepull.go |
| @@ -27,10 +27,10 @@ import ( |
| "time" |
| "github.com/aws/aws-sdk-go-v2/aws" |
| - awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| "github.com/aws/aws-sdk-go-v2/service/costexplorer" |
| cetypes "github.com/aws/aws-sdk-go-v2/service/costexplorer/types" |
| "github.com/aws/smithy-go" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| ) |
| // resolveCECosts is billPass's entry point into the seam: the test/fake |
| @@ -55,7 +55,7 @@ func (s *Server) realCECosts(ctx context.Context, start, end time.Time) (map[str |
| return nil, fmt.Errorf("console: cost explorer disabled for this process") |
| } |
| s.ceClientOnce.Do(func() { |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx) |
| if err != nil { |
| // The AWS config itself failed to resolve — opaque and |
| // permanent for this process: there is no "retry the SDK's |
diff --git a/internal/dnsrec/route53.go b/internal/dnsrec/route53.go| index 96ab91d5f..915a93fca 100644 |
| --- a/internal/dnsrec/route53.go |
| +++ b/internal/dnsrec/route53.go |
| @@ -18,6 +18,7 @@ import ( |
| awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| "github.com/aws/aws-sdk-go-v2/service/route53" |
| r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| ) |
| type route53Writer struct { |
| @@ -33,7 +34,7 @@ func NewRoute53Writer(ctx context.Context) (Writer, error) { |
| if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" { |
| opts = append(opts, awsconfig.WithRegion("us-east-1")) |
| } |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx, opts...) |
| if err != nil { |
| return nil, fmt.Errorf("dnsrec: load aws config: %w", err) |
| } |
diff --git a/internal/edge/route53dns.go b/internal/edge/route53dns.go| index 66f9324e2..c7ad6fe8b 100644 |
| --- a/internal/edge/route53dns.go |
| +++ b/internal/edge/route53dns.go |
| @@ -26,6 +26,7 @@ import ( |
| awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| "github.com/aws/aws-sdk-go-v2/service/route53" |
| r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| ) |
| // route53InSyncTimeout bounds the wait for a ChangeResourceRecordSets to |
| @@ -57,7 +58,7 @@ func newRoute53DNS(ctx context.Context) (dnsProvider, error) { |
| if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" { |
| opts = append(opts, awsconfig.WithRegion("us-east-1")) |
| } |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx, opts...) |
| if err != nil { |
| return nil, fmt.Errorf("route53: load aws config: %w", err) |
| } |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index e6a33008a..d970f9dec 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -3926,10 +3926,9 @@ func runAdopt(args []string, buildClaims ClaimFactory) { |
| // the rest of adopt startup, including its fatal registry diagnostics, is real. |
| func runAdoptWithEnsureSystemUnits(args []string, buildClaims ClaimFactory, ensureUnits func()) { |
| reapStagingWrites(release.OptDir(), dataDir(), "/etc", log.Printf) |
| - // claimEntryContext may open the AWS store, whose SDK accepts credential_process. |
| if os.Geteuid() == 0 { |
| if err := repairOffcloudCredentials("/etc/carlos/credentials"); err != nil { |
| - log.Fatalf("adopt: offcloud credentials: %v", err) |
| + log.Printf("ALERT: adopt: offcloud credentials: %v", err) |
| } |
| } |
| claimCtx, err := claimEntryContext(context.Background(), os.Getenv, openDeployStore) |
diff --git a/internal/host/claimproduction_marker.go b/internal/host/claimproduction_marker.go| index a88a6686d..76e34f185 100644 |
| --- a/internal/host/claimproduction_marker.go |
| +++ b/internal/host/claimproduction_marker.go |
| @@ -10,6 +10,7 @@ import ( |
| "github.com/aws/aws-sdk-go-v2/service/s3" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| ) |
| type productionClaimMarkers struct { |
| @@ -18,7 +19,7 @@ type productionClaimMarkers struct { |
| } |
| func newProductionClaimMarkers(ctx context.Context, bucket, region string) (ClaimMarkerStore, error) { |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region), awsconfig.WithRetryMaxAttempts(1)) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region), awsconfig.WithRetryMaxAttempts(1)) |
| if err != nil { |
| return nil, err |
| } |
diff --git a/internal/host/memwatch.go b/internal/host/memwatch.go| index 0a74e14f6..a6eb39e3e 100644 |
| --- a/internal/host/memwatch.go |
| +++ b/internal/host/memwatch.go |
| @@ -11,9 +11,9 @@ import ( |
| "time" |
| "github.com/aws/aws-sdk-go-v2/aws" |
| - awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| "github.com/aws/aws-sdk-go-v2/service/cloudwatch" |
| "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| ) |
| // memwatch (memory-pressure spec §2.4) publishes two CloudWatch metrics a |
| @@ -88,7 +88,7 @@ func memwatchLoop() { |
| // own object store (internal/lease/endpoint.go's note) would aim the |
| // CloudWatch client at nonsense, which is one more reason this duty |
| // is opt-in per box rather than on wherever credentials resolve. |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx) |
| if err != nil { |
| log.Printf("agent: memwatch disabled: load aws config: %v", err) |
| return |
diff --git a/internal/host/offcloudcredentials_linux.go b/internal/host/offcloudcredentials_linux.go| index b5bfafd5f..917269628 100644 |
| --- a/internal/host/offcloudcredentials_linux.go |
| +++ b/internal/host/offcloudcredentials_linux.go |
| @@ -5,11 +5,15 @@ package host |
| import ( |
| "errors" |
| "fmt" |
| + "io" |
| "log" |
| "os" |
| "os/user" |
| "strconv" |
| + "strings" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| + "github.com/carlosframework/platform/internal/safefile" |
| "golang.org/x/sys/unix" |
| ) |
| @@ -44,11 +48,11 @@ func openOffcloudCredentials(path string) (_ *os.File, err error) { |
| return nil, fmt.Errorf("not a plain single-link regular file") |
| } |
| // Reopen the pinned regular inode, never the caller's mutable pathname. |
| - opened, err := os.Open(fmt.Sprintf("/proc/self/fd/%d", fd)) |
| + opened, err := unix.Open(fmt.Sprintf("/proc/self/fd/%d", fd), unix.O_RDONLY|unix.O_CLOEXEC, 0) |
| if err != nil { |
| return nil, err |
| } |
| - return opened, nil |
| + return os.NewFile(uintptr(opened), path), nil |
| } |
| func checkOffcloudCredentials(path string) (os.FileInfo, error) { |
| @@ -60,9 +64,14 @@ func checkOffcloudCredentials(path string) (os.FileInfo, error) { |
| return f.Stat() |
| } |
| -// Root's SDK accepts credential_process from this file. The service may read |
| -// the credentials, but must never own or write them. Missing means a cloud box. |
| -func repairOffcloudCredentials(path string) error { |
| +// Missing means a cloud box. Repair replaces the inode so a service process |
| +// holding a writable descriptor cannot change the credentials root will read. |
| +func repairOffcloudCredentials(path string) (err error) { |
| + defer func() { |
| + if err != nil { |
| + log.Printf("ALERT: refusing unsafe offcloud credentials %s: %v", path, err) |
| + } |
| + }() |
| f, err := openOffcloudCredentials(path) |
| if errors.Is(err, os.ErrNotExist) { |
| return nil |
| @@ -82,14 +91,41 @@ func repairOffcloudCredentials(path string) error { |
| return secureOffcloudCredentials(f, gid) |
| } |
| -func secureOffcloudCredentials(f *os.File, gid int) error { |
| - if err := unix.Fchown(int(f.Fd()), 0, gid); err != nil { |
| - log.Printf("ALERT: offcloud credentials ownership repair %s: %v", f.Name(), err) |
| +func secureOffcloudCredentials(f *os.File, gid int) (err error) { |
| + defer func() { |
| + if err != nil { |
| + log.Printf("ALERT: refusing unsafe offcloud credentials %s: %v", f.Name(), err) |
| + } |
| + }() |
| + var st unix.Stat_t |
| + if err := unix.Fstat(int(f.Fd()), &st); err != nil { |
| return err |
| } |
| - if err := unix.Fchmod(int(f.Fd()), 0o640); err != nil { |
| - log.Printf("ALERT: offcloud credentials mode repair %s: %v", f.Name(), err) |
| + body, err := io.ReadAll(io.LimitReader(f, rootaws.MaxCredentialsSize+1)) |
| + if err != nil { |
| return err |
| } |
| - return nil |
| + if len(body) > rootaws.MaxCredentialsSize { |
| + return io.ErrShortBuffer |
| + } |
| + credentials, err := rootaws.ParseStaticCredentials(body) |
| + if err != nil { |
| + return err |
| + } |
| + if st.Uid == 0 && st.Gid == uint32(gid) && st.Mode&0o7777 == 0o640 { |
| + return nil |
| + } |
| + var validated strings.Builder |
| + fmt.Fprintf(&validated, "[default]\naws_access_key_id=%s\naws_secret_access_key=%s\n", credentials.AccessKeyID, credentials.SecretAccessKey) |
| + if credentials.SessionToken != "" { |
| + fmt.Fprintf(&validated, "aws_session_token=%s\n", credentials.SessionToken) |
| + } |
| + return safefile.Write(f.Name(), 0o640, func(w io.Writer) error { |
| + fresh := w.(*os.File) |
| + if err := fresh.Chown(0, gid); err != nil { |
| + return err |
| + } |
| + _, err := io.WriteString(w, validated.String()) |
| + return err |
| + }) |
| } |
diff --git a/internal/host/offcloudcredentials_linux_test.go b/internal/host/offcloudcredentials_linux_test.go| index 6b5801878..fafedfedb 100644 |
| --- a/internal/host/offcloudcredentials_linux_test.go |
| +++ b/internal/host/offcloudcredentials_linux_test.go |
| @@ -53,6 +53,134 @@ func TestOffcloudCredentialsCreationAndRepair(t *testing.T) { |
| } |
| } |
| +func TestOffcloudCredentialsRepairReplacesWritableInode(t *testing.T) { |
| + if os.Geteuid() != 0 { |
| + t.Skip("root required to replace service-owned credentials with root:service ownership") |
| + } |
| + const serviceID = 65534 |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + contents := []byte("[default]\naws_access_key_id=X\naws_secret_access_key=Y\naws_session_token=Z\n") |
| + if err := os.WriteFile(path, contents, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chown(path, serviceID, serviceID); err != nil { |
| + t.Fatal(err) |
| + } |
| + writable, err := os.OpenFile(path, os.O_RDWR, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer writable.Close() |
| + old, err := writable.Stat() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f, err := openOffcloudCredentials(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer f.Close() |
| + if err := secureOffcloudCredentials(f, serviceID); err != nil { |
| + t.Fatal(err) |
| + } |
| + fresh, err := os.Stat(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if os.SameFile(old, fresh) { |
| + t.Fatal("repair kept the service-writable inode") |
| + } |
| + if _, err := writable.WriteAt([]byte("[default]\ncredential_process=/bin/false\n"), 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := os.ReadFile(path); err != nil || !bytes.Equal(got, contents) { |
| + t.Fatalf("old writable descriptor affected repaired credentials: %q, %v", got, err) |
| + } |
| +} |
| + |
| +func TestOffcloudCredentialsCorrectMetadataUnchanged(t *testing.T) { |
| + if os.Geteuid() != 0 { |
| + t.Skip("root required to verify root:service ownership is left unchanged") |
| + } |
| + const serviceID = 65534 |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + contents := []byte("# preserve this file verbatim\n[default]\naws_access_key_id = X\naws_secret_access_key = Y\n") |
| + if err := os.WriteFile(path, contents, 0o640); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chown(path, 0, serviceID); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(path, 0o640); err != nil { |
| + t.Fatal(err) |
| + } |
| + var before, after unix.Stat_t |
| + if err := unix.Stat(path, &before); err != nil { |
| + t.Fatal(err) |
| + } |
| + f, err := openOffcloudCredentials(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer f.Close() |
| + if err := secureOffcloudCredentials(f, serviceID); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := unix.Stat(path, &after); err != nil { |
| + t.Fatal(err) |
| + } |
| + if before.Ino != after.Ino || before.Ctim != after.Ctim || before.Mode != after.Mode || before.Uid != after.Uid || before.Gid != after.Gid { |
| + t.Fatal("repair mutated already-correct credential metadata") |
| + } |
| + if got, err := os.ReadFile(path); err != nil || !bytes.Equal(got, contents) { |
| + t.Fatalf("already-correct credentials changed: %q, %v", got, err) |
| + } |
| +} |
| + |
| +func TestOffcloudCredentialsInvalidContentsNotRepaired(t *testing.T) { |
| + for _, contents := range []string{ |
| + "[default]\ncredential_process=/bin/false\n", |
| + "[default]\naws_access_key_id=X\naws_secret_access_key=Y\nregion=eu-west-1\n", |
| + "[default]\naws_access_key_id=X\n", |
| + "[other]\naws_access_key_id=X\naws_secret_access_key=Y\n", |
| + } { |
| + t.Run(strings.ReplaceAll(contents, "\n", ";"), func(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + var before, after unix.Stat_t |
| + if err := unix.Stat(path, &before); err != nil { |
| + t.Fatal(err) |
| + } |
| + f, err := openOffcloudCredentials(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer f.Close() |
| + var logs bytes.Buffer |
| + previous := log.Writer() |
| + log.SetOutput(&logs) |
| + defer log.SetOutput(previous) |
| + if err := secureOffcloudCredentials(f, os.Getegid()); err == nil { |
| + t.Fatal("accepted invalid credentials") |
| + } |
| + if !strings.Contains(logs.String(), "ALERT:") { |
| + t.Fatalf("refusal did not alert: %s", logs.String()) |
| + } |
| + if err := unix.Stat(path, &after); err != nil { |
| + t.Fatal(err) |
| + } |
| + if before.Ino != after.Ino || before.Ctim != after.Ctim || before.Mode != after.Mode || before.Uid != after.Uid || before.Gid != after.Gid { |
| + t.Fatal("invalid credentials metadata changed") |
| + } |
| + if got, err := os.ReadFile(path); err != nil || string(got) != contents { |
| + t.Fatalf("invalid credentials changed: %q, %v", got, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| func TestOffcloudCredentialsRefuseLinksAndSpecials(t *testing.T) { |
| for _, kind := range []string{"symlink", "dangling", "hardlink", "fifo", "directory", "parent-symlink"} { |
| t.Run(kind, func(t *testing.T) { |
diff --git a/internal/host/registrychild_exec_linux.go b/internal/host/registrychild_exec_linux.go| index f5d101c28..76d616956 100644 |
| --- a/internal/host/registrychild_exec_linux.go |
| +++ b/internal/host/registrychild_exec_linux.go |
| @@ -6,8 +6,10 @@ import ( |
| "fmt" |
| "os" |
| "os/exec" |
| + "os/user" |
| "runtime" |
| "strconv" |
| + "syscall" |
| "golang.org/x/sys/unix" |
| ) |
| @@ -46,11 +48,51 @@ func registryChildStart(cmd *exec.Cmd) error { |
| } |
| func registryChildHarden() error { |
| - // Exec resets dumpability after setuid. Reset it before arguments or any |
| - // service-controlled input are read, blocking same-uid ptrace/proc access. |
| + // Exec as root keeps runtime startup inaccessible to the service user. |
| + // Resolve the fixed service identity without inspecting child arguments. |
| + // syscall's credential setters update every thread, including with cgo. |
| + if err := syscall.Setgroups(nil); err != nil { |
| + return err |
| + } |
| + service, err := user.Lookup(serviceUser) |
| + if err != nil { |
| + return err |
| + } |
| + uid, err := strconv.Atoi(service.Uid) |
| + if err != nil { |
| + return err |
| + } |
| + gid, err := strconv.Atoi(service.Gid) |
| + if err != nil { |
| + return err |
| + } |
| + if uid <= 0 || gid < 0 { |
| + return fmt.Errorf("registry: invalid service credentials") |
| + } |
| + if err := syscall.Setresgid(gid, gid, gid); err != nil { |
| + return err |
| + } |
| + if err := syscall.Setresuid(uid, uid, uid); err != nil { |
| + return err |
| + } |
| + // Changing credentials resets dumpability to suid_dumpable. Explicitly |
| + // require zero too, before reading any registry arguments or pipe input. |
| if err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil { |
| return fmt.Errorf("registry: disable dumpability: %w", err) |
| } |
| + dumpable, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0) |
| + if err != nil { |
| + return err |
| + } |
| + ruid, euid, suid := unix.Getresuid() |
| + rgid, egid, sgid := unix.Getresgid() |
| + groups, err := os.Getgroups() |
| + if err != nil { |
| + return err |
| + } |
| + if dumpable != 0 || ruid != uid || euid != uid || suid != uid || rgid != gid || egid != gid || sgid != gid || len(groups) != 0 { |
| + return fmt.Errorf("registry: child credentials were not dropped") |
| + } |
| return nil |
| } |
diff --git a/internal/host/registrychild_exec_linux_test.go b/internal/host/registrychild_exec_linux_test.go| index ff762b166..0cafb04be 100644 |
| --- a/internal/host/registrychild_exec_linux_test.go |
| +++ b/internal/host/registrychild_exec_linux_test.go |
| @@ -4,6 +4,7 @@ package host |
| import ( |
| "bytes" |
| + "fmt" |
| "io" |
| "os" |
| "os/exec" |
| @@ -12,6 +13,7 @@ import ( |
| "runtime" |
| "strconv" |
| "strings" |
| + "syscall" |
| "testing" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -22,10 +24,31 @@ func TestRegistryChildHardeningHelper(t *testing.T) { |
| if os.Args[len(os.Args)-1] != "--hardening-probe" { |
| return |
| } |
| - // Malformed input must still leave the child non-dumpable. |
| + if os.Geteuid() != 0 { |
| + t.Fatal("child lost root before its in-process credential drop") |
| + } |
| + service, err := user.Lookup(serviceUser) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + uid, err := strconv.Atoi(service.Uid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + gid, err := strconv.Atoi(service.Gid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Malformed input must still drop all credentials and disable dumpability. |
| if err := RunRegistryChild(nil); err == nil { |
| t.Fatal("invalid arguments accepted") |
| } |
| + ruid, euid, suid := unix.Getresuid() |
| + rgid, egid, sgid := unix.Getresgid() |
| + groups, err := os.Getgroups() |
| + if err != nil || ruid != uid || euid != uid || suid != uid || rgid != gid || egid != gid || sgid != gid || len(groups) != 0 { |
| + t.Fatalf("child credentials = uid %d:%d:%d, gid %d:%d:%d, groups %v: %v", ruid, euid, suid, rgid, egid, sgid, groups, err) |
| + } |
| dumpable, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0) |
| if err != nil || dumpable != 0 { |
| t.Fatalf("dumpable = %d, %v", dumpable, err) |
| @@ -42,10 +65,31 @@ func TestRegistryChildHardeningHelper(t *testing.T) { |
| if err != nil || !bytes.Contains(status, []byte("NoNewPrivs:\t1\n")) { |
| t.Fatalf("thread %s lacks no-new-privileges: %v", thread.Name(), err) |
| } |
| + for _, want := range []string{ |
| + fmt.Sprintf("Uid:\t%d\t%d\t%d\t%d\n", uid, uid, uid, uid), |
| + fmt.Sprintf("Gid:\t%d\t%d\t%d\t%d\n", gid, gid, gid, gid), |
| + "Groups:\t\n", |
| + } { |
| + if !bytes.Contains(status, []byte(want)) { |
| + t.Fatalf("thread %s lacks exact credentials %q: %s", thread.Name(), want, status) |
| + } |
| + } |
| + } |
| + if err := syscall.Setresuid(0, 0, 0); err == nil { |
| + t.Fatal("child regained root uid") |
| + } |
| + if err := syscall.Setresgid(0, 0, 0); err == nil { |
| + t.Fatal("child regained root gid") |
| } |
| } |
| func TestRegistryChildHardening(t *testing.T) { |
| + if os.Geteuid() != 0 { |
| + t.Skip("requires root to verify the child's in-process credential drop and dumpability") |
| + } |
| + if _, err := user.Lookup(serviceUser); err != nil { |
| + t.Skip("requires the carlos service account") |
| + } |
| // Keep this thread pinned while verifying the spawn did not change it. |
| runtime.LockOSThread() |
| defer runtime.UnlockOSThread() |
diff --git a/internal/host/registrychild_unix.go b/internal/host/registrychild_unix.go| index 75e938aa9..bae0307e4 100644 |
| --- a/internal/host/registrychild_unix.go |
| +++ b/internal/host/registrychild_unix.go |
| @@ -10,7 +10,6 @@ import ( |
| "os/exec" |
| "path/filepath" |
| "strconv" |
| - "syscall" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -65,7 +64,7 @@ func registryChildPipes(cmd *exec.Cmd, uid, gid int) (io.WriteCloser, io.ReadClo |
| cmd.Env = append(cmd.Env, "CARLOS_HOST_CLAIM="+value) |
| } |
| cmd.Dir = "/" |
| - cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid), Groups: []uint32{}}} |
| + cmd.SysProcAttr = nil |
| cmd.Stderr = os.Stderr |
| input, err := cmd.StdinPipe() |
| if err != nil { |
diff --git a/internal/host/registrychild_unix_test.go b/internal/host/registrychild_unix_test.go| index 295acfe36..974c2be08 100644 |
| --- a/internal/host/registrychild_unix_test.go |
| +++ b/internal/host/registrychild_unix_test.go |
| @@ -31,9 +31,9 @@ func TestRegistryChildDropsCredentials(t *testing.T) { |
| t.Skip("requires root to set the child uid/gid and clear supplementary groups") |
| } |
| t.Setenv("CARLOS_HOST_CLAIM", "true") |
| - service, err := user.Lookup("nobody") |
| + service, err := user.Lookup(serviceUser) |
| if err != nil { |
| - t.Skip("requires an unprivileged nobody account") |
| + t.Skip("requires the carlos service account") |
| } |
| uid, err := strconv.Atoi(service.Uid) |
| if err != nil || uid <= 0 { |
| @@ -155,7 +155,7 @@ func TestRegistryChildCommandEnvironment(t *testing.T) { |
| if len(cmd.Env) != 1 || cmd.Env[0] != "CARLOS_HOST_CLAIM=true" { |
| t.Fatalf("worker environment = %q", cmd.Env) |
| } |
| - if cmd.Dir != "/" || cmd.SysProcAttr.Credential.Uid == 0 || cmd.SysProcAttr.Credential.NoSetGroups || len(cmd.SysProcAttr.Credential.Groups) != 0 { |
| + if cmd.Dir != "/" || cmd.SysProcAttr != nil { |
| t.Fatalf("unsafe worker command: %+v", cmd) |
| } |
| } |
diff --git a/internal/lease/endpoint.go b/internal/lease/endpoint.go| index c2e3dfe78..c115fdf47 100644 |
| --- a/internal/lease/endpoint.go |
| +++ b/internal/lease/endpoint.go |
| @@ -21,6 +21,7 @@ import ( |
| awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| lss3 "github.com/benbjohnson/litestream/s3" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| "github.com/carlosframework/platform/internal/s3compat" |
| ) |
| @@ -59,7 +60,7 @@ func resolveRegion(ctx context.Context, region string) (string, error) { |
| if region != "" { |
| return region, nil // no config load needed: step 1 already decided |
| } |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx) |
| if err != nil { |
| return "", fmt.Errorf("load aws config: %w", err) |
| } |
| @@ -101,7 +102,7 @@ func newLeaserClient(ctx context.Context, region string) (*awss3.Client, string, |
| if region != "" { |
| opts = append(opts, awsconfig.WithRegion(region)) |
| } |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx, opts...) |
| if err != nil { |
| return nil, "", fmt.Errorf("load aws config: %w", err) |
| } |
diff --git a/internal/lease/staging.go b/internal/lease/staging.go| index 767eef1e5..d34779e0c 100644 |
| --- a/internal/lease/staging.go |
| +++ b/internal/lease/staging.go |
| @@ -18,6 +18,7 @@ import ( |
| v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" |
| awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| ) |
| // The staging mirror (moves spec §7.3, model 14): the writer of a move's |
| @@ -129,7 +130,7 @@ func NewStagingMirror(ctx context.Context, srcRegion, dstRegion string) (*Stagin |
| if dstRegion != "" { |
| opts = append(opts, awsconfig.WithRegion(dstRegion)) |
| } |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx, opts...) |
| if err != nil { |
| return nil, fmt.Errorf("load aws config: %w", err) |
| } |
diff --git a/internal/mail/aws.go b/internal/mail/aws.go| index 682b21e1b..f57133c8a 100644 |
| --- a/internal/mail/aws.go |
| +++ b/internal/mail/aws.go |
| @@ -39,6 +39,7 @@ import ( |
| "github.com/aws/aws-sdk-go-v2/service/sesv2" |
| sesv2types "github.com/aws/aws-sdk-go-v2/service/sesv2/types" |
| "github.com/aws/aws-sdk-go-v2/service/sts" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| ) |
| // mailFromBehaviour is REJECT_MESSAGE and must stay that way. |
| @@ -106,7 +107,7 @@ var _ Provisioner = (*awsProvisioner)(nil) |
| // and composing it needs the account id. Resolving it once at construction |
| // keeps every walk free of an extra round trip. |
| func NewAWSProvisioner(ctx context.Context, region string) (Provisioner, error) { |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| if err != nil { |
| return nil, fmt.Errorf("mail: load aws config: %w", err) |
| } |
diff --git a/internal/objstore/aws.go b/internal/objstore/aws.go| index 931b713b5..fc765c665 100644 |
| --- a/internal/objstore/aws.go |
| +++ b/internal/objstore/aws.go |
| @@ -18,6 +18,7 @@ import ( |
| awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" |
| "github.com/aws/smithy-go" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| ) |
| // policyName is the inline policy every store user carries. One name, one |
| @@ -82,7 +83,7 @@ func NewAWSProvisioner(ctx context.Context, region, endpoint, boundary string) ( |
| if err != nil { |
| return nil, err |
| } |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| if err != nil { |
| return nil, fmt.Errorf("objstore: load aws config: %w", err) |
| } |
| @@ -97,7 +98,7 @@ func NewAWSProvisioner(ctx context.Context, region, endpoint, boundary string) ( |
| // The economics storage sweep (console.sizeProvisionedStore) is the first: |
| // it sizes a tenant's provisioned bucket directly, with no IAM involved. |
| func S3ClientFor(ctx context.Context, region, endpoint string) (*awss3.Client, error) { |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| if err != nil { |
| return nil, fmt.Errorf("objstore: load aws config: %w", err) |
| } |
diff --git a/internal/release/store.go b/internal/release/store.go| index aef07aeac..1c8af98bf 100644 |
| --- a/internal/release/store.go |
| +++ b/internal/release/store.go |
| @@ -22,9 +22,9 @@ import ( |
| "time" |
| "github.com/aws/aws-sdk-go-v2/aws" |
| - awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| "github.com/aws/smithy-go" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| "github.com/carlosframework/platform/internal/safefile" |
| "github.com/carlosframework/platform/internal/s3compat" |
| @@ -671,13 +671,14 @@ type S3Store struct { |
| Bucket string |
| } |
| -// NewS3Store builds a store on the standard AWS credential/region chain. |
| +// NewS3Store uses the standard AWS chain for non-root callers. Root never loads |
| +// shared profiles; rootaws admits only safe static off-cloud credentials files. |
| // Everything that makes a non-AWS store work — CARLOS_S3_ENDPOINT, path-style |
| // addressing, when-required checksums, and any per-provider quirk — now lives |
| // in internal/s3compat, so this store and the hibernation lease cannot drift |
| // apart against the same bucket. See that package for the quirk table. |
| func NewS3Store(ctx context.Context, bucket string) (*S3Store, error) { |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx) |
| if err != nil { |
| return nil, fmt.Errorf("s3store: load aws config: %w", err) |
| } |
diff --git a/internal/rootaws/config.go b/internal/rootaws/config.go| new file mode 100644 |
| index 000000000..0990f3ee8 |
| --- /dev/null |
| +++ b/internal/rootaws/config.go |
| @@ -0,0 +1,62 @@ |
| +// Package rootaws keeps root's AWS clients away from executable shared profiles. |
| +package rootaws |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| + "github.com/aws/aws-sdk-go-v2/credentials" |
| +) |
| + |
| +const credentialsPath = "/etc/carlos/credentials" |
| + |
| +// LoadDefaultConfig preserves the SDK chain for non-root callers. Root disables |
| +// both shared-file lists, including paths selected through HOME or AWS_* vars. |
| +func LoadDefaultConfig(ctx context.Context, opts ...func(*awsconfig.LoadOptions) error) (aws.Config, error) { |
| + if os.Geteuid() != 0 { |
| + return awsconfig.LoadDefaultConfig(ctx, opts...) |
| + } |
| + return loadRootConfig(ctx, credentialsPath, opts...) |
| +} |
| + |
| +func loadRootConfig(ctx context.Context, path string, opts ...func(*awsconfig.LoadOptions) error) (aws.Config, error) { |
| + // The SDK cannot ignore an environment profile per call. Refuse it rather |
| + // than mutate process-wide environment while other goroutines may read it. |
| + if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" { |
| + err := fmt.Errorf("root AWS clients do not support AWS_PROFILE or AWS_DEFAULT_PROFILE") |
| + log.Printf("ALERT: %v", err) |
| + return aws.Config{}, err |
| + } |
| + static, err := readStaticCredentials(path) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("ALERT: refusing unsafe offcloud credentials %s: %v", path, err) |
| + return aws.Config{}, fmt.Errorf("root AWS credentials: %w", err) |
| + } |
| + // Append last so callers cannot restore file loading or select a profile. |
| + opts = append(opts, awsconfig.WithSharedConfigFiles([]string{}), |
| + awsconfig.WithSharedCredentialsFiles([]string{}), awsconfig.WithSharedConfigProfile("")) |
| + if err == nil && !(envAccessKey() != "" && envSecretKey() != "") { |
| + opts = append(opts, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( |
| + static.AccessKeyID, static.SecretAccessKey, static.SessionToken))) |
| + } |
| + return awsconfig.LoadDefaultConfig(ctx, opts...) |
| +} |
| + |
| +func envAccessKey() string { |
| + if v := os.Getenv("AWS_ACCESS_KEY_ID"); v != "" { |
| + return v |
| + } |
| + return os.Getenv("AWS_ACCESS_KEY") |
| +} |
| + |
| +func envSecretKey() string { |
| + if v := os.Getenv("AWS_SECRET_ACCESS_KEY"); v != "" { |
| + return v |
| + } |
| + return os.Getenv("AWS_SECRET_KEY") |
| +} |
diff --git a/internal/rootaws/config_test.go b/internal/rootaws/config_test.go| new file mode 100644 |
| index 000000000..2af7c03bd |
| --- /dev/null |
| +++ b/internal/rootaws/config_test.go |
| @@ -0,0 +1,155 @@ |
| +package rootaws |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| +) |
| + |
| +func TestParseStaticCredentials(t *testing.T) { |
| + valid := "# comment\n[default]\naws_access_key_id = ACCESS\naws_secret_access_key = SECRET/+==\naws_session_token = TOKEN/+==\n" |
| + got, err := ParseStaticCredentials([]byte(valid)) |
| + if err != nil || got.AccessKeyID != "ACCESS" || got.SecretAccessKey != "SECRET/+==" || got.SessionToken != "TOKEN/+==" { |
| + t.Fatalf("static parse failed: %v", err) |
| + } |
| + for name, body := range map[string]string{ |
| + "process": valid + "credential_process = touch /never-run\n", |
| + "unknown": valid + "region = eu-west-1\n", |
| + "other-profile": valid + "[other]\ncredential_process = bad\n", |
| + "duplicate-profile": valid + "[default]\n", |
| + "duplicate-key": valid + "aws_access_key_id = SECOND\n", |
| + "missing-secret": "[default]\naws_access_key_id=ACCESS\n", |
| + "no-profile": "aws_access_key_id=ACCESS\naws_secret_access_key=SECRET\n", |
| + "empty": "", |
| + "nul": valid + "\x00", |
| + "oversize": strings.Repeat("#", MaxCredentialsSize+1), |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + if _, err := ParseStaticCredentials([]byte(body)); err == nil { |
| + t.Fatal("accepted invalid static credentials") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func isolatedAWSEnv(t *testing.T) { |
| + t.Helper() |
| + for _, key := range []string{"AWS_PROFILE", "AWS_DEFAULT_PROFILE", "AWS_ACCESS_KEY_ID", "AWS_ACCESS_KEY", "AWS_SECRET_ACCESS_KEY", "AWS_SECRET_KEY", "AWS_SESSION_TOKEN", "AWS_WEB_IDENTITY_TOKEN_FILE", "AWS_ROLE_ARN", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_CONTAINER_CREDENTIALS_FULL_URI"} { |
| + t.Setenv(key, "") |
| + } |
| + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") |
| + t.Setenv("AWS_REGION", "eu-west-1") |
| +} |
| + |
| +func TestRootConfigNeverLoadsSharedFiles(t *testing.T) { |
| + for _, selector := range []string{"HOME", "AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "options", "profile"} { |
| + t.Run(selector, func(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + dir := t.TempDir() |
| + t.Setenv("HOME", dir) |
| + t.Setenv("AWS_CONFIG_FILE", filepath.Join(dir, "missing-config")) |
| + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(dir, "missing-keys")) |
| + marker := filepath.Join(dir, "executed") |
| + // If the SDK ever selects this profile, retrieval executes touch. |
| + body := "[default]\ncredential_process = touch " + marker + "\n" |
| + bad := filepath.Join(dir, "untrusted") |
| + if err := os.WriteFile(bad, []byte(body), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + var opts []func(*awsconfig.LoadOptions) error |
| + switch selector { |
| + case "HOME": |
| + if err := os.Mkdir(filepath.Join(dir, ".aws"), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range []string{"config", "credentials"} { |
| + if err := os.WriteFile(filepath.Join(dir, ".aws", name), []byte(body), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + t.Setenv("AWS_CONFIG_FILE", "") |
| + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "") |
| + case "options": |
| + opts = append(opts, awsconfig.WithSharedConfigFiles([]string{bad}), awsconfig.WithSharedCredentialsFiles([]string{bad})) |
| + case "profile": |
| + t.Setenv("AWS_PROFILE", "default") |
| + t.Setenv("AWS_CONFIG_FILE", bad) |
| + default: |
| + t.Setenv(selector, bad) |
| + } |
| + cfg, err := loadRootConfig(context.Background(), filepath.Join(dir, "absent"), opts...) |
| + if err == nil { |
| + if _, err := cfg.Credentials.Retrieve(context.Background()); err == nil { |
| + t.Fatal("unexpected credentials without an allowed source") |
| + } |
| + } else if selector != "profile" { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(marker); !os.IsNotExist(err) { |
| + t.Fatal("credential_process executed") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRootConfigEnvironmentCredentials(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "ENV_SECRET") |
| + t.Setenv("AWS_SESSION_TOKEN", "ENV_TOKEN") |
| + cfg, err := loadRootConfig(context.Background(), filepath.Join(t.TempDir(), "absent")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := cfg.Credentials.Retrieve(context.Background()) |
| + if err != nil || got.AccessKeyID != "ENV_ACCESS" || got.SecretAccessKey != "ENV_SECRET" || got.SessionToken != "ENV_TOKEN" { |
| + t.Fatalf("environment credentials failed: %v", err) |
| + } |
| +} |
| + |
| +func TestRootConfigRefusesProfilesExplicitly(t *testing.T) { |
| + for _, name := range []string{"AWS_PROFILE", "AWS_DEFAULT_PROFILE"} { |
| + t.Run(name, func(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "ENV_SECRET") |
| + t.Setenv(name, "default") |
| + var logs bytes.Buffer |
| + old := log.Writer() |
| + log.SetOutput(&logs) |
| + defer log.SetOutput(old) |
| + _, err := loadRootConfig(context.Background(), filepath.Join(t.TempDir(), "absent")) |
| + if err == nil || !strings.Contains(err.Error(), "do not support") || !strings.Contains(logs.String(), "ALERT:") { |
| + t.Fatalf("profile not explicitly refused with an alert: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestNonRootConfigKeepsSharedCredentials(t *testing.T) { |
| + if os.Geteuid() == 0 { |
| + t.Skip("requires non-root to exercise unchanged SDK behavior") |
| + } |
| + isolatedAWSEnv(t) |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "credentials") |
| + if err := os.WriteFile(path, []byte("[default]\naws_access_key_id=SHARED\naws_secret_access_key=SECRET\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", path) |
| + t.Setenv("AWS_CONFIG_FILE", filepath.Join(dir, "absent")) |
| + cfg, err := LoadDefaultConfig(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := cfg.Credentials.Retrieve(context.Background()) |
| + if err != nil || got.AccessKeyID != "SHARED" { |
| + t.Fatalf("non-root shared credentials changed: %v", err) |
| + } |
| +} |
diff --git a/internal/rootaws/credentials.go b/internal/rootaws/credentials.go| new file mode 100644 |
| index 000000000..e3f374376 |
| --- /dev/null |
| +++ b/internal/rootaws/credentials.go |
| @@ -0,0 +1,53 @@ |
| +package rootaws |
| + |
| +import ( |
| + "bytes" |
| + "fmt" |
| + "strings" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| +) |
| + |
| +const MaxCredentialsSize = 64 << 10 |
| + |
| +// ParseStaticCredentials accepts only one default profile with literal keys. |
| +// Errors never include credential values or executable configuration text. |
| +func ParseStaticCredentials(body []byte) (aws.Credentials, error) { |
| + var empty aws.Credentials |
| + if len(body) > MaxCredentialsSize || bytes.IndexByte(body, 0) >= 0 { |
| + return empty, fmt.Errorf("invalid credentials size or encoding") |
| + } |
| + profile := false |
| + values := make(map[string]string) |
| + for _, line := range strings.Split(string(body), "\n") { |
| + line = strings.TrimSpace(line) |
| + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { |
| + continue |
| + } |
| + if strings.HasPrefix(line, "[") { |
| + if line != "[default]" || profile { |
| + return empty, fmt.Errorf("credentials require one default profile") |
| + } |
| + profile = true |
| + continue |
| + } |
| + key, value, found := strings.Cut(line, "=") |
| + key, value = strings.TrimSpace(key), strings.TrimSpace(value) |
| + if !profile || !found || value == "" { |
| + return empty, fmt.Errorf("invalid static credentials entry") |
| + } |
| + switch key { |
| + case "aws_access_key_id", "aws_secret_access_key", "aws_session_token": |
| + default: |
| + return empty, fmt.Errorf("unsupported credentials key") |
| + } |
| + if _, exists := values[key]; exists || strings.ContainsAny(value, "\r\t ") { |
| + return empty, fmt.Errorf("duplicate or invalid static credentials entry") |
| + } |
| + values[key] = value |
| + } |
| + if values["aws_access_key_id"] == "" || values["aws_secret_access_key"] == "" { |
| + return empty, fmt.Errorf("credentials require access and secret keys") |
| + } |
| + return aws.Credentials{AccessKeyID: values["aws_access_key_id"], SecretAccessKey: values["aws_secret_access_key"], SessionToken: values["aws_session_token"], Source: "CarlosStaticCredentials"}, nil |
| +} |
diff --git a/internal/rootaws/credentials_linux_test.go b/internal/rootaws/credentials_linux_test.go| new file mode 100644 |
| index 000000000..d5f866848 |
| --- /dev/null |
| +++ b/internal/rootaws/credentials_linux_test.go |
| @@ -0,0 +1,97 @@ |
| +package rootaws |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func TestRootCredentialsRejectUnsafeFiles(t *testing.T) { |
| + for _, kind := range []string{"symlink", "dangling", "hardlink", "fifo", "directory", "service-owner", "group-write", "other-write", "process"} { |
| + t.Run(kind, func(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "credentials") |
| + target := filepath.Join(dir, "target") |
| + body := []byte("[default]\naws_access_key_id=ACCESS\naws_secret_access_key=SECRET\n") |
| + if err := os.WriteFile(target, body, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + var err error |
| + switch kind { |
| + case "symlink": |
| + err = os.Symlink(target, path) |
| + case "dangling": |
| + err = os.Symlink(filepath.Join(dir, "missing"), path) |
| + case "hardlink": |
| + err = os.Link(target, path) |
| + case "fifo": |
| + err = unix.Mkfifo(path, 0600) |
| + case "directory": |
| + err = os.Mkdir(path, 0700) |
| + default: |
| + if os.Geteuid() != 0 && kind != "service-owner" { |
| + t.Skip("requires root-owned fixture to isolate mode and content checks") |
| + } |
| + if kind == "process" { |
| + body = append(body, []byte("credential_process=bad\n")...) |
| + } |
| + err = os.WriteFile(path, body, 0600) |
| + if err == nil { |
| + switch kind { |
| + case "service-owner": |
| + if os.Geteuid() == 0 { |
| + err = os.Chown(path, 65534, 65534) |
| + } |
| + case "group-write": |
| + err = os.Chmod(path, 0660) |
| + case "other-write": |
| + err = os.Chmod(path, 0602) |
| + } |
| + } |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var logs bytes.Buffer |
| + old := log.Writer() |
| + log.SetOutput(&logs) |
| + defer log.SetOutput(old) |
| + if _, err := loadRootConfig(context.Background(), path); err == nil { |
| + t.Fatal("unsafe file accepted") |
| + } |
| + if !strings.Contains(logs.String(), "ALERT:") { |
| + t.Fatal("unsafe file refusal did not alert") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRootCredentialsStaticProvider(t *testing.T) { |
| + if os.Geteuid() != 0 { |
| + t.Skip("requires root-owned credentials fixture") |
| + } |
| + isolatedAWSEnv(t) |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + if err := os.WriteFile(path, []byte("[default]\naws_access_key_id=STATIC\naws_secret_access_key=SECRET\naws_session_token=TOKEN\n"), 0640); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg, err := loadRootConfig(context.Background(), path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The provider holds the parsed snapshot and never asks the SDK to reread it. |
| + if err := os.WriteFile(path, []byte("[default]\ncredential_process=bad\n"), 0640); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := cfg.Credentials.Retrieve(context.Background()) |
| + if err != nil || got.AccessKeyID != "STATIC" || got.SessionToken != "TOKEN" { |
| + t.Fatalf("static snapshot failed: %v", err) |
| + } |
| +} |
diff --git a/internal/rootaws/credentials_other.go b/internal/rootaws/credentials_other.go| new file mode 100644 |
| index 000000000..495e483ff |
| --- /dev/null |
| +++ b/internal/rootaws/credentials_other.go |
| @@ -0,0 +1,13 @@ |
| +//go:build !linux && !darwin |
| + |
| +package rootaws |
| + |
| +import ( |
| + "fmt" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| +) |
| + |
| +func readStaticCredentials(string) (aws.Credentials, error) { |
| + return aws.Credentials{}, fmt.Errorf("root static credentials require Linux or Darwin") |
| +} |
diff --git a/internal/rootaws/credentials_unix.go b/internal/rootaws/credentials_unix.go| new file mode 100644 |
| index 000000000..df2bd6dc7 |
| --- /dev/null |
| +++ b/internal/rootaws/credentials_unix.go |
| @@ -0,0 +1,31 @@ |
| +//go:build linux || darwin |
| + |
| +package rootaws |
| + |
| +import ( |
| + "fmt" |
| + "io" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func readStaticCredentials(path string) (aws.Credentials, error) { |
| + f, err := openCredentials(path) |
| + if err != nil { |
| + return aws.Credentials{}, err |
| + } |
| + defer f.Close() |
| + var st unix.Stat_t |
| + if err := unix.Fstat(int(f.Fd()), &st); err != nil { |
| + return aws.Credentials{}, err |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 || st.Uid != 0 || st.Mode&0o022 != 0 { |
| + return aws.Credentials{}, fmt.Errorf("credentials must be root-owned, single-link regular and not group- or other-writable") |
| + } |
| + body, err := io.ReadAll(io.LimitReader(f, MaxCredentialsSize+1)) |
| + if err != nil { |
| + return aws.Credentials{}, err |
| + } |
| + return ParseStaticCredentials(body) |
| +} |
diff --git a/internal/rootaws/open_darwin.go b/internal/rootaws/open_darwin.go| new file mode 100644 |
| index 000000000..aa9519c67 |
| --- /dev/null |
| +++ b/internal/rootaws/open_darwin.go |
| @@ -0,0 +1,11 @@ |
| +package rootaws |
| + |
| +import ( |
| + "os" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func openCredentials(path string) (*os.File, error) { |
| + return os.OpenFile(path, os.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0) |
| +} |
diff --git a/internal/rootaws/open_linux.go b/internal/rootaws/open_linux.go| new file mode 100644 |
| index 000000000..2da6db489 |
| --- /dev/null |
| +++ b/internal/rootaws/open_linux.go |
| @@ -0,0 +1,13 @@ |
| +package rootaws |
| + |
| +import ( |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/safefile" |
| +) |
| + |
| +func openCredentials(path string) (*os.File, error) { |
| + // safefile pins through O_PATH|O_NOFOLLOW, checks the inode and reopens |
| + // that descriptor, rejecting links and special files before opening them. |
| + return safefile.OpenFile(path, os.O_RDONLY, 0) |
| +} |
diff --git a/internal/safefile/reap_linux.go b/internal/safefile/reap_linux.go| index 01bf03fbe..5d8670629 100644 |
| --- a/internal/safefile/reap_linux.go |
| +++ b/internal/safefile/reap_linux.go |
| @@ -166,6 +166,8 @@ func reapStage(parent int, name string, dir *os.File, cutoff time.Time) error { |
| } |
| // Unlinkat never follows a replacement symlink. Recheck the parent entry |
| // before removing the empty directory, keeping all content deletion anchored. |
| + // A substitution after this check can remove a different empty directory. |
| + // That accepted race cannot follow a symlink or remove nonempty replacements. |
| if !reapSameEntry(parent, name, &stage) { |
| return nil |
| } |
diff --git a/internal/steering/route53.go b/internal/steering/route53.go| index 28fd8fcdf..6ec1fa7a0 100644 |
| --- a/internal/steering/route53.go |
| +++ b/internal/steering/route53.go |
| @@ -27,6 +27,7 @@ import ( |
| awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| "github.com/aws/aws-sdk-go-v2/service/route53" |
| r53types "github.com/aws/aws-sdk-go-v2/service/route53/types" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| ) |
| const ( |
| @@ -113,7 +114,7 @@ func NewClient(ctx context.Context) (*route53.Client, error) { |
| if strings.TrimSpace(os.Getenv("AWS_REGION")) == "" && strings.TrimSpace(os.Getenv("AWS_DEFAULT_REGION")) == "" { |
| opts = append(opts, awsconfig.WithRegion("us-east-1")) |
| } |
| - cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) |
| + cfg, err := rootaws.LoadDefaultConfig(ctx, opts...) |
| if err != nil { |
| return nil, fmt.Errorf("steering: load aws config: %w", err) |
| } |