Skip to content

Commit b90170b

Browse files
committed
fix(aws): harden AWS SSO importer and provisioner against hostile inputs
Address findings from a pre-PR security review. Each fix is local to the AWS SSO net-new code introduced in the previous commit; pre-existing SDK bugs surfaced by the same review are deferred to a follow-up PR. Importer (plugins/aws/sso_importer.go): - Validate sso_start_url (https + non-empty host) - Regex-check sso_account_id (12 digits) and sso_region - Reject NUL bytes mid-value - Refuse non-regular, symlinked, or non-owned AWS_CONFIG_FILE overrides - Match SDK ~/ prefix convention (bare ~root no longer silently joined) - Use ini.LoadSources directly with strict botocore-parity options: KeyValueDelimiters="=", IgnoreContinuation=true, Loose=true Provisioner (plugins/aws/sso_provisioner.go): - assertSSOTokenCacheSafe rejects symlinks, non-regular files, group/world readable modes, and files not owned by the current uid before passing the path to ssocreds.New - translateSSORetrieveError whitelists known smithy codes (Unauthorized, Forbidden, ResourceNotFound, TooManyRequests); unknown codes get a generic plugin-controlled message so server-controlled error text doesn't reach user-visible output - Wrap sso:GetRoleCredentials in context.WithTimeout(30s) SDK validator (sdk/schema/credential_type.go): - Replace the previous severity downgrade with an opt-in AllowsExternalSecretCache flag on CredentialType. The "must have at least one secret field" check is restored to Error severity globally; only the SSO Profile (whose bearer token lives in the AWS SDK's external cache) opts out Tests (plugins/aws/sso_*_test.go): - Hostile-input cases for the importer (non-HTTPS, file:// scheme, short account ID, malformed region, NUL byte, malformed section header, duplicate-section last-wins) - Direct unit tests for assertSSOTokenCacheSafe and validateExternalConfigPath covering symlink, world-readable, directory, non-existent - Smithy-error translation table with a token-leak guard that asserts no JSON-key-shaped or JWT-shaped fragment from a hostile server message ever appears in the translated user-visible error Deferred to follow-up PR (pre-existing code, out of scope here): - F-2: plugins/registry.go:50-60 GetCredentialType ignores credentialName - F-6: sdk/schema/plugin.go:116-134 MarshalJSON truncates to Credentials[0] - F-7: plugins/aws/cli_provisioner.go:28-53 strip-by-value mis-routing - F-9: sdk/importer/helpers.go:41-53 SanitizeNameHint byte-truncation - F-15: plugins/aws/sts_provisioner.go:399-405 log.SetOutput global state
1 parent 30e52bf commit b90170b

8 files changed

Lines changed: 534 additions & 26 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ require (
3434
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.23 // indirect
3535
github.com/aws/aws-sdk-go-v2/service/sso v1.12.4
3636
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.4 // indirect
37-
github.com/aws/smithy-go v1.13.5 // indirect
37+
github.com/aws/smithy-go v1.13.5
3838
github.com/danieljoos/wincred v1.1.2 // indirect
3939
github.com/davecgh/go-spew v1.1.1 // indirect
4040
github.com/dvsekhvalnov/jose2go v1.6.0 // indirect

plugins/aws/aws.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,23 @@ func AWSCLI() schema.Executable {
1515
NeedsAuth: needsauth.IfAll(
1616
needsauth.NotForHelpOrVersion(),
1717
needsauth.NotWithoutArgs(),
18-
// `aws sso login` and `aws sso logout` write/erase the very token
19-
// the SSO Profile provisioner reads from disk; provisioning before
20-
// they run creates a chicken-and-egg failure.
18+
// Each entry below intentionally bypasses provisioning. Subcommands not listed here
19+
// (e.g. `aws configure import`, `aws configure export-credentials`) still flow through
20+
// the provisioner because they exchange or mutate credentials and benefit from the
21+
// 1Password-managed surface.
22+
//
23+
// `aws sso login`/`logout` write/erase the very token the SSO Profile provisioner reads
24+
// from disk; provisioning before they run creates a chicken-and-egg failure.
2125
needsauth.NotWhenContainsArgs("sso", "login"),
2226
needsauth.NotWhenContainsArgs("sso", "logout"),
23-
// `aws configure sso` and `aws configure sso-session` set up the
24-
// SSO configuration in ~/.aws/config; no provisioned credentials needed.
27+
// `aws configure sso` and `aws configure sso-session` set up SSO configuration in
28+
// ~/.aws/config; they touch no AWS APIs and need no provisioned credentials.
2529
needsauth.NotWhenContainsArgs("configure", "sso"),
2630
needsauth.NotWhenContainsArgs("configure", "sso-session"),
27-
// Read-only / diagnostic configure subcommands don't make API calls.
31+
// `aws configure list` and `list-profiles` are read-only diagnostic queries against
32+
// the local config; `get` and `set` mutate ~/.aws/config or ~/.aws/credentials but
33+
// make no remote API call. Provisioning would be a no-op at best and a confusing
34+
// "missing credentials" error at worst when the user is just inspecting state.
2835
needsauth.NotWhenContainsArgs("configure", "list"),
2936
needsauth.NotWhenContainsArgs("configure", "list-profiles"),
3037
needsauth.NotWhenContainsArgs("configure", "get"),

plugins/aws/sso_importer.go

Lines changed: 118 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ package aws
33
import (
44
"context"
55
"fmt"
6+
"net/url"
67
"os"
8+
"regexp"
79
"strings"
10+
"syscall"
811

912
"gopkg.in/ini.v1"
1013

@@ -18,21 +21,45 @@ const (
1821
ssoSessionSectionPrefix = "sso-session "
1922
)
2023

24+
// AWS account IDs are exactly 12 decimal digits; AWS regions are lowercase letters with one or two
25+
// dashes separating segments and a trailing digit (e.g. `us-east-1`, `ap-southeast-2`).
26+
var (
27+
ssoAccountIDRE = regexp.MustCompile(`^[0-9]{12}$`)
28+
ssoRegionRE = regexp.MustCompile(`^[a-z]{2}-[a-z]+-[0-9]+$`)
29+
)
30+
2131
// TrySSOConfigFile looks for AWS IAM Identity Center profiles in ~/.aws/config.
2232
// It supports both the legacy form (sso_start_url on the profile) and the
2333
// consolidated form (sso_session = NAME referencing an [sso-session NAME] section).
2434
func TrySSOConfigFile() sdk.Importer {
2535
return func(ctx context.Context, in sdk.ImportInput, out *sdk.ImportOutput) {
2636
sourcePath := os.Getenv("AWS_CONFIG_FILE")
27-
if sourcePath == "" {
37+
envOverride := sourcePath != ""
38+
if !envOverride {
2839
sourcePath = "~/.aws/config"
2940
}
3041

31-
configPath := sourcePath
32-
if strings.HasPrefix(configPath, "~") {
33-
configPath = in.FromHomeDir(strings.TrimPrefix(configPath, "~"))
34-
} else {
35-
configPath = in.FromRootDir(configPath)
42+
// Match the SDK convention (sdk/importer/file_importer.go): only `~/` (with slash) is
43+
// expanded against the home directory. A bare `~root/...` form would otherwise be silently
44+
// joined to the *current* user's home, which is misleading at best and security-relevant if
45+
// an attacker can pre-place a file there.
46+
var configPath string
47+
switch {
48+
case strings.HasPrefix(sourcePath, "~/"):
49+
configPath = in.FromHomeDir(strings.TrimPrefix(sourcePath, "~/"))
50+
default:
51+
configPath = in.FromRootDir(sourcePath)
52+
}
53+
54+
// When `AWS_CONFIG_FILE` is honoured, refuse anything that is not a regular file owned by
55+
// the current user. Defends against a malicious `.envrc` (direnv, asdf, mise) pinning the
56+
// importer to a hostile config file when `op` is run from that directory.
57+
if envOverride {
58+
if err := validateExternalConfigPath(configPath); err != nil {
59+
attempt := out.NewAttempt(importer.SourceFile(sourcePath))
60+
attempt.AddError(err)
61+
return
62+
}
3663
}
3764

3865
contents, err := os.ReadFile(configPath)
@@ -51,9 +78,23 @@ func TrySSOConfigFile() sdk.Importer {
5178

5279
attempt := out.NewAttempt(importer.SourceFile(sourcePath))
5380

54-
configFile, err := importer.FileContents(contents).ToINI()
81+
// Strict botocore-parity loader: `=` is the only key/value delimiter (botocore rejects `:`),
82+
// line continuation is disabled (botocore reads each `\` literally), and `Loose: true` lets
83+
// the loader produce a partial result instead of bricking the entire import on a single
84+
// malformed section. AllowShadows defaults to false, which matches botocore last-wins
85+
// semantics for duplicate `[profile X]` sections; we surface no diagnostic to keep parity.
86+
configFile, err := ini.LoadSources(ini.LoadOptions{
87+
KeyValueDelimiters: "=",
88+
IgnoreContinuation: true,
89+
Loose: true,
90+
}, contents)
5591
if err != nil {
5692
attempt.AddError(err)
93+
// Fall through; with Loose: true the loader returns a partial *ini.File on most parse
94+
// errors and only short-circuits on truly catastrophic ones. Either way, valid sections
95+
// in the result still produce candidates.
96+
}
97+
if configFile == nil {
5798
return
5899
}
59100

@@ -82,6 +123,32 @@ func TrySSOConfigFile() sdk.Importer {
82123
}
83124
}
84125

126+
// validateExternalConfigPath enforces the safety properties for an `AWS_CONFIG_FILE` override:
127+
// the path must resolve to a regular file owned by the current user. Anything else is rejected.
128+
func validateExternalConfigPath(path string) error {
129+
fi, err := os.Lstat(path)
130+
if err != nil {
131+
// If the path does not exist, downstream `os.ReadFile` will report it; treat it as a
132+
// non-error here so we do not double-report.
133+
if os.IsNotExist(err) {
134+
return nil
135+
}
136+
return fmt.Errorf("AWS_CONFIG_FILE %q: %w", path, err)
137+
}
138+
if fi.Mode()&os.ModeSymlink != 0 {
139+
return fmt.Errorf("AWS_CONFIG_FILE %q is a symlink; refusing to follow", path)
140+
}
141+
if !fi.Mode().IsRegular() {
142+
return fmt.Errorf("AWS_CONFIG_FILE %q is not a regular file", path)
143+
}
144+
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
145+
if st.Uid != uint32(os.Geteuid()) {
146+
return fmt.Errorf("AWS_CONFIG_FILE %q is not owned by the current user", path)
147+
}
148+
}
149+
return nil
150+
}
151+
85152
// profileNameFromSection returns the bare profile name for a [profile NAME] or
86153
// [default] section, and false for any other section type.
87154
func profileNameFromSection(sectionName string) (string, bool) {
@@ -106,9 +173,10 @@ func collectSSOSessions(configFile *ini.File) map[string]*ini.Section {
106173
return sessions
107174
}
108175

109-
// buildSSOFields returns the candidate fields for a profile section, or (nil, nil)
110-
// if the profile is not SSO-bearing or is missing required keys. A non-nil error
111-
// indicates a malformed reference (e.g. unknown sso_session) and should be reported.
176+
// buildSSOFields returns the candidate fields for a profile section, or (nil, nil) if the profile
177+
// is not SSO-bearing or is missing required keys. A non-nil error indicates a malformed reference
178+
// or an invalid value (bad URL, malformed account ID, etc.) and should be reported via
179+
// `attempt.AddError`. Errors are scoped to a single profile so other valid profiles still import.
112180
func buildSSOFields(profileName string, section *ini.Section, ssoSessions map[string]*ini.Section) (map[sdk.FieldName]string, error) {
113181
hasSSOSession := keyHasValue(section, "sso_session")
114182
hasLegacyStartURL := keyHasValue(section, "sso_start_url")
@@ -120,6 +188,9 @@ func buildSSOFields(profileName string, section *ini.Section, ssoSessions map[st
120188

121189
if hasSSOSession {
122190
sessionName := section.Key("sso_session").Value()
191+
if containsNUL(sessionName) {
192+
return nil, fmt.Errorf("profile %q sso_session value contains a NUL byte", profileName)
193+
}
123194
sessionSection, ok := ssoSessions[sessionName]
124195
if !ok {
125196
return nil, fmt.Errorf("profile %q references unknown sso-session %q", profileName, sessionName)
@@ -152,9 +223,46 @@ func buildSSOFields(profileName string, section *ini.Section, ssoSessions map[st
152223
return nil, nil
153224
}
154225

226+
for _, v := range fields {
227+
if containsNUL(v) {
228+
return nil, fmt.Errorf("profile %q contains a NUL byte in one of its SSO fields", profileName)
229+
}
230+
}
231+
232+
if err := validateSSOStartURL(fields[fieldname.SSOStartURL]); err != nil {
233+
return nil, fmt.Errorf("profile %q: %w", profileName, err)
234+
}
235+
if !ssoAccountIDRE.MatchString(fields[fieldname.SSOAccountID]) {
236+
return nil, fmt.Errorf("profile %q: sso_account_id %q is not a 12-digit AWS account ID", profileName, fields[fieldname.SSOAccountID])
237+
}
238+
if !ssoRegionRE.MatchString(fields[fieldname.SSORegion]) {
239+
return nil, fmt.Errorf("profile %q: sso_region %q is not a valid AWS region", profileName, fields[fieldname.SSORegion])
240+
}
241+
155242
return fields, nil
156243
}
157244

245+
// validateSSOStartURL enforces an HTTPS scheme and a non-empty host. We do not pin the host to
246+
// `*.awsapps.com`: AWS supports custom SSO start URLs (e.g. `https://signin.aws.amazon.com/...`),
247+
// and an over-tight allowlist would reject legitimate enterprise configurations.
248+
func validateSSOStartURL(s string) error {
249+
u, err := url.Parse(s)
250+
if err != nil {
251+
return fmt.Errorf("sso_start_url %q is not a valid URL: %w", s, err)
252+
}
253+
if u.Scheme != "https" {
254+
return fmt.Errorf("sso_start_url %q must use https://", s)
255+
}
256+
if u.Host == "" {
257+
return fmt.Errorf("sso_start_url %q has no host component", s)
258+
}
259+
return nil
260+
}
261+
262+
func containsNUL(s string) bool {
263+
return strings.IndexByte(s, 0) >= 0
264+
}
265+
158266
func keyHasValue(section *ini.Section, key string) bool {
159267
return section.HasKey(key) && section.Key(key).Value() != ""
160268
}

0 commit comments

Comments
 (0)