lz有考虑加强一下安全性吗? 比如后端不明文存数据,用保险库密码通过PBKDF2派生密钥,通过AES-256加密所有信息,把webdav完全迁移到前端之类的,如果有跨域问题的话可以让后端给个代理接口(但不需要明文存储凭据)。 另外目前的的鉴权好像没签名,可以整个jwt之类的。
🧠 GPT-5.2 Thinking 先给结论:这份代码“能跑出 TOTP 验证码”,但作为可公开部署的 2FA 管理/保险库应用,存在多处“高危级别”的漏洞与设计缺陷——其中最严重的是:会话 Token 可伪造导致任意账号接管(IDOR/认证绕过)、密码与保险库密码用 SHA-256 直哈希(不符合业界最佳实践)、所谓 encrypted_secret 实际明文存储 2FA 密钥、以及把 2FA 密钥写入云端日志/Telegram(极高敏感数据外泄面)。下面我按“系统数据流 → 关键安全/功能 Bug → 具体修复路径”循序渐进拆开讲。 ⸻ 先把这套系统的“真实数据流”讲清楚:你现在的 2FA 体系实际上如何运转 你这份 Worker 同时提供了:前端单页(HTML_CONTENT)+ 一组 /api/* JSON API。整体链路可以分成四条主线: (A) 账号体系(注册/登录/会话) • 注册:/api/register 把 username 与 sha256(password) 写入 users.password_hash。 • 登录:/api/login 校验同样的 SHA-256 哈希匹配后,返回 sessionToken = "${id}-${expiry}-${random}"。这里的 expiry 是当前时间 + 2 小时,random 是 crypto.getRandomValues 生成的 32 字节十六进制串。 • 鉴权:所有需要登录的 API 通过 Authorization: Bearer <sessionToken>,在 Worker 里 getUserIdFromRequest() 仅做三件事: 1. split('-') 取 userId 与 expiry; 2. 判断是否过期; 3. 直接返回 userId。 注意:这里没有任何“签名校验/服务端存储校验”,这会成为最大漏洞根源。 (B) 保险库(vault password) • users.vault_password_hash 用于区分“是否设置过保险库密码”。 • 前端解锁逻辑:浏览器把 vaultPassword 放在 sessionStorage,解锁时调用 /api/verify-vault-password 比对 sha256(vaultPassword) 是否等于 DB 中的 vault_password_hash。 • 但关键点:服务端并没有把“保险库解锁状态”作为权限条件。也就是说,“保险库密码”目前只是前端 UX 上的门槛,而不是一个真正的加密/授权边界。 (C) 2FA 密钥与账号保存 • 常用账号保存:/api/save-account 把 secret 规整成 Base32(去空格、去 -_、大写)后写入 saved_accounts.encrypted_secret。 • 名字叫 encrypted_secret,但并没有任何加密步骤。这意味着数据库一旦泄露,所有 2FA 密钥会被直接还原;同时任何能拿到会话的人也能直接拉取密钥。 (D) TOTP 生成与日志 • TOTP 算法在浏览器执行:Base32 解码 → 时间步(30 秒)→ HMAC-SHA1 → 动态截断 → %1000000 得 6 位码。这个算法流程与 RFC 6238 的标准做法一致(HMAC + 动态截断 + 30 秒步长是典型组合)。  • “云端记录”:/api/save-totp-log 会把 secret(密钥本体)、IP、UA 写到 totp_logs;并且如果配置了 Telegram Bot,还会把密钥与验证码推送到 Telegram。 从这四条链路你应该能看出:你现在的系统把“2FA 的最高价值资产”(共享密钥 secret)放在了多个高风险位置:数据库字段、日志表、Telegram 消息、WebDAV 备份文件、前端 Local/Session Storage。只要其中任一环被攻破,2FA 就等价失效。 ⸻ 最高危 Bug:会话 Token 可伪造 = 任意账号接管(这是“立刻必须修”的) 你当前的 sessionToken 结构是:<userId>-<expiry>-<random>,并且 getUserIdFromRequest() 只解析与检查 expiry,不校验 token 是否由服务端签发。换句话说: • 攻击者无需知道任何密码; • 只要猜到/枚举一个用户的 userId(自增整数非常好猜),就能自己构造: victimUserId-(未来很久的时间戳)-随便填点字符串 • 你的服务端会把它当成合法登录态,允许访问: • /api/saved-accounts(直接拉取该用户的全部 2FA 密钥明文) • /api/cloud-history(拉取日志里的密钥) • /api/update-account、/api/delete-account(篡改/删除对方保存项) • 以及所有以 user_id 为条件的 D1 查询接口 在 OWASP 的会话管理视角里:Session ID/Token 一旦建立,就等价于最强认证方式本身,因此必须具备不可预测性与不可伪造性,并能被正确失效与轮换。  你现在做到了“随机串看起来很随机”,但没做到“服务端验证 token 的真实性”,所以随机串再强也没有用——攻击者完全可以绕过。 最佳实践修复路线(按优先级,从最稳妥到较轻量): 1. 服务端存储会话(推荐) • 新建 sessions 表:session_id (随机), user_id, expires_at, created_at, revoked_at。 • 登录时生成高熵 session_id(32 bytes 随机),写入 D1;返回给前端。 • 每次请求:从 header/cookie 取 session_id,查表确认存在且未过期/未撤销。 • 登出时:把该 session 标记 revoked_at 或直接删行。这样“登出”才真正有效。 这条路线与 OWASP “可终止会话、会话失效”等要求高度一致,也更容易做“全端登出/改密后踢下线”等能力。  2. 使用签名 Token(JWT/自定义 HMAC)但必须校验签名 • 你可以保留 userId、exp 这样的声明,但必须对 payload 做 HMAC-SHA256 签名(用 Worker 的 secret env 变量),服务端收到后验证签名一致才放行。 • 否则依旧可伪造。 • 需要注意:签名 token 仍然难以“即时登出”(除非引入黑名单/版本号),所以纯 JWT 往往需要配合服务端状态(例如 token version、revocation list)才能达到安全登出。 3. 把 token 放到 HttpOnly Cookie(推荐配合方案 1 或 2) • 你现在把 token 放 sessionStorage,XSS 一旦发生就会被直接读走。 • 用 Set-Cookie 下发 HttpOnly; Secure; SameSite=Lax/Strict 可以显著降低被脚本窃取的概率(仍需防 CSRF,但 SameSite 能缓解)。这也是业界常见组合。  这一条是“是否有 bug”的分水岭:只要不修,你的系统不是“可能被攻破”,而是“默认可被接管”。 ⸻ 第二组高危问题:密码/保险库密码用 SHA-256 直哈希,不符合最佳实践;保险库也没有真正加密边界 3.1 密码哈希:SHA-256 直哈希的问题是什么? SHA-256 是“通用摘要函数”,设计目标是快,而密码存储恰恰需要慢(让离线爆破成本变高)。OWASP 的 Password Storage Cheat Sheet 明确给出:应使用专用密码哈希(如 Argon2id / bcrypt / scrypt),在需要 PBKDF2 时也应设置足够的工作因子。  你当前 sha256(password) 没有盐(salt)、没有工作因子(iterations),数据库一旦泄露,攻击者可以用 GPU 极高速爆破并复用到其它站点(撞库风险)。 Cloudflare Workers 环境里很多人会用 WebCrypto PBKDF2,但还要注意 workerd 对 PBKDF2 迭代次数存在安全争议/限制讨论(社区里就有“迭代数不够安全”的 issue)。  这意味着你如果要“自己造密码学轮子”,必须非常谨慎地选择参数,并进行基准测试与后续参数升级策略。 3.2 保险库密码:即使你把 vault_password_hash 存得更安全,也挡不住“密钥明文存储” 现在的“保险库”只是:前端解锁后显示常用账号。但密钥 secret 是明文存到 D1 的,所以: • 数据库泄露:保险库密码无意义; • 会话被盗/伪造:攻击者直接调用 /api/saved-accounts 取走全部 secret; • 甚至你自己在 /api/save-totp-log 与 Telegram 里也在外发 secret(后面会讲)。 3.3 最佳实践:把“保险库密码”变成真正的密钥保护机制(两条可选架构) 架构 A:客户端加密(更贴合“零信任保险库”) • 服务器永远只存密文:ciphertext + salt + iv + kdf_params; • 解密只在浏览器进行:用户输入 vaultPassword → KDF 派生 AES-GCM key → 解密显示; • 服务器不需要知道 vaultPassword,也无法解密用户密钥。 这种设计对“服务器被拖库”的韧性最强,符合“保险库”产品的常见安全边界模型(服务端即便被攻破也拿不到明文 secret)。 架构 B:服务端加密(实现简单但边界弱一些) • 服务器用 vaultPassword 派生 key 或用服务端主密钥加密; • 但这样服务器理论上能解密,风险比架构 A 高。 如果你只是自用、且信任服务端环境,这条路也可行,但“保险库”这个词会被削弱。 无论 A/B,你都需要参考 OWASP 的加密存储原则:为敏感数据“静态加密”、正确管理密钥与随机数、不要把“可逆加密”当密码存储手段等。  ⸻ 第三组高危问题:你在日志与 Telegram 中直接记录/发送 2FA 密钥(这会让 2FA 直接失去意义) 你的 /api/save-totp-log 做了两件非常危险的事: 1. 往 totp_logs 写入 secret(2FA 共享密钥) 2. 可选地把 username + secret + code + IP 发送到 Telegram 在 2FA/TOTP 体系里,secret 是长期共享密钥,一旦泄露,攻击者就可以在自己的设备上生成与你完全同步的 TOTP,从此“验证码”再也不是第二因素,而是“攻击者也有的第一因素”。RFC 6238 的安全性基础就是 secret 的机密性。  从威胁模型角度看,日志系统/消息系统往往比主数据库更容易“旁路泄露”: • 日志可能被开发/运维/第三方平台访问; • Telegram Bot token 一旦泄露,所有历史消息可被导出; • 即使 token 不泄露,消息在多个设备、云端同步,也扩大暴露面; • 你还把 IP 与时间一起记录,相当于做了可审计的“2FA 使用轨迹”,隐私风险更高。 最佳实践建议: • 绝不记录明文 secret。如果你需要“去重/统计”,可以记录 HMAC(server_secret, secret) 的短指纹(不可逆地标识同一 secret),而不是 secret 本体。 • Telegram 通知只发送“事件发生了”即可,比如:账号名(或匿名标识)、时间、IP(可选),不要带 secret,不要带验证码 code。 • 如果你想做“异常检测”,更应该记录:请求来源、设备指纹、失败次数、频率等,而不是把密钥本体放进任何日志链路。 • 这也与 OWASP 对认证与会话安全的常见建议一致:敏感凭据不应出现在日志与外部通道中。  就“有没有 bug”而言:这不一定是“代码运行 bug”,但在安全语义上是功能性灾难——你部署出去后,任何能访问日志/Telegram 的人都能克隆你的 2FA。 ⸻ WebDAV 备份/恢复:可用性与安全性都存在坑(有些是“会失败”的 bug,有些是“会被滥用”的 bug) 5.1 可用性层面的 bug / 不稳定点 你用 PROPFIND Depth:1 拉目录,然后用正则: xmlText.match(/<D:href>([^<]2fa_backup[^<].json)</D:href>/i) 只取第一个匹配项。这里至少有四个现实问题: 1. 拿到的不一定是“最新备份” WebDAV 的 PROPFIND 返回通常包含多个 <D:response>,并带 <getlastmodified>。你虽然在请求体里要了 <getlastmodified/>,但却没有解析它来排序。结果可能恢复的是旧备份,甚至是列表里第一个出现的随机项(不同服务实现返回顺序不一致)。 2. href 可能是 URL 编码/包含基路径 不同 WebDAV 服务会返回绝对路径、相对路径、带编码字符的路径。你现在把 fileUrl 拼接为: • 若 filePath 不以 http 开头:url.replace(//$/, '') + (filePath...) + filePath 这个拼接在遇到“服务端返回的是完整路径但不含域名”或“返回路径已含 folder 前缀”时,容易得到重复路径或缺斜杠的 URL,导致 404。 3. 只用正则解析 XML 很脆弱 WebDAV XML 命名空间前缀不一定是 D:,可能是 d: 或无前缀;有的返回还会换行缩进、实体编码。正则会在这些情况下失效。稳妥做法是用 XML 解析器(或至少更健壮的匹配逻辑),然后按 displayname/getlastmodified 选择目标文件。 4. 错误处理对 207 Multi-Status 没有精确区分 response.ok 对 207 是 true(因为 2xx),所以你的 “连接成功” 分支大体能跑,但如果需要进一步判断哪些条目失败,你现在没有解析 Multi-Status 的细节。 5.2 安全层面的 bug / 可被滥用点 1. 你把 WebDAV 凭据明文存到 localStorage webdavPassword 被 localStorage.setItem('webdavPassword', password) 保存,这意味着: • 任意同源脚本(包括被注入的 XSS、被劫持的第三方 CDN 脚本)都能读走; • 浏览器扩展/调试工具也更容易导出。 这在安全上非常危险,尤其你还引入了外部 CDN 脚本(jsQR)。供应链风险并不是理论问题。 2. Worker 端作为“任意 URL 的代理”,存在 SSRF/滥用风险 你的 /api/webdav-test|upload|download 会对用户提供的 url 发起 fetch(),并带 Basic Auth。虽然 Workers 没有传统意义的内网,但攻击者可以把你的 Worker 当作“边缘代理”去扫描/请求任意公网地址,带宽与请求额度可能被打爆,甚至触发平台风控。 最佳实践是: • 至少限制为 https://; • 加 allowlist(例如只允许已知 WebDAV 域名)或用户自定义域名但需手动验证; • 加速率限制与审计; • 上传/下载大小限制,避免被当作数据中转站。 3. btoa 在 Workers 里一般可用,但仍建议对非 ASCII 用户名/密码做 UTF-8 处理 你现在 btoa(${username}😒{password}) 在遇到非 ASCII 字符可能出错。一般 WebDAV 凭据多为 ASCII,但不排除国际化账号。关于 btoa 在 Workers 可用性,常见环境确实支持,但仍建议编码稳妥处理。  ⸻ Cloudflare Workers / D1 部署层面的“坑”:混用模块语法、全局 DB 注入方式、以及 D1 外键语义 这部分不一定导致“立刻报错”,但会导致维护困难或在某些部署模式下行为混乱。 1. 混用 addEventListener('fetch') 与 export default { fetch() } Cloudflare Workers 有两种常见写法:传统 Service Worker 风格(addEventListener)与 ES Modules 风格(export default)。官方在介绍 Modules 时明确强调“用 export 语义替代旧的 addEventListener 语法”,实际项目中通常二选一以避免歧义。  你现在两套都写了: • 如果你的 Worker 以 Modules 方式运行,export default fetch 是主入口,addEventListener 可能不会按你预期参与; • 如果以 Service Worker 方式运行,addEventListener 是主入口,export default 可能被忽略; 虽然你当前两个入口都调用 handleRequest(),多数情况下看起来“能用”,但这会让后续迁移/调试非常痛苦(尤其当你开始用 import、拆文件、或引入框架时)。 2. D1 的最佳实践是用 env.DB 贯穿调用,而不是写到 globalThis.DB Cloudflare 文档与示例通常是从 context.env 或 env.DB 取 binding 来执行查询。  你把 env.DB 写到 globalThis.DB,短期可行,但会引入两个问题: • 测试/多环境:全局变量让依赖变隐式,后续很难在本地 mock; • 并发心智模型:Workers 的同一 isolate 里可能复用全局状态(虽然这里 DB binding 本身是稳定对象,但把“请求上下文”写全局是坏味道)。更干净的做法是把 env 作为参数传到 handleAPI/handler 中,或者用闭包构造 router。 3. 你的 SQL 外键写法在 D1 语义下基本 OK 你对 saved_accounts.user_id 做了外键与 ON DELETE CASCADE,这在 D1 是支持且默认按 SQLite 外键启用语义执行的。  需要注意的反而是:未来做迁移/改表时,D1 对外键 defer 的行为与 SQLite 的一些细节有关(例如 defer_foreign_keys 并不会阻止 ON DELETE CASCADE 的级联执行),这会影响“重建表”式迁移策略。  这不是你当前的运行 bug,但如果你后面用 ORM 自动迁移,很容易踩坑导致级联删除误伤数据。 ⸻ 下一步行动建议(按“先止血、再升级”的顺序) 1. 立刻修会话鉴权:引入 sessions 表做服务端会话校验 + 登出可撤销;同时把 token 改为 HttpOnly Cookie(或至少签名校验)。  2. 立刻停止记录/外发 secret:totp_logs 不存 secret;Telegram 不发 secret/code,只发事件摘要。  3. 把 encrypted_secret 变成真加密:优先走“客户端加密、服务端存密文”的保险库模型,并参考 OWASP 的加密存储原则。  4. 升级密码存储:改用 Argon2id/bcrypt/scrypt 或(受限时)PBKDF2+salt+高工作因子,并制定参数升级策略,遵循 OWASP Password Storage 建议。  5. WebDAV 恢复逻辑改为解析 XML + 选最新文件,并限制 URL/速率/大小,避免被当代理滥用;同时不要把 WebDAV 密码放 localStorage。 6. 清理 Worker 入口写法:只保留一种(推荐 ES Modules export default { fetch }),并把 env 作为依赖显式传递。  如果你愿意,我可以在你现有结构上,直接给出一版“最小改动但能达标”的补丁方案:包括 sessions 表 SQL、鉴权中间件、以及把 saved_accounts.encrypted_secret 改为 AES-GCM 密文存储的字段设计(salt/iv/ciphertext 组织方式)。
@dajiji666 #16 虽然PBKDF2不那么安全,但要想在worker环境里用也只能是它了...argon在web crypto里没现成的轮子啊... 至于加密密钥,用bitwarden的那种派生方式还算是安全吧... AI扯了一大通,我就不贴过来了,感兴趣的mjj可以自行查看。 https://github.com/copilot/share/02234302-4084-8861-8943-c043c4f849dd
@rainyfall #9 确实

lz有考虑加强一下安全性吗?
比如后端不明文存数据,用保险库密码通过PBKDF2派生密钥,通过AES-256加密所有信息,把webdav完全迁移到前端之类的,如果有跨域问题的话可以让后端给个代理接口(但不需要明文存储凭据)。
另外目前的的鉴权好像没签名,可以整个jwt之类的。
@soga #13 最好提醒大伙儿一下
目前这个安全性上不了生成环境的...
🧠 GPT-5.2 Thinking
先给结论:这份代码“能跑出 TOTP 验证码”,但作为可公开部署的 2FA 管理/保险库应用,存在多处“高危级别”的漏洞与设计缺陷——其中最严重的是:会话 Token 可伪造导致任意账号接管(IDOR/认证绕过)、密码与保险库密码用 SHA-256 直哈希(不符合业界最佳实践)、所谓 encrypted_secret 实际明文存储 2FA 密钥、以及把 2FA 密钥写入云端日志/Telegram(极高敏感数据外泄面)。下面我按“系统数据流 → 关键安全/功能 Bug → 具体修复路径”循序渐进拆开讲。
⸻
你这份 Worker 同时提供了:前端单页(HTML_CONTENT)+ 一组 /api/* JSON API。整体链路可以分成四条主线:
(A) 账号体系(注册/登录/会话)
• 注册:/api/register 把 username 与 sha256(password) 写入 users.password_hash。
• 登录:/api/login 校验同样的 SHA-256 哈希匹配后,返回 sessionToken = "${id}-${expiry}-${random}"。这里的 expiry 是当前时间 + 2 小时,random 是 crypto.getRandomValues 生成的 32 字节十六进制串。
• 鉴权:所有需要登录的 API 通过 Authorization: Bearer <sessionToken>,在 Worker 里 getUserIdFromRequest() 仅做三件事:
1. split('-') 取 userId 与 expiry;
2. 判断是否过期;
3. 直接返回 userId。
注意:这里没有任何“签名校验/服务端存储校验”,这会成为最大漏洞根源。
(B) 保险库(vault password)
• users.vault_password_hash 用于区分“是否设置过保险库密码”。
• 前端解锁逻辑:浏览器把 vaultPassword 放在 sessionStorage,解锁时调用 /api/verify-vault-password 比对 sha256(vaultPassword) 是否等于 DB 中的 vault_password_hash。
• 但关键点:服务端并没有把“保险库解锁状态”作为权限条件。也就是说,“保险库密码”目前只是前端 UX 上的门槛,而不是一个真正的加密/授权边界。
(C) 2FA 密钥与账号保存
• 常用账号保存:/api/save-account 把 secret 规整成 Base32(去空格、去 -_、大写)后写入 saved_accounts.encrypted_secret。
• 名字叫 encrypted_secret,但并没有任何加密步骤。这意味着数据库一旦泄露,所有 2FA 密钥会被直接还原;同时任何能拿到会话的人也能直接拉取密钥。
(D) TOTP 生成与日志
• TOTP 算法在浏览器执行:Base32 解码 → 时间步(30 秒)→ HMAC-SHA1 → 动态截断 → %1000000 得 6 位码。这个算法流程与 RFC 6238 的标准做法一致(HMAC + 动态截断 + 30 秒步长是典型组合)。 
• “云端记录”:/api/save-totp-log 会把 secret(密钥本体)、IP、UA 写到 totp_logs;并且如果配置了 Telegram Bot,还会把密钥与验证码推送到 Telegram。
从这四条链路你应该能看出:你现在的系统把“2FA 的最高价值资产”(共享密钥 secret)放在了多个高风险位置:数据库字段、日志表、Telegram 消息、WebDAV 备份文件、前端 Local/Session Storage。只要其中任一环被攻破,2FA 就等价失效。
⸻
你当前的 sessionToken 结构是:<userId>-<expiry>-<random>,并且 getUserIdFromRequest() 只解析与检查 expiry,不校验 token 是否由服务端签发。换句话说:
• 攻击者无需知道任何密码;
• 只要猜到/枚举一个用户的 userId(自增整数非常好猜),就能自己构造:
victimUserId-(未来很久的时间戳)-随便填点字符串
• 你的服务端会把它当成合法登录态,允许访问:
• /api/saved-accounts(直接拉取该用户的全部 2FA 密钥明文)
• /api/cloud-history(拉取日志里的密钥)
• /api/update-account、/api/delete-account(篡改/删除对方保存项)
• 以及所有以 user_id 为条件的 D1 查询接口
在 OWASP 的会话管理视角里:Session ID/Token 一旦建立,就等价于最强认证方式本身,因此必须具备不可预测性与不可伪造性,并能被正确失效与轮换。 
你现在做到了“随机串看起来很随机”,但没做到“服务端验证 token 的真实性”,所以随机串再强也没有用——攻击者完全可以绕过。
最佳实践修复路线(按优先级,从最稳妥到较轻量):
1. 服务端存储会话(推荐)
• 新建 sessions 表:session_id (随机), user_id, expires_at, created_at, revoked_at。
• 登录时生成高熵 session_id(32 bytes 随机),写入 D1;返回给前端。
• 每次请求:从 header/cookie 取 session_id,查表确认存在且未过期/未撤销。
• 登出时:把该 session 标记 revoked_at 或直接删行。这样“登出”才真正有效。
这条路线与 OWASP “可终止会话、会话失效”等要求高度一致,也更容易做“全端登出/改密后踢下线”等能力。 
2. 使用签名 Token(JWT/自定义 HMAC)但必须校验签名
• 你可以保留 userId、exp 这样的声明,但必须对 payload 做 HMAC-SHA256 签名(用 Worker 的 secret env 变量),服务端收到后验证签名一致才放行。
• 否则依旧可伪造。
• 需要注意:签名 token 仍然难以“即时登出”(除非引入黑名单/版本号),所以纯 JWT 往往需要配合服务端状态(例如 token version、revocation list)才能达到安全登出。
3. 把 token 放到 HttpOnly Cookie(推荐配合方案 1 或 2)
• 你现在把 token 放 sessionStorage,XSS 一旦发生就会被直接读走。
• 用 Set-Cookie 下发 HttpOnly; Secure; SameSite=Lax/Strict 可以显著降低被脚本窃取的概率(仍需防 CSRF,但 SameSite 能缓解)。这也是业界常见组合。 
这一条是“是否有 bug”的分水岭:只要不修,你的系统不是“可能被攻破”,而是“默认可被接管”。
⸻
3.1 密码哈希:SHA-256 直哈希的问题是什么?
SHA-256 是“通用摘要函数”,设计目标是快,而密码存储恰恰需要慢(让离线爆破成本变高)。OWASP 的 Password Storage Cheat Sheet 明确给出:应使用专用密码哈希(如 Argon2id / bcrypt / scrypt),在需要 PBKDF2 时也应设置足够的工作因子。 
你当前 sha256(password) 没有盐(salt)、没有工作因子(iterations),数据库一旦泄露,攻击者可以用 GPU 极高速爆破并复用到其它站点(撞库风险)。
Cloudflare Workers 环境里很多人会用 WebCrypto PBKDF2,但还要注意 workerd 对 PBKDF2 迭代次数存在安全争议/限制讨论(社区里就有“迭代数不够安全”的 issue)。 
这意味着你如果要“自己造密码学轮子”,必须非常谨慎地选择参数,并进行基准测试与后续参数升级策略。
3.2 保险库密码:即使你把 vault_password_hash 存得更安全,也挡不住“密钥明文存储”
现在的“保险库”只是:前端解锁后显示常用账号。但密钥 secret 是明文存到 D1 的,所以:
• 数据库泄露:保险库密码无意义;
• 会话被盗/伪造:攻击者直接调用 /api/saved-accounts 取走全部 secret;
• 甚至你自己在 /api/save-totp-log 与 Telegram 里也在外发 secret(后面会讲)。
3.3 最佳实践:把“保险库密码”变成真正的密钥保护机制(两条可选架构)
架构 A:客户端加密(更贴合“零信任保险库”)
• 服务器永远只存密文:ciphertext + salt + iv + kdf_params;
• 解密只在浏览器进行:用户输入 vaultPassword → KDF 派生 AES-GCM key → 解密显示;
• 服务器不需要知道 vaultPassword,也无法解密用户密钥。
这种设计对“服务器被拖库”的韧性最强,符合“保险库”产品的常见安全边界模型(服务端即便被攻破也拿不到明文 secret)。
架构 B:服务端加密(实现简单但边界弱一些)
• 服务器用 vaultPassword 派生 key 或用服务端主密钥加密;
• 但这样服务器理论上能解密,风险比架构 A 高。
如果你只是自用、且信任服务端环境,这条路也可行,但“保险库”这个词会被削弱。
无论 A/B,你都需要参考 OWASP 的加密存储原则:为敏感数据“静态加密”、正确管理密钥与随机数、不要把“可逆加密”当密码存储手段等。 
⸻
你的 /api/save-totp-log 做了两件非常危险的事:
1. 往 totp_logs 写入 secret(2FA 共享密钥)
2. 可选地把 username + secret + code + IP 发送到 Telegram
在 2FA/TOTP 体系里,secret 是长期共享密钥,一旦泄露,攻击者就可以在自己的设备上生成与你完全同步的 TOTP,从此“验证码”再也不是第二因素,而是“攻击者也有的第一因素”。RFC 6238 的安全性基础就是 secret 的机密性。 
从威胁模型角度看,日志系统/消息系统往往比主数据库更容易“旁路泄露”:
• 日志可能被开发/运维/第三方平台访问;
• Telegram Bot token 一旦泄露,所有历史消息可被导出;
• 即使 token 不泄露,消息在多个设备、云端同步,也扩大暴露面;
• 你还把 IP 与时间一起记录,相当于做了可审计的“2FA 使用轨迹”,隐私风险更高。
最佳实践建议:
• 绝不记录明文 secret。如果你需要“去重/统计”,可以记录 HMAC(server_secret, secret) 的短指纹(不可逆地标识同一 secret),而不是 secret 本体。
• Telegram 通知只发送“事件发生了”即可,比如:账号名(或匿名标识)、时间、IP(可选),不要带 secret,不要带验证码 code。
• 如果你想做“异常检测”,更应该记录:请求来源、设备指纹、失败次数、频率等,而不是把密钥本体放进任何日志链路。
• 这也与 OWASP 对认证与会话安全的常见建议一致:敏感凭据不应出现在日志与外部通道中。 
就“有没有 bug”而言:这不一定是“代码运行 bug”,但在安全语义上是功能性灾难——你部署出去后,任何能访问日志/Telegram 的人都能克隆你的 2FA。
⸻
5.1 可用性层面的 bug / 不稳定点
你用 PROPFIND Depth:1 拉目录,然后用正则:
xmlText.match(/<D:href>([^<]2fa_backup[^<].json)</D:href>/i)
只取第一个匹配项。这里至少有四个现实问题:
1. 拿到的不一定是“最新备份”
WebDAV 的 PROPFIND 返回通常包含多个 <D:response>,并带 <getlastmodified>。你虽然在请求体里要了 <getlastmodified/>,但却没有解析它来排序。结果可能恢复的是旧备份,甚至是列表里第一个出现的随机项(不同服务实现返回顺序不一致)。
2. href 可能是 URL 编码/包含基路径
不同 WebDAV 服务会返回绝对路径、相对路径、带编码字符的路径。你现在把 fileUrl 拼接为:
• 若 filePath 不以 http 开头:url.replace(//$/, '') + (filePath...) + filePath
这个拼接在遇到“服务端返回的是完整路径但不含域名”或“返回路径已含 folder 前缀”时,容易得到重复路径或缺斜杠的 URL,导致 404。
3. 只用正则解析 XML 很脆弱
WebDAV XML 命名空间前缀不一定是 D:,可能是 d: 或无前缀;有的返回还会换行缩进、实体编码。正则会在这些情况下失效。稳妥做法是用 XML 解析器(或至少更健壮的匹配逻辑),然后按 displayname/getlastmodified 选择目标文件。
4. 错误处理对 207 Multi-Status 没有精确区分
response.ok 对 207 是 true(因为 2xx),所以你的 “连接成功” 分支大体能跑,但如果需要进一步判断哪些条目失败,你现在没有解析 Multi-Status 的细节。
5.2 安全层面的 bug / 可被滥用点
1. 你把 WebDAV 凭据明文存到 localStorage
webdavPassword 被 localStorage.setItem('webdavPassword', password) 保存,这意味着:
• 任意同源脚本(包括被注入的 XSS、被劫持的第三方 CDN 脚本)都能读走;
• 浏览器扩展/调试工具也更容易导出。
这在安全上非常危险,尤其你还引入了外部 CDN 脚本(jsQR)。供应链风险并不是理论问题。
2. Worker 端作为“任意 URL 的代理”,存在 SSRF/滥用风险
你的 /api/webdav-test|upload|download 会对用户提供的 url 发起 fetch(),并带 Basic Auth。虽然 Workers 没有传统意义的内网,但攻击者可以把你的 Worker 当作“边缘代理”去扫描/请求任意公网地址,带宽与请求额度可能被打爆,甚至触发平台风控。
最佳实践是:
• 至少限制为 https://;
• 加 allowlist(例如只允许已知 WebDAV 域名)或用户自定义域名但需手动验证;
• 加速率限制与审计;
• 上传/下载大小限制,避免被当作数据中转站。
3. btoa 在 Workers 里一般可用,但仍建议对非 ASCII 用户名/密码做 UTF-8 处理
你现在 btoa(${username}😒{password}) 在遇到非 ASCII 字符可能出错。一般 WebDAV 凭据多为 ASCII,但不排除国际化账号。关于 btoa 在 Workers 可用性,常见环境确实支持,但仍建议编码稳妥处理。 
⸻
这部分不一定导致“立刻报错”,但会导致维护困难或在某些部署模式下行为混乱。
1. 混用 addEventListener('fetch') 与 export default { fetch() }
Cloudflare Workers 有两种常见写法:传统 Service Worker 风格(addEventListener)与 ES Modules 风格(export default)。官方在介绍 Modules 时明确强调“用 export 语义替代旧的 addEventListener 语法”,实际项目中通常二选一以避免歧义。 
你现在两套都写了:
虽然你当前两个入口都调用 handleRequest(),多数情况下看起来“能用”,但这会让后续迁移/调试非常痛苦(尤其当你开始用 import、拆文件、或引入框架时)。
Cloudflare 文档与示例通常是从 context.env 或 env.DB 取 binding 来执行查询。 
你把 env.DB 写到 globalThis.DB,短期可行,但会引入两个问题:
你对 saved_accounts.user_id 做了外键与 ON DELETE CASCADE,这在 D1 是支持且默认按 SQLite 外键启用语义执行的。 
需要注意的反而是:未来做迁移/改表时,D1 对外键 defer 的行为与 SQLite 的一些细节有关(例如 defer_foreign_keys 并不会阻止 ON DELETE CASCADE 的级联执行),这会影响“重建表”式迁移策略。 
这不是你当前的运行 bug,但如果你后面用 ORM 自动迁移,很容易踩坑导致级联删除误伤数据。
⸻
下一步行动建议(按“先止血、再升级”的顺序)
1. 立刻修会话鉴权:引入 sessions 表做服务端会话校验 + 登出可撤销;同时把 token 改为 HttpOnly Cookie(或至少签名校验)。 
2. 立刻停止记录/外发 secret:totp_logs 不存 secret;Telegram 不发 secret/code,只发事件摘要。 
3. 把 encrypted_secret 变成真加密:优先走“客户端加密、服务端存密文”的保险库模型,并参考 OWASP 的加密存储原则。 
4. 升级密码存储:改用 Argon2id/bcrypt/scrypt 或(受限时)PBKDF2+salt+高工作因子,并制定参数升级策略,遵循 OWASP Password Storage 建议。 
5. WebDAV 恢复逻辑改为解析 XML + 选最新文件,并限制 URL/速率/大小,避免被当代理滥用;同时不要把 WebDAV 密码放 localStorage。
6. 清理 Worker 入口写法:只保留一种(推荐 ES Modules export default { fetch }),并把 env 作为依赖显式传递。 
如果你愿意,我可以在你现有结构上,直接给出一版“最小改动但能达标”的补丁方案:包括 sessions 表 SQL、鉴权中间件、以及把 saved_accounts.encrypted_secret 改为 AES-GCM 密文存储的字段设计(salt/iv/ciphertext 组织方式)。
不错啊
@dajiji666 #16 虽然PBKDF2不那么安全,但要想在worker环境里用也只能是它了...argon在web crypto里没现成的轮子啊...
至于加密密钥,用bitwarden的那种派生方式还算是安全吧...
AI扯了一大通,我就不贴过来了,感兴趣的mjj可以自行查看。
https://github.com/copilot/share/02234302-4084-8861-8943-c043c4f849dd