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

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
+124 -28
View File
@@ -24,19 +24,44 @@ import (
// Core Data 时间戳基准:2001-01-01 UTC
var appleEpoch = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
// 允许的时间窗口 [1970, 2100)。脏备份里会出现 NaN / ±Inf / 量级离谱的值,
// 而 int64 转换对超范围值是回绕而非饱和——实测 1e30 恰好回绕成 0(=基准点 2001)、
// 1e12 变成 2123 年,都是「看着正常」的伪时间,再被当成 first/last_visit_at
// 污染整条时间轴。必须先卡输入秒数:回绕后的结果可能又落回合法区间,只查结果挡不住。
var (
appleMinSec = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Sub(appleEpoch).Seconds()
appleMaxSec = time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC).Sub(appleEpoch).Seconds()
)
func appleTime(v sql.NullFloat64) *time.Time {
if !v.Valid {
if !v.Valid || math.IsNaN(v.Float64) || math.IsInf(v.Float64, 0) {
return nil
}
sec := math.Floor(v.Float64)
if sec < appleMinSec || sec > appleMaxSec {
return nil
}
// 秒的整数部分和小数部分分开算:8 亿量级的秒乘 1e6/1e9 已超出 float64 的
// 精确整数范围(2^53),一次性相乘再取整会系统性吃掉最后 1 微秒,
// 往返导出后时间戳就对不上了。
sec := math.Floor(v.Float64)
us := int64(sec)*1_000_000 + int64(math.Round((v.Float64-sec)*1e6))
t := appleEpoch.Add(time.Duration(us) * time.Microsecond).Local()
return &t
}
// validCoord 判定坐标是否可用。SQLite 的 REAL 列可能塞进 NaN,备份也可能带出
// ±90 / ±180 之外的值;这类值会让地图与迷雾索引的网格算出 NaN 或越界格。
// 无效就按「无坐标」处理——与管线里 COALESCE(NULL,0) 的既有约定一致。
func validCoord(lat, lon float64) bool {
if math.IsNaN(lat) || math.IsInf(lat, 0) || lat < -90 || lat > 90 {
return false
}
if math.IsNaN(lon) || math.IsInf(lon, 0) || lon < -180 || lon > 180 {
return false
}
return true
}
type Activity struct {
SrcPK int
Name string
@@ -165,6 +190,23 @@ type Backup struct {
ActivityUUID map[int]string
}
// maxDBBytes 是备份包内单个 SQLite 文件解压后的上限。真机的库是几十 MB 量级,
// 2 GiB 足够宽松,但足以挡住 zip bomb——压缩比上千时几 MB 的包能解出 TB 级。
const maxDBBytes = 2 << 30
// copyLimited 把 r 拷进 w,超过 limit 就报错。
// zip 头里的 UncompressedSize 是攻击者可控字段,不能只信声明,必须在读的时候掐住。
func copyLimited(w io.Writer, r io.Reader, limit int64) error {
n, err := io.Copy(w, io.LimitReader(r, limit+1))
if err != nil {
return err
}
if n > limit {
return fmt.Errorf("解压后超过 %d 字节,疑似异常备份包", limit)
}
return nil
}
// Open 解压备份包到 dir,返回可用的 SQLite 连接。
// 调用方负责 Close 与删除 dir。
func Open(archivePath, dir string) (*sql.DB, error) {
@@ -177,6 +219,28 @@ func Open(archivePath, dir string) (*sql.DB, error) {
}
defer zr.Close()
dbPath, err := unpackArchive(&zr.Reader, dir, maxDBBytes)
if err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
// 把 WAL 合并回主库,避免后续读取时依赖 -shm 状态
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
db.Close()
return nil, fmt.Errorf("合并 WAL 失败: %w", err)
}
return db, nil
}
// unpackArchive 把包里的 Core Data 主库及其 WAL / SHM 解压到 dir,返回主库路径。
// limit 是单个条目解压后的字节上限;抽成参数是为了让上限本身可测
// (真上限 2 GiB,测试里没法造出一个 2 GiB 的 zip bomb)。
func unpackArchive(zr *zip.Reader, dir string, limit int64) (string, error) {
var dbPath string
for _, f := range zr.File {
base := filepath.Base(f.Name)
@@ -191,38 +255,39 @@ func Open(archivePath, dir string) (*sql.DB, error) {
dst := filepath.Join(dir, base)
rc, err := f.Open()
if err != nil {
return nil, err
return "", err
}
out, err := os.Create(dst)
if err != nil {
rc.Close()
return nil, err
return "", err
}
_, cerr := io.Copy(out, rc)
cerr := copyLimited(out, rc, limit)
rc.Close()
out.Close()
if cerr != nil {
return nil, cerr
os.Remove(dst)
return "", fmt.Errorf("解压 %s: %w", base, cerr)
}
if strings.HasSuffix(low, ".sqlite") {
dbPath = dst
}
}
if dbPath == "" {
return nil, fmt.Errorf("备份包内未找到 .sqlite 数据库文件")
return "", fmt.Errorf("备份包内未找到 .sqlite 数据库文件")
}
return dbPath, nil
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?_pragma=busy_timeout(5000)")
// drainRows 收尾 rows 循环。只 Close 不查 Err:驱动或连接中途出错时 Next() 只会
// 返回 false、从不报错,半张表就被当成完整的交出去——数据静默变少,日志一片空白。
func drainRows(rows *sql.Rows, what string) error {
err := rows.Err()
rows.Close()
if err != nil {
return nil, err
return fmt.Errorf("%s: %w", what, err)
}
db.SetMaxOpenConns(1)
// 把 WAL 合并回主库,避免后续读取时依赖 -shm 状态
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
db.Close()
return nil, fmt.Errorf("合并 WAL 失败: %w", err)
}
return db, nil
return nil
}
// Parse 读取 Core Data 库,转换为领域模型。
@@ -244,7 +309,9 @@ func Parse(db *sql.DB) (*Backup, error) {
}
b.TagGroups[pk] = name
}
rows.Close()
if err = drainRows(rows, "读取标签分组"); err != nil {
return nil, err
}
// 标签(含所属分组关系 Z_11TAGS_ 的逆向:ZTAG.ZGROUP -> ZTAGGROUP)
tagGroup := map[int]int{}
@@ -260,7 +327,9 @@ func Parse(db *sql.DB) (*Backup, error) {
}
tagGroup[pk] = g
}
grows.Close()
if err = drainRows(grows, "读取标签分组关系"); err != nil {
return nil, err
}
trows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,'') FROM ZTAG`)
if err != nil {
@@ -275,7 +344,9 @@ func Parse(db *sql.DB) (*Backup, error) {
t.GroupName = b.TagGroups[tagGroup[t.SrcPK]]
b.Tags = append(b.Tags, t)
}
trows.Close()
if err = drainRows(trows, "读取标签"); err != nil {
return nil, err
}
// 类型 / 活动
arows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,''),
@@ -293,7 +364,9 @@ func Parse(db *sql.DB) (*Backup, error) {
a.IsHome, a.IsWork, a.IsExcluded, a.IsArchived = h == 1, w == 1, e == 1, ar == 1
b.Activities = append(b.Activities, a)
}
arows.Close()
if err = drainRows(arows, "读取活动类型"); err != nil {
return nil, err
}
// 交通方式
trrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,'') FROM ZTRANSPORT`)
@@ -308,7 +381,9 @@ func Parse(db *sql.DB) (*Backup, error) {
}
b.Transports = append(b.Transports, t)
}
trrows.Close()
if err = drainRows(trrows, "读取交通方式"); err != nil {
return nil, err
}
// 地点
lrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCATEGORY_,''),
@@ -327,9 +402,14 @@ func Parse(db *sql.DB) (*Backup, error) {
lrows.Close()
return nil, err
}
if !validCoord(l.Lat, l.Lon) {
l.Lat, l.Lon = 0, 0
}
b.Locations = append(b.Locations, l)
}
lrows.Close()
if err = drainRows(lrows, "读取地点"); err != nil {
return nil, err
}
// 访问记录
vrows, err := db.Query(`SELECT Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_, ZDEPARTUREDATE_,
@@ -363,7 +443,9 @@ func Parse(db *sql.DB) (*Backup, error) {
visitIdx[v.SrcPK] = len(b.Visits)
b.Visits = append(b.Visits, v)
}
vrows.Close()
if err = drainRows(vrows, "读取访问记录"); err != nil {
return nil, err
}
// 访问 → 标签
vtrows, err := db.Query(`SELECT Z_18VISITS_, Z_11TAGS_5 FROM Z_11VISITS_`)
@@ -380,7 +462,9 @@ func Parse(db *sql.DB) (*Backup, error) {
b.Visits[i].TagSrcPKs = append(b.Visits[i].TagSrcPKs, tid)
}
}
vtrows.Close()
if err = drainRows(vtrows, "读取访问标签"); err != nil {
return nil, err
}
// 位移
mrows, err := db.Query(`SELECT Z_PK, ZTRANSPORT_, ZVISITFROM_, ZVISITTO_, ZSTART_, ZEND_ FROM ZMOVEMENT`)
@@ -411,7 +495,9 @@ func Parse(db *sql.DB) (*Backup, error) {
m.End = appleTime(en)
b.Movements = append(b.Movements, m)
}
mrows.Close()
if err = drainRows(mrows, "读取位移记录"); err != nil {
return nil, err
}
// 小时级天气
wrows, err := db.Query(`SELECT Z_PK, ZVISIT, ZDATE_, COALESCE(ZISDAYLIGHT,0),
@@ -453,7 +539,9 @@ func Parse(db *sql.DB) (*Backup, error) {
}
b.Weather = append(b.Weather, w)
}
wrows.Close()
if err = drainRows(wrows, "读取天气记录"); err != nil {
return nil, err
}
if err := db.QueryRow(`SELECT count(*) FROM ZRAWVISIT`).Scan(&b.RawVisitNum); err != nil {
b.RawVisitNum = 0
@@ -645,7 +733,9 @@ func captureMeta(db *sql.DB) (*CoreDataMeta, error) {
m.Entities[name] = EntityInfo{Ent: ent, Max: max}
}
}
rows.Close()
if err = drainRows(rows, "读取 Z_PRIMARYKEY"); err != nil {
return nil, err
}
drows, err := db.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name LIKE 'Z%' AND sql IS NOT NULL`)
if err != nil {
@@ -659,7 +749,9 @@ func captureMeta(db *sql.DB) (*CoreDataMeta, error) {
}
m.DDL[name] = ddl
}
drows.Close()
if err = drainRows(drows, "读取建表 DDL"); err != nil {
return nil, err
}
// 访问↔标签关联表:表名形如 Z_<tagEnt>VISITS_,两列分别指向 Visit / Tag 实体。
visitEnt, okV := m.Entities["Visit"]
@@ -692,7 +784,11 @@ func captureMeta(db *sql.DB) (*CoreDataMeta, error) {
tcol = cname
}
}
cerr := crows.Err()
crows.Close()
if cerr != nil {
continue
}
if vcol != "" && tcol != "" {
m.JoinTable = JoinTableInfo{Name: tname, VisitCol: vcol, TagCol: tcol}
break