carlos / platform Public

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>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 583b78ff66bf7a9494b2b54c0f9244e840f098d5 parent 9a0f80f
30 files changed, +738 −42
  • internal/alerting/alerting.go +2 −1
  • internal/awsinv/awsinv.go +2 −2
  • internal/awsinv/exact_instance.go +2 −1
  • internal/console/cepull.go +2 −2
  • internal/dnsrec/route53.go +2 −1
  • internal/edge/route53dns.go +2 −1
  • internal/host/agent.go +1 −2
  • internal/host/claimproduction_marker.go +2 −1
  • internal/host/memwatch.go +2 −2
  • internal/host/offcloudcredentials_linux.go +47 −11
  • internal/host/offcloudcredentials_linux_test.go +128 −0
  • internal/host/registrychild_exec_linux.go +44 −2
  • internal/host/registrychild_exec_linux_test.go +45 −1
  • internal/host/registrychild_unix.go +1 −2
  • internal/host/registrychild_unix_test.go +3 −3
  • internal/lease/endpoint.go +3 −2
  • internal/lease/staging.go +2 −1
  • internal/mail/aws.go +2 −1
  • internal/objstore/aws.go +3 −2
  • internal/release/store.go +4 −3
  • internal/rootaws/config.go +62 −0
  • internal/rootaws/config_test.go +155 −0
  • internal/rootaws/credentials.go +53 −0
  • internal/rootaws/credentials_linux_test.go +97 −0
  • internal/rootaws/credentials_other.go +13 −0
  • internal/rootaws/credentials_unix.go +31 −0
  • internal/rootaws/open_darwin.go +11 −0
  • internal/rootaws/open_linux.go +13 −0
  • internal/safefile/reap_linux.go +2 −0
  • internal/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)
}