forked from kevin/meshtastic_mqtt_server
安全加固:MQTT broker 新增可选连接认证(bcrypt 用户+匿名开关+按 IP 失败限速,配置明文密码首启自动转哈希,默认关闭零影响),py 迁移脚本数据库口令改环境变量(新增 db_config.example.py 模板),新增 doc/SECURITY_FIX_TODO.md 安全修复清单,后端 v1.3.0
This commit is contained in:
+144
-6
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user