安全加固:MQTT broker 新增可选连接认证(bcrypt 用户+匿名开关+按 IP 失败限速,配置明文密码首启自动转哈希,默认关闭零影响),py 迁移脚本数据库口令改环境变量(新增 db_config.example.py 模板),新增 doc/SECURITY_FIX_TODO.md 安全修复清单,后端 v1.3.0

This commit is contained in:
2026-08-20 16:51:33 +08:00
parent fbd105bd6c
commit fe0cf036cf
11 changed files with 925 additions and 18 deletions
+144 -6
View File
@@ -6,7 +6,10 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
)
@@ -28,9 +31,27 @@ type Config struct {
}
type MQTTConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
TLS TLSConfig `yaml:"tls"`
Host string `yaml:"host"`
Port int `yaml:"port"`
TLS TLSConfig `yaml:"tls"`
Auth MQTTAuthConfig `yaml:"auth"`
}
// MQTTAuthConfig 控制 MQTT broker 的 CONNECT 认证。
// Enabled=false 时行为与历史版本一致(全部放行)。
type MQTTAuthConfig struct {
Enabled bool `yaml:"enabled"`
AllowAnonymous bool `yaml:"allow_anonymous"`
Users []MQTTAuthUser `yaml:"users"`
}
// MQTTAuthUser 是一个可连接 broker 的账号。
// Password 仅支持写在配置里由首次载入时自动转为 PasswordHash,
// 序列化写回时剔除明文;也可直接提供 password_hash(bcrypt)。
type MQTTAuthUser struct {
Username string `yaml:"username"`
Password string `yaml:"-"`
PasswordHash string `yaml:"password_hash,omitempty"`
}
type TLSConfig struct {
@@ -113,9 +134,22 @@ type rawAIConfig struct {
}
type rawMQTTConfig struct {
Host *string `yaml:"host"`
Port *int `yaml:"port"`
TLS *rawTLSConfig `yaml:"tls"`
Host *string `yaml:"host"`
Port *int `yaml:"port"`
TLS *rawTLSConfig `yaml:"tls"`
Auth *rawMQTTAuthConfig `yaml:"auth"`
}
type rawMQTTAuthConfig struct {
Enabled *bool `yaml:"enabled"`
AllowAnonymous *bool `yaml:"allow_anonymous"`
Users *[]rawMQTTAuthUser `yaml:"users"`
}
type rawMQTTAuthUser struct {
Username *string `yaml:"username"`
Password *string `yaml:"password"`
PasswordHash *string `yaml:"password_hash"`
}
type rawTLSConfig struct {
@@ -172,6 +206,11 @@ func Default() *Config {
CertFile: "",
KeyFile: "",
},
Auth: MQTTAuthConfig{
Enabled: false,
AllowAnonymous: false,
Users: []MQTTAuthUser{},
},
},
Meshtastic: MeshtasticConfig{
PSK: "AQ==",
@@ -377,6 +416,45 @@ func normalize(raw rawConfig) (*Config, bool) {
cfg.MQTT.TLS.KeyFile = *raw.MQTT.TLS.KeyFile
}
}
if raw.MQTT.Auth == nil {
changed = true
} else {
if raw.MQTT.Auth.Enabled == nil {
changed = true
} else {
cfg.MQTT.Auth.Enabled = *raw.MQTT.Auth.Enabled
}
if raw.MQTT.Auth.AllowAnonymous == nil {
changed = true
} else {
cfg.MQTT.Auth.AllowAnonymous = *raw.MQTT.Auth.AllowAnonymous
}
if raw.MQTT.Auth.Users == nil {
changed = true
} else {
users := make([]MQTTAuthUser, 0, len(*raw.MQTT.Auth.Users))
for _, ru := range *raw.MQTT.Auth.Users {
u := MQTTAuthUser{}
if ru.Username == nil {
changed = true
} else {
u.Username = *ru.Username
}
if ru.Password == nil {
changed = true
} else {
u.Password = *ru.Password
}
if ru.PasswordHash == nil {
changed = true
} else {
u.PasswordHash = *ru.PasswordHash
}
users = append(users, u)
}
cfg.MQTT.Auth.Users = users
}
}
}
if raw.Meshtastic == nil {
@@ -525,6 +603,18 @@ func normalize(raw rawConfig) (*Config, bool) {
}
}
// 明文 password 自动转为 bcrypt 哈希,并标记 changed 以便写回时剔除明文。
for i := range cfg.MQTT.Auth.Users {
if cfg.MQTT.Auth.Users[i].Password != "" {
if hashed, err := bcrypt.GenerateFromPassword([]byte(cfg.MQTT.Auth.Users[i].Password), bcrypt.DefaultCost); err == nil {
cfg.MQTT.Auth.Users[i].PasswordHash = string(hashed)
cfg.MQTT.Auth.Users[i].Password = ""
changed = true
}
// 散列失败(如密码超过 72 字节)时保留明文,交给 Validate 报错。
}
}
return cfg, changed
}
@@ -532,6 +622,9 @@ func Validate(cfg *Config) error {
if cfg.MQTT.Port <= 0 || cfg.MQTT.Port > 65535 {
return fmt.Errorf("invalid mqtt port %d: must be 1-65535", cfg.MQTT.Port)
}
if err := validateMQTTAuth(cfg.MQTT.Auth); err != nil {
return err
}
switch cfg.Database.Driver {
case DriverSQLite:
if cfg.Database.SQLite.Path == "" {
@@ -570,6 +663,51 @@ func Validate(cfg *Config) error {
return nil
}
func validateMQTTAuth(auth MQTTAuthConfig) error {
seen := make(map[string]bool, len(auth.Users))
for _, u := range auth.Users {
if u.Username == "" {
return fmt.Errorf("mqtt.auth.users[].username is required")
}
if seen[u.Username] {
return fmt.Errorf("mqtt.auth.users: duplicate username %q", u.Username)
}
seen[u.Username] = true
if u.Password != "" {
return fmt.Errorf("mqtt.auth.users[%s]: password 无法转为哈希(长度须 <= 72 字节),或直接改用 password_hash", u.Username)
}
if u.PasswordHash != "" && !isBcryptHash(u.PasswordHash) {
return fmt.Errorf("mqtt.auth.users[%s]: password_hash 不是合法的 bcrypt 散列($2a$/$2b$/$2y$ 开头),可用 htpasswd -bnBC 10 \"\" '密码' 生成", u.Username)
}
}
if auth.Enabled {
if !auth.AllowAnonymous && len(auth.Users) == 0 {
return fmt.Errorf("mqtt.auth.enabled 为 true 时必须配置至少一个用户,或设置 allow_anonymous: true")
}
for _, u := range auth.Users {
if u.PasswordHash == "" {
return fmt.Errorf("mqtt.auth.users[%s]: 启用认证时必须提供 password 或 password_hash", u.Username)
}
}
}
return nil
}
// isBcryptHash 校验 $2a$/$2b$/$2y$<cost>$<53位散列> 的 bcrypt 格式。
func isBcryptHash(s string) bool {
parts := strings.Split(s, "$")
if len(parts) != 4 || parts[0] != "" {
return false
}
if parts[1] != "2a" && parts[1] != "2b" && parts[1] != "2y" {
return false
}
if _, err := strconv.Atoi(parts[2]); err != nil {
return false
}
return len(parts[3]) == 53
}
func Write(path string, cfg *Config) error {
data, err := yaml.Marshal(cfg)
if err != nil {
+84
View File
@@ -0,0 +1,84 @@
package config
import (
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func loadRaw(t *testing.T, data string) rawConfig {
t.Helper()
var raw rawConfig
if err := yaml.Unmarshal([]byte(data), &raw); err != nil {
t.Fatalf("yaml: %v", err)
}
return raw
}
func TestNormalizePlaintextPasswordBecomesHash(t *testing.T) {
raw := loadRaw(t, `
mqtt:
auth:
enabled: true
users:
- username: mesh
password: secret
`)
cfg, changed := normalize(raw)
if !changed {
t.Fatal("plaintext password must mark config changed")
}
if err := Validate(cfg); err != nil {
t.Fatalf("validate: %v", err)
}
u := cfg.MQTT.Auth.Users[0]
if u.Password != "" {
t.Error("plaintext must be cleared after hashing")
}
if !strings.HasPrefix(u.PasswordHash, "$2") {
t.Errorf("expected bcrypt hash, got %q", u.PasswordHash)
}
out, err := yaml.Marshal(cfg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(out), "password: secret") {
t.Error("serialized config must not contain the plaintext password")
}
if !strings.Contains(string(out), "password_hash") {
t.Error("serialized config must contain password_hash")
}
}
func TestValidateAuthErrors(t *testing.T) {
cases := []struct {
name string
yaml string
want string
}{
{"启用但无用户", "mqtt:\n auth:\n enabled: true\n", "至少一个用户"},
{"坏哈希", "mqtt:\n auth:\n enabled: true\n users:\n - username: a\n password_hash: not-bcrypt\n", "bcrypt"},
{"缺哈希", "mqtt:\n auth:\n enabled: true\n users:\n - username: a\n", "password_hash"},
{"重复用户", "mqtt:\n auth:\n enabled: true\n users:\n - username: a\n password_hash: " + fakeHash + "\n - username: a\n password_hash: " + fakeHash + "\n", "duplicate"},
}
for _, tc := range cases {
cfg, _ := normalize(loadRaw(t, tc.yaml))
err := Validate(cfg)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: got %v, want error containing %q", tc.name, err, tc.want)
}
}
}
func TestValidateAuthDisabledOk(t *testing.T) {
cfg, _ := normalize(loadRaw(t, "mqtt:\n"))
if err := Validate(cfg); err != nil {
t.Fatalf("disabled auth must pass: %v", err)
}
if cfg.MQTT.Auth.Enabled {
t.Error("auth must default to disabled")
}
}
const fakeHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"