审计修复:后端(安全 / 数据一致性 / 性能)
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:
+124
-28
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user