安全加固:Web 登录防爆破(IP+用户名双维度限速 5次/分钟锁10分钟,未知用户名 dummy bcrypt 防枚举),瓦片代理 SSRF 加固(DialContext 拒绝内网/链路本地/CGNAT/ULA 与重定向限制,Content-Type image 白名单+nosniff),/api/discard-details 公开去敏(新增 admin 全字段端点),公开地图源接口外部 URL/key 一律代理化,install.sh 随机管理员密码+非回环默认口令拒绝启动,后端 v1.4.0

This commit is contained in:
2026-08-20 17:00:41 +08:00
parent fe0cf036cf
commit f21e8337af
13 changed files with 658 additions and 48 deletions
+6 -5
View File
@@ -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 部署示例
+18 -25
View File
@@ -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 - 中优先(迭代内)
+13 -2
View File
@@ -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" <<EOF
mqtt:
host: 0.0.0.0
@@ -91,7 +95,7 @@ database:
path: ${DATA_DIR}/${SERVICE_NAME}.db
mysql:
dsn: ""
web:
web:
enabled: true
host: 0.0.0.0
port: 8080
@@ -99,7 +103,7 @@ web:
static_dir: ${INSTALL_DIR}/dist
admin:
username: admin
password: admin
password: ${ADMIN_PASSWORD}
session_secret: ""
session_secure: false
console_log:
@@ -143,3 +147,10 @@ systemctl restart "${SERVICE_NAME}"
echo "部署完成,服务状态:"
systemctl --no-pager --full status "${SERVICE_NAME}"
if [[ -n "${ADMIN_PASSWORD:-}" ]]; then
echo
echo "======================================================"
echo " Web 管理后台初始账号: admin / ${ADMIN_PASSWORD}"
echo " 该密码仅显示这一次,请立即登录并修改!"
echo "======================================================"
fi
+10 -3
View File
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -163,11 +164,11 @@ func AdminDTO(row storepkg.MapTileSourceRecord) gin.H {
return gin.H{"id": row.ID, "name": row.Name, "url_template": row.URLTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom, "enabled": row.Enabled, "is_default": row.IsDefault, "proxy_enabled": row.ProxyEnabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
}
// PublicDTO 是给前端用户使用的视图:当 ProxyEnabled 为 true 时,url 改写为
// 通过本服务的 /api/map/{hash} 代理路径避免暴露上游瓦片地址。
// PublicDTO 是给前端用户使用的视图:外部 http(s) 模板一律改写为经本服务的
// /api/map/{hash} 代理路径,避免向下游暴露上游瓦片地址与密钥
func PublicDTO(row storepkg.MapTileSourceRecord) gin.H {
urlTemplate := row.URLTemplate
if row.ProxyEnabled {
if isExternalTileURLTemplate(urlTemplate) {
hash := row.URLTemplateHash
if hash == "" {
hash = storepkg.MapTileSourceHash(row.URLTemplate)
@@ -176,3 +177,9 @@ func PublicDTO(row storepkg.MapTileSourceRecord) gin.H {
}
return gin.H{"id": row.ID, "name": row.Name, "url_template": urlTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom}
}
// isExternalTileURLTemplate 判断模板是否指向外部 http/https 资源。
func isExternalTileURLTemplate(template string) bool {
t := strings.ToLower(strings.TrimSpace(template))
return strings.HasPrefix(t, "http://") || strings.HasPrefix(t, "https://")
}
@@ -0,0 +1,45 @@
package mapsource
import (
"testing"
storepkg "meshtastic_mqtt_server/internal/store"
)
func TestIsExternalTileURLTemplate(t *testing.T) {
for _, in := range []string{"http://tile.openstreetmap.org/x", "https://webst03.is.autonavi.com/appmaptile?key=1", "HTTPS://EXAMPLE.COM/x"} {
if !isExternalTileURLTemplate(in) {
t.Errorf("%q should be external", in)
}
}
for _, in := range []string{"/api/map/abc?x={x}", "", " /relative/path "} {
if isExternalTileURLTemplate(in) {
t.Errorf("%q should not be external", in)
}
}
}
func TestPublicDTOAlwaysProxiesExternalURL(t *testing.T) {
row := storepkg.MapTileSourceRecord{
URLTemplate: "https://tile.example.com/style=7&x={x}&y={y}&z={z}&key=SECRET_KEY",
}
dto := PublicDTO(row)
got, _ := dto["url_template"].(string)
want := "/api/map/" + storepkg.MapTileSourceHash(row.URLTemplate) + "?x={x}&y={y}&z={z}"
if got != want {
t.Errorf("url_template = %q, want %q", got, want)
}
if dto["url_template"].(string) == row.URLTemplate {
t.Error("external url_template must not be exposed")
}
}
func TestPublicDTOKeepsRelativeTemplate(t *testing.T) {
row := storepkg.MapTileSourceRecord{
URLTemplate: "/api/map/abc?x={x}&y={y}&z={z}",
}
dto := PublicDTO(row)
if got := dto["url_template"].(string); got != row.URLTemplate {
t.Errorf("relative template should stay as-is, got %q", got)
}
}
+173
View File
@@ -0,0 +1,173 @@
// Package ratelimit 提供按 key(IP、用户名等)计数的失败限速器。
//
// 语义:key 在 Window 时间窗内连续失败 MaxFailures 次后,封锁 BlockFor 时长;
// 成功调用 Reset 清空计数。并发安全,内部定期清理过期条目。
package ratelimit
import (
"fmt"
"sync"
"time"
)
const (
defaultMaxFailures = 5
defaultWindow = time.Minute
defaultBlockFor = 10 * time.Minute
)
// FailureLimiter 按 key 跟踪失败次数并执行临时封锁。
type FailureLimiter struct {
mu sync.Mutex
max int
window time.Duration
blockFor time.Duration
fails map[string]*failState
now func() time.Time
stopped chan struct{}
stopOnce sync.Once
maxEntries int
}
type failState struct {
count int
windowStart time.Time
blockedUntil time.Time
}
// Options 自定义限速参数,零值使用默认(5 次/分钟,封锁 10 分钟)。
type Options struct {
MaxFailures int
Window time.Duration
BlockFor time.Duration
}
// New 构造限速器并启动清理协程;服务退出时应调用 Stop。
func New(opts Options) *FailureLimiter {
if opts.MaxFailures <= 0 {
opts.MaxFailures = defaultMaxFailures
}
if opts.Window <= 0 {
opts.Window = defaultWindow
}
if opts.BlockFor <= 0 {
opts.BlockFor = defaultBlockFor
}
l := &FailureLimiter{
max: opts.MaxFailures,
window: opts.Window,
blockFor: opts.BlockFor,
fails: make(map[string]*failState),
now: time.Now,
stopped: make(chan struct{}),
maxEntries: 8192,
}
go l.cleanupLoop()
return l
}
// Stop 结束清理协程。
func (l *FailureLimiter) Stop() error {
l.stopOnce.Do(func() { close(l.stopped) })
return nil
}
func (l *FailureLimiter) cleanupLoop() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-l.stopped:
return
case <-ticker.C:
l.purge()
}
}
}
func (l *FailureLimiter) purge() {
now := l.now()
l.mu.Lock()
defer l.mu.Unlock()
for key, st := range l.fails {
if now.After(st.blockedUntil) && now.Sub(st.windowStart) > 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))
}
+81
View File
@@ -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")
}
}
+94 -2
View File
@@ -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"
}
+83
View File
@@ -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("<html><script>alert(1)</script></html>")
if got := mapTileContentType(html); got != "application/octet-stream" {
t.Errorf("html must be blocked, got %q", got)
}
svg := []byte("<svg xmlns='http://www.w3.org/2000/svg'></svg>")
if got := mapTileContentType(svg); got != "application/octet-stream" {
t.Errorf("svg must be blocked, got %q", got)
}
}
func TestIsLoopbackHost(t *testing.T) {
}
+45 -3
View File
@@ -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}
}
+28
View File
@@ -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))
+53
View File
@@ -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)
}
}
}
+2 -1
View File
@@ -232,7 +232,8 @@ export function getPositions(limit = 500, offset = 0, nodeIdOrOptions: string |
}
export function getDiscardDetails(limit = 100, offset = 0): Promise<ListResponse<DiscardDetails>> {
return getJSON<ListResponse<DiscardDetails>>(listPath('/api/discard-details', limit, offset))
// 公开接口 /api/discard-details 已去敏(不含客户端 IP/原始报文),管理页改用 admin 全字段端点。
return getJSON<ListResponse<DiscardDetails>>(listPath('/api/admin/discard-details', limit, offset))
}
export function deleteDiscardDetailsByIDs(ids: number[]): Promise<{ status: string; deleted_count: number }> {