fs: fix passwords and tokens appearing in the debug log during rclone config

Previously running rclone config (or driving it via the rc API or web
GUI) with -vv would write secrets to the debug log.

This was dangerous as users debugging a failing config flow often
paste their -vv logs into the forum or GitHub issues.

These values are now redacted from the log as "XXX". Values whose
option is known are only redacted if the option is marked IsPassword
or Sensitive, so normal answers remain visible.

Use --dump auth to see the unredacted values when debugging a config
flow - rclone prints a warning that secrets will appear in the log
when this is in effect.

This was discovered by CodeQL: https://github.com/rclone/rclone/security/code-scanning/182
This commit is contained in:
Nick Craig-Wood
2026-07-16 16:11:22 +01:00
parent 454430a057
commit 8b812fff28
4 changed files with 103 additions and 8 deletions
+3 -1
View File
@@ -3267,7 +3267,9 @@ The available flags are:
- `auth` dumps HTTP headers like `headers`, but also includes any `Authorization:` - `auth` dumps HTTP headers like `headers`, but also includes any `Authorization:`
headers. This means the output will probably contain sensitive information. headers. This means the output will probably contain sensitive information.
Use `headers` to dump without `Authorization:` headers. Can be very verbose. Use `headers` to dump without `Authorization:` headers. Can be very verbose.
Useful for debugging only. Useful for debugging only. This flag also makes the debug log of the config
process (e.g. `rclone config -vv`) show answers to questions, passwords and
tokens which are otherwise redacted.
- `bodies` dumps HTTP headers and bodies. May contain sensitive info. - `bodies` dumps HTTP headers and bodies. May contain sensitive info.
Can be very verbose. Useful for debugging only. Note that the bodies Can be very verbose. Useful for debugging only. Note that the bodies
are buffered in memory so don't use this for enormous files. are buffered in memory so don't use this for enormous files.
+56 -5
View File
@@ -11,6 +11,7 @@ import (
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"sync"
"github.com/rclone/rclone/fs/config/configmap" "github.com/rclone/rclone/fs/config/configmap"
) )
@@ -462,11 +463,61 @@ func configAll(ctx context.Context, name string, m configmap.Mapper, ri *RegInfo
return nil, fmt.Errorf("internal error: bad state %q", state) return nil, fmt.Errorf("internal error: bad state %q", state)
} }
// redactDumpAuthWarnOnce makes sure the --dump auth warning is only shown once
var redactDumpAuthWarnOnce sync.Once
// RedactValue makes value safe for inclusion in the debug log.
//
// Non-empty values are replaced with "XXX" as they may contain
// secrets such as passwords or tokens, unless --dump auth is in use
// in which case the value is returned quoted.
func RedactValue(ci *ConfigInfo, value string) string {
if value == "" || ci.Dump&DumpAuth != 0 {
return fmt.Sprintf("%q", value)
}
return "XXX"
}
// RedactOptionValue is like RedactValue except that value is only
// redacted if opt is unknown (nil), a password or sensitive.
func RedactOptionValue(ci *ConfigInfo, opt *Option, value string) string {
if opt == nil || opt.IsPassword || opt.Sensitive {
return RedactValue(ci, value)
}
return fmt.Sprintf("%q", value)
}
// redactConfigOut renders out for the debug log, redacting any
// values which may contain secrets.
func redactConfigOut(ci *ConfigInfo, out *ConfigOut) string {
if out == nil {
return "<nil>"
}
var b strings.Builder
fmt.Fprintf(&b, "{State:%q", out.State)
if out.Option != nil {
fmt.Fprintf(&b, " Option:%s=%s", out.Option.Name, RedactOptionValue(ci, out.Option, out.Option.String()))
}
if out.OAuth != nil {
b.WriteString(" OAuth:set")
}
if out.Error != "" {
fmt.Fprintf(&b, " Error:%q", out.Error)
}
fmt.Fprintf(&b, " Result:%s}", RedactValue(ci, out.Result))
return b.String()
}
func backendConfigStep(ctx context.Context, name string, m configmap.Mapper, ri *RegInfo, choices configmap.Getter, in ConfigIn) (out *ConfigOut, err error) { func backendConfigStep(ctx context.Context, name string, m configmap.Mapper, ri *RegInfo, choices configmap.Getter, in ConfigIn) (out *ConfigOut, err error) {
ci := GetConfig(ctx) ci := GetConfig(ctx)
Debugf(name, "config in: state=%q, result=%q", in.State, in.Result) if ci.Dump&DumpAuth != 0 {
redactDumpAuthWarnOnce.Do(func() {
Logf(nil, "--dump auth is in use - debug output may contain secrets such as passwords and tokens")
})
}
Debugf(name, "config in: state=%q, result=%s", in.State, RedactValue(ci, in.Result))
defer func() { defer func() {
Debugf(name, "config out: out=%+v, err=%v", out, err) Debugf(name, "config out: out=%s, err=%v", redactConfigOut(ci, out), err)
}() }()
switch { switch {
@@ -510,13 +561,13 @@ func backendConfigStep(ctx context.Context, name string, m configmap.Mapper, ri
} }
// If override value is set in the choices then use that // If override value is set in the choices then use that
if result, ok := choices.Get(out.Option.Name); ok { if result, ok := choices.Get(out.Option.Name); ok {
Debugf(nil, "Override value found, choosing value %q for state %q", result, out.State) Debugf(nil, "Override value found, choosing value %s for state %q", RedactOptionValue(ci, out.Option, result), out.State)
return ConfigResult(out.State, result) return ConfigResult(out.State, result)
} }
// If AutoConfirm is set, choose the default value // If AutoConfirm is set, choose the default value
if ci.AutoConfirm { if ci.AutoConfirm {
result := fmt.Sprint(out.Option.Default) result := fmt.Sprint(out.Option.Default)
Debugf(nil, "Auto confirm is set, choosing default %q for state %q, override by setting config parameter %q", result, out.State, out.Option.Name) Debugf(nil, "Auto confirm is set, choosing default %s for state %q, override by setting config parameter %q", RedactOptionValue(ci, out.Option, result), out.State, out.Option.Name)
return ConfigResult(out.State, result) return ConfigResult(out.State, result)
} }
// If fs.ConfigEdit is set then make the default value // If fs.ConfigEdit is set then make the default value
@@ -527,7 +578,7 @@ func backendConfigStep(ctx context.Context, name string, m configmap.Mapper, ri
oldValue := newOption.Value oldValue := newOption.Value
err = newOption.Set(value) err = newOption.Set(value)
if err != nil { if err != nil {
Errorf(nil, "Failed to set %q from %q - using default: %v", out.Option.Name, value, err) Errorf(nil, "Failed to set %q from %s - using default: %v", out.Option.Name, RedactOptionValue(ci, newOption, value), err)
} else { } else {
newOption.Default = newOption.Value newOption.Default = newOption.Value
newOption.Value = oldValue newOption.Value = oldValue
+40
View File
@@ -37,6 +37,46 @@ func TestStatePop(t *testing.T) {
assert.Equal(t, "a", state) assert.Equal(t, "a", state)
} }
func TestRedactValue(t *testing.T) {
ci := &ConfigInfo{}
assert.Equal(t, `""`, RedactValue(ci, ""))
assert.Equal(t, "XXX", RedactValue(ci, "potato"))
ci.Dump = DumpAuth
assert.Equal(t, `""`, RedactValue(ci, ""))
assert.Equal(t, `"potato"`, RedactValue(ci, "potato"))
}
func TestRedactOptionValue(t *testing.T) {
ci := &ConfigInfo{}
plain := &Option{Name: "chunk_size"}
password := &Option{Name: "pass", IsPassword: true}
sensitive := &Option{Name: "token", Sensitive: true}
assert.Equal(t, `"potato"`, RedactOptionValue(ci, plain, "potato"))
assert.Equal(t, "XXX", RedactOptionValue(ci, password, "potato"))
assert.Equal(t, "XXX", RedactOptionValue(ci, sensitive, "potato"))
assert.Equal(t, "XXX", RedactOptionValue(ci, nil, "potato"))
assert.Equal(t, `""`, RedactOptionValue(ci, password, ""))
ci.Dump = DumpAuth
assert.Equal(t, `"potato"`, RedactOptionValue(ci, password, "potato"))
assert.Equal(t, `"potato"`, RedactOptionValue(ci, nil, "potato"))
}
func TestRedactConfigOut(t *testing.T) {
ci := &ConfigInfo{}
assert.Equal(t, "<nil>", redactConfigOut(ci, nil))
assert.Equal(t, `{State:"state" Result:""}`, redactConfigOut(ci, &ConfigOut{State: "state"}))
out := &ConfigOut{
State: "state",
Option: &Option{Name: "pass", IsPassword: true, Value: "obscured"},
OAuth: struct{}{},
Error: "boom",
Result: "secret",
}
assert.Equal(t, `{State:"state" Option:pass=XXX OAuth:set Error:"boom" Result:XXX}`, redactConfigOut(ci, out))
ci.Dump = DumpAuth
assert.Equal(t, `{State:"state" Option:pass="obscured" OAuth:set Error:"boom" Result:"secret"}`, redactConfigOut(ci, out))
}
func TestMatchProvider(t *testing.T) { func TestMatchProvider(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
config string config string
+4 -2
View File
@@ -716,8 +716,9 @@ version recommended):
// Find the overridden options // Find the overridden options
inM := ri.Options.NonDefault(m) inM := ri.Options.NonDefault(m)
delete(inM, fs.ConfigToken) // delete token as we are refreshing it delete(inM, fs.ConfigToken) // delete token as we are refreshing it
ci := fs.GetConfig(ctx)
for k, v := range inM { for k, v := range inM {
fs.Debugf(nil, "sending %s = %q", k, v) fs.Debugf(nil, "sending %s = %s", k, fs.RedactOptionValue(ci, ri.Options.Get(k), v))
} }
// Encode them into a string // Encode them into a string
mCopyString, err := inM.Encode() mCopyString, err := inM.Encode()
@@ -748,9 +749,10 @@ version recommended):
} }
// Save the config updates // Save the config updates
if newFormat { if newFormat {
ci := fs.GetConfig(ctx)
for k, v := range outM { for k, v := range outM {
m.Set(k, v) m.Set(k, v)
fs.Debugf(nil, "received %s = %q", k, v) fs.Debugf(nil, "received %s = %s", k, fs.RedactOptionValue(ci, ri.Options.Get(k), v))
} }
} else { } else {
m.Set(fs.ConfigToken, code) m.Set(fs.ConfigToken, code)