logo NodeSeekbeta

AI手搓了个2FA 验证码生成器(明文 垃圾🗑)

12
  • lz有考虑加强一下安全性吗?
    比如后端不明文存数据,用保险库密码通过PBKDF2派生密钥,通过AES-256加密所有信息,把webdav完全迁移到前端之类的,如果有跨域问题的话可以让后端给个代理接口(但不需要明文存储凭据)。
    另外目前的的鉴权好像没签名,可以整个jwt之类的。

  • @soga #13 最好提醒大伙儿一下 xhj017
    目前这个安全性上不了生成环境的...

  • 🧠 GPT-5.2 Thinking

    先给结论:这份代码“能跑出 TOTP 验证码”,但作为可公开部署的 2FA 管理/保险库应用,存在多处“高危级别”的漏洞与设计缺陷——其中最严重的是:会话 Token 可伪造导致任意账号接管(IDOR/认证绕过)、密码与保险库密码用 SHA-256 直哈希(不符合业界最佳实践)、所谓 encrypted_secret 实际明文存储 2FA 密钥、以及把 2FA 密钥写入云端日志/Telegram(极高敏感数据外泄面)。下面我按“系统数据流 → 关键安全/功能 Bug → 具体修复路径”循序渐进拆开讲。

    1. 先把这套系统的“真实数据流”讲清楚:你现在的 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 就等价失效。

    1. 最高危 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”的分水岭:只要不修,你的系统不是“可能被攻破”,而是“默认可被接管”。

    1. 第二组高危问题:密码/保险库密码用 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 的加密存储原则:为敏感数据“静态加密”、正确管理密钥与随机数、不要把“可逆加密”当密码存储手段等。 

    1. 第三组高危问题:你在日志与 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。

    1. 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 可用性,常见环境确实支持,但仍建议编码稳妥处理。 

    1. 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 组织方式)。

  • 不错啊

12

你好啊,陌生人!

我的朋友,看起来你是新来的,如果想参与到讨论中,点击下面的按钮!

📈用户数目📈

目前论坛共有61022位seeker

🎉欢迎新用户🎉