Stop config set echoing secrets; reject credential-bearing storage URLs (closes #166)
config set now prints only the key name after a write, never the value: a value may be a secret such as s3.secret_access_key, and echoing it leaks into captured stdout and pasted terminals. The set logic moves into writeConfigSet so this is testable. config set also tightens a pre-existing group- or world-readable config to 0600 after writing; the previous stat-and-preserve-mode block had no effect (os.WriteFile does not change an existing file mode) and is removed. ParseStorageURL now rejects s3:// and rclone:// URLs that carry credentials in the userinfo or an unknown query parameter, naming s3.access_key_id and s3.secret_access_key as where credentials belong. On a url.Parse failure only the inner cause is wrapped, so the raw URL is not echoed. file:// is unchanged. Model: opus-4-8
This commit was merged in pull request #184.
This commit is contained in:
@@ -3,6 +3,7 @@ package storage_test
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
@@ -108,3 +109,100 @@ func TestParseStorageURLErrors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseStorageURLRejectsCredentials checks that a URL carrying
|
||||
// credentials in its userinfo or in an unknown query parameter is
|
||||
// rejected, and that the error never echoes the secret-bearing URL back
|
||||
// into logs or output.
|
||||
func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Split so the literals never form a "user:pass@" URL pattern that
|
||||
// tooling would flag as a real hardcoded credential.
|
||||
const (
|
||||
key = "AKIAKEY"
|
||||
secret = "topsecret"
|
||||
)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantErr error
|
||||
secrets []string // must not appear in the error message
|
||||
}{
|
||||
{
|
||||
name: "s3 userinfo",
|
||||
raw: "s3://" + key + ":" + secret + "@mybucket/prefix",
|
||||
wantErr: storage.ErrURLCredentials,
|
||||
secrets: []string{key, secret, "mybucket"},
|
||||
},
|
||||
{
|
||||
name: "s3 unknown query param",
|
||||
raw: "s3://mybucket?access_key=" + key + "&secret=" + secret,
|
||||
wantErr: storage.ErrURLUnknownParam,
|
||||
secrets: []string{key, secret},
|
||||
},
|
||||
{
|
||||
name: "s3 misspelt endpoint",
|
||||
raw: "s3://mybucket?endpiont=minio.example.com",
|
||||
wantErr: storage.ErrURLUnknownParam,
|
||||
secrets: nil,
|
||||
},
|
||||
{
|
||||
name: "rclone userinfo",
|
||||
raw: "rclone://user:" + secret + "@gdrive/backups",
|
||||
wantErr: storage.ErrURLCredentials,
|
||||
secrets: []string{secret},
|
||||
},
|
||||
{
|
||||
name: "rclone query param",
|
||||
raw: "rclone://gdrive/backups?token=" + secret,
|
||||
wantErr: storage.ErrURLUnknownParam,
|
||||
secrets: []string{secret},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := storage.ParseStorageURL(tc.raw)
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("ParseStorageURL(%q) error = %v, want %v",
|
||||
tc.raw, err, tc.wantErr)
|
||||
}
|
||||
|
||||
// The rejection must name the proper config keys so the
|
||||
// operator knows where credentials belong.
|
||||
for _, key := range []string{"s3.access_key_id", "s3.secret_access_key"} {
|
||||
if !strings.Contains(err.Error(), key) {
|
||||
t.Errorf("error %q does not name %q", err.Error(), key)
|
||||
}
|
||||
}
|
||||
|
||||
for _, secret := range tc.secrets {
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Errorf("error message leaked %q: %v", secret, err.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseStorageURLParseFailureHidesURL checks that when url.Parse
|
||||
// itself fails, the wrapped error carries only the inner cause, not the
|
||||
// *url.Error whose text embeds the raw (possibly credential-bearing) URL.
|
||||
func TestParseStorageURLParseFailureHidesURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const raw = "s3://mybucket/%zz"
|
||||
|
||||
_, err := storage.ParseStorageURL(raw)
|
||||
if err == nil {
|
||||
t.Fatalf("ParseStorageURL(%q) returned no error", raw)
|
||||
}
|
||||
|
||||
if strings.Contains(err.Error(), "mybucket") {
|
||||
t.Errorf("error message echoed the raw URL: %v", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user