diff --git a/server/internal/handler/push.go b/server/internal/handler/push.go index 72a138b..6117c7c 100644 --- a/server/internal/handler/push.go +++ b/server/internal/handler/push.go @@ -9,6 +9,7 @@ import ( "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/push" "github.com/agentmail/gateway/internal/repo" + "github.com/google/uuid" ) /* @@ -75,6 +76,28 @@ func RegisterPushToken(w http.ResponseWriter, r *http.Request) { Error(w, http.StatusBadRequest, "token 非法(不能为空,最长 512 字符)") return } + /* + * session_id 非空时必须是**合法 UUID**(dsh 2026-09-17 报的真 bug)。 + * + * 为什么这个校验值得加(那个 bug 无声地活了很久): + * 客户端一度把这个字段填成了**服务器地址**(`https://…/api/v1`),而这里只 TrimSpace、 + * 允许任何字符串 ⇒ 不报错、不影响收信、不进日志。而**投递路径不看这个字段** + * (发通知用的是邮件自己的 `m.SessionID`,见 notify/mail.go;dispatch 只用 token 的 + * Provider/Token),所以它错了也**没有任何机制会报警**。 + * 可这个字段存在的唯一目的就是“点通知回到那条会话”—— 等哪天真按它路由, + * 人会被送到别的会话,而根因在一个看起来“存进去了”的字段上。 + * + * 代价与取舍:老客户端(上报过错值的)下次上报会吃 **400**;按契约 400 归静默, + * 所以不打扰用户、只是那一次不上报(下次带对值就恢复)。用一次静默重试换掉 + * “静默存错值”,值。 + * 注意:这里**只校验非空值**——空串是契约允许的(“客户端还没进任何会话”)。 + */ + if sid := strings.TrimSpace(req.SessionID); sid != "" { + if _, err := uuid.Parse(sid); err != nil { + Error(w, http.StatusBadRequest, "session_id 非法(必须是会话 UUID,或留空表示“还没有当前会话”)") + return + } + } if err := repo.UpsertPushToken(r.Context(), req.Provider, req.Token, user.Username, strings.TrimSpace(req.SessionID), strings.TrimSpace(req.DeviceName)); err != nil { Error(w, http.StatusInternalServerError, "登记推送地址失败") diff --git a/server/internal/handler/push_test.go b/server/internal/handler/push_test.go index 8693b50..664015c 100644 --- a/server/internal/handler/push_test.go +++ b/server/internal/handler/push_test.go @@ -13,6 +13,7 @@ import ( "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/models" "github.com/agentmail/gateway/internal/push" + "github.com/google/uuid" ) /* @@ -68,7 +69,7 @@ func TestPushTokenEndpointsWorkWithoutProviderCreds(t *testing.T) { rec := httptest.NewRecorder() RegisterPushToken(rec, pushReq(http.MethodPost, - `{"provider":"hms","token":"tok-abc123456","device_name":"我的手机","session_id":"sess-1"}`, "alice")) + `{"provider":"hms","token":"tok-abc123456","device_name":"我的手机","session_id":"`+testSID+`"}`, "alice")) if rec.Code != http.StatusOK { t.Fatalf("没配推送时登记也必须是 200(那是正常配置,不是故障),实际 %d: %s", rec.Code, rec.Body.String()) } @@ -98,7 +99,7 @@ func TestPushTokenEndpointsWorkWithoutProviderCreds(t *testing.T) { if _, leaked := item["token"]; leaked { t.Fatal("不该回 token 全文(多一处泄漏面)") } - if item["session_id"] != "sess-1" || item["provider"] != "hms" { + if item["session_id"] != testSID || item["provider"] != "hms" { t.Fatalf("登记字段不对: %v", item) } @@ -187,3 +188,51 @@ func TestPushTokenEndpointInputValidation(t *testing.T) { t.Fatalf("未登录的注销应 401,实际 %d", rec.Code) } } + +/* +★ session_id 的格式契约(pi 2026-09-17,由 dsh 报的真 bug 催生)。 + +那个 bug:客户端一度把这个字段填成**服务器地址**(`https://…/api/v1`),而服务端 +只 TrimSpace、允许任何字符串 ⇒ 不报错、不影响收信、不进日志;而**投递路径不看它** +(通知里的 session_id 来自邮件自己,见 notify/mail.go),所以它错了也没有任何机制会报警。 +但这个字段存在的唯一目的就是「点通知回到那条会话」—— 存错值 = 将来路由到错会话。 + +所以这里钉三条: + ① 非空且不是 UUID ⇒ **400**(把"静默存错值"换成"这次静默不上报"); + ② 空串仍然合法(契约:客户端还没进任何会话); + ③ 合法 UUID ⇒ 照存(登记成功)。 +*/ +func TestPushTokenSessionIDMustBeUUID(t *testing.T) { + setupPushHandlerDB(t) + user := "bob" + + cases := []struct { + name string + sid string + status int + }{ + {"非法:服务器地址顶替(就是这个 bug 的形状)", "https://mail.jianfgit.xyz/api/v1", http.StatusBadRequest}, + {"非法:随便一个词", "sess-1", http.StatusBadRequest}, + {"合法:空串(还没进任何会话)", "", http.StatusOK}, + {"合法:真 UUID", testSID, http.StatusOK}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + body := `{"provider":"hms","token":"tok-fmt-` + c.name + `","session_id":"` + c.sid + `"}` + rec := httptest.NewRecorder() + RegisterPushToken(rec, pushReq(http.MethodPost, body, user)) + if rec.Code != c.status { + t.Fatalf("session_id=%q 期望 %d,实际 %d(%s)", c.sid, c.status, rec.Code, rec.Body.String()) + } + // 错误报文要自带药方:说清"要么是 UUID、要么留空",而不是只说"非法"。 + if c.status == http.StatusBadRequest { + if msg, _ := decodeBody(t, rec)["error"].(string); !strings.Contains(msg, "UUID") { + t.Fatalf("400 的文案没告诉人怎么改:%q", msg) + } + } + }) + } +} + +// testSID 是一个合法会话 UUID(handler 层不校验它是否真的存在 —— 那是会话解析的事) +var testSID = uuid.New().String()