myxce/src/frontend/apiserver/backend/auth/config_file_test.go
Rongsong Shen f91642dbc0
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>
2026-10-10 08:58:19 +00:00

256 lines
7.1 KiB
Go

package auth
import (
"os"
"strings"
"testing"
)
func TestConfigFileProvider_Authenticate(t *testing.T) {
yaml := `
provider: config_file
config_file:
users:
- username: admin
password: secret
roles: [cluster_admin]
- username: viewer
password: pass
roles: [basic_user]
`
provider, err := NewConfigFileProvider([]byte(yaml))
if err != nil {
t.Fatalf("NewConfigFileProvider: %v", err)
}
tests := []struct {
name string
user string
pass string
wantErr bool
}{
{"valid admin", "admin", "secret", false},
{"valid viewer", "viewer", "pass", false},
{"wrong password", "admin", "wrong", true},
{"unknown user", "nobody", "pass", true},
{"empty user", "", "pass", true},
{"empty password", "admin", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := provider.Authenticate(tt.user, tt.pass)
if (err != nil) != tt.wantErr {
t.Errorf("Authenticate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestConfigFileProvider_GetUserRoles(t *testing.T) {
yaml := `
provider: config_file
config_file:
users:
- username: admin
password: admin
roles: [cluster_admin, operator]
- username: guest
password: guest
roles: []
`
provider, err := NewConfigFileProvider([]byte(yaml))
if err != nil {
t.Fatalf("NewConfigFileProvider: %v", err)
}
if roles := provider.GetUserRoles("admin"); len(roles) != 2 {
t.Errorf("admin roles = %v, want 2", roles)
}
if roles := provider.GetUserRoles("guest"); len(roles) != 0 {
t.Errorf("guest roles = %v, want empty", roles)
}
if roles := provider.GetUserRoles("unknown"); roles != nil {
t.Errorf("unknown roles = %v, want nil", roles)
}
}
func TestConfigFileProvider_Name(t *testing.T) {
p := &ConfigFileProvider{}
if got := p.Name(); got != "config_file" {
t.Errorf("Name() = %q, want %q", got, "config_file")
}
}
func TestNewConfigFileProvider_Errors(t *testing.T) {
t.Run("invalid yaml", func(t *testing.T) {
_, err := NewConfigFileProvider([]byte("{{invalid"))
if err == nil {
t.Error("expected error for invalid yaml")
}
})
t.Run("no users", func(t *testing.T) {
_, err := NewConfigFileProvider([]byte("provider: config_file\nconfig_file:\n users: []"))
if err == nil {
t.Error("expected error for empty users list")
}
})
// bug-1951: an auth.yaml without a config_file block must be a clear
// configuration error, not a nil-pointer panic.
t.Run("missing config_file block", func(t *testing.T) {
_, err := NewConfigFileProvider([]byte("provider: config_file\n"))
if err == nil {
t.Fatal("expected error for missing config_file block")
}
if !strings.Contains(err.Error(), "config_file") {
t.Errorf("error %q should name the missing config_file block", err)
}
})
t.Run("empty username", func(t *testing.T) {
_, err := NewConfigFileProvider([]byte("provider: config_file\nconfig_file:\n users:\n - username: ''\n password: x"))
if err == nil {
t.Error("expected error for empty username")
}
})
}
func TestPAMProvider_Name(t *testing.T) {
p := &PAMProvider{}
if got := p.Name(); got != "pam" {
t.Errorf("Name() = %q, want %q", got, "pam")
}
}
func TestLDAPProvider_Name(t *testing.T) {
p := &LDAPProvider{}
if got := p.Name(); got != "ldap" {
t.Errorf("Name() = %q, want %q", got, "ldap")
}
if err := p.Authenticate("u", "p"); err == nil {
t.Error("expected not-implemented error")
}
}
func TestOAuth2Provider_Name(t *testing.T) {
p := &OAuth2Provider{}
if got := p.Name(); got != "oauth2" {
t.Errorf("Name() = %q, want %q", got, "oauth2")
}
if err := p.Authenticate("u", "p"); err == nil {
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")
}
})
}
}