diff --git a/README.md b/README.md index b510f25..f45c053 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ web: session_secure: false ``` -> 生产环境请修改默认管理员密码,并设置足够长、随机的 `session_secret`。如果通过 HTTPS 访问 Web 管理后台,建议将 `session_secure` 设置为 `true`。 +> 生产环境请修改默认管理员密码(非回环监听时仍为默认值 `admin` 将拒绝启动),并设置足够长、随机的 `session_secret`。如果通过 HTTPS 访问 Web 管理后台,建议将 `session_secure` 设置为 `true`。 ## 使用 SQLite 部署 @@ -244,12 +244,13 @@ htpasswd -bnBC 10 "" '你的密码' | tr -d ':\n' - 健康检查:`http://服务器地址:8080/api/health` - MQTT broker:`服务器地址:1883` -Web 管理后台默认账号: +Web 管理后台账号:`install.sh` 首次部署会生成随机管理员密码并打印一次;手动部署请通过环境变量设置: -- 用户名:`admin` -- 密码:`admin` +```bash +export MESH_ADMIN_PASSWORD='change-me' +``` -生产环境请务必修改默认密码。 +> 安全守卫:当 Web 监听非回环地址且口令仍为默认值 `admin` 时,程序拒绝启动。请勿在公网使用默认口令。 ## systemd 部署示例 diff --git a/doc/SECURITY_FIX_TODO.md b/doc/SECURITY_FIX_TODO.md index a708a0f..afd70bb 100644 --- a/doc/SECURITY_FIX_TODO.md +++ b/doc/SECURITY_FIX_TODO.md @@ -28,35 +28,28 @@ ## P1 - 高优先(1-2 周内) -- [ ] **T3 install.sh 不再生成 admin/admin 默认口令** - - 位置:`install.sh:90-92`;`internal/config/config.go:184-199` - - 方案:首启随机生成 16 字节密码打印到终端(仅一次);或 web 绑定非 loopback 且口令为默认值时拒绝启动;`README.md:219-224` 删除默认口令描述 - - 验收:新部署无法用 admin/admin 登录 +- [x] **T3 install.sh 不再生成 admin/admin 默认口令(2026-08-20 完成)** + - `install.sh` 首启随机生成 16 位管理员密码并仅打印一次;`main.go` 新增守卫:Web 监听非回环地址且口令仍为默认 `admin` 时拒绝启动(`guardDefaultAdminPassword`);README 同步 + - 验收:新部署拿到随机口令;公网 0.0.0.0+admin/admin 无法启动 ✓(冒烟验证) + - 注意:已部署实例不受影响(守卫只拦默认口令);本地开发若用 0.0.0.0+admin/admin 需先改口令 -- [ ] **T4 登录防爆破与用户名枚举** - - 位置:`internal/web/web.go:227-248` - - 方案: - 1. 按 IP + 用户名维度限速(如 5 次/分钟,锁定 10 分钟,内存或 login_log 实现均可) - 2. 用户不存在时也执行一次 `bcrypt.CompareHashAndPassword`(dummy hash)消除时间侧信道 - - 验收:连续错误登录返回 429;存在/不存在用户名的响应耗时一致 +- [x] **T4 登录防爆破与用户名枚举(2026-08-20 完成)** + - 新增 `internal/ratelimit`(按 key 失败计数+临时封锁,默认 5 次/分钟锁定 10 分钟);登录按来源 IP + 用户名双维度限速,超限返回 429 + - 未知用户名也执行 dummy bcrypt,消除时间侧信道 + - 验收:连续 5 次错误→第 6 次 429;未知/已知用户名耗时一致 ✓(冒烟验证) -- [ ] **T5 瓦片代理 SSRF 加固** - - 位置:`internal/web/map_tile_proxy_routes.go:35,79-87` - - 方案: - 1. 自定义 `http.Transport.DialContext`:解析后拒绝 loopback/RFC1918/169.254.0.0/16/CGNAT/组播/IPv6 ULA 地址(防 DNS rebinding,必须在 connect 时按 IP 校验) - 2. `CheckRedirect`:每跳重新校验目标,最多 2 跳 - 3. `writeMapTile`(198-202 行)Content-Type 白名单:仅 `image/*` 通过,否则强制 `application/octet-stream`(堵同源 HTML XSS) - - 验收:模板指向 `http://169.254.169.254/...` 及外网 302->内网均失败;上游返回 HTML 时浏览器下载而非渲染 +- [x] **T5 瓦片代理 SSRF 加固(2026-08-20 完成)** + - `map_tile_proxy_routes.go`:自定义 DialContext 按解析 IP 拒绝 loopback/私网/链路本地/CGNAT/组播/ULA(DNS rebinding 安全);CheckRedirect 限 2 跳且每跳同样受 Dial 检查;`mapTileContentType` 仅放行 `image/*`,其余强制 `application/octet-stream` + `X-Content-Type-Options: nosniff`(堵同源 HTML XSS) + - 验收:元数据地址 502;外网 302→内网 502;HTML 响应不再以 text/html 下发 ✓(单测+端到端) -- [ ] **T6 `/api/discard-details` 去敏** - - 位置:`internal/web/web.go:154-161,632-634`(线上实测匿名可读 MQTT 客户端 ID/IP/端口/raw_base64) - - 方案:公开响应剔除 `mqtt_remote_addr/host/port`、`raw_base64`;完整数据仅 `RequireAdmin` 分组提供(或直接整体移入 admin) - - 验收:匿名请求响应中无 IP 与原始报文字段 +- [x] **T6 `/api/discard-details` 去敏(2026-08-20 完成)** + - 公开接口改用 `discardDetailsPublicDTO`,剔除 `mqtt_remote_addr/host/port` 与 `raw_base64`;新增 `GET /api/admin/discard-details` 全字段端点(RequireAdmin);前端 `AdminDiscardDetails` 改调 admin 端点 + - 验收:匿名响应无 IP/原始报文;管理页功能保留 ✓(冒烟验证) + - 注意:前端变更需重新 build 部署;`/api/text-messages` 仍公开返回 `mqtt_remote_host`(同源泄露,已列入 P3 观察) -- [ ] **T7 高德地图 key 不再下发到前端** - - 位置:`internal/mapsource/admin_map_source_routes.go:36-47`(线上 `/api/map-source/enabled` 已泄露 `key=35206f...`) - - 方案:外部模板一律走服务端代理(存 hash 形式);`enabled` 接口只返回代理 URL 不返回原始 url_template - - 验收:`/api/map-source/enabled` 响应中无任何 `key=`/外部域名 +- [x] **T7 外部瓦片 URL/key 不再下发前端(2026-08-20 完成)** + - `PublicDTO` 对外部 http(s) 模板一律改写为 `/api/map/{hash}` 代理路径(不再依赖 proxy_enabled 标志);高德 key 等不再出现在 `/api/map-source/enabled` 响应中 + - 验收:公开响应无 `key=`/外部域名;瓦片经代理加载 ✓(单测+端到端) ## P2 - 中优先(迭代内) diff --git a/install.sh b/install.sh index abbf8ff..f223e00 100644 --- a/install.sh +++ b/install.sh @@ -65,6 +65,10 @@ find "${INSTALL_DIR}/dist" -type d -exec chmod 0755 {} \; find "${INSTALL_DIR}/dist" -type f -exec chmod 0644 {} \; if [[ ! -f "${CONFIG_DIR}/config.yaml" ]]; then + ADMIN_PASSWORD="$(head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 16)" + if [[ -z "${ADMIN_PASSWORD}" ]]; then + ADMIN_PASSWORD="admin" # 极低概率兜底,启动守卫会强制修改 + fi cat > "${CONFIG_DIR}/config.yaml" < l.window { + delete(l.fails, key) + } + } +} + +// Blocked 报告 key 当前是否被封锁;未封锁时返回剩余限制描述。 +func (l *FailureLimiter) Blocked(key string) bool { + if key == "" { + return false + } + now := l.now() + l.mu.Lock() + defer l.mu.Unlock() + st, ok := l.fails[key] + return ok && now.Before(st.blockedUntil) +} + +// BlockedRemaining 返回封锁剩余时长;未封锁返回 0。 +func (l *FailureLimiter) BlockedRemaining(key string) time.Duration { + if key == "" { + return 0 + } + now := l.now() + l.mu.Lock() + defer l.mu.Unlock() + st, ok := l.fails[key] + if !ok || now.After(st.blockedUntil) { + return 0 + } + return time.Until(st.blockedUntil) +} + +// Fail 记录一次失败;达到阈值返回 true 表示本次触发封锁。 +func (l *FailureLimiter) Fail(key string) bool { + if key == "" { + return false + } + now := l.now() + l.mu.Lock() + defer l.mu.Unlock() + st, ok := l.fails[key] + if !ok || now.Sub(st.windowStart) > l.window { + st = &failState{windowStart: now} + l.fails[key] = st + if len(l.fails) > l.maxEntries { + l.purgeLocked(now) + } + } + st.count++ + if st.count >= l.max { + st.blockedUntil = now.Add(l.blockFor) + st.count = 0 + st.windowStart = now + return true + } + return false +} + +// Reset 清除 key 的失败计数。 +func (l *FailureLimiter) Reset(key string) { + if key == "" { + return + } + l.mu.Lock() + defer l.mu.Unlock() + delete(l.fails, key) +} + +func (l *FailureLimiter) purgeLocked(now time.Time) { + for key, st := range l.fails { + if now.After(st.blockedUntil) && now.Sub(st.windowStart) > l.window { + delete(l.fails, key) + } + } +} + +// BlockedError 构造统一的封锁提示文案。 +func (l *FailureLimiter) BlockedError(key string) error { + return fmt.Errorf("too many failed attempts, retry after %s", l.BlockedRemaining(key).Round(time.Second)) +} diff --git a/internal/ratelimit/ratelimit_test.go b/internal/ratelimit/ratelimit_test.go new file mode 100644 index 0000000..61313c6 --- /dev/null +++ b/internal/ratelimit/ratelimit_test.go @@ -0,0 +1,81 @@ +package ratelimit + +import ( + "testing" + "time" +) + +func newTestLimiter(t *testing.T, opts Options) *FailureLimiter { + t.Helper() + if opts.MaxFailures == 0 { + opts.MaxFailures = 3 + } + l := New(opts) + t.Cleanup(func() { _ = l.Stop() }) + return l +} + +func TestFailBlocksAndExpires(t *testing.T) { + l := newTestLimiter(t, Options{}) + now := time.Unix(1700000000, 0) + l.now = func() time.Time { return now } + + for i := 0; i < 5; i++ { + l.Fail("1.2.3.4") + } + if !l.Blocked("1.2.3.4") { + t.Fatal("key should be blocked after 5 failures") + } + now = now.Add(11 * time.Minute) + if l.Blocked("1.2.3.4") { + t.Fatal("key should be unblocked after blockFor") + } +} + +func TestFailWindowResets(t *testing.T) { + l := newTestLimiter(t, Options{}) + now := time.Unix(1700000000, 0) + l.now = func() time.Time { return now } + + for i := 0; i < 4; i++ { + l.Fail("ip") + } + now = now.Add(2 * time.Minute) // 超过 window,计数应清零 + l.Fail("ip") + l.Fail("ip") + if l.Blocked("ip") { + t.Fatal("counts should reset after window passes") + } +} + +func TestSuccessResets(t *testing.T) { + l := newTestLimiter(t, Options{}) + l.Fail("ip") + l.Reset("ip") + if l.Blocked("ip") { + t.Fatal("reset must clear failures") + } +} + +func TestDifferentKeysIndependent(t *testing.T) { + l := newTestLimiter(t, Options{}) + for i := 0; i < 10; i++ { + l.Fail("a") + } + if !l.Blocked("a") { + t.Fatal("a should be blocked") + } + if l.Blocked("b") { + t.Fatal("b must not be affected by a") + } +} + +func TestEmptyKeyIgnored(t *testing.T) { + l := newTestLimiter(t, Options{}) + for i := 0; i < 100; i++ { + l.Fail("") + } + if l.Blocked("") { + t.Fatal("empty key must not be tracked") + } +} diff --git a/internal/web/map_tile_proxy_routes.go b/internal/web/map_tile_proxy_routes.go index b0bb947..aa8bb50 100644 --- a/internal/web/map_tile_proxy_routes.go +++ b/internal/web/map_tile_proxy_routes.go @@ -1,10 +1,13 @@ package web import ( + "context" "errors" "fmt" "io" + "net" "net/http" + "net/netip" "os" "path/filepath" "strconv" @@ -20,6 +23,7 @@ import ( const ( mapTileCacheControl = "public, max-age=86400" maxMapTileBytes = 10 << 20 + maxMapTileRedirects = 2 ) type mapTileProxy struct { @@ -32,11 +36,88 @@ func registerMapTileProxyRoutes(r gin.IRouter, store *storepkg.Store, cacheDir s proxy := &mapTileProxy{ store: store, cacheDir: cacheDir, - client: &http.Client{Timeout: 15 * time.Second}, + client: newMapTileHTTPClient(), } r.GET("/map/:sourceHash", proxy.handle) } +// newMapTileHTTPClient 构造带 SSRF 防护的 HTTP 客户端: +// - DialContext 在连接时按解析后的 IP 拒绝内网/链路本地/元数据等地址,防 DNS rebinding; +// - CheckRedirect 限制跳转次数,每次跳转同样经过 DialContext 检查。 +func newMapTileHTTPClient() *http.Client { + dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second} + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + if !isMapTileDialTargetAllowed(ctx, network, addr) { + return nil, fmt.Errorf("blocked map tile dial target %q", addr) + } + return dialer.DialContext(ctx, network, addr) + }, + MaxIdleConns: 8, + IdleConnTimeout: 60 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + } + return &http.Client{ + Timeout: 15 * time.Second, + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= maxMapTileRedirects { + return errors.New("too many map tile redirects") + } + return nil + }, + } +} + +// isMapTileDialTargetAllowed 检查拨号目标是否允许: +// 仅允许 tcp 协议,且解析出的每个 IP 均为公网地址。 +func isMapTileDialTargetAllowed(ctx context.Context, network, addr string) bool { + if network != "tcp" && network != "tcp4" && network != "tcp6" { + return false + } + host, _, err := net.SplitHostPort(addr) + if err != nil { + return false + } + if ip, err := netip.ParseAddr(host); err == nil { + return isPublicAddr(ip) + } + // 主机名:解析后要求所有结果均为公网地址,避免 DNS rebinding 落到内网。 + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil || len(ips) == 0 { + return false + } + for _, r := range ips { + addr, ok := netip.AddrFromSlice(r.IP) + if !ok || !isPublicAddr(addr) { + return false + } + } + return true +} + +// isPublicAddr 判定 IP 是否可安全外访;拒绝回环、私有、链路本地、CGNAT、 +// 组播、未指定与 IPv6 ULA 地址。 +func isPublicAddr(ip netip.Addr) bool { + if !ip.IsValid() { + return false + } + ip = ip.Unmap() + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { + return false + } + // CGNAT 100.64.0.0/10 不在 IsPrivate 范围内,单独拦截。 + if ip.Is4() { + ipv4 := ip.As4() + ipv4Int := uint32(ipv4[0])<<24 | uint32(ipv4[1])<<16 | uint32(ipv4[2])<<8 | uint32(ipv4[3]) + if ipv4Int >= 0x64400000 && ipv4Int <= 0x647fffff { + return false + } + } + return true +} + func (p *mapTileProxy) handle(c *gin.Context) { sourceHash := strings.ToLower(c.Param("sourceHash")) if !isMapTileSourceHash(sourceHash) { @@ -196,7 +277,18 @@ func writeMapTileCacheFile(path string, data []byte) error { } func writeMapTile(c *gin.Context, data []byte) { - contentType := http.DetectContentType(data) + contentType := mapTileContentType(data) c.Header("Cache-Control", mapTileCacheControl) + c.Header("X-Content-Type-Options", "nosniff") c.Data(http.StatusOK, contentType, data) } + +// mapTileContentType 只放行 image/*,其余一律按二进制下发, +// 防止上游返回 HTML/脚本在本站同源渲染(存储型 XSS 面)。 +func mapTileContentType(data []byte) string { + detected := http.DetectContentType(data) + if strings.HasPrefix(detected, "image/") { + return detected + } + return "application/octet-stream" +} diff --git a/internal/web/map_tile_proxy_test.go b/internal/web/map_tile_proxy_test.go new file mode 100644 index 0000000..0bbf969 --- /dev/null +++ b/internal/web/map_tile_proxy_test.go @@ -0,0 +1,83 @@ +package web + +import ( + "context" + "net/netip" + "testing" +) + +func TestIsPublicAddr(t *testing.T) { + cases := []struct { + ip string + allow bool + }{ + {"8.8.8.8", true}, + {"1.1.1.1", true}, + {"2001:4860:4860::8888", true}, + {"127.0.0.1", false}, + {"::1", false}, + {"10.0.0.1", false}, + {"172.16.5.4", false}, + {"192.168.1.1", false}, + {"169.254.169.254", false}, // AWS metadata + {"100.64.0.1", false}, // CGNAT + {"100.127.255.254", false}, // CGNAT + {"224.0.0.1", false}, // multicast + {"0.0.0.0", false}, + {"fc00::1", false}, // ULA + {"fe80::1", false}, // link-local IPv6 + {"", false}, + } + for _, tc := range cases { + addr, err := netip.ParseAddr(tc.ip) + got := false + if err == nil { + got = isPublicAddr(addr) + } + if got != tc.allow { + t.Errorf("isPublicAddr(%q)=%v, want %v", tc.ip, got, tc.allow) + } + } +} + +func TestIsMapTileDialTargetAllowed(t *testing.T) { + cases := []struct { + network string + addr string + allow bool + }{ + {"tcp", "8.8.8.8:443", true}, + {"tcp4", "1.1.1.1:80", true}, + {"tcp", "127.0.0.1:80", false}, + {"tcp", "10.0.0.1:80", false}, + {"tcp", "169.254.169.254:80", false}, + {"tcp", "100.64.1.1:80", false}, + {"tcp6", "[::1]:1883", false}, + {"unix", "/tmp/x.sock", false}, + {"tcp", "no-such-host.invalid:80", false}, + {"tcp", "localhost:80", false}, // 解析到 127.0.0.1 + } + for _, tc := range cases { + if got := isMapTileDialTargetAllowed(context.Background(), tc.network, tc.addr); got != tc.allow { + t.Errorf("isMapTileDialTargetAllowed(%q, %q)=%v, want %v", tc.network, tc.addr, got, tc.allow) + } + } +} + +func TestMapTileContentType(t *testing.T) { + png := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a} + if got := mapTileContentType(png); got != "image/png" { + t.Errorf("png: got %q", got) + } + html := []byte("") + if got := mapTileContentType(html); got != "application/octet-stream" { + t.Errorf("html must be blocked, got %q", got) + } + svg := []byte("") + if got := mapTileContentType(svg); got != "application/octet-stream" { + t.Errorf("svg must be blocked, got %q", got) + } +} + +func TestIsLoopbackHost(t *testing.T) { +} diff --git a/internal/web/web.go b/internal/web/web.go index 1be0c28..500aab8 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -22,6 +22,7 @@ import ( llmadminpkg "meshtastic_mqtt_server/internal/llmadmin" mappkg "meshtastic_mqtt_server/internal/mapsource" mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward" + "meshtastic_mqtt_server/internal/ratelimit" rspkg "meshtastic_mqtt_server/internal/runtimesettings" signpkg "meshtastic_mqtt_server/internal/sign" storepkg "meshtastic_mqtt_server/internal/store" @@ -84,7 +85,7 @@ func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, return r } -const BackendVersion = "1.3.0" +const BackendVersion = "1.4.0" var CommitVersion = "dev" @@ -157,7 +158,8 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str return } rows, err := store.ListDiscardDetails(opts) - writeListResponse(c, rows, opts, err, discardDetailsDTO) + // 公开接口去敏:不含 MQTT 客户端 IP/端口与原始报文,完整数据见 /api/admin/discard-details。 + writeListResponse(c, rows, opts, err, discardDetailsPublicDTO) }) r.GET("/positions", func(c *gin.Context) { opts, ok := parseListOptions(c) @@ -194,6 +196,8 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str } func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender, aiService LLMProviderReloader) { + // 登录防爆破:按来源 IP 与用户名双维度限速。 + loginLimiter := ratelimit.New(ratelimit.Options{}) type loginRequest struct { Username string `json:"username"` Password string `json:"password"` @@ -223,6 +227,8 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma remoteAddr, remoteHost := remoteInfo(c) _ = store.InsertLoginLog(storepkg.LoginLogRecord{Username: username, UserID: userID, Success: success, Reason: reason, RemoteAddr: remoteAddr, RemoteHost: remoteHost, UserAgent: c.GetHeader("User-Agent")}) } + // dummyAdminPasswordHash 是固定 bcrypt 散列,用于未知用户名登录时的耗时对齐。 + const dummyAdminPasswordHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy" r.POST("/login", func(c *gin.Context) { var req loginRequest @@ -231,12 +237,34 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma c.JSON(http.StatusBadRequest, gin.H{"error": "invalid login request"}) return } + _, ipKey := remoteInfo(c) + userKey := "user:" + req.Username + if loginLimiter.Blocked(ipKey) || loginLimiter.Blocked(userKey) { + recordLogin(c, req.Username, nil, false, "rate limited") + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many login attempts, retry later"}) + return + } + user, err := store.GetUserByUsername(req.Username) - if err != nil || user.Role != auth.AdminRole || !auth.VerifyPassword(user.PasswordHash, req.Password) { + if err != nil || user.Role != auth.AdminRole { + // 未知用户名也执行一次 bcrypt 比较,消除用户名枚举的时间侧信道。 + _ = auth.VerifyPassword(dummyAdminPasswordHash, req.Password) + loginLimiter.Fail(ipKey) + loginLimiter.Fail(userKey) recordLogin(c, req.Username, nil, false, "invalid username or password") c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) return } + if !auth.VerifyPassword(user.PasswordHash, req.Password) { + loginLimiter.Fail(ipKey) + loginLimiter.Fail(userKey) + recordLogin(c, req.Username, nil, false, "invalid username or password") + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"}) + return + } + loginLimiter.Reset(ipKey) + loginLimiter.Reset(userKey) + cookie, err := sessions.NewCookie(*user) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -399,6 +427,14 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma rows, err := store.ListLoginLogs(opts) writeListResponse(c, rows, opts, err, loginLogDTO) }) + protected.GET("/discard-details", func(c *gin.Context) { + opts, ok := parseListOptions(c) + if !ok { + return + } + rows, err := store.ListDiscardDetails(opts) + writeListResponse(c, rows, opts, err, discardDetailsDTO) + }) protected.POST("/discard-details/batch-delete", func(c *gin.Context) { var req struct { IDs []uint64 `json:"ids"` @@ -633,6 +669,12 @@ func discardDetailsDTO(row storepkg.DiscardDetailsRecord) gin.H { return gin.H{"id": row.ID, "topic": row.Topic, "error": row.Error, "payload_len": row.PayloadLen, "raw_base64": row.RawBase64, "mqtt_client_id": ptrString(row.MQTTClientID), "mqtt_username": ptrString(row.MQTTUsername), "mqtt_listener": ptrString(row.MQTTListener), "mqtt_remote_addr": ptrString(row.MQTTRemoteAddr), "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "mqtt_remote_port": ptrString(row.MQTTRemotePort), "created_at": row.CreatedAt, "content_json": row.ContentJSON} } +// discardDetailsPublicDTO 是公开接口的无敏感字段视图: +// 剔除 MQTT 客户端 IP/端口与原始报文,避免泄露发布者身份。 +func discardDetailsPublicDTO(row storepkg.DiscardDetailsRecord) gin.H { + return gin.H{"id": row.ID, "topic": row.Topic, "error": row.Error, "payload_len": row.PayloadLen, "mqtt_client_id": ptrString(row.MQTTClientID), "mqtt_username": ptrString(row.MQTTUsername), "mqtt_listener": ptrString(row.MQTTListener), "created_at": row.CreatedAt, "content_json": row.ContentJSON} +} + func positionDTO(row storepkg.PositionRecord) gin.H { return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "created_at": row.CreatedAt, "content_json": row.ContentJSON} } diff --git a/main.go b/main.go index 1ebaffa..005de73 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "os/signal" "runtime" "strconv" + "strings" "syscall" "time" @@ -359,6 +360,9 @@ func parseArgs() (*configpkg.Config, error) { // run 创建 MQTT broker 和 Web 服务,并阻塞等待退出信号。 func run(cfg *configpkg.Config) error { + if err := guardDefaultAdminPassword(cfg); err != nil { + return err + } store, err := storepkg.OpenStore(cfg.Database, cfg.ConsoleLog.SQL) if err != nil { return err @@ -586,6 +590,30 @@ func startMQTTServer(cfg *configpkg.Config, store *storepkg.Store, dbQueue *stor return server, hook, authHook, addr, nil } +// guardDefaultAdminPassword 防止 Web 管理后台以默认口令监听非回环地址。 +// 新部署请通过 MESH_ADMIN_PASSWORD 或在配置中修改 web.admin.password 后再启动。 +func guardDefaultAdminPassword(cfg *configpkg.Config) error { + if !cfg.Web.Enabled || !cfg.Web.PortEnabled { + return nil + } + if cfg.Web.Admin.Password != "" && cfg.Web.Admin.Password != "admin" { + return nil + } + if isLoopbackHost(cfg.Web.Host) { + return nil + } + return errors.New("web.admin.password 仍为默认值 admin 且监听非回环地址,拒绝启动:请设置环境变量 MESH_ADMIN_PASSWORD 或修改配置 web.admin.password 后重试") +} + +func isLoopbackHost(host string) bool { + switch strings.ToLower(strings.TrimSpace(host)) { + case "localhost", "127.0.0.1", "::1": + return true + default: + return false // 空值会绑定全部网卡,按非回环处理 + } +} + // printJSON 将记录编码为 JSON 后按数据包类型着色输出。 func printJSON(record map[string]any) { printJSONBytes(record, mqtpp.MustJSON(record)) diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..9066d31 --- /dev/null +++ b/main_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "testing" + + configpkg "meshtastic_mqtt_server/internal/config" +) + +func TestIsLoopbackHost(t *testing.T) { + for _, in := range []string{"localhost", "127.0.0.1", "::1", "LOCALHOST"} { + if !isLoopbackHost(in) { + t.Errorf("%q should be loopback", in) + } + } + for _, in := range []string{"0.0.0.0", "", "192.168.1.5", " ", "meshmap.lmve.net"} { + if isLoopbackHost(in) { + t.Errorf("%q should not be loopback", in) + } + } +} + +func TestGuardDefaultAdminPassword(t *testing.T) { webEnabled := func(host, password string, portEnabled bool) *configpkg.Config { + return &configpkg.Config{ + Web: configpkg.WebConfig{ + Enabled: true, + PortEnabled: portEnabled, + Host: host, + Admin: configpkg.WebAdminConfig{Username: "admin", Password: password}, + }, + } + } + + cases := []struct { + name string + cfg *configpkg.Config + reject bool + }{ + {"公网+默认口令", webEnabled("0.0.0.0", "admin", true), true}, + {"公网+自定义口令", webEnabled("0.0.0.0", "s3cret!", true), false}, + {"回环+默认口令", webEnabled("127.0.0.1", "admin", true), false}, + {"空口令", webEnabled("0.0.0.0", "", true), true}, + {"仅socket", webEnabled("0.0.0.0", "admin", false), false}, + } + for _, tc := range cases { + err := guardDefaultAdminPassword(tc.cfg) + if tc.reject && err == nil { + t.Errorf("%s: expected rejection", tc.name) + } + if !tc.reject && err != nil { + t.Errorf("%s: unexpected rejection: %v", tc.name, err) + } + } +} diff --git a/meshmap_frontend/src/api.ts b/meshmap_frontend/src/api.ts index a0c3a46..125ec58 100644 --- a/meshmap_frontend/src/api.ts +++ b/meshmap_frontend/src/api.ts @@ -232,7 +232,8 @@ export function getPositions(limit = 500, offset = 0, nodeIdOrOptions: string | } export function getDiscardDetails(limit = 100, offset = 0): Promise> { - return getJSON>(listPath('/api/discard-details', limit, offset)) + // 公开接口 /api/discard-details 已去敏(不含客户端 IP/原始报文),管理页改用 admin 全字段端点。 + return getJSON>(listPath('/api/admin/discard-details', limit, offset)) } export function deleteDiscardDetailsByIDs(ids: number[]): Promise<{ status: string; deleted_count: number }> {