mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
fix: close remaining security remediation gaps
This commit is contained in:
@@ -23,3 +23,17 @@ func VerifyPassword(storedHash, plain string) (bool, bool) {
|
|||||||
}
|
}
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsLegacyPasswordHash(storedHash string) bool {
|
||||||
|
storedHash = strings.TrimSpace(storedHash)
|
||||||
|
if len(storedHash) != 32 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range storedHash {
|
||||||
|
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,3 +23,16 @@ func TestVerifyPasswordAcceptsLegacyMD5(t *testing.T) {
|
|||||||
t.Fatalf("VerifyPassword() = (%v,%v), want (true,true)", ok, legacy)
|
t.Fatalf("VerifyPassword() = (%v,%v), want (true,true)", ok, legacy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsLegacyPasswordHash(t *testing.T) {
|
||||||
|
if !IsLegacyPasswordHash("3c85cdebade1c51cf64ca9f3c09d182d") {
|
||||||
|
t.Fatal("expected 32-char hex MD5 hash to be legacy")
|
||||||
|
}
|
||||||
|
hash, err := HashPassword("admin_user")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HashPassword() error = %v", err)
|
||||||
|
}
|
||||||
|
if IsLegacyPasswordHash(hash) {
|
||||||
|
t.Fatalf("expected bcrypt hash not to be legacy: %q", hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package repo
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -19,6 +21,7 @@ import (
|
|||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
"go-backend/internal/security"
|
||||||
"go-backend/internal/store/model"
|
"go-backend/internal/store/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -416,13 +419,23 @@ func prepareSQLiteLegacyColumns(db *gorm.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func seedData(db *gorm.DB) {
|
func seedData(db *gorm.DB) {
|
||||||
adminUser := model.User{
|
var adminCount int64
|
||||||
ID: 1, User: "admin_user", Pwd: "3c85cdebade1c51cf64ca9f3c09d182d",
|
if err := db.Model(&model.User{}).Where("id = ?", 1).Count(&adminCount).Error; err == nil && adminCount == 0 {
|
||||||
RoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0,
|
adminPwd, err := security.HashPassword("admin_user")
|
||||||
FlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000,
|
if err != nil {
|
||||||
UpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true}, Status: 1,
|
log.Printf("seed admin password hash failed: %v", err)
|
||||||
|
} else {
|
||||||
|
adminUser := model.User{
|
||||||
|
ID: 1, User: "admin_user", Pwd: adminPwd,
|
||||||
|
RoleID: 0, ExpTime: 2727251700000, Flow: 99999, InFlow: 0, OutFlow: 0,
|
||||||
|
FlowResetTime: 1, Num: 99999, CreatedTime: 1748914865000,
|
||||||
|
UpdatedTime: sql.NullInt64{Int64: 1754011744252, Valid: true},
|
||||||
|
Status: 1,
|
||||||
|
PasswordChangedAt: 1748914865000,
|
||||||
|
}
|
||||||
|
db.Create(&adminUser)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
db.Where("id = ?", 1).FirstOrCreate(&adminUser)
|
|
||||||
|
|
||||||
appNameConfig := model.ViteConfig{ID: 1, Name: "app_name", Value: "flux", Time: 1755147963000}
|
appNameConfig := model.ViteConfig{ID: 1, Name: "app_name", Value: "flux", Time: 1755147963000}
|
||||||
db.Where("id = ?", 1).FirstOrCreate(&appNameConfig)
|
db.Where("id = ?", 1).FirstOrCreate(&appNameConfig)
|
||||||
@@ -2353,26 +2366,31 @@ func (r *Repository) Import(backup *model.BackupData, types []string) (*model.Im
|
|||||||
func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) {
|
func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error) {
|
||||||
count := 0
|
count := 0
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
item := model.User{
|
pwdHash, status, err := normalizeImportedUserPassword(u.Pwd, u.Status)
|
||||||
ID: u.ID,
|
if err != nil {
|
||||||
User: u.User,
|
return count, err
|
||||||
Pwd: u.Pwd,
|
|
||||||
RoleID: u.RoleID,
|
|
||||||
ExpTime: u.ExpTime,
|
|
||||||
Flow: u.Flow,
|
|
||||||
InFlow: u.InFlow,
|
|
||||||
OutFlow: u.OutFlow,
|
|
||||||
FlowResetTime: u.FlowResetTime,
|
|
||||||
Num: u.Num,
|
|
||||||
CreatedTime: u.CreatedTime,
|
|
||||||
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
|
||||||
Status: u.Status,
|
|
||||||
}
|
}
|
||||||
err := tx.Clauses(clause.OnConflict{
|
item := model.User{
|
||||||
|
ID: u.ID,
|
||||||
|
User: u.User,
|
||||||
|
Pwd: pwdHash,
|
||||||
|
RoleID: u.RoleID,
|
||||||
|
ExpTime: u.ExpTime,
|
||||||
|
Flow: u.Flow,
|
||||||
|
InFlow: u.InFlow,
|
||||||
|
OutFlow: u.OutFlow,
|
||||||
|
FlowResetTime: u.FlowResetTime,
|
||||||
|
Num: u.Num,
|
||||||
|
CreatedTime: u.CreatedTime,
|
||||||
|
UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
|
||||||
|
Status: status,
|
||||||
|
PasswordChangedAt: now,
|
||||||
|
}
|
||||||
|
err = tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
"user", "pwd", "role_id", "exp_time", "flow", "in_flow", "out_flow",
|
"user", "pwd", "role_id", "exp_time", "flow", "in_flow", "out_flow",
|
||||||
"flow_reset_time", "num", "updated_time", "status",
|
"flow_reset_time", "num", "updated_time", "status", "password_changed_at",
|
||||||
}),
|
}),
|
||||||
}).Create(&item).Error
|
}).Create(&item).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2416,6 +2434,33 @@ func importUsers(tx *gorm.DB, users []model.UserBackup, now int64) (int, error)
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeImportedUserPassword(password string, status int) (string, int, error) {
|
||||||
|
password = strings.TrimSpace(password)
|
||||||
|
if strings.HasPrefix(password, "$2") {
|
||||||
|
return password, status, nil
|
||||||
|
}
|
||||||
|
if security.IsLegacyPasswordHash(password) || password == "" {
|
||||||
|
replacement, err := randomPasswordHash()
|
||||||
|
if err != nil {
|
||||||
|
return "", status, err
|
||||||
|
}
|
||||||
|
return replacement, 0, nil
|
||||||
|
}
|
||||||
|
hash, err := security.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return "", status, err
|
||||||
|
}
|
||||||
|
return hash, status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomPasswordHash() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return security.HashPassword(hex.EncodeToString(buf))
|
||||||
|
}
|
||||||
|
|
||||||
func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) {
|
func importNodes(tx *gorm.DB, nodes []model.NodeBackup, now int64) (int, error) {
|
||||||
count := 0
|
count := 0
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
|
|||||||
@@ -5,9 +5,32 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/security"
|
||||||
"go-backend/internal/store/model"
|
"go-backend/internal/store/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestSeedDataDefaultAdminUsesBcrypt(t *testing.T) {
|
||||||
|
r, err := Open(filepath.Join(t.TempDir(), "seed.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repo: %v", err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
admin, err := r.GetUserByUsername("admin_user")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get admin user: %v", err)
|
||||||
|
}
|
||||||
|
if admin == nil {
|
||||||
|
t.Fatal("expected seeded admin user")
|
||||||
|
}
|
||||||
|
if security.IsLegacyPasswordHash(admin.Pwd) {
|
||||||
|
t.Fatalf("seeded admin password is legacy MD5: %q", admin.Pwd)
|
||||||
|
}
|
||||||
|
if ok, legacy := security.VerifyPassword(admin.Pwd, "admin_user"); !ok || legacy {
|
||||||
|
t.Fatalf("VerifyPassword() = (%v,%v), want (true,false)", ok, legacy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBackupRoundTripsTunnelProbeTarget(t *testing.T) {
|
func TestBackupRoundTripsTunnelProbeTarget(t *testing.T) {
|
||||||
source, err := Open(filepath.Join(t.TempDir(), "source.db"))
|
source, err := Open(filepath.Join(t.TempDir(), "source.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -136,6 +159,62 @@ func TestImportIgnoresSensitiveConfigs(t *testing.T) {
|
|||||||
assertConfigValue(t, r, "cloudflare_secret_key", "cloudflare-before")
|
assertConfigValue(t, r, "cloudflare_secret_key", "cloudflare-before")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImportUsersDoesNotStoreLegacyMD5Passwords(t *testing.T) {
|
||||||
|
r, err := Open(filepath.Join(t.TempDir(), "legacy-user-import.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repo: %v", err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
backup := &model.BackupData{
|
||||||
|
Version: "1.0",
|
||||||
|
Users: []model.UserBackup{{
|
||||||
|
ID: 55,
|
||||||
|
User: "legacy-import-user",
|
||||||
|
Pwd: "3c85cdebade1c51cf64ca9f3c09d182d",
|
||||||
|
RoleID: 1,
|
||||||
|
ExpTime: 2727251700000,
|
||||||
|
Flow: 99999,
|
||||||
|
InFlow: 0,
|
||||||
|
OutFlow: 0,
|
||||||
|
FlowResetTime: 1,
|
||||||
|
Num: 99999,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
Status: 1,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.Import(backup, []string{"users"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Import() error = %v", err)
|
||||||
|
}
|
||||||
|
if result.UsersImported != 1 {
|
||||||
|
t.Fatalf("UsersImported = %d, want 1", result.UsersImported)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := r.GetUserByUsername("legacy-import-user")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get imported user: %v", err)
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
t.Fatal("expected imported user")
|
||||||
|
}
|
||||||
|
if security.IsLegacyPasswordHash(user.Pwd) {
|
||||||
|
t.Fatalf("imported password remained legacy MD5: %q", user.Pwd)
|
||||||
|
}
|
||||||
|
if ok, _ := security.VerifyPassword(user.Pwd, "admin_user"); ok {
|
||||||
|
t.Fatal("legacy imported password should not remain usable")
|
||||||
|
}
|
||||||
|
if user.Status != 0 {
|
||||||
|
t.Fatalf("legacy imported user status = %d, want disabled status 0", user.Status)
|
||||||
|
}
|
||||||
|
if user.PasswordChangedAt <= 0 {
|
||||||
|
t.Fatalf("PasswordChangedAt = %d, want import revocation timestamp", user.PasswordChangedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func seedConfig(t *testing.T, r *Repository, name, value string) {
|
func seedConfig(t *testing.T, r *Repository, name, value string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := r.DB().Exec(`
|
if err := r.DB().Exec(`
|
||||||
|
|||||||
@@ -144,7 +144,15 @@ export const configCache = {
|
|||||||
|
|
||||||
// 设置缓存的配置
|
// 设置缓存的配置
|
||||||
set: (key: string, value: string): void => {
|
set: (key: string, value: string): void => {
|
||||||
const cacheKey = CACHE_PREFIX + key;
|
const normalizedKey = key.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!shouldPersistConfigKey(normalizedKey)) {
|
||||||
|
configCache.remove(normalizedKey);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = CACHE_PREFIX + normalizedKey;
|
||||||
|
|
||||||
localStorage.setItem(cacheKey, value);
|
localStorage.setItem(cacheKey, value);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user