fix(apiserver): bcrypt passwords and default-credential warnings in config-file auth (bug-1947)
Cause: ConfigFileProvider.Authenticate compared the stored plaintext password with != (timing side channel, passwords readable from the file), and config/auth.yaml shipped admin/admin, user1/user1 etc. as plaintext with nothing reporting that a default credential was active. Fix: auth.yaml passwords may be bcrypt hashes (golang.org/x/crypto/bcrypt, v0.23.0 as already pinned in go.mod, now vendored); plaintext entries are still accepted but compared in constant time (SHA-256 + subtle), and an unknown user costs the same bcrypt work. The provider reports startup warnings (SecurityWarner, logged by main) for every plaintext entry and for every shipped default credential still active. The shipped auth.yaml and auth.yaml.example now store bcrypt hashes of the same dev passwords, so tests/regression/run.sh (admin/admin) keeps working. tools/encrypt gains -action=hash to generate entries. Tests: TestConfigFileProvider_BcryptPassword, TestConfigFileProvider_SecurityWarnings, TestShippedAuthConfig_HashedDefaults. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d3ce298232
commit
f91642dbc0
14 changed files with 1037 additions and 16 deletions
|
|
@ -1,6 +1,7 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
|
@ -144,3 +145,112 @@ func TestOAuth2Provider_Name(t *testing.T) {
|
|||
t.Error("expected not-implemented error")
|
||||
}
|
||||
}
|
||||
|
||||
// bcrypt hashes (cost 10) used by the bug-1947 tests.
|
||||
const (
|
||||
testS3cretHash = "$2a$10$lvIFyyQJsUB5cZz68N0Yl.9sMCtqXARtlLnRmPc//gjDJfiOzplPq" // "s3cret"
|
||||
testAdminHash = "$2a$10$WVwHd5Nd22UP1DfIpkekjum0yYVGctD2N.hheBP.5ZxGL92W/u5Ja" // "admin"
|
||||
)
|
||||
|
||||
// bug-1947: auth.yaml passwords may be stored as bcrypt hashes.
|
||||
func TestConfigFileProvider_BcryptPassword(t *testing.T) {
|
||||
yaml := `
|
||||
provider: config_file
|
||||
config_file:
|
||||
users:
|
||||
- username: alice
|
||||
password: "` + testS3cretHash + `"
|
||||
roles: [basic_user]
|
||||
`
|
||||
provider, err := NewConfigFileProvider([]byte(yaml))
|
||||
if err != nil {
|
||||
t.Fatalf("NewConfigFileProvider: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pass string
|
||||
wantErr bool
|
||||
}{
|
||||
{"correct password", "s3cret", false},
|
||||
{"wrong password", "S3cret", true},
|
||||
{"empty password", "", true},
|
||||
{"the stored hash itself", testS3cretHash, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := provider.Authenticate("alice", tt.pass)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Authenticate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// bug-1947: startup warnings for plaintext entries and known default
|
||||
// credentials; none for a bcrypt entry with a non-default password.
|
||||
func TestConfigFileProvider_SecurityWarnings(t *testing.T) {
|
||||
yaml := `
|
||||
provider: config_file
|
||||
config_file:
|
||||
users:
|
||||
- username: alice
|
||||
password: "` + testS3cretHash + `"
|
||||
roles: [basic_user]
|
||||
- username: bob
|
||||
password: plain-not-default
|
||||
roles: [basic_user]
|
||||
- username: admin
|
||||
password: "` + testAdminHash + `"
|
||||
roles: [cluster_admin]
|
||||
`
|
||||
provider, err := NewConfigFileProvider([]byte(yaml))
|
||||
if err != nil {
|
||||
t.Fatalf("NewConfigFileProvider: %v", err)
|
||||
}
|
||||
warnings := provider.SecurityWarnings()
|
||||
joined := strings.Join(warnings, "\n")
|
||||
if !strings.Contains(joined, `"bob"`) || !strings.Contains(joined, "plaintext") {
|
||||
t.Errorf("expected a plaintext warning for bob, got %q", warnings)
|
||||
}
|
||||
if !strings.Contains(joined, `"admin"`) || !strings.Contains(joined, "default") {
|
||||
t.Errorf("expected a default-credential warning for admin, got %q", warnings)
|
||||
}
|
||||
if strings.Contains(joined, `"alice"`) {
|
||||
t.Errorf("alice has a bcrypt non-default password, got warning %q", warnings)
|
||||
}
|
||||
for _, w := range warnings {
|
||||
if strings.Contains(w, "plain-not-default") || strings.Contains(w, "$2a$") {
|
||||
t.Errorf("warning must not contain a password or hash: %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bug-1947: the shipped auth.yaml (and its example) store only bcrypt
|
||||
// hashes; the dev default admin/admin still logs in (tests/regression/run.sh
|
||||
// relies on it) but is reported as a default credential at startup.
|
||||
func TestShippedAuthConfig_HashedDefaults(t *testing.T) {
|
||||
for _, path := range []string{"../../config/auth.yaml", "../../config/auth.yaml.example"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
provider, err := NewConfigFileProvider(data)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConfigFileProvider: %v", err)
|
||||
}
|
||||
for name, u := range provider.users {
|
||||
if !isBcryptHash(u.Password) {
|
||||
t.Errorf("user %q: shipped password is not a bcrypt hash", name)
|
||||
}
|
||||
}
|
||||
if err := provider.Authenticate("admin", "admin"); err != nil {
|
||||
t.Errorf("admin/admin dev login must keep working: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(provider.SecurityWarnings(), "\n"), `"admin"`) {
|
||||
t.Errorf("admin/admin must be reported as a default credential")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue