audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证 (注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。 安全 - CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板 - 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性) - 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段 - GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition - 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品 数据一致性 - 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐) - Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error - 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致 - 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows - 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全 性能 - placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛 - 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍 另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
178 lines
6.3 KiB
Go
178 lines
6.3 KiB
Go
package web
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http/httptest"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// S8 回归:GPX 轨迹必须按属主圈定。
|
||
//
|
||
// gpx_tracks 带 user_id(为将来多用户留位),但两个入口都没用它:
|
||
// - `GET /api/gpx` 是**公开**接口,却把整张表端出去;
|
||
// - `POST /admin/gpx/delete` 的 id 来自客户端,只按 id 删就能删别人的。
|
||
//
|
||
// 单用户站点上影响不大,但这是「越权」而不是「影响小」——而且两处都只差一个 user_id 条件。
|
||
func TestGpxOwnershipScoping(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
ctx, pg := f.ctx, f.pg
|
||
|
||
var owner int64
|
||
if err := pg.QueryRowContext(ctx, `SELECT user_id FROM datasets WHERE id=$1`, f.dsID).Scan(&owner); err != nil {
|
||
t.Fatalf("读数据集属主失败: %v", err)
|
||
}
|
||
|
||
// 外来账号:只为了拥有一条不属于本站主的轨迹
|
||
foreign := fmt.Sprintf("e2e-gpx-other-%d", time.Now().UnixNano())
|
||
var foreignID int64
|
||
if err := pg.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name)
|
||
VALUES ($1,'x','gpx-other') RETURNING id`, foreign).Scan(&foreignID); err != nil {
|
||
t.Fatalf("建外来账号失败: %v", err)
|
||
}
|
||
mkTrack := func(uid int64, name string) int64 {
|
||
t.Helper()
|
||
var id int64
|
||
if err := pg.QueryRowContext(ctx, `INSERT INTO gpx_tracks
|
||
(user_id, name, point_count, length_km, points)
|
||
VALUES ($1,$2,1,1.0,'[[40.0,116.0]]'::jsonb) RETURNING id`, uid, name).Scan(&id); err != nil {
|
||
t.Fatalf("插入轨迹 %s 失败: %v", name, err)
|
||
}
|
||
return id
|
||
}
|
||
strayTrack := mkTrack(foreignID, "FOREIGN-"+foreign)
|
||
ownTrack := mkTrack(owner, "OWNER-"+foreign)
|
||
t.Cleanup(func() {
|
||
pg.ExecContext(ctx, `DELETE FROM gpx_tracks WHERE id IN ($1,$2)`, strayTrack, ownTrack)
|
||
pg.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, foreignID)
|
||
})
|
||
|
||
// 1) 公开接口不许端出别人的轨迹。
|
||
// 判据不依赖「哪份数据集恰好是活跃的」:只要求返回的每条都属于活跃属主,
|
||
// 而外来那条必须不在其中——后者才是安全不变量。
|
||
var activeOwner int64
|
||
if err := pg.QueryRowContext(ctx, `SELECT user_id FROM datasets
|
||
WHERE is_active AND status='done' ORDER BY uploaded_at DESC LIMIT 1`).Scan(&activeOwner); err != nil {
|
||
t.Fatalf("读活跃数据集属主失败: %v", err)
|
||
}
|
||
rec := httptest.NewRecorder()
|
||
f.srv.apiGpx(rec, httptest.NewRequest("GET", "/api/gpx", nil))
|
||
if rec.Code != 200 {
|
||
t.Fatalf("apiGpx 返回 %d: %s", rec.Code, rec.Body.String())
|
||
}
|
||
body := rec.Body.String()
|
||
if strings.Contains(body, "FOREIGN-") {
|
||
t.Errorf("公开接口把别的账号的轨迹也端出来了:%s", truncate(body, 300))
|
||
}
|
||
if !strings.Contains(body, "OWNER-") {
|
||
t.Fatalf("活跃数据集属主(user_id=%d)自己的轨迹没返回,用例失去意义:%s",
|
||
activeOwner, truncate(body, 300))
|
||
}
|
||
|
||
// 2) 删除别人账号的轨迹必须被拒,而且不能真删掉。
|
||
victim := httptest.NewRecorder()
|
||
req := httptest.NewRequest("POST", "/admin/gpx/delete",
|
||
strings.NewReader(url.Values{"id": {fmt.Sprint(strayTrack)}}.Encode()))
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
f.srv.adminGpxDelete(victim, req.WithContext(f.ctx))
|
||
if msg := flashMsg(t, victim); !strings.Contains(msg, "不存在") {
|
||
t.Errorf("删别人的轨迹应当回「不存在」,实际 %q", msg)
|
||
}
|
||
var still int
|
||
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM gpx_tracks WHERE id=$1`, strayTrack).Scan(&still); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if still != 1 {
|
||
t.Errorf("别人的轨迹被删掉了(id=%d)——越权删除", strayTrack)
|
||
}
|
||
|
||
// 3) 删自己的要能删掉(别为堵越权把正常路径也堵死了)。
|
||
okRec := httptest.NewRecorder()
|
||
req = httptest.NewRequest("POST", "/admin/gpx/delete",
|
||
strings.NewReader(url.Values{"id": {fmt.Sprint(ownTrack)}}.Encode()))
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
f.srv.adminGpxDelete(okRec, req.WithContext(f.ctx))
|
||
if msg := flashMsg(t, okRec); !strings.Contains(msg, "已删除") {
|
||
t.Errorf("删自己的轨迹应当成功,实际 %q", msg)
|
||
}
|
||
var gone int
|
||
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM gpx_tracks WHERE id=$1`, ownTrack).Scan(&gone); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if gone != 0 {
|
||
t.Errorf("自己的轨迹没被删掉(id=%d)", ownTrack)
|
||
}
|
||
}
|
||
|
||
// truncate 只用于把失败信息压短,超长时截断。
|
||
func truncate(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n] + "…"
|
||
}
|
||
|
||
// sqlText 取出函数体里所有反引号包的 SQL 片段。
|
||
//
|
||
// 守卫必须只看 SQL:函数里的**中文注释**也会出现「user_id」这个词,
|
||
// 直接 strings.Contains(body, "user_id") 会被注释顶包——变异掉真条件照样绿。
|
||
func sqlText(body string) string {
|
||
var b strings.Builder
|
||
for i := 0; ; {
|
||
a := strings.IndexByte(body[i:], '`')
|
||
if a < 0 {
|
||
break
|
||
}
|
||
a += i
|
||
z := strings.IndexByte(body[a+1:], '`')
|
||
if z < 0 {
|
||
break
|
||
}
|
||
z += a + 1
|
||
b.WriteString(body[a+1 : z])
|
||
b.WriteByte('\n')
|
||
i = z + 1
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// S8 静态守卫:两个入口的 SQL 都必须带 user_id 条件。
|
||
// 行为用例要导一份备份包才能跑(约 6 秒);这条永远不 skip,防止「改回去」时静默回归。
|
||
func TestGpxQueriesCarryUserID(t *testing.T) {
|
||
for _, c := range []struct {
|
||
file string
|
||
sig string
|
||
want []string
|
||
}{
|
||
{"handlers_public.go", "func (s *Server) apiGpx(", []string{"user_id"}},
|
||
{"handlers_admin.go", "func (s *Server) adminGpxDelete(", []string{"user_id"}},
|
||
} {
|
||
data, err := os.ReadFile(pkgFile(t, c.file))
|
||
if err != nil {
|
||
t.Fatalf("读 %s 失败: %v", c.file, err)
|
||
}
|
||
body := sliceFunc(t, string(data), c.sig)
|
||
q := sqlText(body)
|
||
if strings.TrimSpace(q) == "" {
|
||
t.Fatalf("%s 的 %s 里没找到 SQL 字符串,守卫的切片判据需要更新", c.file, c.sig)
|
||
}
|
||
for _, w := range c.want {
|
||
if !strings.Contains(q, w) {
|
||
t.Errorf("%s 的 %s 的 SQL 里没有 %q:轨迹没按属主圈定", c.file, c.sig, w)
|
||
}
|
||
}
|
||
}
|
||
// 删不到时的反馈也要有断言:不然「删别人的」会回一句「已删除」。
|
||
data, err := os.ReadFile(pkgFile(t, "handlers_admin.go"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
del := sliceFunc(t, string(data), "func (s *Server) adminGpxDelete(")
|
||
if !strings.Contains(del, "RowsAffected") {
|
||
t.Error("adminGpxDelete 没看受影响行数:删不到也会回「已删除」")
|
||
}
|
||
}
|