Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions internal/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ var configListCmd = &cobra.Command{
sort.Strings(keys)

for _, key := range keys {
// A token here predates the refusal in `config set`, or was added
// by hand. Never print it: `config list` output gets pasted into
// issues and terminals far more casually than an explicit
// `config get <key>` does.
if config.IsCredentialKey(key) {
fmt.Printf("%s=%s\n", key, config.RedactedValue)
continue
}
fmt.Printf("%s=%v\n", key, settings[key])
}
},
Expand Down Expand Up @@ -61,6 +69,14 @@ var configSetCmd = &cobra.Command{
key := args[0]
value := args[1]

// Credentials do not belong in a plaintext config file, and cu would
// not read one back if they were — authentication uses the keyring.
if config.IsCredentialKey(key) {
fmt.Fprintf(os.Stderr, "Refusing to write %q to the config file — it would be stored in plaintext and never used.\n", key)
fmt.Fprintln(os.Stderr, "Authenticate with 'cu auth login' instead; the token is kept in your system keyring.")
os.Exit(1)
}

// Handle boolean values
if strings.ToLower(value) == "true" || strings.ToLower(value) == "false" {
config.Set(key, strings.ToLower(value) == "true")
Expand Down
32 changes: 29 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,28 @@ var (
staged = map[string]interface{}{}
)

// credentialKeys are never accepted from a project .cu.yml. That file is
// committed and reviewed like code, so honouring a token there would let any
// repository you clone substitute the credential used for API calls.
// credentialKeys never reach a config file. They are refused from a project
// .cu.yml — that file is committed and reviewed like code, so honouring a token
// there would let any repository you clone substitute the credential used for
// API calls — and they are never staged for the global config either, since
// credentials belong in the OS keyring, not a plaintext YAML file.
var credentialKeys = []string{"api_token"}

// IsCredentialKey reports whether a config key holds a credential.
func IsCredentialKey(key string) bool {
for _, k := range credentialKeys {
if strings.EqualFold(key, k) {
return true
}
}
return false
}

// RedactedValue is substituted for credential values in any bulk output. It
// deliberately does not claim where the value lives: a key found here is a
// plaintext leftover, not the keyring entry cu actually authenticates with.
const RedactedValue = "<redacted — cu authenticates via the system keyring, not this file>"

// globalPath returns the global config file to write. An explicit --config
// always wins; otherwise a discovered file is used only while it still lives
// under the configured directory, since DefaultConfigDir is a variable that
Expand Down Expand Up @@ -172,8 +189,17 @@ func Get(key string) interface{} {

// Set sets a configuration value for this process and stages it for the global
// config file, so a following Save persists it there.
//
// Credential keys are applied in-process but never staged: writing them to
// ~/.config/cu/config.yaml would put a secret on disk in plaintext, and nothing
// reads it back — authentication goes through the OS keyring. Callers that take
// a key from the user should refuse it outright via IsCredentialKey rather than
// relying on this backstop, so the user gets told instead of silently ignored.
func Set(key string, value interface{}) {
viper.Set(key, value)
if IsCredentialKey(key) {
return
}
staged[key] = value
}

Expand Down
41 changes: 41 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -613,3 +613,44 @@ func TestWorkingDirectoryIsNotAGlobalConfigSource(t *testing.T) {
assert.NotEqual(t, filepath.Join(repo, ConfigFileName+"."+ConfigType), viper.ConfigFileUsed(),
"the working directory must not be searched for the global config")
}

func TestCredentialKeysAreNeverStaged(t *testing.T) {
t.Run("IsCredentialKey", func(t *testing.T) {
assert.True(t, IsCredentialKey("api_token"))
assert.True(t, IsCredentialKey("API_TOKEN"), "matching is case-insensitive")
assert.False(t, IsCredentialKey("default_list"))
})

t.Run("Set applies in-process but does not persist", func(t *testing.T) {
cfgDir, _ := newLayeredFixture(t, "default_space: global-space\n", "default_list: from-project\n")
require.NoError(t, Init(""))

Set("api_token", "sk-should-not-be-written")
Set("default_list", "persisted")
require.NoError(t, Save())

// Available to the running process...
assert.Equal(t, "sk-should-not-be-written", GetString("api_token"))

// ...but never written to disk.
written, err := os.ReadFile(filepath.Join(cfgDir, ConfigFileName+"."+ConfigType))
require.NoError(t, err)
assert.NotContains(t, string(written), "sk-should-not-be-written",
"a credential must never reach the config file")
assert.Contains(t, string(written), "persisted", "ordinary keys are still saved")
})

t.Run("a pre-existing plaintext token is preserved, not silently dropped", func(t *testing.T) {
// Deleting a user's data would be a surprise; refusing to add more is
// the fix. `config list` redacts whatever is already there.
cfgDir, _ := newLayeredFixture(t, "api_token: legacy-token\n", "default_list: from-project\n")
require.NoError(t, Init(""))

Set("default_list", "x")
require.NoError(t, Save())

written, err := os.ReadFile(filepath.Join(cfgDir, ConfigFileName+"."+ConfigType))
require.NoError(t, err)
assert.Contains(t, string(written), "legacy-token")
})
}
Loading