carlos / platform Public

rootaws, lease: root scrubs AWS file and profile selectors at start; root Litestream gets off-cloud keys explicitly; environment keys win over the file; SDK quote and comment parsing (#267)

ScrubEnvironment (main, console) removes AWS_CONFIG_FILE,
AWS_SHARED_CREDENTIALS_FILE, AWS_PROFILE, AWS_DEFAULT_PROFILE and
AWS_SDK_LOAD_CONFIG for euid 0, and points the file selectors at an
impossible path when root's HOME is not root-owned, so third-party SDK
loads (Litestream's s3 replica client) cannot read service-writable
config. Root replica clients get static keys from the validated off-cloud
file; environment keys and instance profiles keep the default chain.
Complete environment keys are used without opening the file; an unsafe
file refuses on use rather than at config load. The static parser strips
one quote pair and inline comments the way the SDK does.
Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 6fab64c7ea5e3b8c3e009666721268bca5deb3f1 parent 583b78f
17 files changed, +630 −25
  • cmd/console/main.go +2 −0
  • internal/lease/checkpoint.go +4 −1
  • internal/lease/claimgeneration.go +10 −2
  • internal/lease/claimgeneration_test.go +8 −2
  • internal/lease/endpoint.go +23 −3
  • internal/lease/endpoint_test.go +133 −2
  • internal/lease/lease.go +15 −5
  • internal/lease/retention_window_test.go +8 −2
  • internal/rootaws/config.go +43 −5
  • internal/rootaws/config_test.go +142 −0
  • internal/rootaws/credentials.go +20 −2
  • internal/rootaws/credentials_linux_test.go +42 −1
  • internal/rootaws/environment.go +37 −0
  • internal/rootaws/environment_other.go +5 −0
  • internal/rootaws/environment_test.go +111 −0
  • internal/rootaws/environment_unix.go +25 −0
  • main.go +2 −0

CI failed — 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/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 c115fdf47..37deb79a4 100644
--- a/internal/lease/endpoint.go
+++ b/internal/lease/endpoint.go
@@ -16,8 +16,11 @@ 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"
@@ -118,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
@@ -127,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 —
@@ -146,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/rootaws/config.go b/internal/rootaws/config.go
index 0990f3ee8..c8229d77e 100644
--- a/internal/rootaws/config.go
+++ b/internal/rootaws/config.go
@@ -15,6 +15,23 @@ import (
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) {
@@ -32,15 +49,36 @@ func loadRootConfig(ctx context.Context, path string, opts ...func(*awsconfig.Lo
log.Printf("ALERT: %v", err)
return aws.Config{}, err
}
- static, err := readStaticCredentials(path)
- if err != nil && !errors.Is(err, os.ErrNotExist) {
- log.Printf("ALERT: refusing unsafe offcloud credentials %s: %v", path, err)
- return aws.Config{}, fmt.Errorf("root AWS credentials: %w", err)
+ // 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(""))
- if err == nil && !(envAccessKey() != "" && envSecretKey() != "") {
+ 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)))
}
diff --git a/internal/rootaws/config_test.go b/internal/rootaws/config_test.go
index 2af7c03bd..f54defb37 100644
--- a/internal/rootaws/config_test.go
+++ b/internal/rootaws/config_test.go
@@ -10,6 +10,7 @@ import (
"testing"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/credentials"
)
func TestParseStaticCredentials(t *testing.T) {
@@ -38,6 +39,99 @@ func TestParseStaticCredentials(t *testing.T) {
}
}
+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"} {
@@ -153,3 +247,51 @@ func TestNonRootConfigKeepsSharedCredentials(t *testing.T) {
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
index e3f374376..d5993a946 100644
--- a/internal/rootaws/credentials.go
+++ b/internal/rootaws/credentials.go
@@ -31,8 +31,10 @@ func ParseStaticCredentials(body []byte) (aws.Credentials, error) {
profile = true
continue
}
- key, value, found := strings.Cut(line, "=")
- key, value = strings.TrimSpace(key), strings.TrimSpace(value)
+ // 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")
}
@@ -51,3 +53,19 @@ func ParseStaticCredentials(body []byte) (aws.Credentials, error) {
}
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
index d5f866848..80dcb739e 100644
--- a/internal/rootaws/credentials_linux_test.go
+++ b/internal/rootaws/credentials_linux_test.go
@@ -63,7 +63,11 @@ func TestRootCredentialsRejectUnsafeFiles(t *testing.T) {
old := log.Writer()
log.SetOutput(&logs)
defer log.SetOutput(old)
- if _, err := loadRootConfig(context.Background(), path); err == nil {
+ 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:") {
@@ -73,6 +77,39 @@ func TestRootCredentialsRejectUnsafeFiles(t *testing.T) {
}
}
+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")
@@ -82,6 +119,10 @@ func TestRootCredentialsStaticProvider(t *testing.T) {
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)
diff --git a/internal/rootaws/environment.go b/internal/rootaws/environment.go
new file mode 100644
index 000000000..f1b090fc8
--- /dev/null
+++ b/internal/rootaws/environment.go
@@ -0,0 +1,37 @@
+package rootaws
+
+import (
+ "log"
+ "os"
+)
+
+// A child of a device cannot exist, even if a service user creates directories.
+const disabledSharedFile = "/dev/null/carlos-root-aws"
+
+// ScrubEnvironment must run before dispatch or goroutines in each executable.
+// It also protects SDK loads in dependencies that do not call this package.
+func ScrubEnvironment() {
+ scrubEnvironment(os.Geteuid(), rootOwnedHome)
+}
+
+func scrubEnvironment(euid int, trustedHome func(string) bool) {
+ if euid != 0 {
+ return
+ }
+ changed := false
+ for _, key := range []string{"AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_PROFILE", "AWS_DEFAULT_PROFILE"} {
+ if _, set := os.LookupEnv(key); set {
+ changed = true
+ }
+ _ = os.Unsetenv(key)
+ }
+ // SDK v2 ignores this switch; older SDKs must not opt into shared config.
+ _ = os.Unsetenv("AWS_SDK_LOAD_CONFIG")
+ if home := os.Getenv("HOME"); home == "" || !trustedHome(home) {
+ _ = os.Setenv("AWS_CONFIG_FILE", disabledSharedFile)
+ _ = os.Setenv("AWS_SHARED_CREDENTIALS_FILE", disabledSharedFile)
+ }
+ if changed {
+ log.Print("ALERT: root AWS shared-file and profile environment overrides ignored")
+ }
+}
diff --git a/internal/rootaws/environment_other.go b/internal/rootaws/environment_other.go
new file mode 100644
index 000000000..0d3aebf40
--- /dev/null
+++ b/internal/rootaws/environment_other.go
@@ -0,0 +1,5 @@
+//go:build !linux && !darwin
+
+package rootaws
+
+func rootOwnedHome(string) bool { return false }
diff --git a/internal/rootaws/environment_test.go b/internal/rootaws/environment_test.go
new file mode 100644
index 000000000..f6c8ad386
--- /dev/null
+++ b/internal/rootaws/environment_test.go
@@ -0,0 +1,111 @@
+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
+ home string
+ trusted bool
+ }{
+ {"root-home", 0, "/root", true},
+ {"service-home", 0, "/home/carlos", false},
+ {"missing-home", 0, "", true},
+ {"non-root", 1001, "/home/carlos", false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv("HOME", tc.home)
+ keys := []string{"AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AWS_PROFILE", "AWS_DEFAULT_PROFILE"}
+ for _, key := range keys {
+ t.Setenv(key, "untrusted")
+ }
+ t.Setenv("AWS_SDK_LOAD_CONFIG", "1")
+ t.Setenv("AWS_ACCESS_KEY_ID", "KEEP")
+ t.Setenv("AWS_SECRET_ACCESS_KEY", "KEEP_SECRET")
+ t.Setenv("AWS_SESSION_TOKEN", "KEEP_TOKEN")
+ var logs bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&logs)
+ defer log.SetOutput(old)
+ scrubEnvironment(tc.euid, func(home string) bool {
+ if home != tc.home {
+ t.Fatal("wrong home checked")
+ }
+ return tc.trusted
+ })
+ for _, key := range keys {
+ got, set := os.LookupEnv(key)
+ want := ""
+ if tc.euid != 0 {
+ want = "untrusted"
+ } else if (key == "AWS_CONFIG_FILE" || key == "AWS_SHARED_CREDENTIALS_FILE") && (tc.home == "" || !tc.trusted) {
+ want = disabledSharedFile
+ }
+ if got != want || set != (want != "") {
+ t.Errorf("%s = %q, set=%v; want %q", key, got, set, want)
+ }
+ }
+ wantLoad, wantLogs := "", 1
+ if tc.euid != 0 {
+ wantLoad, wantLogs = "1", 0
+ }
+ load, set := os.LookupEnv("AWS_SDK_LOAD_CONFIG")
+ if load != wantLoad || set != (tc.euid != 0) || strings.Count(logs.String(), "ALERT:") != wantLogs {
+ t.Fatal("load-config switch or log count differs")
+ }
+ if 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 TestScrubEnvironmentBlocksDependencySharedConfig(t *testing.T) {
+ isolatedAWSEnv(t)
+ dir := t.TempDir()
+ marker := filepath.Join(dir, "executed")
+ path := filepath.Join(dir, "config")
+ if err := os.WriteFile(path, []byte("[default]\ncredential_process = touch "+marker+"\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("HOME", dir)
+ t.Setenv("AWS_CONFIG_FILE", path)
+ t.Setenv("AWS_SHARED_CREDENTIALS_FILE", path)
+ t.Setenv("AWS_PROFILE", "default")
+ t.Setenv("AWS_DEFAULT_PROFILE", "default")
+ t.Setenv("AWS_SDK_LOAD_CONFIG", "1")
+ scrubEnvironment(0, func(string) bool { return false })
+ // 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 TestRootOwnedHomeRejectsUntrustedPaths(t *testing.T) {
+ for _, path := range []string{"", ".", filepath.Join(t.TempDir(), "absent"), t.TempDir()} {
+ if rootOwnedHome(path) {
+ t.Errorf("trusted replaceable or missing HOME %q", path)
+ }
+ }
+}
diff --git a/internal/rootaws/environment_unix.go b/internal/rootaws/environment_unix.go
new file mode 100644
index 000000000..e339df0e8
--- /dev/null
+++ b/internal/rootaws/environment_unix.go
@@ -0,0 +1,25 @@
+//go:build linux || darwin
+
+package rootaws
+
+import (
+ "path/filepath"
+
+ "golang.org/x/sys/unix"
+)
+
+func rootOwnedHome(home string) bool {
+ if !filepath.IsAbs(home) {
+ return false
+ }
+ // Check the parents too: a service user must not be able to replace HOME.
+ for path := filepath.Clean(home); ; path = filepath.Dir(path) {
+ var st unix.Stat_t
+ if unix.Lstat(path, &st) != nil || st.Mode&unix.S_IFMT != unix.S_IFDIR || st.Uid != 0 || st.Mode&0o022 != 0 {
+ return false
+ }
+ if path == "/" {
+ return true
+ }
+ }
+}
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