mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
fix(remotedevice): device/push 校验 payload,杜绝「假成功」
## 实测坑(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 精确跑通的。
This commit is contained in:
@ -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"})
|
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "device_id required"})
|
||||||
return
|
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\":\"<id>\",\"payload\":{\"op\":\"cmd\",\"req_id\":\"<rid>\",\"cmd_type\":\"shell|homeagent\",\"command\":\"...\"}};payload 是**协议帧本身**,不是 command 简写",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := p.registry.PushJSON(req.DeviceID, req.Payload); err != nil {
|
if err := p.registry.PushJSON(req.DeviceID, req.Payload); err != nil {
|
||||||
writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": err.Error()})
|
writeJSON(w, http.StatusNotFound, map[string]interface{}{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
89
internal/plugins/remotedevice/push_validation_test.go
Normal file
89
internal/plugins/remotedevice/push_validation_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user