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

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
+107 -9
View File
@@ -4,10 +4,12 @@ import (
"bytes"
"context"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -82,7 +84,7 @@ func (s *Server) exportGeoJSON(w http.ResponseWriter, r *http.Request) {
}
b.WriteString(`]}`)
w.Header().Set("Content-Type", "application/geo+json; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="places.geojson"`)
w.Header().Set("Content-Disposition", downloadDisposition("places.geojson"))
w.Write([]byte(b.String()))
}
@@ -120,10 +122,74 @@ func (s *Server) exportGPX(w http.ResponseWriter, r *http.Request) {
b.WriteString("</gpx>\n")
w.Header().Set("Content-Type", "application/gpx+xml; charset=utf-8")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="places-%s.gpx"`, time.Now().Format("20060102")))
downloadDisposition("places-"+time.Now().Format("20060102")+".gpx"))
w.Write([]byte(b.String()))
}
// downloadDisposition 拼 Content-Disposition。
//
// 导出包的名字里带着 `datasets.original_name`,那是**上传时的文件名**——含引号、斜杠、
// 控制字符都正常,甚至可能整段 `"; filename*=…` 之类的内容。原样塞进 `filename="…"`
// 就能提前闭合引号、往这个头里塞参数(net/http 只把 CR/LF 换成空格,管不了引号)。
// 所以给两个值:
// - `filename=` 只留 ASCII 安全字符,老浏览器兜底;
// - `filename*=`(RFC 5987)按 UTF-8 百分号编码带真实名字,现代浏览器优先取它,
// 中文名不会像以前那样被当成 Latin-1 乱码。
func downloadDisposition(name string) string {
if strings.TrimSpace(name) == "" {
name = "download"
}
ext := filepath.Ext(name)
stem := asciiSafe(strings.TrimSuffix(name, ext))
// 纯中文名的词干映射完就是一串下划线,给个能读的兜底名;但不能把扩展名一起丢掉,
// 否则用户下到手里是个没法双击打开的「download」。
if !hasAlnum(stem) {
stem = "download"
}
return fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", stem+asciiSafe(ext), rfc5987Escape(name))
}
// asciiSafe 把不能安全写进 `filename="…"` 的字符换成下划线:
// 引号能提前闭合这个参数,斜杠/反斜杠会在落盘时被当成路径,`;` `=` 能伪造后续参数。
func asciiSafe(s string) string {
return strings.Map(func(r rune) rune {
if r < 0x20 || r > 0x7e {
return '_'
}
switch r {
case '"', '\\', '/', ';', ':', '=', '*', '?', '<', '>', '|':
return '_'
}
return r
}, s)
}
func hasAlnum(s string) bool {
return strings.ContainsFunc(s, func(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
})
}
// rfc5987Escape 按 RFC 5987 的 attr-char 编码:ASCII 字母数字与 `. _ - ~` 原样,
// 其余**逐字节**百分号编码(UTF-8 的每个字节各编一次,这正是规范要求的)。
// 故意只放行这个窄集合:`;` `=` 这类在 attr-char 之外,漏掉就能截断参数。
func rfc5987Escape(s string) string {
const hexDigits = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '.' || c == '_' || c == '-' || c == '~' {
b.WriteByte(c)
continue
}
b.WriteByte('%')
b.WriteByte(hexDigits[c>>4])
b.WriteByte(hexDigits[c&0x0f])
}
return b.String()
}
// baselinePath 返回某数据集的基线库路径。基线是导入时从原始备份包留下的单文件副本,
// 导出时以它为底做覆盖,从而保住 Core Data 的元数据表、索引、持久化历史,
// 以及我们并不落库的实体(原始 GPS 采样点 ZRAWVISIT、ZKEYWORD、ZTRIP* 等)。
@@ -150,7 +216,8 @@ func (s *Server) ensureBaseline(ctx context.Context, datasetID int64) (string, e
// findSourceArchive 在 uploads 目录里按 datasets.sha256 找回当初上传的备份包。
func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string, error) {
var sha string
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(sha256,'') FROM datasets WHERE id=$1`, datasetID).Scan(&sha); err != nil {
var size int64
if err := s.db.QueryRowContext(ctx, `SELECT COALESCE(sha256,''), COALESCE(size_bytes,0) FROM datasets WHERE id=$1`, datasetID).Scan(&sha, &size); err != nil {
return "", err
}
files, _ := filepath.Glob(filepath.Join(s.cfg.UploadDir, "*"))
@@ -163,19 +230,41 @@ func (s *Server) findSourceArchive(ctx context.Context, datasetID int64) (string
}
return fi.ModTime().After(fj.ModTime())
})
if f := pickArchiveBySha(files, sha, size, func(f string) (string, error) {
got, _, err := ingest.File(f)
return got, err
}); f != "" {
return f, nil
}
return "", fmt.Errorf("找不到当初上传的原始备份包(可能已清理),请到后台重新上传一次该 .rondbackup")
}
// pickArchiveBySha 从候选文件里挑出摘要匹配的那一份(没有则返回空串)。
//
// 先按字节数筛一道再算摘要:uploads 里可能堆着几十份几百 MB 的备份,每份都得完整读一遍
// 才比得出 sha256,而 sha256 相同就一定字节数相同——大小对不上的根本不必读。
// 没有 sha 可比时(老库或退化数据)不做筛选,保持「取最新的那份」的旧行为。
//
// hash 做成参数是为了让用例能数「到底算了几次摘要」:只看最终返回哪个文件,
// 带不带这道筛选完全一样,区分不出来。
func pickArchiveBySha(files []string, sha string, size int64, hash func(string) (string, error)) string {
for _, f := range files {
if fi, err := os.Stat(f); err != nil || fi.IsDir() {
fi, err := os.Stat(f)
if err != nil || fi.IsDir() {
continue
}
got, _, err := ingest.File(f)
if sha != "" && size > 0 && fi.Size() != size {
continue
}
got, err := hash(f)
if err != nil {
continue
}
if sha == "" || got == sha {
return f, nil
return f
}
}
return "", fmt.Errorf("找不到当初上传的原始备份包(可能已清理),请到后台重新上传一次该 .rondbackup")
return ""
}
// exportRondbackup 以基线库为底,把当前数据集写回为 rond 原始格式的 .rondbackup 并下载。
@@ -204,6 +293,15 @@ func (s *Server) exportRondbackup(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="%s-export-%s.rondbackup"`, stem, time.Now().Format("20060102")))
w.Write(buf.Bytes())
downloadDisposition(stem+"-export-"+time.Now().Format("20060102")+".rondbackup"))
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
// 整包先落内存是为了绝不写出半截文件,代价是整个下载都得在写死线内完成。
// 全站 120s 是按页面响应定的,慢链路下拉几十 MB 不够——只放宽这一条路由。
if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(10 * time.Minute)); err != nil {
log.Printf("导出:无法延长写超时,仍按全站限制走: %v", err)
}
w.WriteHeader(http.StatusOK)
if _, err := buf.WriteTo(w); err != nil {
log.Printf("导出:下载写出中断: %v", err)
}
}