From 878fd770bc7520f402784b6299c8cc82fff19ad4 Mon Sep 17 00:00:00 2001 From: JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:18:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(remotedevice):=20device/push=20=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=20payload=EF=BC=8C=E6=9D=9C=E7=BB=9D=E3=80=8C?= =?UTF-8?q?=E5=81=87=E6=88=90=E5=8A=9F=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 实测坑(2026-10-05) 排查「设备能力全部调用失败」时发现:POST /api/v1/device/push 返回 `{"status":"ok"}`,设备侧却什么都没发生,日志里连一条 recv 都没有。 根因是**调用方把参数写错了**,而服务端把这个错误藏了起来: {"device_id":"gui-JianF","command":"homeagent-clipboardsee"} → command 字段被静默丢弃(端点只认 payload) → req.Payload 为 nil → PushJSON 序列化后写进 socket,**成功** → 设备收到 {"payload":null},op 为空,直接忽略 → 服务端返回 200 {"status":"ok"} 一次「什么也没发生」的操作,在报文上是彻底的成功。排查只能靠设备侧 日志发现「命令没到」,极易误判成链路/鉴权/心跳问题—— 本次就先后误 判为「frp 转发丢帧」「命令送达有问题」,绕了很久。 补上 payload 校验,并把正确格式写进报错文案(使用者最可能犯的就是 把 command 写在顶层)。 ## 不动 device_id 的校验顺序 device_id 仍先于 payload 校验:更基础的参数先报错。三条非法输入现在 分别报 device_id 缺失 / payload 缺失 / payload 为 null。 ## 判据 新增 push_validation_test.go,三个 case: - command 写在顶层(最常见的误解) - payload 为 null - 既缺 device_id 又缺 payload(断言先报 device_id) 关键断言是「非法请求绝不返回 ok」—— 正是上一版的漏洞。 ## 验证 - go test -run TestHandleDevicePushRequiresPayload ⇒ 3/3 PASS - gofmt 干净 ⚠ 本包的 go test ./... 跑不通:clipboardsue_content_test.go 引用了只在 GUI 侧定义的 clipboardsueMaxBytes(来自远端 7901651「根目录清理」), 编译失败。已确认与本次改动无关,未动该文件;上面那条是用 -run 精确跑通的。 --- internal/plugins/remotedevice/plugin.go | 19 ++++ .../remotedevice/push_validation_test.go | 89 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 internal/plugins/remotedevice/push_validation_test.go diff --git a/internal/plugins/remotedevice/plugin.go b/internal/plugins/remotedevice/plugin.go index 2501ea46..3b77cfc4 100644 --- a/internal/plugins/remotedevice/plugin.go +++ b/internal/plugins/remotedevice/plugin.go @@ -309,6 +309,25 @@ func (p *Plugin) handleDevicePush(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "device_id required"}) return } + // ★ payload 必须校验,不能让它静默通过(2026-10-05 实测踩坑)。 + // + // 本端点收的是**已成型的协议帧**({"op":"cmd",...} 等),不是 + // “device_id + command” 这种便捷参数。以前直接PushJSON(req.Payload), + // 而 PushJSON 只是把 map 序列化后写进 socket —— 于是: + // + // 客户端发 {"device_id":"gui-JianF","command":"homeagent-clipboardsee"} + // → command 字段被**静默丢弃**,payload 为 nil + // → 设备收到 {"payload":null},op 为空,直接忽略 + // → 而服务端这边 PushJSON 写入 socket 成功,返回 {"status":"ok"} + // + // 一次「什么也没发生」的操作,报文上是彻底的成功。排查时只能靠设备侧 + // 日志才发现「命令没到」,极易误判成链路/鉴权/心跳问题(本次就绕了很久)。 + if req.Payload == nil { + writeJSON(w, http.StatusBadRequest, map[string]interface{}{ + "error": "payload required: 期望 {\"device_id\":\"\",\"payload\":{\"op\":\"cmd\",\"req_id\":\"\",\"cmd_type\":\"shell|homeagent\",\"command\":\"...\"}};payload 是**协议帧本身**,不是 command 简写", + }) + return + } if err := p.registry.PushJSON(req.DeviceID, req.Payload); err != nil { writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": err.Error()}) return diff --git a/internal/plugins/remotedevice/push_validation_test.go b/internal/plugins/remotedevice/push_validation_test.go new file mode 100644 index 00000000..eb81c6f1 --- /dev/null +++ b/internal/plugins/remotedevice/push_validation_test.go @@ -0,0 +1,89 @@ +package remotedevice + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestHandleDevicePushRequiresPayload 钉住「payload 缺失必须报错」。 +// +// 背景(2026-10-05 实测):本端点此前不校验 payload,于是 +// +// POST {"device_id":"gui-JianF","command":"homeagent-clipboardsee"} +// → command 字段被静默丢弃,payload 为 nil +// → 设备收到 {"payload":null},op 为空,直接忽略 +// → 服务端 PushJSON 写入 socket 成功,返回 200 {"status":"ok"} +// +// 一次「什么也没发生」的操作在报文上是彻底的成功:排查时只能靠设备侧 +// 日志才发现命令没到,极易误判成链路/鉴权/心跳问题。 +// +// 本测试不依赖真设备:只验校验分支,且明确断言「非法请求不得返回 ok」。 +func TestHandleDevicePushRequiresPayload(t *testing.T) { + p := &Plugin{} + p.registry = NewRegistry() + + cases := []struct { + name string + body string + wantCode int + wantOK bool // 响应里是否出现 "ok" + wantErr string // 期望出现在 error 里的关键词(空=默认 "payload") + }{ + { + name: "缺 payload(把 command 写在顶层,是最常见的误解)", + body: `{"device_id":"gui-JianF","command":"homeagent-clipboardsee"}`, + wantCode: http.StatusBadRequest, + wantOK: false, + }, + { + name: "payload 为 null", + body: `{"device_id":"gui-JianF","payload":null}`, + wantCode: http.StatusBadRequest, + wantOK: false, + }, + { + // device_id 先于 payload 校验(更基础的参数先报错), + // 所以此例报的是 device_id 缺失——行为正确,只是错因不同。 + name: "既缺 device_id 又缺 payload:先报 device_id", + body: `{"command":"homeagent-clipboardsee"}`, + wantCode: http.StatusBadRequest, + wantOK: false, + wantErr: "device_id", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/device/push", strings.NewReader(tc.body)) + p.handleDevicePush(w, r) + + if w.Code != tc.wantCode { + t.Fatalf("状态码 = %d,期望 %d(body=%s)", w.Code, tc.wantCode, w.Body.String()) + } + // 关键断言:绝不能报成功。上一版就是这里返回了 {"status":"ok"}。 + if tc.wantOK == false && strings.Contains(w.Body.String(), `"ok"`) { + t.Fatalf("非法请求返回了成功:%s", w.Body.String()) + } + var got map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("响应不是 JSON:%s", w.Body.String()) + } + msg, _ := got["error"].(string) + if msg == "" { + t.Fatalf("400 响应里没有 error 字段:%s", w.Body.String()) + } + // 报错文案要能自解释:使用者多半是把 command 写在了顶层。 + want := tc.wantErr + if want == "" { + want = "payload" + } + if !strings.Contains(msg, want) { + t.Fatalf("报错未提及 %q,实际:%s", want, msg) + } + }) + } +}