Files
tamakyi b4c2a8452f 审计修复:后端(安全 / 数据一致性 / 性能)
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 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
2026-09-24 20:37:16 +08:00

178 lines
6.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 没看受影响行数:删不到也会回「已删除」")
}
}