Skip to content

Commit d0bfbd1

Browse files
committed
docs(proj): final review
1 parent 57f7ccb commit d0bfbd1

1 file changed

Lines changed: 118 additions & 0 deletions

File tree

docs/audit/cross-repo-findings.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# moyu 跨仓审计发现核对
2+
3+
> 来源:`../kungal-docs/claude``../kungal-docs/gpt` 两份外部审计(对
4+
> `kun-oauth-admin` / `kun-galgame-nuxt4` / **`kun-galgame-patch-next`(moyu,本仓)** 三仓的只读安全/正确性审计)。
5+
>
6+
> 本文**只摘录与本仓(moyu)有关的发现**,并逐条以**当前代码**核对真伪、给出当前
7+
> `file:line` 证据与处理建议。核对方式:逐条读源码 + 对外部审计的“证伪项”反向确认。
8+
>
9+
> [`docs/api/checks/`](../api/checks/README.md)(2026-05-29 本仓 API 字段对齐审计 + 修复)
10+
> 的关系见 [§4](#四-与本仓-api-审计的关系)。核对日期:2026-05-30。
11+
>
12+
> 分类:**成立(OPEN)** 待处理 · **有意(BY-DESIGN)** 机制属实但为有意权衡 · **已证伪** 外部审计判为不成立、本文复核确认。
13+
14+
## 摘要
15+
16+
外部审计列入本仓(moyu)的发现共 **22 条**(HIGH 2 / MEDIUM 4 / LOW 16,含 cross 跨仓项的 moyu 部分)+ 证伪 5 条。本文逐条以当前代码复核结论:
17+
18+
| 结论 | 数量 | 说明 |
19+
|---|---:|---|
20+
| 成立(OPEN),建议处理 | 19 | 其中 **HIGH 2**(F004、GPT-H01)/ MEDIUM 1(F025)/ LOW 16 |
21+
| 有意(BY-DESIGN)/可接受 | 3 | F072、F024、F032(F024/F032 实为同一议题)|
22+
| 已证伪(复核确认非缺陷)| 5 | F006、F026、F027、F028、F071(行删除正确;多级回复一旦上线有潜在计数漂移)|
23+
24+
> 合计 22 条 moyu 相关发现(19 OPEN + 3 BY-DESIGN)+ 5 条证伪。
25+
26+
> ⚠️ **两条 HIGH 建议优先修复**`F004`(聊天引用预览泄露任意私聊消息内容+发送者名,IDOR)、`GPT-H01`(图床客户端解析错信封 → 上传成功却回空 hash/url,截图/编辑器配图静默坏图)。两者本仓今日 API 审计**未覆盖**
27+
28+
---
29+
30+
## 一、成立(OPEN)— 建议处理
31+
32+
### 🔴 高危
33+
34+
#### F004 · 聊天 `reply_to_id` 未做房间归属校验 → 引用预览泄露任意私聊消息(IDOR)
35+
36+
- 位置(当前代码):
37+
- `internal/chat/service/service.go` `CreateMessage`:解析了**发帖**房间成员,但 `ReplyToID: replyToID` 原样写入,**未校验被引用消息属于本房间**
38+
- `internal/chat/handler/handler.go:124` `MessagesByIDs(replyIDs)``internal/chat/repository/repository.go:316` `GetMessagesByIDs``r.db.Where("id IN ?", ids)` —— **`chat_room_id` 过滤**
39+
- `handler.go:133-141``markdown.MustRender(q.Content)` 与发送者名回填进 `quote_message` 返回给房间所有成员。
40+
- 影响:任一登录用户在自己所在房间发消息,把 `reply_to_id` 指向**自己不在场的私聊房间**里的任意 `chat_message.id`,下次拉消息时引用预览即返回那条私聊消息的渲染正文 + 作者名。逐 id 枚举即可读取任意私聊内容。
41+
- 修复:`CreateMessage` 内,若 `replyToID != nil``GetMessage(*replyToID)` 后校验 `target.ChatRoomID == room.ID`,否则拒绝/置空;并让引用构建走**已存在的**按房间作用域版本 `ListMessagesByIDsInRoom``repository.go:192-201``Where("chat_room_id = ? AND id IN ?")`)作为纵深防御。
42+
- 备注:今日 API 审计修了 `ToggleReaction` 的同类 IDOR(已加 `IsMember`),但**未触及本引用路径**。修复模式现成(同文件已有作用域版本)。
43+
44+
#### GPT-H01 · 图床客户端解析旧响应信封 → 上传成功却返回空 hash/url(截图/配图静默坏图)
45+
46+
- 位置(当前代码):
47+
- `pkg/imageclient/client.go:163-164` 成功体解码进**** `UploadResult``client.go:90-98`,无 `data` 包裹):`var out UploadResult; json.Unmarshal(raw, &out)`
48+
- **决定性证据**:真实 image_service(上游 `kun-oauth-admin``platform/image`)的**自带 HTTP 测试** `internal/platform/image/handler_http_test.go` 把上传响应解成信封 `envelope{Code int; Message; Data json.RawMessage}`,再从 `env.Data` 里取 `hash/url/variant_urls`(成功 handler `Upload` 末行 `response.Success(c, result)`)。即服务**实际返回 `{code,message,data:{hash,url,variant_urls}}`(带信封)**。moyu 这份解裸对象 → `Hash/URL/VariantURLs` 全留零值,`json.Unmarshal` 不报错 → 代理回 200 但 payload 全空。
49+
- 误导根源:本仓 `docs/image_service/03-api-design.md`(约 97-110 行)画的是**无信封**的成功体(且把错误体画成 `{error:{...}}`),与运行中的服务(`{code,message,data}`,错误也是 `{code,message}`)都不一致 —— 客户端的成功体****错误体解析双双照这份过期文档写错了。
50+
- 影响:`POST /api/v1/upload/image-service`(截图编辑器/milkdown 配图,`apps/web``useGalgameEdit.ts``data.hash/url/variant_urls`)上传成功却拿到空 hash/url → 插入空图/坏图。**静默失败**
51+
- 修复:`client.go:163` 改解 `var env struct{ Data UploadResult \`json:"data"\` }``env.Data`(对齐权威 SDK);并订正 `docs/image_service/03-api-design.md` 的信封。
52+
- 备注:GPT 原报告还点了 `/img` URL helper(`MainURL`/`VariantURL`),经核对这些 helper 在上传路径**未被调用**(dead code),真正的 bug 是上面的**信封不匹配**。今日 API 审计把该端点标了 ✅ 但注明“上游未实测”——正是没实测才漏了它。
53+
54+
### 🟠 中危
55+
56+
#### F025 · wiki 同步在单个 DB 事务内串行调用 OAuth 发奖 HTTP(I/O-in-tx,最大批 1000)
57+
58+
- 位置:`internal/infrastructure/cron/wiki_sync.go:75``db.Transaction` 包住整批)→ `:142` `mp.Adjust(ctx, ...)`(同步 HTTP)→ `:151` `tx.Exec("UPDATE \"user\" SET moemoepoint ...")``wikiBatchLimit=1000``:38`)。直接违反 `pkg/moemoepoint/awarder.go:24` 的约定“award 必须在任何 DB 事务**之外**调用”。
59+
- 影响:OAuth 慢/抖时,一次 cron tick 可让单个 Postgres 事务持开数十秒(受 `cron.go` 2min ctx 上限),串行 ≤1000 次 HTTP,钉住连接 + 持锁;一条坏消息回滚整页、每 tick 重复中毒。正确性(幂等)无损,是**可用性/运维**风险。
60+
- 修复:事务内只做幂等 INSERT + 通知写入;发奖 + 缓存镜像移到 commit 之后用 `Awarder.Award`(已为 post-commit、按稳定键 `moyu:wiki_approved:<id>` replay-safe);或大幅调小 `wikiBatchLimit`
61+
62+
### 🟡 低危(成立,建议成批治理)
63+
64+
| ID | 端点/位置(当前代码)| 问题 | 建议修复 |
65+
|---|---|---|---|
66+
| **F029** | `internal/user/service/service.go` Follow/Unfollow + `repository.go:128-153` | 关系写入与 denormalized 计数更新**不在同一事务**(今日修了 rowsAffected 守卫,但仍是两段事务)→ 两写之间崩溃会永久 drift 计数 |`CreateFollow/DeleteFollow``UpdateFollowCounts` 合进一个 `db.Transaction`(实测库当前无 drift)|
67+
| **F034** | `internal/patch/handler/handler.go`(23 处 `ErrBadRequest(err.Error())`| 多处把 service 的 not-found/无权限/DB 错统一拍成 HTTP 400 并回显原始 `err.Error()`(如 `ToggleCommentLike``comment not found` 应 404)| service 返回哨兵错误(ErrNotFound/ErrForbidden),handler `errors.As` 分类映射;`ErrBadRequest` 只留给 Bind/Validate |
68+
| **GPT-M03** | `internal/app/router.go:312` `/resource/:id`(无限流)+ `internal/common/handler.go:438-461` | 主资源 `content/code/password` 公开下发、**无限流**,可逐 id 枚举绕过 `/link` 的 30/min |`/resource/:id` 路由加同款 `RateLimit(...,30,time.Minute)`(payload 不变,FE 详情页仍需就地渲染下载,见 §4)|
69+
| **GPT-M04** | `internal/user/service/service.go:254-273` + `repository.go:208-211` | 签到读 `daily_check_in`**无条件** `Update("daily_check_in",1)`(无 `WHERE ...=0`/rowsAffected)→ 并发两请求都成功、各回一个随机奖励数(萌萌点因稳定键不会双发)| repo 改 `Where("id=? AND daily_check_in=0").Update(...)` 返回 rowsAffected;service 把 0 视为“今日已签到” |
70+
| **F066** | `internal/auth/service/service.go:145-160` + `auth/repository CreateUser` | `FindOrCreateUserByID` 读后插、无 `OnConflict` → 全新用户并发首登一个 200 一个 500 | `Clauses(clause.OnConflict{DoNothing:true}).Create` 后重查;重复键映射为重取而非 500 |
71+
| **F067** | `internal/patch/service/service.go` like/favorite(`:609/621``:1051/1062``:1081/1090`| 可逆奖励用 relation 自增 id 作幂等键,丢失的 unlike 会留下永久 +1(fire-and-forget,无重试)| 若要精确反转,过 durable outbox/重试,或两向都用 `(content,liker)` 稳定键带 on/off 后缀(当前为有意的 best-effort,可接受+记录)|
72+
| **F068** | `internal/infrastructure/cron/wiki_sync.go:130-194`(nil 守卫 132/164/176/188)| `target_user_id==nil` 的可执行消息被静默消费(幂等标记已写、无效果、**无日志**;kungal 同处会 `slog.Warn`| 跳过时补 `slog.Warn`,并考虑该情形不写幂等标记以便修正后重投 |
73+
| **F069** | `internal/app/router.go:93` `PUT /patch/resource/:id/download` | 无 auth、无限流 → 下载计数可被任意刷(污染排行/排序)| 至少加 `RateLimit`(理想再加 optionalAuth/按 session 去重)|
74+
| **F070** | `internal/patch/service/service.go:1180-1186` | `CreateLikeCommentNotification` 已实现但**全仓无调用方**(死代码)→ 评论被赞从不通知作者 | like 成功后 `go CreateLikeCommentNotification(...)`,或删除死代码 |
75+
| **F073** | `internal/chat/handler/handler.go:194` | `lastMsgs, _ := h.svc.LatestMessagePerRoom(...)` 吞错 → DB 抖动时房间列表静默丢“最后一条预览” | 捕获并 `slog.Warn`(仍可返回列表)|
76+
| **F074** | `internal/user/repository/repository.go:31-53` | 四个 profile 计数 helper 丢弃 `Count().Error` → DB 错时公开资料把计数显示为 0 | 返回 `(int64,error)` 并上抛/记录(与同文件下方 list helper 一致)|
77+
| **F075** | `internal/admin/handler/handler.go:420` | `pending, badVndb, _ := CountOrphanPatches()` 丢错 → DB 抖动时孤儿补丁面板把 pending/bad_vndb 显示为 0 | 检查并 `ErrInternal`,或标注部分数据 |
78+
| **F077** | `internal/middleware/auth.go:506-518` | refresh 永久拒判定只认 `{10002,10003,15003}` + HTTP 401/403;而 OAuth 文档里 `15005`(grant 未启用)/`15008`(client secret 错)/`10014`(封禁)可走 HTTP 200 信封 → 落到 transient 分支 → **每请求无限重试、永不清会话** |`10014/15005/15008` 加入永久集合,别只靠 HTTP 状态码(文档明示“业务 401 走 HTTP 200”)|
79+
| **F083** | `internal/patch/service/service.go:399-419``:414` 返回 `ErrRecordNotFound`)+ `handler.go:685-691``:688` `ErrInternal`| `GetRandomPatch` 在 SFW 采样全 NSFW 时把 not-found 映射成 HTTP 500(罕见但会触发告警)| handler 里 `errors.Is(err, gorm.ErrRecordNotFound)` → 返回 404 或空 `{id:0}` |
80+
| **F085** | `internal/infrastructure/cron/cron.go:26` `cron.New()`(无 `WithLocation`)+ `:29` `"0 0 * * *"` | 每日重置(`daily_check_in/image_count/upload_size`)按**进程本地时区**午夜;签到幂等键日期也用本地 `time.Now()` | `cron.New(cron.WithLocation(loc))` 显式 `Asia/Shanghai`,并对齐签到键日期 |
81+
| **GPT-L02** | `pkg/config/config.go:133-134``KUN_IMAGE_SERVICE_BASE_URL`/`KUN_IMAGE_CDN_BASE` 默认 `127.0.0.1`)+ `.env.example``KUN_IMAGE_*` | 生产漏配不 fail-fast,静默回落 localhost;`.env.example` 没有这些键,照抄即落默认 | 这两个 URL 在 prod 模式用 `mustGetEnv`/校验非 localhost;`.env.example``KUN_IMAGE_*`(凭据回落到项目 OAuth client 是**有意设计**,见 `app.go:142-148` 注释,保留)|
82+
83+
---
84+
85+
## 二、有意(BY-DESIGN)/ 可接受 —— 机制属实,但为有意权衡
86+
87+
- **F072 · 特权删除不做房间作用域**`internal/chat/service/service.go:148-158` + `handler.go:425`):admin/mod 可软删自己不在场私聊里的消息。机制属实,但方法注释明示“发送者或 admin/mod 可删”,软删留 `deleted_by_id` 审计、不泄露内容(生成墓碑)。属**有意的全局审核能力**,不必改(若要私聊豁免审核,可收紧为仅 admin)。
88+
- **F024 / F032 · 下游封禁/降权滞后到 token 刷新**`internal/middleware/auth.go:180`、soft-window 后台刷新 `:171-177`,包头注释 1-9):角色读自缓存的 access_token JWT、不逐请求回查 OAuth;窗口 ≈ access TTL(且 soft-window 那一次请求会带旧角色放行)。这是**短时令牌下游“仅验签”**的标准设计且代码有注释说明;硬过期路径对永久拒判会删会话 fail-closed。属有意权衡(敏感操作若需即时回收权限,可缩短 TTL 或对高危路由回查)。
89+
90+
---
91+
92+
## 三、已证伪(复核确认非缺陷)
93+
94+
| ID | 外部审计原断言 | 本文复核结论 |
95+
|---|---|---|
96+
| **F006** | “moyu 完全没有 image client,也不 reference-ping → 头像/banner 会被 GC” | **证伪成立**:moyu 有 `pkg/imageclient``New/Upload/MainURL/VariantURL``app.go:149-155` 接线),故“没有 client”不实;moyu 不做 reference-ping 是**有意**(保活责任在上游 image_service/OAuth 的自有 cmd job)。注:保活无害性依赖上游 repo,本仓内只能确认 client 存在 + 故意不 ping。|
97+
| **F026** | “签到幂等键取自 goroutine 里的 `time.Now()`| **证伪成立**:键是稳定的 `moyu:checkin:<uid>:<date>``service.go:271-272`),`time.Now()` 只取日历日;once-per-day 由 DB flag 把关。(另见 GPT-M04:DB flag 的 check-and-set 非原子,是另一回事。)|
98+
| **F027** | “ghost/null-galgame 的 approved 消息先标记已处理 → 永久丢 +3” | **证伪成立**`wiki_sync.go:126-128``m.Galgame==nil` 直接 `return nil`,按文档 §7 是**约定的 no-op**(galgame 已被硬删,无补丁页可通知、无奖励对象)。|
99+
| **F028** | “UpdateResource 在 key 未变时跳过 s3_key 前缀校验 → 旧/伪造 key 持久化” | **证伪成立**`service.go:823-828` 仅在 `update.S3Key != existing.S3Key` 时校验前缀(即**未引入新 key**才跳过);跨行篡改另有 owner 校验(`:794`)+ s3_key 唯一索引兜底。|
100+
| **F071** | “删父评论不为孙级回复减 `comment_count`| **行删除部分证伪成立**`patch_comment.parent_id` FK `ON DELETE CASCADE` 递归删除子+孙行,无孤儿。**但存在潜在计数漂移**`CountCommentAndReplies``repository.go:207-212`)只数 `id 或直接 parent_id`,CASCADE 却连孙级一起删 → 一旦**多级回复**可创建,`comment_count` 会按已计入的孙级数量上漂。当前前端无多级回复 composer,故 dormant。若上线多级回复,建议把计数改为 `WITH RECURSIVE`。(与今日 API 审计 `patch-write` 域记的“嵌套回复计数”遗留同源。)|
101+
102+
---
103+
104+
## 四、与本仓 API 审计的关系
105+
106+
[`docs/api/checks/`](../api/checks/README.md)(2026-05-29)是本仓**自审 159 个 API 端点的字段对齐 + 修复**;本文是核对**外部跨仓审计**对本仓的发现。两者部分重叠,已在上文逐条注明:
107+
108+
- **已被今日 API 审计修复、本轮无需再处理的同类项**:聊天 `ToggleReaction` IDOR(≠F004 的 reply 路径)、公开列表流批量泄露下载载荷(`/home`/`/resource`/recs/`/user/:id/resource``StripResourceSecrets`;但 **GPT-M03 的单资源 `/resource/:id` 主体仍按 FE 需要保留** → 仅缺限流,见 §1)、`/link` 限流改按 user 键、admin 删评论计数、评论创建 critical 修复等。
109+
- **本文新增、今日 API 审计未覆盖的****F004****GPT-H01**(两条 HIGH)、F025、F029 的原子性、F034 的错误映射、GPT-M04 签到竞态、F066/F067/F068/F069/F070/F073/F074/F075/F077/F083/F085/GPT-L02。
110+
111+
## 五、建议处理顺序
112+
113+
1. **立即**`F004`(私聊内容泄露 IDOR)、`GPT-H01`(图床上传静默坏图)—— 两条 HIGH,修法明确且小。
114+
2. **其次**`F025`(I/O-in-tx,OAuth 抖动下连接/锁风险)、`GPT-M03`/`F069`(给 `/resource/:id`、下载计数加限流)、`GPT-M04`/`F066`(check-and-set 原子化)。
115+
3. **成批治理 LOW**`.Error` 吞错簇(F073/F074/F075)、错误码/状态码规范(F034/F083/F077)、时区(F085)、配置 fail-fast(GPT-L02)、计数原子性(F029)、死代码/通知(F070)。
116+
4. **记录备查**:F072、F024/F032(有意)、F071(多级回复上线前的潜在项)。
117+
118+
> 本文仅为核对与列举;除已在 `docs/api/checks/` 修复的重叠项外,上述 OPEN 项**尚未改动代码**

0 commit comments

Comments
 (0)