LoopV 文档

01 — 项目文档

LoopV 隐私安全自审报告

源文件 docs/security-review.md

评估日期: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.tshandleMessage 只读 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-PolicyX-Frame-OptionsReferrer-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(高危,立即)

  1. /api/history 强制登录 + 撤回消息(deleted 1/2)返回时清空原文(F1)
  2. 封禁/删除用户时广播 kick 断开其全部 WebSocket(F2)
  3. 用户自助注销账号 + 级联数据删除(F3)
  4. 删除操作级联清理 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 年自然过期)。如需即时失效需配合缓存版本化或缩短缓存时长,列入后续产品决策。