评估日期:2026-09-04 · 评估类型:代码级静态安全与隐私自审 · 结果状态:发现高危项,建议立即整改(已于评估当日全部整改完毕并部署)
整改状态:P0/P1/P2 与附带项(F1-F13、G1-G4)已于 2026-09-04 全部修复并部署,详见文末「整改记录」。
一、评估范围
本次自审覆盖处理用户真实数据的核心服务:
| 对象 | 范围 |
|---|---|
| chat.loopv.net + admin.loopv.net | Worker 全量路由(worker.ts)、WebSocket(chat-room.ts)、限流(rate-limiter.ts)、认证(auth.ts)、D1 迁移 SQL、前端存储 |
| R2 / D1 / DO | 媒体对象存储、数据库表、会话与限流 Durable Object |
| 静态站点 | portal/docs 的外部请求与第三方依赖 |
| 排除项 | 本次为代码级自审,未做运行时渗透测试、未核查 Cloudflare 账户侧配置 |
二、总体结论
认证、上传、消息输入侧的基础安全控制扎实(哈希、限流、黑名单、域名隔离均到位),但自审发现 2 个高危功能/授权缺口:撤回消息原文可经匿名 API 全量拉取(撤回仅是「视觉撤回」);封禁/删除用户不中断其已建立的 WebSocket 连接(封禁治理存在执行空窗)。两者都与对外隐私政策的承诺直接相关,在修复前不宜对外宣称撤回即不可见、封禁即断连。其余缺口集中在数据生命周期与最小化,按文末优先级整改。
三、已核实的安全控制(保持现状)
| 领域 | 控制 | 位置 |
|---|---|---|
| 口令存储 | PBKDF2-SHA256、100000 迭代、16B 随机盐、常量时间比较 | auth.ts |
| HTTP 会话 | 32B 随机 token、7 天过期、登出即删、封禁即删 session 行 | auth.ts / worker.ts |
| 上传安全 | 危险 MIME 与扩展名双重黑名单、按类型大小限制、nosniff |
worker.ts |
| 暴力破解 | RateLimiter DO 按 IP 限流(10min/5 次失败 → 锁 15min),成功登录清零 | rate-limiter.ts |
| WS 输入 | 发送 800ms 节流、内容 5000 字符上限、media_url 仅 /media/ 前缀 |
chat-room.ts |
| 域名隔离 | admin API 仅 admin 域可访问(本地 localhost 放行) | worker.ts |
| CORS | 白名单收窄为 chat/admin.loopv.net + localhost | worker.ts |
| DO 状态 | serializeAttachment 存认证态,Hibernation 下不依赖内存 Map | chat-room.ts |
| 日志 | 仅 7 处 console.error 且不含用户内容;无消息正文日志 | — |
| 错误信息 | 面向客户端返回定制中文文案,无堆栈/SQL 细节泄露 | — |
| 依赖面 | 运行时仅 hono 一个依赖;无第三方统计/广告 SDK | package.json |
四、发现与建议
高危(优先整改)
F1 · 撤回消息原文经 /api/history 匿名可全量拉取(撤回=视觉撤回)
- 现状:
GET /api/history(worker.ts:459)未强制登录(全局authMiddleware仅注入user不拦截),SELECT返回content/media_url全文,WHERE仅排除deleted=3(:469/475/481)——即deleted=1(用户撤回)/2(管理员撤回)的消息原文与媒体 URL 原样返回。 - 前端 chat 界面正确显示占位「消息已撤回」,但任何不带 token 的请求即可翻页拉取全部撤回消息原文;无速率限制,可脚本化抓取全量历史。前端强制登录与 API 匿名访问存在授权不一致。
- 影响:用户「撤回」预期被击穿;历史内容可被第三方无差别抓取;与产品对外「撤回后不可见」的表述存在授权层差距(见 F1 建议修复后回写政策措辞)。
- 建议:
/api/history增加强制登录校验;且对deleted IN (1,2)的消息,返回时清空content/media_url(或仅deleted=0时返回原文,前端再按状态展示占位)。修复后再向用户承诺「撤回后原文不可见」。
F2 · 封禁/删除用户不中断其已建立的 WebSocket 连接
- 现状:封禁仅
DELETE FROM sessions(worker.ts:971-973);chat-room.ts的handleMessage只读serializeAttachment(:180),无 banned 实时校验;DO 无 kick 广播。删除用户同理(worker.ts:832-833 不通知 DO)。前端仅在启动时调一次/api/auth/me,无轮询。 - 影响:被封禁/被删除用户已连的 WS 可继续发消息并广播,直至其主动断开或刷新;封禁这一核心治理手段存在执行空窗,对外「封禁后无法再登录」的表述仅覆盖 HTTP 会话、未覆盖已建立的 WS 连接。
- 建议:封禁/删除时经内部广播向 DO 下发
kick(含 userId),chat-room 关闭该用户全部 socket,前端收 kick 清 session 回登录页。
F3 · 无用户自助注销/删除账号能力
- 现状:无任何用户侧注销接口;仅 admin 可删除用户(不能删管理员、不能删自己)。chat 端无「注销账号」入口。
- 影响:用户无法自助行使删除权(《个人信息保护法》第 47 条)。
- 建议:新增自助注销流程(二次确认 + 密码验证),级联清理 sessions / messages 归属 / R2 媒体。
F4 · 「删除」不真正删除数据(软删除 + R2 对象永不清理)
- 现状:管理员删除消息仅
UPDATE messages SET deleted=3(对外隐藏);D1 行与媒体对象仍完整保留;R2 无任何删除接口——更换头像、删除用户均遗留孤儿对象;已删除消息的媒体 URL 仍可直链访问(URL 不可枚举,但曾出现在历史/广播中的链接永久有效)。媒体响应immutable, max-age=31536000(worker.ts:437/556),未来做物理删除还需同步清理缓存,否则旧内容最长可再存活 1 年。 - 影响:与「已删除」语义预期不符;存储无限增长;用户要求彻底删除时无法兑现。
- 建议:删除消息/用户时级联删除对应 R2 对象并考虑缓存 purge;提供历史数据物理清除入口。
中风险(列入整改)
F5 · 测试账号明文密码入库且列表接口全量返回 plain_password 列
- 现状:
is_test=1用户明文密码写入 D1(设计使然);但GET /api/admin/users对所有用户 SELECT 了plain_password列(普通用户为 null),属多余字段外泄,违反数据最小化。 - 建议:列表查询移除该列,仅在创建测试用户时一次性回显明文。
F6 · 过期 session 行永不清理
- 现状:sessions 表有
idx_sessions_expires索引但无清理任务;过期行永久累积。 - 影响:D1 缓慢膨胀;泄库时可回溯大量历史 token(均已过期,验证价值低)。个人项目规模下影响有限。
- 建议:Worker 定期(如每日)删除
expires_at < now的行;或登录校验时惰性清理。
F7 · 前端会话凭证存 localStorage(XSS 面)
- 现状:token 存 localStorage(chat/admin 均如此);页面无 CSP。
- 影响:若页面被注入 XSS 即可窃取会话。当前前端渲染用户内容全部走 textContent(已核实 app.js,innerHTML 仅用于硬编码 SVG 图标),无第三方脚本注入点,实际风险被充分缓解;但纵深不足。
- 建议:保持无第三方脚本 + textContent 渲染纪律;补充 CSP 头作为纵深。
F8 · 响应头缺 CSP / 基础安全头
- 现状:media 有
nosniff;静态 HTML 与 API 未设置Content-Security-Policy、X-Frame-Options、Referrer-Policy。 - 建议:Worker 对 HTML/API 统一附加基础安全头。
低风险(记录备查)
- F9 · 注册/登录接口的用户名探测:注册返回 409「用户名已被占用」、登录对已封禁账号返回 403「账号已被封禁」(worker.ts:327,区别于未存在用户的 401「用户名或密码错误」),构成两条用户名/账号状态枚举通道。聊天室对外展示昵称而非用户名、用户名不公开,影响低;建议注册与登录文案统一、封禁与不存在账号均返回同一文案。
- F10 · RateLimiter 记录无 TTL 清理:
rl:{ip}键仅成功登录时/reset删除,窗口过期后残留旧键。实际每 IP 独立 DO 实例、每实例仅 1 键且无关联业务信息,残留总量≈被攻击过的 IP 数,影响低;建议 DO alarm 定期清理过期键。 - F11 · 媒体文件名为可推测结构:头像
avatar-{userId}-{ts}、媒体{ts}-{6位随机},6 位随机来自Math.random()非加密随机。头像在聊天中本就公开展示,媒体 URL 随 history 下发;文件名结构仅在「对已删/撤回媒体越权直读」场景构成兜底,F1 修复后影响基本消除。建议改全随机命名。 - F12 · 广播暴露自增 userId:WS 消息、在线列表携带自增 userId,可推测注册总量。影响低。
- F13 · 第三方字体请求:chat/admin/portal/docs 均从 Google Fonts 拉取字体,向 Google 暴露访客 IP/UA(无业务数据)。建议长期 self-host 字体。
附带发现(产品缺口,非安全项)
- G1 · 无修改密码能力:全库无改密 API,凭证泄露后用户无法自助轮换,只能删除重建。
- G2 · 删除用户后用户名可被重新注册:
DELETE FROM users不保留 username 占用(无 tombstone),且 messages 存昵称快照、默认昵称=用户名——新注册同名用户可能被误认为历史消息作者。建议删除时对 username 做保留标记或 tombstone。 - G3 · 密码长度下限 6:建议提至 8,与登录限流并存更稳妥。
- G4 ·
c.req.json()无 try/catch:非法 JSON 返回 500 而非 400,可用性噪音,无信息泄露。
五、合规对照摘要(中国《个人信息保护法》视角)
| 原则 | 现状评估 |
|---|---|
| 告知同意 | 需补齐:注册流程无隐私政策提示/链接/勾选(文档已就位 docs/privacy-policy.md,落地入口待实现) |
| 最小必要 | 采集字段极少(用户名/密码/昵称),良好;但 plain_password 全量返回属超标 |
| 目的限定 | 收集仅用于提供服务与防滥用,良好 |
| 保存期限 | 需补齐:消息/媒体长期保存无期限策略;应声明保留期并提供清除手段 |
| 删除权 | 需补齐:F1/F3/F4——无自助注销、删除不落库、撤回仍可经 API 拉取 |
| 安全措施 | 基础输入侧控制良好,但存在授权/封禁语义执行缺口(F1/F2),需修复后评级 |
| 跨境传输 | 数据存 Cloudflare 境外节点。PIPL 第 39 条要求向境外提供个人信息取得单独同意,不能混入注册同意——隐私政策已如实披露,同意流程落地时应单列该条 |
六、整改优先级清单
历史快照:以下为评估当日(2026-09-04)输出的整改清单,实际已按「七、整改记录」全部实施并部署,此处保留仅供追溯。
P0(高危,立即)
/api/history强制登录 + 撤回消息(deleted 1/2)返回时清空原文(F1)- 封禁/删除用户时广播 kick 断开其全部 WebSocket(F2)
- 用户自助注销账号 + 级联数据删除(F3)
- 删除操作级联清理 R2 对象 + 缓存 purge 策略(F4)
P1(合规与最小化)
5. 注册流程接入《隐私政策》链接与同意勾选(含跨境单独同意项,PIPL 39 条)
6. admin 用户列表不再返回 plain_password 多余列(F5)
7. 过期 session 清理任务(F6)
P2(加固与体验) 8. 全站基础安全头 + CSP(F8) 9. 统一注册/登录账号枚举文案(F9) 10. RateLimiter 过期键清理(F10) 11. 媒体文件名改全随机(F11) 12. Google Fonts self-host(F13) 13. 补改密能力 / username tombstone / 密码下限 8(G1-G3,可选)
七、整改记录(2026-09-04 实施)
本报告评估当日,P0/P1/P2 与附带项(F1-F13、G1-G4)已全部修复并部署:
| 编号 | 修复内容 | 状态 |
|---|---|---|
| F1 | /api/history 强制登录;撤回消息(deleted 1/2)返回时清空 content/media_url/media_type |
✅ 已修复 |
| F2 | 封禁/删除用户/自助注销/改密后经 DO /kick 断开其全部 WebSocket;前端收到 kick 事件清 session 回登录页 |
✅ 已修复 |
| F3 | 新增 POST /api/auth/delete-account(密码校验、管理员 403、级联删消息/会话/账号 + R2 媒体) |
✅ 已修复 |
| F4 | 删除消息/批量删除级联清理 R2 对象;更换头像删除旧对象;自助注销用户完整清除其消息与媒体;admin 删除用户保留其历史消息(公共聊天记录),记录 username tombstone 防归属混淆 | ✅ 已修复 |
| F5 | admin 用户列表不再返回 plain_password(仅创建测试用户时一次性回显);前端移除密码列 |
✅ 已修复 |
| F6 | 登录/注册成功后惰性清理过期 session | ✅ 已修复 |
| P1-合规 | 注册需勾选《隐私政策》同意(前后端双校验),并同步更新 chat 手册与隐私政策 | ✅ 已修复 |
| F8 | 全局安全头(nosniff/X-Frame-Options/Referrer-Policy)+ HTML CSP(含 fonts/data/blob 源);serveAsset 兜底包装保证静态页生效 |
✅ 已修复 |
| F9 | 封禁账号登录不再返回 403,与不存在/密码错误统一 401 文案(收敛枚举) | ✅ 已修复 |
| F10 | RateLimiter DO 增加每日 Alarm 清理过期键 | ✅ 已修复 |
| F11 | 媒体/头像文件名改 32 位加密随机,不再含 userId/时间戳 | ✅ 已修复 |
| G1 | 新增 POST /api/auth/change-password(改密后清空全部 session + kick 下线) |
✅ 已修复 |
| G2 | 注销/删除用户记录 username tombstone(settings deleted_usernames),注册时拒绝同名 |
✅ 已修复 |
| G3 | 密码下限 6 → 8(注册/改密/创建测试用户,含前端 placeholder 与手册) | ✅ 已修复 |
| F13 | Google Fonts self-host(portal/docs 用 @fontsource 本地打包;chat/admin 静态字体复制至 public/{chat,admin}/fonts/ 并加 @font-face,移除全部外链与 CSP 白名单) |
✅ 已修复 |
| G4 | c.req.json() 非法输入统一 400(新增 safeJson 工具包裹全部 JSON 请求体解析) |
✅ 已修复 |
残余风险提示:媒体删除仍受浏览器 Cache-Control: immutable, max-age=31536000 本地缓存影响(服务端对象已删、新请求 404;已缓存副本最长 1 年自然过期)。如需即时失效需配合缓存版本化或缩短缓存时长,列入后续产品决策。