Merge remote-tracking branch 'origin/main' into moves-phase1-5b
55 files changed,
+2374
−83
cmd/console/main.go+2 −0internal/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+6 −0internal/host/bootstrap.go+9 −24internal/host/claimproduction_marker.go+2 −1internal/host/claimroleassembly_linux.go+1 −1internal/host/claimroleassembly_linux_test.go+53 −0internal/host/host_test.go+34 −10internal/host/memwatch.go+2 −2internal/host/offcloudcredentials_linux.go+131 −0internal/host/offcloudcredentials_linux_test.go+251 −0internal/host/offcloudcredentials_other.go+16 −0internal/host/registrychild_exec_linux.go+71 −0internal/host/registrychild_exec_linux_test.go+100 −0internal/host/registrychild_exec_other.go+8 −0internal/host/registrychild_unix.go+5 −3internal/host/registrychild_unix_test.go+3 −3internal/host/stagingcleanup.go+32 −0internal/host/stagingcleanup_linux_test.go+74 −0internal/host/systemapply.go+1 −0internal/host/units/carlos-system-recover.service+1 −1internal/host/units/carlos-system-update.service+1 −1internal/lease/checkpoint.go+4 −1internal/lease/claimgeneration.go+10 −2internal/lease/claimgeneration_test.go+8 −2internal/lease/endpoint.go+26 −5internal/lease/endpoint_test.go+133 −2internal/lease/lease.go+15 −5internal/lease/retention_window_test.go+8 −2internal/lease/staging.go+2 −1internal/mail/aws.go+2 −1internal/objstore/aws.go+3 −2internal/provision/plan9_b8_census_test.go+2 −2internal/registry/remote.go+1 −0internal/release/store.go+4 −3internal/rootaws/config.go+100 −0internal/rootaws/config_test.go+297 −0internal/rootaws/credentials.go+71 −0internal/rootaws/credentials_linux_test.go+138 −0internal/rootaws/credentials_other.go+13 −0internal/rootaws/credentials_unix.go+31 −0internal/rootaws/environment.go+97 −0internal/rootaws/environment_test.go+151 −0internal/rootaws/open_darwin.go+11 −0internal/rootaws/open_linux.go+13 −0internal/safefile/reap_linux.go+179 −0internal/safefile/reap_linux_test.go+230 −0internal/safefile/reap_other.go+6 −0internal/steering/route53.go+2 −1main.go+2 −0
CI passed — run details
diff --git a/cmd/console/main.go b/cmd/console/main.go| index f44d65d5c..ab1ef043d 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -99,11 +99,13 @@ import ( |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| "github.com/carlosframework/platform/internal/status" |
| "github.com/carlosframework/platform/internal/steering" |
| ) |
| func main() { |
| + rootaws.ScrubEnvironment() |
| log.SetFlags(0) |
| if len(os.Args) < 2 { |
| usage() |
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 72d832150..d970f9dec 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -3925,6 +3925,12 @@ func runAdopt(args []string, buildClaims ClaimFactory) { |
| // runAdoptWithEnsureSystemUnits keeps the root-only unit effect injectable; |
| // 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) |
| + if os.Geteuid() == 0 { |
| + if err := repairOffcloudCredentials("/etc/carlos/credentials"); err != nil { |
| + log.Printf("ALERT: adopt: offcloud credentials: %v", err) |
| + } |
| + } |
| claimCtx, err := claimEntryContext(context.Background(), os.Getenv, openDeployStore) |
| if err != nil { |
| log.Fatalf("adopt: host claims: %v", err) |
diff --git a/internal/host/bootstrap.go b/internal/host/bootstrap.go| index 4ed7efbf8..86045311d 100644 |
| --- a/internal/host/bootstrap.go |
| +++ b/internal/host/bootstrap.go |
| @@ -329,27 +329,20 @@ func (f *repeatableFlag) Set(v string) error { |
| // wrong-region-fails-at-IAM holds off-cloud too. The flag is explicit; no |
| // IMDS sniffing (the agent's one IMDS probe is already gated for non-EC2). |
| // |
| -// The credential check runs BEFORE any system mutation: a failed precondition |
| -// leaves the box untouched, same as a failed bootstrap() would. |
| +// Credential shape and mode checks precede bootstrap mutations. A rejected |
| +// credential precondition leaves the box untouched. |
| func bootstrapOffcloud(root string, consumers []string) error { |
| cred := filepath.Join(root, "etc", "carlos", "credentials") |
| - fi, err := os.Stat(cred) |
| + fi, err := checkOffcloudCredentials(cred) |
| if err != nil { |
| return fmt.Errorf("offcloud: stage the scoped credentials file first: %s (0600, see infra/remote-host.md): %w", cred, err) |
| } |
| - if fi.Mode().Perm() != 0o600 { |
| - return fmt.Errorf("offcloud: %s must be mode 0600, is %o", cred, fi.Mode().Perm()) |
| + if fi.Mode().Perm() != 0o600 && fi.Mode().Perm() != 0o640 { |
| + return fmt.Errorf("offcloud: %s must be mode 0600 or 0640, is %o", cred, fi.Mode().Perm()) |
| } |
| if err := bootstrap(root, consumers); err != nil { |
| return err |
| } |
| - // Ownership is a live-box-only step, same as ensureServiceUser/chownToService |
| - // in bootstrap() above — a staging root has no service user to chown to. |
| - if filepath.Clean(root) == "/" { |
| - if err := chownToService(cred); err != nil { // same helper the data dirs use |
| - return err |
| - } |
| - } |
| fmt.Printf("offcloud bootstrap complete.\n ensure /etc/carlos/host.env sets:\n AWS_SHARED_CREDENTIALS_FILE=/etc/carlos/credentials\n then stage /opt/carlos/carlos and start carlos-edge.service (see infra/remote-host.md)\n") |
| return nil |
| } |
| @@ -601,6 +594,10 @@ func bootstrapWith(root string, consumers []string, planJSON bool) error { |
| return nil |
| } |
| + // Repair before arming any root pass that may load these credentials. |
| + if err := repairOffcloudCredentials("/etc/carlos/credentials"); err != nil { |
| + return err |
| + } |
| for _, args := range systemctlPlan(enabledUnits) { |
| if err := systemctl(args...); err != nil { |
| return err |
| @@ -728,18 +725,6 @@ func bootstrapOwner(owner, group string) (int, int, error) { |
| return uid, gid, err |
| } |
| -func chownToService(path string) error { |
| - uid, gid, err := bootstrapOwner(serviceUser, serviceUser) |
| - if err != nil { |
| - return err |
| - } |
| - _, err = ownRegistryFile(path, uid, gid) |
| - if err != nil { |
| - log.Printf("ALERT: bootstrap: refusing ownership of %s: %v", path, err) |
| - } |
| - return err |
| -} |
| - |
| func systemctl(args ...string) error { |
| out, err := exec.Command("systemctl", args...).CombinedOutput() |
| if err != nil { |
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/claimroleassembly_linux.go b/internal/host/claimroleassembly_linux.go| index 0663c0817..ac9baee27 100644 |
| --- a/internal/host/claimroleassembly_linux.go |
| +++ b/internal/host/claimroleassembly_linux.go |
| @@ -206,7 +206,7 @@ func verifiedClaimExecutable(ctx context.Context, cmd *exec.Cmd, path, digest st |
| if err = unix.Fstat(fd, &before); err != nil { |
| return refuse(err) |
| } |
| - if before.Mode&unix.S_IFMT != unix.S_IFREG || before.Nlink != 1 || before.Mode&0111 == 0 || before.Mode&0022 != 0 { |
| + if before.Uid != 0 || before.Mode&unix.S_IFMT != unix.S_IFREG || before.Nlink != 1 || before.Mode&0111 == 0 || before.Mode&0022 != 0 { |
| return refuse(hostbox.ErrChanged) |
| } |
| hash := sha256.New() |
diff --git a/internal/host/claimroleassembly_linux_test.go b/internal/host/claimroleassembly_linux_test.go| index 3c8d5b2fa..1bad58bed 100644 |
| --- a/internal/host/claimroleassembly_linux_test.go |
| +++ b/internal/host/claimroleassembly_linux_test.go |
| @@ -11,9 +11,14 @@ import ( |
| "os/exec" |
| "path/filepath" |
| "testing" |
| + |
| + "golang.org/x/sys/unix" |
| ) |
| func TestClaimExecutablePinsVerifiedBytes(t *testing.T) { |
| + if os.Geteuid() != 0 { |
| + t.Skip("requires root to create and replace a trusted executable") |
| + } |
| path := filepath.Join(t.TempDir(), "binary") |
| body := []byte("approved executable bytes") |
| if err := os.WriteFile(path, body, 0555); err != nil { |
| @@ -40,6 +45,54 @@ func TestClaimExecutablePinsVerifiedBytes(t *testing.T) { |
| t.Fatal("changed bytes admitted") |
| } |
| } |
| + |
| +func TestClaimExecutableRefusesNonRootOwner(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "binary") |
| + body := []byte("approved executable bytes") |
| + if err := os.WriteFile(path, body, 0555); err != nil { |
| + t.Fatal(err) |
| + } |
| + if os.Geteuid() == 0 { |
| + if err := os.Chown(path, 65534, 65534); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + digest := sha256.Sum256(body) |
| + if f, err := verifiedClaimExecutable(t.Context(), exec.Command(path), path, hex.EncodeToString(digest[:])); err == nil { |
| + f.Close() |
| + t.Fatal("non-root-owned executable accepted") |
| + } |
| +} |
| + |
| +func TestClaimExecutableAcceptsRootOwnedSystemBinary(t *testing.T) { |
| + path, err := filepath.EvalSymlinks("/usr/bin/true") |
| + if err != nil { |
| + t.Skipf("requires a root-owned system executable: %v", err) |
| + } |
| + var st unix.Stat_t |
| + if err := unix.Stat(path, &st); err != nil || st.Uid != 0 || st.Nlink != 1 || st.Mode&0022 != 0 { |
| + t.Skip("requires a root-owned single-link system executable") |
| + } |
| + body, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + digest := sha256.Sum256(body) |
| + f, err := verifiedClaimExecutable(t.Context(), exec.Command(path), path, hex.EncodeToString(digest[:])) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer f.Close() |
| + got, err := io.ReadAll(f) |
| + if err != nil || string(got) != string(body) { |
| + t.Fatalf("pinned system executable differs: %v", err) |
| + } |
| + digest[0] ^= 1 |
| + if f, err := verifiedClaimExecutable(t.Context(), exec.Command(path), path, hex.EncodeToString(digest[:])); err == nil { |
| + f.Close() |
| + t.Fatal("wrong system executable digest accepted") |
| + } |
| +} |
| func TestClaimExecutableRefusesSymlinkAndWritableArtifact(t *testing.T) { |
| dir := t.TempDir() |
| path := filepath.Join(dir, "binary") |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 2313b98e3..c84c8f20a 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -1167,7 +1167,7 @@ func TestBootstrapPrivilegedPlanEnrollsConsumers(t *testing.T) { |
| // (issue #53 point 4): a box with no EC2 instance profile must have its scoped |
| // credentials staged — and staged at 0600 — before bootstrap proceeds. This is |
| // the staging-root path (no root privileges available in tests), so the |
| -// service-user chown below never runs; that only happens on a live root. |
| +// root:service ownership repair never runs; that only happens on a live root. |
| func TestBootstrapOffcloudRequiresCredentialsFile(t *testing.T) { |
| root := t.TempDir() |
| if err := bootstrapOffcloud(root, nil); err == nil { |
| @@ -1802,14 +1802,18 @@ func TestSystemUnitsSelectPrevByTheFloor(t *testing.T) { |
| floor, verdict string // "" means no file |
| noPrev bool |
| want string |
| + floorKind string |
| }{ |
| - {"no journal", false, "", "", false, "carlos"}, |
| - {"no floor", true, "", "", false, "carlos.prev"}, |
| - {"no floor, no prev", true, "", "", true, "carlos"}, |
| - {"floor and a verdict naming it", true, "v0.30.0\n", "v0.30.0\n", false, "carlos.prev"}, |
| - {"a verdict for an older floor", true, "v0.30.0\n", "v0.29.0\n", false, "carlos"}, |
| - {"floor and no verdict", true, "v0.30.0\n", "", false, "carlos"}, |
| - {"no journal, armed and allowed", false, "v0.30.0\n", "v0.30.0\n", false, "carlos"}, |
| + {"no journal", false, "", "", false, "carlos", ""}, |
| + {"no floor", true, "", "", false, "carlos.prev", ""}, |
| + {"no floor, no prev", true, "", "", true, "carlos", ""}, |
| + {"floor and a verdict naming it", true, "v0.30.0\n", "v0.30.0\n", false, "carlos.prev", ""}, |
| + {"a verdict for an older floor", true, "v0.30.0\n", "v0.29.0\n", false, "carlos", ""}, |
| + {"floor and no verdict", true, "v0.30.0\n", "", false, "carlos", ""}, |
| + {"no journal, armed and allowed", false, "v0.30.0\n", "v0.30.0\n", false, "carlos", ""}, |
| + {"symlink floor with matching verdict", true, "v0.30.0\n", "v0.30.0\n", false, "carlos", "symlink"}, |
| + {"dangling symlink floor", true, "", "", false, "carlos", "dangling"}, |
| + {"directory floor", true, "", "", false, "carlos", "directory"}, |
| } { |
| t.Run(tc.name, func(t *testing.T) { |
| root := t.TempDir() |
| @@ -1827,8 +1831,28 @@ func TestSystemUnitsSelectPrevByTheFloor(t *testing.T) { |
| if tc.journal { |
| os.WriteFile(filepath.Join(opt, "system", "router", "journal.json"), []byte("{}"), 0o600) |
| } |
| - if tc.floor != "" { |
| - os.WriteFile(filepath.Join(data, moves.FloorMinReleaseFile), []byte(tc.floor), 0o644) |
| + floorPath := filepath.Join(data, moves.FloorMinReleaseFile) |
| + switch tc.floorKind { |
| + case "symlink", "dangling": |
| + target := filepath.Join(root, "floor-target") |
| + if tc.floorKind == "symlink" { |
| + if err := os.WriteFile(target, []byte(tc.floor), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := os.Symlink(target, floorPath); err != nil { |
| + t.Fatal(err) |
| + } |
| + case "directory": |
| + if err := os.Mkdir(floorPath, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + default: |
| + if tc.floor != "" { |
| + if err := os.WriteFile(floorPath, []byte(tc.floor), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| } |
| if tc.verdict != "" { |
| os.WriteFile(filepath.Join(opt, moves.PrevAllowedFile), []byte(tc.verdict), 0o644) |
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| new file mode 100644 |
| index 000000000..917269628 |
| --- /dev/null |
| +++ b/internal/host/offcloudcredentials_linux.go |
| @@ -0,0 +1,131 @@ |
| +//go:build linux |
| + |
| +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" |
| +) |
| + |
| +// openOffcloudCredentials pins the parent and refuses links and special files |
| +// before either ownership or mode is changed. O_PATH inspects special files |
| +// without opening a device or blocking on a planted FIFO. |
| +func openOffcloudCredentials(path string) (_ *os.File, err error) { |
| + defer func() { |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("ALERT: refusing unsafe offcloud credentials %s: %v", path, err) |
| + } |
| + }() |
| + parent, name, err := openRegistryParent(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer parent.Close() |
| + fd, err := unix.Openat2(int(parent.Fd()), name, &unix.OpenHow{ |
| + Flags: unix.O_PATH | unix.O_NOFOLLOW | unix.O_CLOEXEC, |
| + Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV, |
| + }) |
| + if err != nil { |
| + return nil, err |
| + } |
| + f := os.NewFile(uintptr(fd), path) |
| + defer f.Close() |
| + var st unix.Stat_t |
| + if err := unix.Fstat(fd, &st); err != nil { |
| + return nil, err |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 { |
| + return nil, fmt.Errorf("not a plain single-link regular file") |
| + } |
| + // Reopen the pinned regular inode, never the caller's mutable pathname. |
| + 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 os.NewFile(uintptr(opened), path), nil |
| +} |
| + |
| +func checkOffcloudCredentials(path string) (os.FileInfo, error) { |
| + f, err := openOffcloudCredentials(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer f.Close() |
| + return f.Stat() |
| +} |
| + |
| +// 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 |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + defer f.Close() |
| + g, err := user.LookupGroup(serviceUser) |
| + if err != nil { |
| + return err |
| + } |
| + gid, err := strconv.Atoi(g.Gid) |
| + if err != nil { |
| + return err |
| + } |
| + return secureOffcloudCredentials(f, gid) |
| +} |
| + |
| +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 |
| + } |
| + body, err := io.ReadAll(io.LimitReader(f, rootaws.MaxCredentialsSize+1)) |
| + if err != nil { |
| + return err |
| + } |
| + 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| new file mode 100644 |
| index 000000000..fafedfedb |
| --- /dev/null |
| +++ b/internal/host/offcloudcredentials_linux_test.go |
| @@ -0,0 +1,251 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func TestOffcloudCredentialsCreationAndRepair(t *testing.T) { |
| + if os.Geteuid() != 0 { |
| + t.Skip("root required for root:service ownership") |
| + } |
| + const serviceID = 65534 |
| + for _, owner := range []int{0, serviceID} { |
| + t.Run(map[int]string{0: "creation", serviceID: "repair"}[owner], func(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + contents := []byte("[default]\naws_access_key_id=X\naws_secret_access_key=Y\n") |
| + if err := os.WriteFile(path, contents, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chown(path, owner, serviceID); err != nil { |
| + t.Fatal(err) |
| + } |
| + for i := 0; i < 2; i++ { |
| + f, err := openOffcloudCredentials(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + err = secureOffcloudCredentials(f, serviceID) |
| + f.Close() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var st unix.Stat_t |
| + if err := unix.Lstat(path, &st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st.Uid != 0 || st.Gid != serviceID || st.Mode&0o7777 != 0o640 { |
| + t.Fatalf("got owner %d:%d mode %o", st.Uid, st.Gid, st.Mode&0o7777) |
| + } |
| + } |
| + if got, err := os.ReadFile(path); err != nil || !bytes.Equal(got, contents) { |
| + t.Fatalf("credentials changed: %q, %v", got, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +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) { |
| + root := t.TempDir() |
| + path := filepath.Join(root, "credentials") |
| + victim := filepath.Join(root, "victim") |
| + if err := os.WriteFile(victim, []byte("preserve"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + var err error |
| + switch kind { |
| + case "symlink": |
| + err = os.Symlink(victim, path) |
| + case "dangling": |
| + err = os.Symlink(filepath.Join(root, "absent"), path) |
| + case "hardlink": |
| + err = os.Link(victim, path) |
| + case "fifo": |
| + err = unix.Mkfifo(path, 0o600) |
| + case "directory": |
| + err = os.Mkdir(path, 0o600) |
| + case "parent-symlink": |
| + err = os.Symlink(root, filepath.Join(root, "linked-parent")) |
| + path = filepath.Join(root, "linked-parent", "victim") |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var logs bytes.Buffer |
| + previous := log.Writer() |
| + log.SetOutput(&logs) |
| + defer log.SetOutput(previous) |
| + if err := repairOffcloudCredentials(path); err == nil { |
| + t.Fatal("accepted unsafe credentials") |
| + } |
| + if !strings.Contains(logs.String(), "ALERT:") { |
| + t.Fatalf("refusal did not alert: %s", logs.String()) |
| + } |
| + fi, err := os.Stat(victim) |
| + if err != nil || fi.Mode().Perm() != 0o600 { |
| + t.Fatalf("victim mode changed: %v, %v", fi, err) |
| + } |
| + if got, err := os.ReadFile(victim); err != nil || string(got) != "preserve" { |
| + t.Fatalf("victim changed: %q, %v", got, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestOffcloudCredentialsMissingCloudFile(t *testing.T) { |
| + if err := repairOffcloudCredentials(filepath.Join(t.TempDir(), "absent", "credentials")); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestBootstrapOffcloudAcceptsRepairedCredentials(t *testing.T) { |
| + root := t.TempDir() |
| + path := filepath.Join(root, "etc", "carlos", "credentials") |
| + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, []byte("[default]\n"), 0o640); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := bootstrapOffcloud(root, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
diff --git a/internal/host/offcloudcredentials_other.go b/internal/host/offcloudcredentials_other.go| new file mode 100644 |
| index 000000000..2af2da277 |
| --- /dev/null |
| +++ b/internal/host/offcloudcredentials_other.go |
| @@ -0,0 +1,16 @@ |
| +//go:build !linux |
| + |
| +package host |
| + |
| +import ( |
| + "fmt" |
| + "os" |
| +) |
| + |
| +func checkOffcloudCredentials(string) (os.FileInfo, error) { |
| + return nil, fmt.Errorf("offcloud credentials operations require Linux") |
| +} |
| + |
| +func repairOffcloudCredentials(string) error { |
| + return fmt.Errorf("offcloud credentials operations require Linux") |
| +} |
diff --git a/internal/host/registrychild_exec_linux.go b/internal/host/registrychild_exec_linux.go| index 84e324f62..76d616956 100644 |
| --- a/internal/host/registrychild_exec_linux.go |
| +++ b/internal/host/registrychild_exec_linux.go |
| @@ -3,9 +3,13 @@ |
| package host |
| import ( |
| + "fmt" |
| "os" |
| "os/exec" |
| + "os/user" |
| + "runtime" |
| "strconv" |
| + "syscall" |
| "golang.org/x/sys/unix" |
| ) |
| @@ -22,9 +26,76 @@ func registryChildExec(path string, uid, gid int) (*exec.Cmd, error) { |
| } |
| func registryChildCloseExec() error { |
| + // This marks descriptors in the parent too; Go opens its own descriptors |
| + // close-on-exec, and ExtraFiles explicitly preserves the pinned executable. |
| return unix.CloseRange(3, ^uint(0), unix.CLOSE_RANGE_CLOEXEC) |
| } |
| +func registryChildStart(cmd *exec.Cmd) error { |
| + started := make(chan error, 1) |
| + go func() { |
| + runtime.LockOSThread() |
| + // Never unlock: exiting this goroutine retires this OS thread. The |
| + // parent keeps its privileges; every thread in the exec'd child inherits |
| + // no-new-privileges, including threads created by cgo or the Go runtime. |
| + if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil { |
| + started <- fmt.Errorf("registry: no new privileges: %w", err) |
| + return |
| + } |
| + started <- cmd.Start() |
| + }() |
| + return <-started |
| +} |
| + |
| +func registryChildHarden() error { |
| + // 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 |
| +} |
| + |
| func closeRegistryExecutable() { |
| // fd 3 is the parent's pinned executable, passed only for exec. |
| unix.Close(3) |
diff --git a/internal/host/registrychild_exec_linux_test.go b/internal/host/registrychild_exec_linux_test.go| index 913f55656..0cafb04be 100644 |
| --- a/internal/host/registrychild_exec_linux_test.go |
| +++ b/internal/host/registrychild_exec_linux_test.go |
| @@ -3,19 +3,119 @@ |
| package host |
| import ( |
| + "bytes" |
| + "fmt" |
| "io" |
| "os" |
| "os/exec" |
| "os/user" |
| "path/filepath" |
| + "runtime" |
| "strconv" |
| "strings" |
| + "syscall" |
| "testing" |
| "github.com/carlosframework/platform/internal/registry" |
| "golang.org/x/sys/unix" |
| ) |
| +func TestRegistryChildHardeningHelper(t *testing.T) { |
| + if os.Args[len(os.Args)-1] != "--hardening-probe" { |
| + return |
| + } |
| + 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) |
| + } |
| + threads, err := os.ReadDir("/proc/self/task") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, thread := range threads { |
| + status, err := os.ReadFile(filepath.Join("/proc/self/task", thread.Name(), "status")) |
| + if os.IsNotExist(err) { |
| + continue |
| + } |
| + 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() |
| + before, err := unix.PrctlRetInt(unix.PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + executable, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd := exec.Command(executable, "-test.run=^TestRegistryChildHardeningHelper$", "--", "--hardening-probe") |
| + var output bytes.Buffer |
| + cmd.Stdout, cmd.Stderr = &output, &output |
| + if err := registryChildStart(cmd); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := cmd.Wait(); err != nil { |
| + t.Fatalf("hardening probe: %v: %s", err, output.String()) |
| + } |
| + after, err := unix.PrctlRetInt(unix.PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) |
| + if err != nil || after != before { |
| + t.Fatalf("parent no-new-privileges changed from %d to %d: %v", before, after, err) |
| + } |
| +} |
| + |
| func TestRegistryChildFDHelper(t *testing.T) { |
| if len(os.Args) == 0 || os.Args[len(os.Args)-1] != "--fd-probe" { |
| return |
diff --git a/internal/host/registrychild_exec_other.go b/internal/host/registrychild_exec_other.go| index 1b1ec6472..52e9f696f 100644 |
| --- a/internal/host/registrychild_exec_other.go |
| +++ b/internal/host/registrychild_exec_other.go |
| @@ -15,4 +15,12 @@ func registryChildCloseExec() error { |
| return errors.New("registry: privileged child requires Linux") |
| } |
| +func registryChildStart(cmd *exec.Cmd) error { |
| + return errors.New("registry: privileged child requires Linux") |
| +} |
| + |
| +func registryChildHarden() error { |
| + return errors.New("registry: privileged child requires Linux") |
| +} |
| + |
| func closeRegistryExecutable() {} |
diff --git a/internal/host/registrychild_unix.go b/internal/host/registrychild_unix.go| index ab98a09eb..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 { |
| @@ -76,7 +75,7 @@ func registryChildPipes(cmd *exec.Cmd, uid, gid int) (io.WriteCloser, io.ReadClo |
| input.Close() |
| return nil, nil, nil, err |
| } |
| - if err = cmd.Start(); err != nil { |
| + if err = registryChildStart(cmd); err != nil { |
| input.Close() |
| output.Close() |
| return nil, nil, nil, err |
| @@ -86,6 +85,9 @@ func registryChildPipes(cmd *exec.Cmd, uid, gid int) (io.WriteCloser, io.ReadClo |
| // RunRegistryChild is the private re-exec entry point, before CLI dispatch. |
| func RunRegistryChild(args []string) error { |
| + if err := registryChildHarden(); err != nil { |
| + return err |
| + } |
| closeRegistryExecutable() |
| readOnly := len(args) == 4 && args[3] == "read-only" |
| if len(args) != 3 && !readOnly { |
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/host/stagingcleanup.go b/internal/host/stagingcleanup.go| new file mode 100644 |
| index 000000000..8f631e04d |
| --- /dev/null |
| +++ b/internal/host/stagingcleanup.go |
| @@ -0,0 +1,32 @@ |
| +package host |
| + |
| +import ( |
| + "path/filepath" |
| + |
| + "github.com/carlosframework/platform/internal/safefile" |
| +) |
| + |
| +// reapStagingWrites runs before a pass needs free space for its own writes. |
| +// Production callers run as root, so service-owned scratch is never removed. |
| +func reapStagingWrites(opt, data, etc string, logf func(string, ...any)) { |
| + roots := []string{opt, data} |
| + if opt != "" { |
| + // Descendant traversal refuses mount crossings. Open known writer |
| + // roots separately too, for boxes with a dedicated versions disk. |
| + roots = append(roots, filepath.Join(opt, "versions"), filepath.Join(opt, "staging"), |
| + filepath.Join(systemRootDir(opt), "staging")) |
| + } |
| + if etc != "" { |
| + roots = append(roots, filepath.Join(etc, "carlos"), filepath.Join(etc, "systemd", "system"), |
| + filepath.Join(etc, "systemd", "journald.conf.d"), filepath.Join(etc, "sysusers.d"), |
| + filepath.Join(etc, "tmpfiles.d")) |
| + } |
| + for _, root := range roots { |
| + if root == "" { |
| + continue |
| + } |
| + if err := safefile.Reap(root); err != nil { |
| + logf("staging cleanup: %s: %v", root, err) |
| + } |
| + } |
| +} |
diff --git a/internal/host/stagingcleanup_linux_test.go b/internal/host/stagingcleanup_linux_test.go| new file mode 100644 |
| index 000000000..fc76e499f |
| --- /dev/null |
| +++ b/internal/host/stagingcleanup_linux_test.go |
| @@ -0,0 +1,74 @@ |
| +package host |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestSystemApplyReapsStagingBeforeLockCreation(t *testing.T) { |
| + f := newApplyFixture(t, "stable", "v1") |
| + roots := []string{ |
| + f.a.optDir, |
| + filepath.Join(f.a.optDir, "versions", "acct", "v1"), |
| + f.a.stagingDir(), |
| + filepath.Join(f.a.dataDir, "releases", "healthy"), |
| + filepath.Join(f.a.etcDir, "carlos"), |
| + filepath.Join(f.a.etcDir, "systemd", "system"), |
| + filepath.Join(f.a.etcDir, "systemd", "journald.conf.d"), |
| + filepath.Join(f.a.etcDir, "sysusers.d"), |
| + filepath.Join(f.a.etcDir, "tmpfiles.d"), |
| + } |
| + var stale, retained []string |
| + for _, root := range roots { |
| + for _, tc := range []struct { |
| + name string |
| + old bool |
| + }{ |
| + {".carlos-write-" + strings.Repeat("a", 32), true}, |
| + {".carlos-write-" + strings.Repeat("b", 32), false}, |
| + {".carlos-write-unrelated", true}, |
| + } { |
| + path := filepath.Join(root, tc.name) |
| + if err := os.MkdirAll(path, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(path, "data"), []byte("interrupted"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if tc.old { |
| + old := time.Now().Add(-2 * time.Hour) |
| + for _, entry := range []string{filepath.Join(path, "data"), path} { |
| + if err := os.Chtimes(entry, old, old); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + if tc.old && strings.HasSuffix(tc.name, strings.Repeat("a", 32)) { |
| + stale = append(stale, path) |
| + } else { |
| + retained = append(retained, path) |
| + } |
| + } |
| + } |
| + // Fail at lock creation, proving cleanup needs neither a usable lock nor |
| + // a successful recovery (which independently removes system staging). |
| + if err := os.Mkdir(f.a.lockPath(), 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.a.Run(t.Context(), false); err == nil { |
| + t.Fatal("directory accepted as lock file") |
| + } |
| + for _, path := range stale { |
| + if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| + t.Errorf("stale staging remains at %s: %v", path, err) |
| + } |
| + } |
| + for _, path := range retained { |
| + if _, err := os.Stat(filepath.Join(path, "data")); err != nil { |
| + t.Errorf("retained staging changed at %s: %v", path, err) |
| + } |
| + } |
| +} |
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go| index 73a8902e9..9825fb1b3 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -256,6 +256,7 @@ func (a *systemApplier) file(name string) string { return filepath.Join(a.rootDi |
| // journal is closed, and re-firing OnFailure= over a finished recovery |
| // would only start it again with nothing left to do. |
| func (a *systemApplier) Run(ctx context.Context, recover bool) (retErr error) { |
| + reapStagingWrites(a.optDir, a.dataDir, a.etcDir, a.ops.logf) |
| if err := os.MkdirAll(a.rootDir(), 0o755); err != nil { |
| return err |
| } |
diff --git a/internal/host/units/carlos-system-recover.service b/internal/host/units/carlos-system-recover.service| index 3da2b581b..ea6465c10 100644 |
| --- a/internal/host/units/carlos-system-recover.service |
| +++ b/internal/host/units/carlos-system-recover.service |
| @@ -26,7 +26,7 @@ DefaultDependencies=yes |
| Type=oneshot |
| TimeoutStartSec=10min |
| EnvironmentFile=-/etc/carlos/host.env |
| -ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; }; then exec /opt/carlos/carlos.prev system apply --recover; fi; exec /opt/carlos/carlos system apply --recover' |
| +ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && [ ! -L /data/.moves/floor.min-release ] && { [ ! -e /data/.moves/floor.min-release ] || [ -f /data/.moves/floor.min-release ]; } && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; }; then exec /opt/carlos/carlos.prev system apply --recover; fi; exec /opt/carlos/carlos system apply --recover' |
| ProtectHome=yes |
| PrivateTmp=yes |
diff --git a/internal/host/units/carlos-system-update.service b/internal/host/units/carlos-system-update.service| index 9bc8d25f9..98b61651c 100644 |
| --- a/internal/host/units/carlos-system-update.service |
| +++ b/internal/host/units/carlos-system-update.service |
| @@ -32,7 +32,7 @@ OnFailure=carlos-system-recover.service |
| Type=oneshot |
| TimeoutStartSec=15min |
| EnvironmentFile=-/etc/carlos/host.env |
| -ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; }; then exec /opt/carlos/carlos.prev system apply; fi; exec /opt/carlos/carlos system apply' |
| +ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ] && [ ! -L /data/.moves/floor.min-release ] && { [ ! -e /data/.moves/floor.min-release ] || [ -f /data/.moves/floor.min-release ]; } && { ! [ -s /data/.moves/floor.min-release ] || cmp -s /data/.moves/floor.min-release /opt/carlos/carlos.prev.allowed; }; then exec /opt/carlos/carlos.prev system apply; fi; exec /opt/carlos/carlos system apply' |
| # --- hardening (the carlos-stage.service set) --- |
| ProtectHome=yes |
diff --git a/internal/lease/checkpoint.go b/internal/lease/checkpoint.go| index a7d7d1ec9..573208fdd 100644 |
| --- a/internal/lease/checkpoint.go |
| +++ b/internal/lease/checkpoint.go |
| @@ -26,7 +26,10 @@ func (h *Held) Checkpoint(ctx context.Context, dbPath string) (receipt Checkpoin |
| stop := context.AfterFunc(h.rn.ctx, cancel) |
| defer stop() |
| defer cancel() |
| - store, db := h.newStore(dbPath, 0) |
| + store, db, err := h.newStore(dbPath, 0) |
| + if err != nil { |
| + return receipt, err |
| + } |
| // Short checkpoints need only the explicit L0 sync. Background snapshots |
| // are unnecessary and v0.5.14 closes the DB before joining those monitors. |
| db.MonitorInterval = 0 |
diff --git a/internal/lease/claimgeneration.go b/internal/lease/claimgeneration.go| index d02f72154..003e848d5 100644 |
| --- a/internal/lease/claimgeneration.go |
| +++ b/internal/lease/claimgeneration.go |
| @@ -39,7 +39,11 @@ func (h *Held) BindGeneration(prefix string, check func(context.Context) error) |
| } |
| client := h.replicaClient |
| if client == nil { |
| - client = newReplicaClient(h.leaser.Bucket, clean, h.region) |
| + var err error |
| + client, err = newReplicaClient(h.leaser.Bucket, clean, h.region) |
| + if err != nil { |
| + return err |
| + } |
| } |
| h.replicaClient = &generationClient{client: client, check: check} |
| h.generationPrefix = clean |
| @@ -69,7 +73,11 @@ func (h *Held) BindRoot(check func(context.Context) error) error { |
| } |
| client := h.replicaClient |
| if client == nil { |
| - client = newReplicaClient(h.leaser.Bucket, base, h.region) |
| + var err error |
| + client, err = newReplicaClient(h.leaser.Bucket, base, h.region) |
| + if err != nil { |
| + return err |
| + } |
| } |
| h.replicaClient = &generationClient{client: client, check: check} |
| // The lease's own path marks the binding, so neither BindGeneration nor |
diff --git a/internal/lease/claimgeneration_test.go b/internal/lease/claimgeneration_test.go| index 68b87a421..65692522a 100644 |
| --- a/internal/lease/claimgeneration_test.go |
| +++ b/internal/lease/claimgeneration_test.go |
| @@ -154,7 +154,10 @@ func TestGenerationClientDisablesRemoteRetentionInRealStore(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| - _, db := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + _, db, err := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if db.RetentionEnabled == bound { |
| t.Fatalf("bound=%v retention=%v", bound, db.RetentionEnabled) |
| } |
| @@ -169,7 +172,10 @@ func TestGenerationClientRealRetentionMonitorsDoNotDelete(t *testing.T) { |
| if err := h.BindGeneration(l.Path+"/g-one/", func(context.Context) error { return nil }); err != nil { |
| t.Fatal(err) |
| } |
| - store, db := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + store, db, err := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if store.RetentionEnabled || db.RetentionEnabled { |
| t.Fatal("remote retention enabled") |
| } |
diff --git a/internal/lease/endpoint.go b/internal/lease/endpoint.go| index c2e3dfe78..37deb79a4 100644 |
| --- a/internal/lease/endpoint.go |
| +++ b/internal/lease/endpoint.go |
| @@ -16,11 +16,15 @@ package lease |
| import ( |
| "context" |
| + "errors" |
| "fmt" |
| + "os" |
| + "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" |
| lss3 "github.com/benbjohnson/litestream/s3" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| "github.com/carlosframework/platform/internal/s3compat" |
| ) |
| @@ -59,7 +63,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 +105,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) |
| } |
| @@ -117,8 +121,25 @@ func newLeaserClient(ctx context.Context, region string) (*awss3.Client, string, |
| // the non-AWS default region) from a non-empty Endpoint on its own, and the |
| // signed-payload and content-MD5 defaults NewReplicaClient already ships are |
| // the ones every compatible in litestream's provider table wants. |
| -func newReplicaClient(bucket, prefix, region string) *lss3.ReplicaClient { |
| +func newReplicaClient(bucket, prefix, region string) (*lss3.ReplicaClient, error) { |
| + return newReplicaClientForUID(bucket, prefix, region, os.Geteuid(), rootaws.OffcloudCredentials) |
| +} |
| + |
| +var errRootReplicaSessionToken = errors.ErrUnsupported |
| + |
| +func newReplicaClientForUID(bucket, prefix, region string, euid int, offcloud func() (aws.Credentials, error)) (*lss3.ReplicaClient, error) { |
| c := lss3.NewReplicaClient() |
| + if euid == 0 { |
| + creds, err := offcloud() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if creds.SessionToken != "" { |
| + return nil, errRootReplicaSessionToken |
| + } |
| + c.AccessKeyID = creds.AccessKeyID |
| + c.SecretAccessKey = creds.SecretAccessKey |
| + } |
| c.Bucket = bucket |
| c.Path = prefix |
| c.Region = region |
| @@ -126,7 +147,7 @@ func newReplicaClient(bucket, prefix, region string) *lss3.ReplicaClient { |
| c.Endpoint = ep |
| c.ForcePathStyle = true |
| } |
| - return c |
| + return c, nil |
| } |
| // ReadReplicaClient is newReplicaClient for READERS outside this package — |
| @@ -145,5 +166,5 @@ func ReadReplicaClient(ctx context.Context, bucket, prefix, region string) (*lss |
| if err != nil { |
| return nil, err |
| } |
| - return newReplicaClient(bucket, prefix, region), nil |
| + return newReplicaClient(bucket, prefix, region) |
| } |
diff --git a/internal/lease/endpoint_test.go b/internal/lease/endpoint_test.go| index 68346f032..dd341a561 100644 |
| --- a/internal/lease/endpoint_test.go |
| +++ b/internal/lease/endpoint_test.go |
| @@ -10,17 +10,21 @@ package lease |
| import ( |
| "context" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "io" |
| "net/http" |
| "net/http/httptest" |
| "os" |
| "path/filepath" |
| + "strings" |
| "sync" |
| "testing" |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| "github.com/benbjohnson/litestream" |
| lss3 "github.com/benbjohnson/litestream/s3" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| ) |
| // fakeS3 is just enough of S3 for the leaser's three verbs (GET/PUT/DELETE on |
| @@ -197,9 +201,13 @@ func TestNewLeaserClient_EndpointConfig(t *testing.T) { |
| // LTX files beside lock.json. A replica client that ignored the override while |
| // the leaser honoured it would split one lease across two stores. |
| func TestNewReplicaClient_EndpointConfig(t *testing.T) { |
| + fakeCreds(t) |
| t.Run("override", func(t *testing.T) { |
| t.Setenv("CARLOS_S3_ENDPOINT", "https://fsn1.your-objectstorage.com") |
| - c := newReplicaClient("b", "p", "eu-central-1") |
| + c, err := newReplicaClient("b", "p", "eu-central-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if c.Endpoint != "https://fsn1.your-objectstorage.com" { |
| t.Errorf("Endpoint = %q, want the override", c.Endpoint) |
| } |
| @@ -213,7 +221,10 @@ func TestNewReplicaClient_EndpointConfig(t *testing.T) { |
| t.Run("no override", func(t *testing.T) { |
| t.Setenv("CARLOS_S3_ENDPOINT", "") |
| - c := newReplicaClient("b", "p", "eu-west-1") |
| + c, err := newReplicaClient("b", "p", "eu-west-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if c.Endpoint != "" || c.ForcePathStyle { |
| t.Errorf("Endpoint/ForcePathStyle = %q/%v, want empty/false without an override", c.Endpoint, c.ForcePathStyle) |
| } |
| @@ -338,3 +349,123 @@ func TestReadReplicaClientResolvesAnEmptyRegionLikeRestore(t *testing.T) { |
| t.Errorf("Endpoint = %q ForcePathStyle = %v, want the override applied", c.Endpoint, c.ForcePathStyle) |
| } |
| } |
| + |
| +func TestRootReplicaClientEnvironmentKeepsDefaultChain(t *testing.T) { |
| + for _, region := range []string{"eu-west-1", ""} { |
| + t.Run("region="+region, func(t *testing.T) { |
| + fakeCreds(t) |
| + t.Setenv("AWS_SESSION_TOKEN", "test-session-token") |
| + t.Setenv("AWS_PROFILE", "") |
| + t.Setenv("AWS_DEFAULT_PROFILE", "") |
| + dir := t.TempDir() |
| + marker := filepath.Join(dir, "executed") |
| + config := filepath.Join(dir, "config") |
| + if err := os.WriteFile(config, []byte("[default]\ncredential_process = touch "+marker+"\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("AWS_CONFIG_FILE", config) |
| + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", config) |
| + var mu sync.Mutex |
| + var locations, listings int |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if !strings.Contains(r.Header.Get("Authorization"), "Credential=test-key/") || r.Header.Get("X-Amz-Security-Token") != "test-session-token" { |
| + t.Error("request did not carry the explicit keys and session token") |
| + } |
| + w.Header().Set("Content-Type", "application/xml") |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if r.URL.Query().Has("location") { |
| + locations++ |
| + fmt.Fprint(w, `<LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/">eu-west-1</LocationConstraint>`) |
| + } else { |
| + listings++ |
| + fmt.Fprint(w, `<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>b</Name><KeyCount>0</KeyCount><IsTruncated>false</IsTruncated></ListBucketResult>`) |
| + } |
| + })) |
| + defer srv.Close() |
| + // Keep Endpoint empty to exercise Litestream's bucket-region lookup. |
| + t.Setenv("CARLOS_S3_ENDPOINT", "") |
| + t.Setenv("AWS_ENDPOINT_URL_S3", srv.URL) |
| + c, err := newReplicaClientForUID("b", "p", region, 0, rootaws.OffcloudCredentials) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c.ForcePathStyle = true |
| + if c.AccessKeyID != "" || c.SecretAccessKey != "" { |
| + t.Fatal("environment credentials must use the upstream default chain") |
| + } |
| + if _, err := c.GenerationsV3(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if listings == 0 || (region == "" && locations != 1) || (region != "" && locations != 0) { |
| + t.Fatalf("locations=%d listings=%d for region %q", locations, listings, region) |
| + } |
| + if _, err := os.Stat(marker); !os.IsNotExist(err) { |
| + t.Fatal("credential_process executed") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestNonRootReplicaClientKeepsDefaultProvider(t *testing.T) { |
| + c, err := newReplicaClientForUID("b", "p", "eu-west-1", 1001, func() (aws.Credentials, error) { |
| + t.Fatal("non-root read root credentials") |
| + return aws.Credentials{}, nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.AccessKeyID != "" || c.SecretAccessKey != "" { |
| + t.Fatal("non-root credential selection changed") |
| + } |
| +} |
| + |
| +func TestRootReplicaClientOffcloudCredentials(t *testing.T) { |
| + for _, token := range []string{"", "FILE_TOKEN"} { |
| + t.Run("token="+token, func(t *testing.T) { |
| + body := "[default]\naws_access_key_id=FILE_ACCESS\naws_secret_access_key=FILE_SECRET\n" |
| + if token != "" { |
| + body += "aws_session_token=" + token + "\n" |
| + } |
| + c, err := newReplicaClientForUID("b", "p", "eu-west-1", 0, func() (aws.Credentials, error) { |
| + return rootaws.ParseStaticCredentials([]byte(body)) |
| + }) |
| + if token != "" { |
| + if !errors.Is(err, errRootReplicaSessionToken) || c != nil { |
| + t.Fatalf("token-bearing file: client=%v err=%v", c, err) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.AccessKeyID != "FILE_ACCESS" || c.SecretAccessKey != "FILE_SECRET" { |
| + t.Fatal("off-cloud file keys were not set on the upstream client") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRootReplicaClientWithoutOffcloudKeepsDefaultChain(t *testing.T) { |
| + c, err := newReplicaClientForUID("b", "p", "eu-west-1", 0, func() (aws.Credentials, error) { |
| + return aws.Credentials{}, nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.AccessKeyID != "" || c.SecretAccessKey != "" { |
| + t.Fatal("instance-profile credentials must use the upstream default chain") |
| + } |
| +} |
| + |
| +func TestRootReplicaClientRefusesUnsafeOffcloudCredentials(t *testing.T) { |
| + refused := errors.New("unsafe file") |
| + c, err := newReplicaClientForUID("b", "p", "eu-west-1", 0, func() (aws.Credentials, error) { |
| + return aws.Credentials{}, refused |
| + }) |
| + if !errors.Is(err, refused) || c != nil { |
| + t.Fatalf("unsafe file: client=%v err=%v", c, err) |
| + } |
| +} |
diff --git a/internal/lease/lease.go b/internal/lease/lease.go| index 1de6e5479..61788e219 100644 |
| --- a/internal/lease/lease.go |
| +++ b/internal/lease/lease.go |
| @@ -302,7 +302,10 @@ func (h *Held) Replicate(ctx context.Context, dbPath string, syncInterval time.D |
| stop := context.AfterFunc(h.rn.ctx, cancel) |
| defer stop() |
| defer cancel() |
| - store, db := h.newStore(dbPath, syncInterval) |
| + store, db, err := h.newStore(dbPath, syncInterval) |
| + if err != nil { |
| + return nil, err |
| + } |
| if err := store.Open(opCtx); err != nil { |
| cancel() |
| closeErr := store.Close(opCtx) |
| @@ -317,7 +320,7 @@ func (h *Held) Replicate(ctx context.Context, dbPath string, syncInterval time.D |
| return &Replicator{leaser: h.leaser, store: store, db: db, rn: h.rn}, nil |
| } |
| -func (h *Held) newStore(dbPath string, syncInterval time.Duration) (*litestream.Store, *litestream.DB) { |
| +func (h *Held) newStore(dbPath string, syncInterval time.Duration) (*litestream.Store, *litestream.DB, error) { |
| db := litestream.NewDB(dbPath) |
| // Preserve the activator namespace: sharing the default metadata directory |
| // with another replicator corrupts shadow state even across sequential runs. |
| @@ -325,7 +328,11 @@ func (h *Held) newStore(dbPath string, syncInterval time.Duration) (*litestream. |
| db.MonitorInterval = dbMonitorInterval |
| client := h.replicaClient |
| if client == nil { |
| - client = newReplicaClient(h.leaser.Bucket, h.leaser.Path, h.region) |
| + var err error |
| + client, err = newReplicaClient(h.leaser.Bucket, h.leaser.Path, h.region) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| } |
| db.Replica = litestream.NewReplicaWithClient(db, client) |
| if syncInterval > 0 { |
| @@ -340,7 +347,7 @@ func (h *Held) newStore(dbPath string, syncInterval time.Duration) (*litestream. |
| store.SetRetentionEnabled(false) |
| } |
| store.L0RetentionCheckInterval = l0RetentionCheckInterval |
| - return store, db |
| + return store, db, nil |
| } |
| // Replicator couples an exclusive S3 lease with a running litestream |
| @@ -453,7 +460,10 @@ func Restore(ctx context.Context, dbPath, bucket, prefix, region string) (bool, |
| if err != nil { |
| return false, err |
| } |
| - client := newReplicaClient(bucket, prefix, region) |
| + client, err := newReplicaClient(bucket, prefix, region) |
| + if err != nil { |
| + return false, err |
| + } |
| // A nested row's instance directory may not exist yet the first time it |
| // wakes: the directory is created by the spawn path (isolated-exec |
diff --git a/internal/lease/retention_window_test.go b/internal/lease/retention_window_test.go| index c5235c0e1..ac38c7884 100644 |
| --- a/internal/lease/retention_window_test.go |
| +++ b/internal/lease/retention_window_test.go |
| @@ -16,7 +16,10 @@ func TestNewStorePinsTheRecoveryWindow(t *testing.T) { |
| l := lss3.NewLeaser() |
| l.Path = "instances/x.example/inc" |
| h := &Held{leaser: l, replicaClient: &generationFake{}} |
| - store, db := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + store, db, err := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if store.SnapshotRetention != SnapshotRetention || SnapshotRetention != 7*24*time.Hour { |
| t.Errorf("SnapshotRetention = %v (constant %v), want 7 days", store.SnapshotRetention, SnapshotRetention) |
| } |
| @@ -37,7 +40,10 @@ func TestNewStoreWindowLeavesAGenerationsRetentionOff(t *testing.T) { |
| if err := h.BindGeneration(l.Path+"/g-one/", func(context.Context) error { return nil }); err != nil { |
| t.Fatal(err) |
| } |
| - store, db := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + store, db, err := h.newStore(filepath.Join(t.TempDir(), "db"), time.Second) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| if store.RetentionEnabled || db.RetentionEnabled { |
| t.Fatal("a bound generation's store has retention on") |
| } |
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/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 1030902f2..19472ae4e 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -538,7 +538,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| // Re-signs only the validated channel pointer; the digest-marker helper only reads. |
| {"internal/release/resign.go", "Signer.claimRawResign", "nonrecord", "sel:Put", "30b9a49890bde1ef0f26886c3dd3eb385de4502bc191e1e9a96cd1b75d4cc8e5"}, |
| // Dispatches CLI verb names including instances and sidecars; record writes remain in reviewed handlers. |
| - {"main.go", "main", "key-site", "lit:instances,lit:sidecars", "ccfb66a13871dca203c7d8bdc7e0781aaeb34736855f64e613bf078335a358e6"}, |
| + {"main.go", "main", "key-site", "lit:instances,lit:sidecars", "81804c8368ee204a8d78b15161fb00bce025c03ae3ee7f920db924ab0cf4fc86"}, |
| {"schedule.go", "renderScheduleList", "key-site", "lit:instances", "af6d666083fbbc753eecc98809d3d307344cc15110362a7e93afab573749ea6e"}, |
| {"internal/activator/layout.go", "LayoutInputs.sockets", "key-site", "lit:instances", "1d11ac5ac5be6a42e676e064771141fe8f4367c78cbb7e5c5cca072f40c27c42"}, |
| {"internal/activator/paths.go", "ExecBackend.pathsFor", "key-site", "lit:instances", "cbc01865e27934cb38baab9329a0867b899149531b4706a533a6956afc7c71b3"}, |
| @@ -627,7 +627,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/fleetapi/fleetapi.go", "store.Put", "record-transport", "def:Put", "68f7424cbbaf8fffde48429ae29198cf6e4fcf768c512657b305224febc11186"}, |
| {"internal/genselect/select.go", "requestLocation", "key-site", "lit:instances", "12ef5b8f94be7575ec7ab5f169736320ee6938440387fe5ea99b96f2a528cca0"}, |
| {"internal/host/bootstrap.go", "const tmpfilesConf", "key-site", "lit:instances", "71d83f240cb1c3ab6f9dd2954b1f3e93b90133362ec12193746f60e75aeced7d"}, |
| - {"internal/host/bootstrap.go", "bootstrapWith", "key-site", "lit:instances", "f75c2a0360970669bc72ef73285ee530dbb8ddc4457ddd73ad91f4939731b75c"}, |
| + {"internal/host/bootstrap.go", "bootstrapWith", "key-site", "lit:instances", "36ff18685872be8adfbf02d05676990bf9097c0fcc9695d3cae2f905ec59665c"}, |
| {"internal/host/claimretire_transport.go", "retirementAcknowledgement", "nonrecord", "sel:Put", "5c09795fbe9b952436d16bcd58e846ac27e9dbcf959fcf1cd6c432af0f3ee416"}, |
| {"internal/host/errship.go", "errshipFlush", "nonrecord", "sel:Put", "82831c08563eab44de07c1c69752555eafe60c70dbf9521d9a4711bc5c104a6b"}, |
| {"internal/host/fleetbind.go", "WriteFleetBinding", "nonrecord", "sel:Put", "44ab984c2c9b30abcc938132118f61a6922ccc0cd40ed2b6cb61c7654aa0a1c3"}, |
diff --git a/internal/registry/remote.go b/internal/registry/remote.go| index 04a5c6b1d..46f507315 100644 |
| --- a/internal/registry/remote.go |
| +++ b/internal/registry/remote.go |
| @@ -66,6 +66,7 @@ func OpenRemote(path string, input io.WriteCloser, output io.ReadCloser, wait fu |
| // OpenRemoteReconnect reads service-controlled answers, not an authority boundary. |
| // Failed or cancelled mutations are never retried; the next call reconnects. |
| +// Canceled does not mean not applied: cancellation can race a committed mutation. |
| func OpenRemoteReconnect(path string, connect func() (io.WriteCloser, io.ReadCloser, func() error, error)) (*Registry, error) { |
| c := &remoteClient{connect: connect} |
| if err := c.reconnect(context.Background()); err != nil { |
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..c8229d77e |
| --- /dev/null |
| +++ b/internal/rootaws/config.go |
| @@ -0,0 +1,100 @@ |
| +// 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" |
| + |
| +// OffcloudCredentials returns the validated root file credentials only when |
| +// complete environment keys do not take precedence. It never contacts IMDS. |
| +func OffcloudCredentials() (aws.Credentials, error) { |
| + return offcloudCredentials(credentialsPath) |
| +} |
| + |
| +func offcloudCredentials(path string) (aws.Credentials, error) { |
| + if envAccessKey() != "" && envSecretKey() != "" { |
| + return aws.Credentials{}, nil |
| + } |
| + creds, err := readStaticCredentials(path) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return aws.Credentials{}, nil |
| + } |
| + return creds, err |
| +} |
| + |
| +// 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 |
| + } |
| + // Complete environment keys are used as given and the fallback file is not |
| + // opened, so a file nobody uses cannot make loading fail. |
| + access, secret := envAccessKey(), envSecretKey() |
| + envKeys := access != "" && secret != "" |
| + var static aws.Credentials |
| + var refused error |
| + if !envKeys { |
| + var err error |
| + static, err = readStaticCredentials(path) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + log.Printf("ALERT: refusing unsafe offcloud credentials %s: %v", path, err) |
| + refused = fmt.Errorf("root AWS credentials: %w", err) |
| + } |
| + if err != nil { |
| + static = aws.Credentials{} |
| + } |
| + } |
| + // Append last so callers cannot restore file loading or select a profile. |
| + opts = append(opts, awsconfig.WithSharedConfigFiles([]string{}), |
| + awsconfig.WithSharedCredentialsFiles([]string{}), awsconfig.WithSharedConfigProfile("")) |
| + switch { |
| + case refused != nil: |
| + // Opening a store must not abort adoption before it uses AWS. Refuse |
| + // retrieval instead, without falling through to another identity. |
| + opts = append(opts, awsconfig.WithCredentialsProvider(aws.CredentialsProviderFunc( |
| + func(context.Context) (aws.Credentials, error) { return aws.Credentials{}, refused }))) |
| + case envKeys: |
| + opts = append(opts, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( |
| + access, secret, os.Getenv("AWS_SESSION_TOKEN")))) |
| + case static.AccessKeyID != "": |
| + 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..f54defb37 |
| --- /dev/null |
| +++ b/internal/rootaws/config_test.go |
| @@ -0,0 +1,297 @@ |
| +package rootaws |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| + "github.com/aws/aws-sdk-go-v2/credentials" |
| +) |
| + |
| +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 TestParseStaticCredentialsSDKSemantics(t *testing.T) { |
| + parse := func(access, secret string) (string, string, error) { |
| + got, err := ParseStaticCredentials([]byte("[default]\naws_access_key_id = " + access + "\naws_secret_access_key = " + secret + "\n")) |
| + return got.AccessKeyID, got.SecretAccessKey, err |
| + } |
| + for name, tc := range map[string]struct{ access, secret, wantAccess, wantSecret string }{ |
| + "embedded-hash": {"ACC#ESS", "SEC#RET", "ACC#ESS", "SEC#RET"}, |
| + "embedded-semicolon": {"ACC;ESS", "SEC;RET", "ACC;ESS", "SEC;RET"}, |
| + "space-hash-comment": {"ACCESS # note", "SECRET ; note", "ACCESS", "SECRET"}, |
| + "tab-comment": {"ACCESS\t# note", "SECRET\t; note", "ACCESS", "SECRET"}, |
| + "double-quotes": {`"ACCESS"`, `"SEC#RET"`, "ACCESS", "SEC#RET"}, |
| + "single-quotes": {`'ACCESS'`, `'SEC;RET'`, "ACCESS", "SEC;RET"}, |
| + "quotes-and-comments": {`"ACCESS" # note`, `'SECRET' ; note`, "ACCESS", "SECRET"}, |
| + "one-pair-only": {`""ACCESS""`, `'"SECRET"'`, `"ACCESS"`, `"SECRET"`}, |
| + "mismatched-quotes": {`"ACCESS'`, `'SECRET`, `"ACCESS'`, `'SECRET`}, |
| + "lone-quote": {`"`, `'`, `"`, `'`}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + access, secret, err := parse(tc.access, tc.secret) |
| + if err != nil || access != tc.wantAccess || secret != tc.wantSecret { |
| + t.Fatalf("parsed %q %q, %v", access, secret, err) |
| + } |
| + }) |
| + } |
| + for name, body := range map[string]string{ |
| + "empty-double-quotes": "[default]\naws_access_key_id = \"\"\naws_secret_access_key = SECRET\n", |
| + "empty-single-quotes": "[default]\naws_access_key_id = ACCESS\naws_secret_access_key = ''\n", |
| + "comment-only-value": "[default]\naws_access_key_id = # note\naws_secret_access_key = SECRET\n", |
| + "quoted-spaces": "[default]\naws_access_key_id = \"AC CESS\"\naws_secret_access_key = SECRET\n", |
| + "process-with-quotes": "[default]\naws_access_key_id = ACCESS\naws_secret_access_key = SECRET\n\"credential_process\" = bad\n", |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + if _, err := ParseStaticCredentials([]byte(body)); err == nil { |
| + t.Fatal("accepted invalid static credentials") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRootConfigEnvironmentKeysIgnoreMalformedFile(t *testing.T) { |
| + for _, token := range []string{"", "ENV_TOKEN"} { |
| + t.Run("token="+token, func(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", token) |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + if err := os.WriteFile(path, []byte("[default]\ncredential_process = touch /never-run\n[other]\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg, err := loadRootConfig(context.Background(), path) |
| + if err != nil { |
| + t.Fatalf("malformed unused file made config fatal: %v", err) |
| + } |
| + got, err := cfg.Credentials.Retrieve(context.Background()) |
| + if err != nil || got.AccessKeyID != "ENV_ACCESS" || got.SecretAccessKey != "ENV_SECRET" || got.SessionToken != token { |
| + t.Fatalf("environment credentials failed: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRootConfigIncompleteEnvironmentKeysStillValidateFile(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS") |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + if err := os.WriteFile(path, []byte("[default]\ncredential_process = bad\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg, err := loadRootConfig(context.Background(), path) |
| + if err != nil { |
| + t.Fatalf("unused unsafe credentials made config loading fatal: %v", err) |
| + } |
| + if _, err := cfg.Credentials.Retrieve(context.Background()); err == nil { |
| + t.Fatal("malformed file accepted without a complete environment key pair") |
| + } |
| +} |
| + |
| +func TestRootConfigEnvironmentKeysBeatCallerCredentials(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "ENV_SECRET") |
| + caller := awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("CALLER", "CALLER_SECRET", "")) |
| + cfg, err := loadRootConfig(context.Background(), filepath.Join(t.TempDir(), "absent"), caller) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := cfg.Credentials.Retrieve(context.Background()) |
| + if err != nil || got.AccessKeyID != "ENV_ACCESS" || got.SecretAccessKey != "ENV_SECRET" { |
| + t.Fatalf("caller credentials overrode the environment: %v", err) |
| + } |
| +} |
| + |
| +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) |
| + } |
| +} |
| + |
| +func TestRootConfigUnsafeFileRefusesOnUseWithoutFallback(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + if err := os.WriteFile(path, []byte("[default]\ncredential_process=bad\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + fallback := credentials.NewStaticCredentialsProvider("OTHER_IDENTITY", "SECRET", "") |
| + cfg, err := loadRootConfig(context.Background(), path, awsconfig.WithCredentialsProvider(fallback)) |
| + if err != nil { |
| + t.Fatalf("opening an unused store would abort adoption: %v", err) |
| + } |
| + if got, err := cfg.Credentials.Retrieve(context.Background()); err == nil || got.AccessKeyID != "" { |
| + t.Fatal("unsafe file fell through to another identity") |
| + } |
| +} |
| + |
| +func TestRootCredentialsOffcloudSelection(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + got, err := offcloudCredentials(path) |
| + if err != nil || got.HasKeys() { |
| + t.Fatalf("absent file: credentials=%v err=%v", got.HasKeys(), err) |
| + } |
| + if err := os.WriteFile(path, []byte("[default]\ncredential_process=bad\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := offcloudCredentials(path); err == nil { |
| + t.Fatal("unsafe file was accepted") |
| + } |
| + t.Run("incomplete environment keys", func(t *testing.T) { |
| + t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS") |
| + if _, err := offcloudCredentials(path); err == nil { |
| + t.Fatal("incomplete environment keys bypassed the unsafe file") |
| + } |
| + }) |
| + for _, names := range [][2]string{{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"}, {"AWS_ACCESS_KEY", "AWS_SECRET_KEY"}} { |
| + t.Run(names[0], func(t *testing.T) { |
| + t.Setenv(names[0], "ENV_ACCESS") |
| + t.Setenv(names[1], "ENV_SECRET") |
| + t.Setenv("AWS_SESSION_TOKEN", "ENV_TOKEN") |
| + got, err := offcloudCredentials(path) |
| + if err != nil || got.HasKeys() || got.SessionToken != "" { |
| + t.Fatalf("environment keys must bypass file and leave static fields unset: %v", err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/rootaws/credentials.go b/internal/rootaws/credentials.go| new file mode 100644 |
| index 000000000..d5993a946 |
| --- /dev/null |
| +++ b/internal/rootaws/credentials.go |
| @@ -0,0 +1,71 @@ |
| +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 |
| + } |
| + // Same order as the SDK: cut the inline comment, then trim, then |
| + // remove one matching pair of quotes. |
| + key, value, found := strings.Cut(trimInlineComment(line), "=") |
| + key, value = strings.TrimSpace(key), unquote(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 |
| +} |
| + |
| +// trimInlineComment matches the SDK's property comments: only a # or ; after |
| +// a space or tab starts one, so values may contain either character. |
| +func trimInlineComment(s string) string { |
| + for _, marker := range []string{" #", " ;", "\t#", "\t;"} { |
| + s, _, _ = strings.Cut(s, marker) |
| + } |
| + return s |
| +} |
| + |
| +func unquote(s string) string { |
| + if len(s) >= 2 && (s[0] == '"' && s[len(s)-1] == '"' || s[0] == '\'' && s[len(s)-1] == '\'') { |
| + return s[1 : len(s)-1] |
| + } |
| + return s |
| +} |
diff --git a/internal/rootaws/credentials_linux_test.go b/internal/rootaws/credentials_linux_test.go| new file mode 100644 |
| index 000000000..80dcb739e |
| --- /dev/null |
| +++ b/internal/rootaws/credentials_linux_test.go |
| @@ -0,0 +1,138 @@ |
| +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) |
| + cfg, err := loadRootConfig(context.Background(), path) |
| + if err != nil { |
| + t.Fatalf("unused unsafe credentials made config loading fatal: %v", err) |
| + } |
| + if _, err := cfg.Credentials.Retrieve(context.Background()); err == nil { |
| + t.Fatal("unsafe file accepted") |
| + } |
| + if !strings.Contains(logs.String(), "ALERT:") { |
| + t.Fatal("unsafe file refusal did not alert") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestRootCredentialsEnvironmentKeysIgnoreServiceOwnedFile(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + t.Setenv("AWS_ACCESS_KEY_ID", "ENV_ACCESS") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "ENV_SECRET") |
| + path := filepath.Join(t.TempDir(), "credentials") |
| + if err := os.WriteFile(path, []byte("[default]\naws_access_key_id=FILE\naws_secret_access_key=SECRET\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if os.Geteuid() == 0 { |
| + if err := os.Chown(path, 65534, 65534); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // Without environment keys the same file is refused, so the file is unsafe. |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "") |
| + unsafeConfig, err := loadRootConfig(context.Background(), path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := unsafeConfig.Credentials.Retrieve(context.Background()); err == nil { |
| + t.Fatal("service-owned fixture was not unsafe") |
| + } |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "ENV_SECRET") |
| + cfg, err := loadRootConfig(context.Background(), path) |
| + if err != nil { |
| + t.Fatalf("service-owned unused file made config fatal: %v", err) |
| + } |
| + got, err := cfg.Credentials.Retrieve(context.Background()) |
| + if err != nil || got.AccessKeyID != "ENV_ACCESS" || got.SecretAccessKey != "ENV_SECRET" { |
| + t.Fatalf("environment credentials failed: %v", err) |
| + } |
| +} |
| + |
| +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) |
| + } |
| + fileCreds, err := offcloudCredentials(path) |
| + if err != nil || fileCreds.AccessKeyID != "STATIC" || fileCreds.SecretAccessKey != "SECRET" || fileCreds.SessionToken != "TOKEN" { |
| + t.Fatalf("off-cloud file credentials failed: %v", 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/environment.go b/internal/rootaws/environment.go| new file mode 100644 |
| index 000000000..343ea793b |
| --- /dev/null |
| +++ b/internal/rootaws/environment.go |
| @@ -0,0 +1,97 @@ |
| +package rootaws |
| + |
| +import ( |
| + "log" |
| + "os" |
| + "strings" |
| +) |
| + |
| +// A child of a device cannot exist, even if a service user creates directories. |
| +const disabledSharedFile = "/dev/null/carlos-root-aws" |
| + |
| +// rootScrubbedEnvironment names every variable that selects a file, a profile, |
| +// an endpoint or a trust store the SDK would use on root's behalf. Root's own |
| +// credentials come from the environment keys, the instance profile, or the |
| +// validated off-cloud file, never from these. |
| +var rootScrubbedEnvironment = []string{ |
| + "AWS_CONFIG_FILE", |
| + "AWS_SHARED_CREDENTIALS_FILE", |
| + "AWS_PROFILE", |
| + "AWS_DEFAULT_PROFILE", |
| + "AWS_SDK_LOAD_CONFIG", |
| + "AWS_CA_BUNDLE", |
| + "AWS_WEB_IDENTITY_TOKEN_FILE", |
| + "AWS_ROLE_ARN", |
| + "AWS_ROLE_SESSION_NAME", |
| + "AWS_CONTAINER_CREDENTIALS_FULL_URI", |
| + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", |
| + "AWS_CONTAINER_AUTHORIZATION_TOKEN", |
| + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", |
| + "AWS_ENDPOINT_URL", |
| + "AWS_EC2_METADATA_SERVICE_ENDPOINT", |
| + "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE", |
| + // Go's TLS trust store reads these unvalidated; AWS transports use it. |
| + "SSL_CERT_FILE", |
| + "SSL_CERT_DIR", |
| +} |
| + |
| +// Service-specific endpoint overrides (AWS_ENDPOINT_URL_S3 and the rest) |
| +// are matched by prefix. |
| +const rootScrubbedEndpointPrefix = "AWS_ENDPOINT_URL_" |
| + |
| +// ScrubEnvironment must run before dispatch or goroutines in each executable. |
| +// It also protects SDK loads in dependencies that do not call this package. |
| +func ScrubEnvironment() { |
| + scrubEnvironment(os.Geteuid()) |
| +} |
| + |
| +func scrubEnvironment(euid int) { |
| + if euid != 0 { |
| + return |
| + } |
| + changed := false |
| + keys := append([]string(nil), rootScrubbedEnvironment...) |
| + for _, kv := range os.Environ() { |
| + if key, _, _ := strings.Cut(kv, "="); strings.HasPrefix(key, rootScrubbedEndpointPrefix) { |
| + keys = append(keys, key) |
| + } |
| + } |
| + for _, key := range keys { |
| + if _, set := os.LookupEnv(key); set { |
| + changed = true |
| + } |
| + _ = os.Unsetenv(key) |
| + } |
| + // A proxy may be needed off-cloud, so it stays, but it never answers for |
| + // the instance metadata service: that is where root's EC2 credentials |
| + // come from. |
| + for _, key := range []string{"NO_PROXY", "no_proxy"} { |
| + _ = os.Setenv(key, withIMDSBypass(os.Getenv(key))) |
| + } |
| + // Root never reads shared config, not even under its own HOME: a |
| + // root-owned HOME can still hold a .aws the service user can write. |
| + _ = os.Setenv("AWS_CONFIG_FILE", disabledSharedFile) |
| + _ = os.Setenv("AWS_SHARED_CREDENTIALS_FILE", disabledSharedFile) |
| + if changed { |
| + log.Print("ALERT: root AWS file, profile, endpoint and trust-store environment overrides ignored") |
| + } |
| +} |
| + |
| +// imdsHosts are the IPv4 and IPv6 instance metadata addresses. |
| +var imdsHosts = []string{"169.254.169.254", "fd00:ec2::254"} |
| + |
| +func withIMDSBypass(noProxy string) string { |
| + entries := strings.FieldsFunc(noProxy, func(r rune) bool { return r == ',' }) |
| + for _, host := range imdsHosts { |
| + found := false |
| + for _, entry := range entries { |
| + if strings.TrimSpace(entry) == host { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + entries = append(entries, host) |
| + } |
| + } |
| + return strings.Join(entries, ",") |
| +} |
diff --git a/internal/rootaws/environment_test.go b/internal/rootaws/environment_test.go| new file mode 100644 |
| index 000000000..3d4dc9774 |
| --- /dev/null |
| +++ b/internal/rootaws/environment_test.go |
| @@ -0,0 +1,151 @@ |
| +package rootaws |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| +) |
| + |
| +func TestScrubEnvironment(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + euid int |
| + }{ |
| + {"root", 0}, |
| + {"non-root", 1001}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + for _, key := range rootScrubbedEnvironment { |
| + t.Setenv(key, "untrusted") |
| + } |
| + t.Setenv("AWS_ENDPOINT_URL_S3", "untrusted") |
| + t.Setenv("AWS_ENDPOINT_URL_STS", "untrusted") |
| + t.Setenv("AWS_ACCESS_KEY_ID", "KEEP") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "KEEP_SECRET") |
| + t.Setenv("AWS_SESSION_TOKEN", "KEEP_TOKEN") |
| + var logs bytes.Buffer |
| + old := log.Writer() |
| + log.SetOutput(&logs) |
| + defer log.SetOutput(old) |
| + scrubEnvironment(tc.euid) |
| + for _, key := range rootScrubbedEnvironment { |
| + got, set := os.LookupEnv(key) |
| + want := "untrusted" |
| + if tc.euid == 0 { |
| + want = "" |
| + if key == "AWS_CONFIG_FILE" || key == "AWS_SHARED_CREDENTIALS_FILE" { |
| + want = disabledSharedFile |
| + } |
| + } |
| + if got != want || set != (want != "") { |
| + t.Errorf("%s = %q, set=%v; want %q", key, got, set, want) |
| + } |
| + } |
| + for _, key := range []string{"AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL_STS"} { |
| + if _, set := os.LookupEnv(key); set != (tc.euid != 0) { |
| + t.Errorf("%s set=%v for euid %d", key, set, tc.euid) |
| + } |
| + } |
| + wantLogs := 0 |
| + if tc.euid == 0 { |
| + wantLogs = 1 |
| + } |
| + if strings.Count(logs.String(), "ALERT:") != wantLogs { |
| + t.Fatalf("alert count: %q", logs.String()) |
| + } |
| + if os.Getenv("AWS_ACCESS_KEY_ID") != "KEEP" || os.Getenv("AWS_SECRET_ACCESS_KEY") != "KEEP_SECRET" || os.Getenv("AWS_SESSION_TOKEN") != "KEEP_TOKEN" { |
| + t.Fatal("environment credentials changed") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestScrubEnvironmentDisablesSharedFilesWithNothingSet(t *testing.T) { |
| + for _, key := range rootScrubbedEnvironment { |
| + t.Setenv(key, "") |
| + os.Unsetenv(key) |
| + } |
| + var logs bytes.Buffer |
| + old := log.Writer() |
| + log.SetOutput(&logs) |
| + defer log.SetOutput(old) |
| + scrubEnvironment(0) |
| + if os.Getenv("AWS_CONFIG_FILE") != disabledSharedFile || os.Getenv("AWS_SHARED_CREDENTIALS_FILE") != disabledSharedFile { |
| + t.Fatal("shared files not disabled for root") |
| + } |
| + if logs.Len() != 0 { |
| + t.Fatalf("alert without overrides: %q", logs.String()) |
| + } |
| +} |
| + |
| +func TestScrubEnvironmentBlocksDependencySharedConfig(t *testing.T) { |
| + isolatedAWSEnv(t) |
| + dir := t.TempDir() |
| + marker := filepath.Join(dir, "executed") |
| + // The planted config sits under HOME, where the SDK looks by default, and |
| + // is also named explicitly. |
| + if err := os.Mkdir(filepath.Join(dir, ".aws"), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + path := filepath.Join(dir, ".aws", "config") |
| + if err := os.WriteFile(path, []byte("[default]\ncredential_process = touch "+marker+"\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, ".aws", "credentials"), []byte("[default]\ncredential_process = touch "+marker+"\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("HOME", dir) |
| + t.Setenv("AWS_CONFIG_FILE", path) |
| + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", path) |
| + t.Setenv("AWS_PROFILE", "default") |
| + t.Setenv("AWS_DEFAULT_PROFILE", "default") |
| + t.Setenv("AWS_SDK_LOAD_CONFIG", "1") |
| + scrubEnvironment(0) |
| + // This deliberately bypasses rootaws, as a third-party SDK call would. |
| + cfg, err := awsconfig.LoadDefaultConfig(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := cfg.Credentials.Retrieve(context.Background()); err == nil { |
| + t.Fatal("unexpected credentials") |
| + } |
| + if _, err := os.Stat(marker); !os.IsNotExist(err) { |
| + t.Fatal("credential_process executed") |
| + } |
| + if _, err := os.Stat(disabledSharedFile); err == nil { |
| + t.Fatal("disabled path exists") |
| + } |
| +} |
| + |
| +func TestScrubEnvironmentKeepsProxyButBypassesIMDS(t *testing.T) { |
| + for _, tc := range []struct{ in, want string }{ |
| + {"", "169.254.169.254,fd00:ec2::254"}, |
| + {"example.com", "example.com,169.254.169.254,fd00:ec2::254"}, |
| + {"169.254.169.254,example.com", "169.254.169.254,example.com,fd00:ec2::254"}, |
| + } { |
| + t.Setenv("HTTP_PROXY", "http://proxy.example.com:3128") |
| + t.Setenv("NO_PROXY", tc.in) |
| + t.Setenv("no_proxy", tc.in) |
| + scrubEnvironment(0) |
| + if os.Getenv("HTTP_PROXY") != "http://proxy.example.com:3128" { |
| + t.Fatal("proxy removed") |
| + } |
| + if got := os.Getenv("NO_PROXY"); got != tc.want { |
| + t.Errorf("NO_PROXY(%q) = %q, want %q", tc.in, got, tc.want) |
| + } |
| + if got := os.Getenv("no_proxy"); got != tc.want { |
| + t.Errorf("no_proxy(%q) = %q, want %q", tc.in, got, tc.want) |
| + } |
| + } |
| + t.Setenv("NO_PROXY", "") |
| + scrubEnvironment(1001) |
| + if os.Getenv("NO_PROXY") != "" { |
| + t.Fatal("non-root NO_PROXY changed") |
| + } |
| +} |
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| new file mode 100644 |
| index 000000000..5d8670629 |
| --- /dev/null |
| +++ b/internal/safefile/reap_linux.go |
| @@ -0,0 +1,179 @@ |
| +package safefile |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "io" |
| + "os" |
| + "strings" |
| + "time" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +// Reap removes abandoned Linux writer staging directories below dir. Traversal |
| +// stays on the opened filesystem and never follows symlinks. Only private |
| +// directories owned by this euid, older than an hour, and containing at most |
| +// the writer's single regular data file are eligible. Missing roots are harmless. |
| +func Reap(dir string) error { |
| + fd, err := openDir(dir, false, 0) |
| + if errors.Is(err, unix.ENOENT) { |
| + return nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + err = reapTree(os.NewFile(uintptr(fd), dir), time.Now().Add(-time.Hour), 0) |
| + if err != nil { |
| + return &os.PathError{Op: "reap staging", Path: dir, Err: err} |
| + } |
| + return nil |
| +} |
| + |
| +func reapStageName(name string) bool { |
| + const prefix = ".carlos-write-" |
| + if !strings.HasPrefix(name, prefix) || len(name) != len(prefix)+32 { |
| + return false |
| + } |
| + for _, c := range name[len(prefix):] { |
| + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +func reapOpenAt(dir int, name string, flags uint64) (int, error) { |
| + return unix.Openat2(dir, name, &unix.OpenHow{ |
| + Flags: flags | unix.O_CLOEXEC | unix.O_NOFOLLOW, |
| + Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | |
| + unix.RESOLVE_NO_XDEV, |
| + }) |
| +} |
| + |
| +func reapSkip(err error) bool { |
| + return errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ELOOP) || |
| + errors.Is(err, unix.EXDEV) || errors.Is(err, unix.ENOTDIR) |
| +} |
| + |
| +func reapTree(dir *os.File, cutoff time.Time, depth int) error { |
| + remaining := 100000 |
| + return reapTreeBudget(dir, cutoff, depth, &remaining) |
| +} |
| + |
| +func reapTreeBudget(dir *os.File, cutoff time.Time, depth int, remaining *int) error { |
| + defer dir.Close() |
| + if depth > 128 { |
| + return fmt.Errorf("staging traversal exceeds 128 directories") |
| + } |
| + var failures error |
| + for { |
| + entries, err := dir.ReadDir(128) |
| + if err != nil && err != io.EOF { |
| + return errors.Join(failures, err) |
| + } |
| + for _, entry := range entries { |
| + if *remaining <= 0 { |
| + return errors.Join(failures, fmt.Errorf("staging traversal exceeds 100000 entries")) |
| + } |
| + *remaining -= 1 |
| + // Type is only a fast rejection; openat2 validates the live entry. |
| + if entry.Type() != 0 && !entry.IsDir() { |
| + continue |
| + } |
| + name := entry.Name() |
| + if strings.HasPrefix(name, ".carlos-write-") && !reapStageName(name) { |
| + continue |
| + } |
| + fd, e := reapOpenAt(int(dir.Fd()), name, unix.O_RDONLY|unix.O_DIRECTORY) |
| + if reapSkip(e) { |
| + continue |
| + } |
| + if e != nil { |
| + failures = errors.Join(failures, e) |
| + continue |
| + } |
| + child := os.NewFile(uintptr(fd), name) |
| + if reapStageName(name) { |
| + e = reapStage(int(dir.Fd()), name, child, cutoff) |
| + child.Close() |
| + } else { |
| + e = reapTreeBudget(child, cutoff, depth+1, remaining) |
| + } |
| + if e != nil { |
| + failures = errors.Join(failures, e) |
| + if *remaining <= 0 { |
| + return failures |
| + } |
| + } |
| + } |
| + if err == io.EOF { |
| + return failures |
| + } |
| + } |
| +} |
| + |
| +func reapOlder(st *unix.Stat_t, cutoff time.Time) bool { |
| + return time.Unix(st.Mtim.Sec, st.Mtim.Nsec).Before(cutoff) |
| +} |
| + |
| +func reapSameEntry(parent int, name string, pinned *unix.Stat_t) bool { |
| + var current unix.Stat_t |
| + return unix.Fstatat(parent, name, ¤t, unix.AT_SYMLINK_NOFOLLOW) == nil && |
| + current.Dev == pinned.Dev && current.Ino == pinned.Ino && |
| + current.Mode == pinned.Mode && current.Uid == pinned.Uid && |
| + current.Nlink == pinned.Nlink |
| +} |
| + |
| +func reapStage(parent int, name string, dir *os.File, cutoff time.Time) error { |
| + fd := int(dir.Fd()) |
| + var stage unix.Stat_t |
| + if err := unix.Fstat(fd, &stage); err != nil { |
| + return err |
| + } |
| + if stage.Uid != uint32(os.Geteuid()) || stage.Mode&0o777 != 0o700 || |
| + !reapOlder(&stage, cutoff) { |
| + return nil |
| + } |
| + entries, err := dir.ReadDir(2) |
| + if err != nil && err != io.EOF { |
| + return err |
| + } |
| + if len(entries) > 1 || len(entries) == 1 && entries[0].Name() != "data" { |
| + return nil |
| + } |
| + if len(entries) == 1 { |
| + data, err := reapOpenAt(fd, "data", unix.O_PATH) |
| + if reapSkip(err) { |
| + return nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + defer unix.Close(data) |
| + var st unix.Stat_t |
| + if err := unix.Fstat(data, &st); err != nil { |
| + return err |
| + } |
| + if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 || |
| + st.Uid != uint32(os.Geteuid()) || !reapOlder(&st, cutoff) || |
| + !reapSameEntry(parent, name, &stage) || !reapSameEntry(fd, "data", &st) { |
| + return nil |
| + } |
| + if err := unix.Unlinkat(fd, "data", 0); err != nil && !errors.Is(err, unix.ENOENT) { |
| + return err |
| + } |
| + } |
| + // 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 |
| + } |
| + err = unix.Unlinkat(parent, name, unix.AT_REMOVEDIR) |
| + if errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENOTEMPTY) || errors.Is(err, unix.ENOTDIR) { |
| + return nil |
| + } |
| + return err |
| +} |
diff --git a/internal/safefile/reap_linux_test.go b/internal/safefile/reap_linux_test.go| new file mode 100644 |
| index 000000000..bd60238e3 |
| --- /dev/null |
| +++ b/internal/safefile/reap_linux_test.go |
| @@ -0,0 +1,230 @@ |
| +package safefile |
| + |
| +import ( |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +func reapTestStage(t *testing.T, parent, name string, data bool) string { |
| + t.Helper() |
| + path := filepath.Join(parent, name) |
| + if err := os.MkdirAll(path, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if data { |
| + if err := os.WriteFile(filepath.Join(path, "data"), []byte("abandoned"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + reapTestAge(t, filepath.Join(path, "data")) |
| + } |
| + reapTestAge(t, path) |
| + return path |
| +} |
| + |
| +func reapTestAge(t *testing.T, path string) { |
| + t.Helper() |
| + old := time.Now().Add(-2 * time.Hour) |
| + if err := os.Chtimes(path, old, old); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestReapStaleWriterStages(t *testing.T) { |
| + root := t.TempDir() |
| + var removed, retained []string |
| + for i, parent := range []string{root, filepath.Join(root, "versions", "v1"), filepath.Join(root, "staging")} { |
| + removed = append(removed, reapTestStage(t, parent, fmt.Sprintf(".carlos-write-%032x", i+1), true)) |
| + } |
| + removed = append(removed, reapTestStage(t, root, ".carlos-write-"+strings.Repeat("e", 32), false)) |
| + fresh := reapTestStage(t, root, ".carlos-write-"+strings.Repeat("f", 32), true) |
| + now := time.Now() |
| + if err := os.Chtimes(fresh, now, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + retained = append(retained, fresh) |
| + active := reapTestStage(t, root, ".carlos-write-"+strings.Repeat("a", 32), true) |
| + if err := os.Chtimes(filepath.Join(active, "data"), now, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + retained = append(retained, active) |
| + for _, name := range []string{"ordinary", ".carlos-write-abc", ".carlos-write-" + strings.Repeat("A", 32), ".carlos-write-" + strings.Repeat("0", 33), ".carlos-write-" + strings.Repeat("g", 32)} { |
| + retained = append(retained, reapTestStage(t, root, name, true)) |
| + } |
| + if err := Reap(root); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, path := range removed { |
| + if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| + t.Errorf("stale stage remains: %s: %v", path, err) |
| + } |
| + } |
| + for _, path := range retained { |
| + if _, err := os.Stat(filepath.Join(path, "data")); err != nil { |
| + t.Errorf("retained stage changed: %s: %v", path, err) |
| + } |
| + } |
| + if err := Reap(filepath.Join(root, "missing")); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestReapRefusesUnsafeStages(t *testing.T) { |
| + for _, kind := range []string{"stage-symlink", "data-symlink", "hardlink", "fifo", "nested", "extra", "writable", "foreign-owner"} { |
| + t.Run(kind, func(t *testing.T) { |
| + root := t.TempDir() |
| + outside := t.TempDir() |
| + victim := filepath.Join(outside, "victim") |
| + if err := os.WriteFile(victim, []byte("untouched"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + name := ".carlos-write-" + strings.Repeat("b", 32) |
| + path := reapTestStage(t, root, name, false) |
| + var err error |
| + switch kind { |
| + case "stage-symlink": |
| + if err = os.Remove(path); err == nil { |
| + err = os.Symlink(outside, path) |
| + } |
| + case "data-symlink": |
| + err = os.Symlink(victim, filepath.Join(path, "data")) |
| + case "hardlink": |
| + err = os.Link(victim, filepath.Join(path, "data")) |
| + case "fifo": |
| + err = unix.Mkfifo(filepath.Join(path, "data"), 0o600) |
| + case "nested": |
| + reapTestStage(t, path, name, true) |
| + case "extra": |
| + err = os.WriteFile(filepath.Join(path, "unknown"), nil, 0o600) |
| + case "writable": |
| + err = os.Chmod(path, 0o777) |
| + case "foreign-owner": |
| + if os.Geteuid() != 0 { |
| + t.Skip("requires root to make a foreign-owned stage") |
| + } |
| + err = os.Chown(path, 65534, 65534) |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if kind != "stage-symlink" { |
| + reapTestAge(t, path) |
| + } |
| + if err := Reap(root); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Lstat(path); err != nil { |
| + t.Fatalf("unsafe stage removed: %v", err) |
| + } |
| + if kind == "nested" { |
| + if _, err := os.Stat(filepath.Join(path, name, "data")); err != nil { |
| + t.Fatalf("unexpected recursive deletion inside stage: %v", err) |
| + } |
| + } |
| + if body, err := os.ReadFile(victim); err != nil || string(body) != "untouched" { |
| + t.Fatalf("victim changed: %q %v", body, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestReapPinsRootAndRefusesSwappedStage(t *testing.T) { |
| + root := t.TempDir() |
| + parent := filepath.Join(root, "parent") |
| + out := filepath.Join(root, "outside") |
| + name := ".carlos-write-" + strings.Repeat("c", 32) |
| + stage := reapTestStage(t, parent, name, true) |
| + victim := reapTestStage(t, out, name, true) |
| + dir, err := OpenDir(parent) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer dir.Close() |
| + pinned, err := OpenDir(stage) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer pinned.Close() |
| + if err := os.Rename(stage, stage+"-moved"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(victim, stage); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reapStage(int(dir.Fd()), name, pinned, time.Now().Add(-time.Hour)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(filepath.Join(stage+"-moved", "data")); err != nil { |
| + t.Fatalf("replaced entry was not refused: %v", err) |
| + } |
| + if err := os.Rename(parent, parent+"-moved"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(out, parent); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reapTree(dir, time.Now().Add(-time.Hour), 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(filepath.Join(victim, "data")); err != nil { |
| + t.Fatalf("followed swapped parent: %v", err) |
| + } |
| + if err := Reap(parent); err == nil { |
| + t.Fatal("accepted symlink root") |
| + } |
| +} |
| + |
| +func TestReapRefusesBindMount(t *testing.T) { |
| + root := t.TempDir() |
| + out := t.TempDir() |
| + name := ".carlos-write-" + strings.Repeat("d", 32) |
| + victim := reapTestStage(t, out, name, true) |
| + mount := filepath.Join(root, "mounted") |
| + if err := os.Mkdir(mount, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := unix.Mount(out, mount, "", unix.MS_BIND, ""); err != nil { |
| + if err == unix.EPERM || err == unix.EACCES { |
| + t.Skip("bind mounts unavailable") |
| + } |
| + t.Fatal(err) |
| + } |
| + defer func() { |
| + if err := unix.Unmount(mount, 0); err != nil { |
| + t.Errorf("unmount: %v", err) |
| + } |
| + }() |
| + if err := Reap(root); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(filepath.Join(victim, "data")); err != nil { |
| + t.Fatalf("traversed bind mount: %v", err) |
| + } |
| +} |
| + |
| +func TestReapContinuesPastUnreadableDirectory(t *testing.T) { |
| + if os.Geteuid() == 0 { |
| + t.Skip("root bypasses directory permissions") |
| + } |
| + root := t.TempDir() |
| + unreadable := filepath.Join(root, "unreadable") |
| + if err := os.Mkdir(unreadable, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(unreadable, 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer os.Chmod(unreadable, 0o700) |
| + stale := reapTestStage(t, root, ".carlos-write-"+strings.Repeat("9", 32), true) |
| + if err := Reap(root); err == nil { |
| + t.Fatal("unreadable directory was not reported") |
| + } |
| + if _, err := os.Lstat(stale); !os.IsNotExist(err) { |
| + t.Fatalf("unreadable sibling prevented cleanup: %v", err) |
| + } |
| +} |
diff --git a/internal/safefile/reap_other.go b/internal/safefile/reap_other.go| new file mode 100644 |
| index 000000000..74f6e02f5 |
| --- /dev/null |
| +++ b/internal/safefile/reap_other.go |
| @@ -0,0 +1,6 @@ |
| +//go:build !linux |
| + |
| +package safefile |
| + |
| +// Reap has no work on platforms whose writer uses ordinary temporary files. |
| +func Reap(dir string) error { 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) |
| } |
diff --git a/main.go b/main.go| index 90efe073b..17d8cfcfc 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -11,11 +11,13 @@ import ( |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/host" |
| "github.com/carlosframework/platform/internal/instanceexec" |
| + "github.com/carlosframework/platform/internal/rootaws" |
| "github.com/carlosframework/platform/internal/version" |
| "github.com/carlosframework/platform/internal/vet" |
| ) |
| func main() { |
| + rootaws.ScrubEnvironment() |
| // Failure is reported through log.Fatal everywhere in this binary, so |
| // the logger is where the "this went wrong" colour goes — once, rather |
| // than at a few hundred call sites. Off unless stderr is a terminal, so |