审计修复:后端(安全 / 数据一致性 / 性能)

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 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
This commit is contained in:
tamakyi
2026-09-24 20:37:16 +08:00
parent 78865fc736
commit b4c2a8452f
70 changed files with 5417 additions and 360 deletions
+36 -17
View File
@@ -86,6 +86,10 @@ type Page struct {
// BackQS 是当前页面地址(含查询串、去掉 back 自己)编码后的 ?back= 参数值,
// 列表页生成指向详情页的链接时用它。必须整串编码:里面有 & 时不编码会把后面的参数截断。
BackQS template.URL
// SelfPath 是 BackQS 的**未编码**原文。需要在这一层再编码一次的场景(编辑页的
// ?back=)必须用它——拿已编码的 BackQS 再编码会变成 %252F…(见 EditPlaceLink)。
SelfPath string
}
// IsAdmin 报告当前访问者是否已登录后台。
@@ -143,12 +147,12 @@ func (p Page) filterPairs() []string {
return kv
}
// backQS 把当前请求的地址编码成 ?back= 的值。
// selfPath 是当前请求的地址(路径 + 规范化后的查询串),**未编码**。
//
// 去掉 back 自身是必须的:详情页的地址里本来就带着 back(来自列表页),
// 原样再编码一层会变成套娃,回退一次只退到上一层的自己。
// 用 url.Values.Encode() 顺带把参数规范化(排序、重新编码),服务端解析结果不变。
func backQS(r *http.Request) template.URL {
func selfPath(r *http.Request) string {
q := r.URL.Query()
q.Del("back")
// ok / err 是重定向带回来的一次性提示,挂进 back 会让它在下一页再弹一次
@@ -158,27 +162,39 @@ func backQS(r *http.Request) template.URL {
if e := q.Encode(); e != "" {
u += "?" + e
}
return template.URL(url.QueryEscape(u))
return u
}
// backFromRequest 取出 ?back= 并只放行**站内路径**。
// backQS 是 selfPath 的编码形式,直接当 ?back= 的值用。
// 需要在这一层再编码一次的场景要用 selfPath,别拿它再转一遍(会变成 %252F…)。
func backQS(r *http.Request) template.URL {
return template.URL(url.QueryEscape(selfPath(r)))
}
// safeInternalPath 只放行**站内路径**,不合法时返回空串。
//
// 返回值会当 template.URL 用(绕过 html/template 的 URL 转义),所以这里必须挡死
// 返回值会当 template.URL 用(绕过 html/template 的 URL 转义),所以必须挡死
// 「//evil.com」这种协议相对地址,以及引号/尖括号这类能跳出属性的字符 ——
// 否则只要有人把链接拼成 /places/1?back=https://evil.com,这里就成了开放重定向。
func backFromRequest(r *http.Request) template.URL {
b := strings.TrimSpace(r.URL.Query().Get("back"))
if b == "" || !strings.HasPrefix(b, "/") || strings.HasPrefix(b, "//") {
// 控制字符(含 %00、%0b)也一律不收:浏览器会把 NUL 之类的字符悄悄吃掉,
// 留着就是给「同一串地址在不同环节解析成不同东西」留口子。
//
// ?back=、后台表单的 next、投票后的跳转三处共用这一份判据,别再各写一遍。
func safeInternalPath(p string) string {
p = strings.TrimSpace(p)
if p == "" || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
return ""
}
for i := 0; i < len(b); i++ {
// 控制字符(含 %00、%0b 这类)与能跳出属性的符号一律不收:
// 浏览器会把 NUL 之类的字符悄悄吃掉,留着就是给「同一串地址在不同环节解析成不同东西」留口子
if c := b[i]; c < 0x20 || c == 0x7f || c == '"' || c == '\'' || c == '<' || c == '>' || c == '\\' {
for i := 0; i < len(p); i++ {
if c := p[i]; c < 0x20 || c == 0x7f || c == '"' || c == '\'' || c == '<' || c == '>' || c == '\\' {
return ""
}
}
return template.URL(b)
return p
}
func backFromRequest(r *http.Request) template.URL {
return template.URL(safeInternalPath(r.URL.Query().Get("back")))
}
// BackRaw 是 Back 的纯字符串形式,给**非 href** 的场景用(表单的 hidden 字段):
@@ -231,14 +247,18 @@ func (p Page) PlaceLink(id int64) template.URL {
// EditPlaceLink 是「到数据编辑页打开这个地点」,同样带上返回地址。
// 优先用列表页给的 Back(站长要的是「改完回地点库」),没有就退回当前这一页。
//
// 这里必须用**未编码**的 SelfPath:BackQS 已经 QueryEscape 过一次,
// 再转一次就成了 %252F…,服务端解出来不以 / 开头会被当成站外地址丢掉,
// 于是「直接打开 /places/1 再点编辑」的编辑页上返回按钮凭空消失。
func (p Page) EditPlaceLink(srcPK int) template.URL {
back := p.Back
back := string(p.Back)
if back == "" {
back = p.BackQS
back = p.SelfPath
}
u := "/admin/edit?open=" + strconv.Itoa(srcPK)
if back != "" {
u += "&back=" + url.QueryEscape(string(back))
u += "&back=" + url.QueryEscape(back)
}
return template.URL(u)
}
@@ -1364,7 +1384,6 @@ func templateFuncs() template.FuncMap {
}
return b
},
"safe": func(s string) template.HTML { return template.HTML(s) },
"colorof": colorOf,
"icon": iconOf,
"iconor": iconOr,