From b0202062af731c38dc6b52625bbb8f9a7a03c932 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 20 Aug 2026 17:19:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA=EF=BC=9Aco?= =?UTF-8?q?nsole=5Flog.meshtastic=20=E9=BB=98=E8=AE=A4=E5=85=B3=E9=97=AD(?= =?UTF-8?q?=E4=B8=8D=E6=89=93=E5=8D=B0=E8=A7=A3=E5=AF=86=E6=98=8E=E6=96=87?= =?UTF-8?q?)=EF=BC=8Csession=5Fsecure=20=E9=BB=98=E8=AE=A4=20true=EF=BC=8C?= =?UTF-8?q?config.yaml=20=E5=9B=9E=E5=86=99=E6=9D=83=E9=99=90=E6=94=B9=200?= =?UTF-8?q?600=EF=BC=8C=E5=85=AC=E5=BC=80=E6=8E=A5=E5=8F=A3=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E4=BF=A1=E6=81=AF=E8=84=B1=E6=95=8F(/api/health?= =?UTF-8?q?=E3=80=81/api/channels=E3=80=81=E5=88=97=E8=A1=A8=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8Ctext-messages=20=E7=A7=BB=E9=99=A4=20mqtt=5Fr?= =?UTF-8?q?emote=5Fhost)=EF=BC=8Cbot=20PSK=20=E4=B8=8D=E5=9B=9E=E6=98=BE(p?= =?UTF-8?q?sk=5Fset+=E6=9B=B4=E6=96=B0=E4=BF=9D=E6=8C=81=E5=8E=9F=E5=80=BC?= =?UTF-8?q?)=EF=BC=8C=E5=89=8D=E7=AB=AF=20help=20=E9=A1=B5=20DOMPurify=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=B6=88=E6=AF=92=EF=BC=8C=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=20v1.6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++++-- doc/SECURITY_FIX_TODO.md | 12 ++++++------ install.sh | 8 +++++--- internal/bot/admin_bot_routes.go | 2 +- internal/config/config.go | 9 ++++++--- internal/store/bot_store.go | 5 +++++ internal/web/web.go | 12 ++++++++---- internal/webutil/webutil.go | 15 ++++++++++++--- meshmap_frontend/package-lock.json | 17 +++++++++++++++++ meshmap_frontend/package.json | 1 + meshmap_frontend/src/components/AdminBot.vue | 15 +++++++++------ .../src/components/AdminHelpEdit.vue | 7 ++++--- meshmap_frontend/src/components/AdminLLM.vue | 4 ++-- meshmap_frontend/src/components/HelpPage.vue | 4 +++- meshmap_frontend/src/types.ts | 2 +- 15 files changed, 84 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index f45c053..e47a71e 100644 --- a/README.md +++ b/README.md @@ -174,10 +174,12 @@ web: username: admin password: admin session_secret: "" - session_secure: false + session_secure: true ``` -> 生产环境请修改默认管理员密码(非回环监听时仍为默认值 `admin` 将拒绝启动),并设置足够长、随机的 `session_secret`。如果通过 HTTPS 访问 Web 管理后台,建议将 `session_secure` 设置为 `true`。 +> 生产环境请修改默认管理员密码(非回环监听时仍为默认值 `admin` 将拒绝启动),并设置足够长、随机的 `session_secret`。`session_secure` 默认为 `true`(HTTPS 部署适用),纯 HTTP 部署需改为 `false` 否则登录 cookie 不会回传。 + +> `console_log.meshtastic` 默认 `false`:解码后的 Meshtastic 数据包可能包含私聊明文,默认不打印;调试时可显式开启。 ## 使用 SQLite 部署 diff --git a/doc/SECURITY_FIX_TODO.md b/doc/SECURITY_FIX_TODO.md index dd766f5..416b249 100644 --- a/doc/SECURITY_FIX_TODO.md +++ b/doc/SECURITY_FIX_TODO.md @@ -81,12 +81,12 @@ ## P3 - 低优先(择机) -- [ ] **T14 解密私聊默认不打控制台日志** - `internal/config/config.go:204-210` 将 `console_log.meshtastic` 默认改 false,或至少对 `text_message` 且 DM 来源脱敏(`main.go:239-241`) -- [ ] **T15 session cookie `Secure: true`** - 生产部署 HTTPS 下设置 `session_secure: true`(`install.sh:94` / config 默认值);确认 nginx 强制 HTTP->HTTPS 跳转 -- [ ] **T16 config.yaml 回写权限** - `internal/config/config.go:573-582` `Write` 改 0600,避免明文密码 0644 可读 -- [ ] **T17 公开接口错误信息脱敏** - `/api/health` 等公开路由将 `err.Error()` 映射为固定文案(`webutil.go:178,191`) -- [ ] **T18 bot PSK 不回显** - `internal/bot/admin_bot_routes.go:308` 改为 `psk_set` 布尔,与 forwarder 路由风格一致 -- [ ] **T19 前端 help 页防御性消毒** - `meshmap_frontend/src/components/HelpPage.vue:38` 的 `v-html` 前增加 DOMPurify(纵深防御,当前依赖服务端 bluemonday) +- [x] **T14 解密内容默认不打控制台日志(2026-08-20 完成)** - `console_log.meshtastic` 默认改 false(新部署);install.sh 模板同步;README 注明调试可显式开启。既有配置显式写 true 的不受影响 +- [x] **T15 session cookie `Secure: true` 默认(2026-08-20 完成)** - config 默认与 install.sh 模板改 `session_secure: true`(HTTPS 部署适用);纯 HTTP 部署需显式改回 false;README 注明 +- [x] **T16 config.yaml 回写权限(2026-08-20 完成)** - `config.Write` 改 0600,配置文件含明文口令仅属主可读 ✓(冒烟验证) +- [x] **T17 公开接口错误信息脱敏(2026-08-20 完成)** - `webutil.WriteListResponse*` 统一返回 "internal error" 并 stderr 记详情;`/api/health`、`/api/channels` 同处理;`/api/text-messages` 移除 `mqtt_remote_host`(与 T6 同源泄露) +- [x] **T18 bot PSK 不回显(2026-08-20 完成)** - bot DTO 改 `psk_set` 布尔;`UpdateBotNode` 空 PSK 保持原值(避免改配置时密钥被重置为 AQ==);前端编辑表单留空保持不变 ✓(冒烟验证) +- [x] **T19 前端 help 页 DOMPurify 消毒(2026-08-20 完成)** - `HelpPage` 与 `AdminHelpEdit` 的 v-html 前增加 `DOMPurify.sanitize`(纵深防御,服务端 bluemonday 之上);新增依赖 `dompurify` --- diff --git a/install.sh b/install.sh index f223e00..9238a36 100644 --- a/install.sh +++ b/install.sh @@ -104,14 +104,16 @@ database: admin: username: admin password: ${ADMIN_PASSWORD} - session_secret: "" - session_secure: false + session_secret: "" + # 前端经 HTTPS(nginx 反代)访问时保持 true;纯 HTTP 部署需改回 false + session_secure: true console_log: web: true mqtt: true llm: true sql: true - meshtastic: true + # 默认不打印解码后的 Meshtastic 数据包(含私聊明文),调试时改回 true + meshtastic: false EOF chown "${SERVICE_USER}:${SERVICE_GROUP}" "${CONFIG_DIR}/config.yaml" chmod 0640 "${CONFIG_DIR}/config.yaml" diff --git a/internal/bot/admin_bot_routes.go b/internal/bot/admin_bot_routes.go index 8b59e31..ed95ff6 100644 --- a/internal/bot/admin_bot_routes.go +++ b/internal/bot/admin_bot_routes.go @@ -305,7 +305,7 @@ func writeBotNodeMutationResponse(c *gin.Context, status int, row *storepkg.BotN } func botNodeDTO(row storepkg.BotNodeRecord) gin.H { - return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": row.NodeNum, "long_name": row.LongName, "short_name": row.ShortName, "enabled": row.Enabled, "default_channel_id": row.DefaultChannelID, "topic_prefix": row.TopicPrefix, "psk": row.PSK, "public_key": row.PublicKey, "private_key_set": row.PrivateKey != "", "nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled, "nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds, "last_nodeinfo_broadcast_at": row.LastNodeInfoBroadcastAt, "llm_queue_enabled": row.LLMQueueEnabled, "llm_include_channel_messages": row.LLMIncludeChannelMessages, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} + return gin.H{"id": row.ID, "node_id": row.NodeID, "node_num": row.NodeNum, "long_name": row.LongName, "short_name": row.ShortName, "enabled": row.Enabled, "default_channel_id": row.DefaultChannelID, "topic_prefix": row.TopicPrefix, "psk_set": row.PSK != "", "public_key": row.PublicKey, "private_key_set": row.PrivateKey != "", "nodeinfo_broadcast_enabled": row.NodeInfoBroadcastEnabled, "nodeinfo_broadcast_interval_seconds": row.NodeInfoBroadcastIntervalSeconds, "last_nodeinfo_broadcast_at": row.LastNodeInfoBroadcastAt, "llm_queue_enabled": row.LLMQueueEnabled, "llm_include_channel_messages": row.LLMIncludeChannelMessages, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt} } func botMessageDTO(row storepkg.BotMessageRecord) gin.H { diff --git a/internal/config/config.go b/internal/config/config.go index 5145431..3fa850b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -233,7 +233,7 @@ func Default() *Config { Username: "admin", Password: "admin", SessionSecret: "", - SessionSecure: false, + SessionSecure: true, }, }, AI: AIConfig{ @@ -245,7 +245,9 @@ func Default() *Config { MQTT: true, LLM: true, SQL: true, - Meshtastic: true, + // 默认不打印解码后的 Meshtastic 数据包:其中包含私聊明文。 + // 需要调试时可显式开启 console_log.meshtastic。 + Meshtastic: false, }, } } @@ -713,7 +715,8 @@ func Write(path string, cfg *Config) error { if err != nil { return fmt.Errorf("encode config file %s: %w", path, err) } - if err := os.WriteFile(path, data, 0644); err != nil { + // 0600:配置文件含明文口令/密钥,只允许属主读写。 + if err := os.WriteFile(path, data, 0600); err != nil { return fmt.Errorf("write config file %s: %w", path, err) } return nil diff --git a/internal/store/bot_store.go b/internal/store/bot_store.go index cf7c262..e411c28 100644 --- a/internal/store/bot_store.go +++ b/internal/store/bot_store.go @@ -116,6 +116,11 @@ func (s *Store) UpdateBotNode(id uint64, input BotNodeInput) (*BotNodeRecord, er preserved := existing.NodeNum input.NodeNum = &preserved } + // PSK 不回显给前端后,更新表单不会携带原值;为空时保持现有 PSK, + // 避免用户只是改个名字就把频道密钥重置为默认 AQ==。 + if strings.TrimSpace(input.PSK) == "" { + input.PSK = existing.PSK + } row, err := s.normalizedBotNodeRecord(input) if err != nil { return nil, err diff --git a/internal/web/web.go b/internal/web/web.go index df723ae..8f55974 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -2,6 +2,7 @@ package web import ( "errors" + "fmt" "net" "net/http" "os" @@ -85,7 +86,7 @@ func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, return r } -const BackendVersion = "1.5.0" +const BackendVersion = "1.6.0" var CommitVersion = "dev" @@ -93,8 +94,9 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str r.GET("/health", func(c *gin.Context) { status := gin.H{"status": "ok", "database": "ok"} if err := store.Ping(); err != nil { + fmt.Fprintf(os.Stderr, "[web] health check database error: %v\n", err) status["status"] = "error" - status["database"] = err.Error() + status["database"] = "unavailable" c.JSON(http.StatusServiceUnavailable, status) return } @@ -153,7 +155,8 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str r.GET("/channels", func(c *gin.Context) { rows, err := store.ListChannels() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + fmt.Fprintf(os.Stderr, "[web] list channels error: %v\n", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } items := make([]gin.H, 0, len(rows)) @@ -684,7 +687,8 @@ func mapReportClusterDTO(row storepkg.MapReportClusterRecord) gin.H { } func textMessageDTO(row storepkg.TextMessageRecord) gin.H { - return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "packet_id": ptrInt64(row.PacketID), "text": ptrString(row.Text), "topic": row.Topic, "channel_id": ptrString(row.ChannelID), "created_at": row.CreatedAt, "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "content_json": row.ContentJSON} + // 不含 mqtt_remote_host:公开接口不暴露发布者 IP(见安全修复 T6 同类处理)。 + return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "packet_id": ptrInt64(row.PacketID), "text": ptrString(row.Text), "topic": row.Topic, "channel_id": ptrString(row.ChannelID), "created_at": row.CreatedAt, "content_json": row.ContentJSON} } func discardDetailsDTO(row storepkg.DiscardDetailsRecord) gin.H { diff --git a/internal/webutil/webutil.go b/internal/webutil/webutil.go index 3c0097d..b56d0a0 100644 --- a/internal/webutil/webutil.go +++ b/internal/webutil/webutil.go @@ -7,13 +7,15 @@ package webutil import ( + "fmt" "net/http" + "os" "strconv" "time" "github.com/gin-gonic/gin" - "meshtastic_mqtt_server/internal/store" + store "meshtastic_mqtt_server/internal/store" ) // ParseListOptions 从请求中读取 limit / offset / since / until / node_id / @@ -175,7 +177,8 @@ func ParseOptionalFloatQuery(c *gin.Context, name string) (float64, bool, bool) // WriteListResponse 把 rows 通过 convert 转成 gin.H 后包装成 {items, limit, offset}。 func WriteListResponse[T any](c *gin.Context, rows []T, opts store.ListOptions, err error, convert func(T) gin.H) { if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + logQueryError(c, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } items := make([]gin.H, 0, len(rows)) @@ -188,7 +191,8 @@ func WriteListResponse[T any](c *gin.Context, rows []T, opts store.ListOptions, // WriteListResponseWithTotal 在 WriteListResponse 基础上额外携带 total 字段。 func WriteListResponseWithTotal[T any](c *gin.Context, rows []T, opts store.ListOptions, total int64, err error, convert func(T) gin.H) { if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + logQueryError(c, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) return } items := make([]gin.H, 0, len(rows)) @@ -198,6 +202,11 @@ func WriteListResponseWithTotal[T any](c *gin.Context, rows []T, opts store.List c.JSON(http.StatusOK, gin.H{"items": items, "limit": opts.Limit, "offset": opts.Offset, "total": total}) } +// logQueryError 把查询错误详情打到 stderr,响应体只回固定文案,避免泄露库表结构。 +func logQueryError(c *gin.Context, err error) { + fmt.Fprintf(os.Stderr, "[web] %s %s query error: %v\n", c.Request.Method, c.Request.URL.Path, err) +} + // PtrString / PtrInt64 / PtrUint64 / PtrFloat64 / PtrBool 把指针解引用成 any, // 用于把数据库可空字段转换成 JSON 时让 nil 序列化为 null。 func PtrString(value *string) any { diff --git a/meshmap_frontend/package-lock.json b/meshmap_frontend/package-lock.json index aeac2c1..64b54af 100644 --- a/meshmap_frontend/package-lock.json +++ b/meshmap_frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "meshmap_frontend", "version": "0.0.0", "dependencies": { + "dompurify": "^3.4.14", "leaflet": "^1.9.4", "vue": "^3.5.34" }, @@ -789,6 +790,13 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.7", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", @@ -993,6 +1001,15 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/enhanced-resolve": { "version": "5.23.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", diff --git a/meshmap_frontend/package.json b/meshmap_frontend/package.json index 7dd0b58..a3d73ed 100644 --- a/meshmap_frontend/package.json +++ b/meshmap_frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "dompurify": "^3.4.14", "leaflet": "^1.9.4", "vue": "^3.5.34" }, diff --git a/meshmap_frontend/src/components/AdminBot.vue b/meshmap_frontend/src/components/AdminBot.vue index aa3f1d6..3827c38 100644 --- a/meshmap_frontend/src/components/AdminBot.vue +++ b/meshmap_frontend/src/components/AdminBot.vue @@ -98,18 +98,20 @@ watch(currentChannelID, () => { } }) -function botPayload(form: { node_num: string | number | null; long_name: string; short_name: string; default_channel_id: string; topic_prefix?: string; psk?: string; nodeinfo_broadcast_enabled?: boolean; nodeinfo_broadcast_interval_seconds?: string | number; enabled: boolean }): BotNodePayload { +function botPayload(form: { node_num: string | number | null; long_name: string; short_name: string; default_channel_id: string; topic_prefix?: string; psk?: string; nodeinfo_broadcast_enabled?: boolean; nodeinfo_broadcast_interval_seconds?: string | number; enabled: boolean }, isCreate = false): BotNodePayload { // 的 v-model 会把绑定值转成 number,而 number 上没有 trim, // 直接调用会抛 "node_num.trim is not a function" 让保存失败。统一转成 string 再 trim。 const nodeNumText = form.node_num == null ? '' : String(form.node_num).trim() const interval = Number(form.nodeinfo_broadcast_interval_seconds || 3600) + const psk = form.psk?.trim() || '' return { node_num: nodeNumText ? Number(nodeNumText) : null, long_name: form.long_name.trim(), short_name: form.short_name.trim(), default_channel_id: form.default_channel_id.trim(), topic_prefix: form.topic_prefix?.trim() || 'msh/CN', - psk: form.psk?.trim() || 'AQ==', + // 创建时缺省 AQ==;更新时不携带 psk(后端保持原值,避免密钥被重置)。 + ...(psk || isCreate ? { psk: psk || 'AQ==' } : {}), nodeinfo_broadcast_enabled: form.nodeinfo_broadcast_enabled ?? true, nodeinfo_broadcast_interval_seconds: Number.isFinite(interval) && interval > 0 ? interval : 3600, enabled: form.enabled, @@ -123,7 +125,8 @@ function resetEdits() { short_name: bot.short_name, default_channel_id: bot.default_channel_id, topic_prefix: bot.topic_prefix, - psk: bot.psk || 'AQ==', + // PSK 不回显,编辑时留空表示保持不变。 + psk: '', nodeinfo_broadcast_enabled: bot.nodeinfo_broadcast_enabled, nodeinfo_broadcast_interval_seconds: String(bot.nodeinfo_broadcast_interval_seconds || 3600), enabled: bot.enabled, @@ -253,7 +256,7 @@ async function createBot() { error.value = '' message.value = '' try { - await createBotNode(botPayload(newBot.value)) + await createBotNode(botPayload(newBot.value, true)) newBot.value = { node_num: '', long_name: '', short_name: '', default_channel_id: 'LongFast', topic_prefix: 'msh/CN', psk: 'AQ==', nodeinfo_broadcast_enabled: true, nodeinfo_broadcast_interval_seconds: '3600', enabled: true } message.value = '机器人已创建' await refreshBots() @@ -503,7 +506,7 @@ onBeforeUnmount(() => { - + @@ -537,7 +540,7 @@ onBeforeUnmount(() => { {{ selectedBot.node_num }}Node Num {{ selectedBot.default_channel_id }}默认频道 {{ selectedBot.topic_prefix || 'msh/CN' }}MQTT 根地址 - {{ selectedBot.psk || 'AQ==' }}频道 PSK + {{ selectedBot.psk_set ? '已配置' : '默认' }}频道 PSK {{ selectedBot.private_key_set ? '已生成' : '未生成' }}机器人密钥 {{ selectedBot.public_key || '-' }}Public Key {{ selectedBot.nodeinfo_broadcast_enabled ? `${selectedBot.nodeinfo_broadcast_interval_seconds}s` : '关闭' }}NodeInfo 广播 diff --git a/meshmap_frontend/src/components/AdminHelpEdit.vue b/meshmap_frontend/src/components/AdminHelpEdit.vue index 8b98829..04381a4 100644 --- a/meshmap_frontend/src/components/AdminHelpEdit.vue +++ b/meshmap_frontend/src/components/AdminHelpEdit.vue @@ -1,5 +1,6 @@