Repository navigation
Conversation
…d user-root isolation checkAdminAuth: no longer trusts the JWT role claim. Logout and demotion previously had no effect on admin routes because only payload.role was read and jti was never checked; it now re-checks the revocation list, the disabled flag and the current database role. authUserFromReq: reject disabled users. JWT only proves the identity was valid when issued; every resolver must re-check current status. Core APIs went through getUserFromContext (which checks), but the S3 gateway reused this function and did not. S3 gateway: PutObject now requires WRITE_CONTENT and DeleteObject requires DELETE; object paths are scoped to the caller's base_path and bucket/key are validated. Previously any authenticated user, including read-only and disabled accounts, could write anywhere. getActualPath: fold dot segments before joining base_path so a user cannot escape their root via plain or percent-encoded traversal. verify: 225 tests pass.
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
openlist-work | 22dd197 | Sep 29 2026, 07:30 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
openlist-tsworkers | 22dd197 | Sep 29 2026, 07:31 AM |
…onfig guard, upload limits, WebAuthn hardening Permission bits: rename/batch_rename/regex_rename use RENAME, remove/remove_empty_directory use DELETE, move/recursive_move use MOVE, copy uses COPY, instead of all sharing WRITE_CONTENT. meta.write_users now enforced at write time; archive read shares the /fs/get meta+password judgement; archive list/meta require READ_ARCHIVES and decompress requires DECOMPRESS plus write, with the destination scoped to the caller root and ZIP entry names restricted to relative-only so ../ cannot escape. Uploads re-check the actual received byte count (Content-Length could be omitted to bypass the limit). map format now throws on damaged JSON instead of returning null, so corruption is no longer mistaken for a fresh deployment that reopens public setup. Storage: key format writes before deleting, saveDb rolls memory/cache back on failed persistence, D1 batches retry per chunk and report partial commits, S3/WebDAV scope paths to the user root, archive fetch follows redirects safely. Sessions: revocation list uses the unified storage backend with bounded reload, multipart sessions are owner-bound, WebAuthn challenges are server-issued and single-use with origin/UP/UV checks and disabled-user rejection. verify: 285 tests pass; SSRF probe 22 block / 8 allow; WebAuthn probe all green.
pikachuren
left a comment
There was a problem hiding this comment.
🙏 感谢 @PIKACHUIM 提交!
🤖 AI 自动审核声明:本评审报告由 AI 自动生成,当前使用 GLM 模型进行分析。
🎯 结论
🔄 Request Changes — 权限修复整体扎实,但上传大小限制的修复不完整,S3 网关完全缺失大小限制
📖 概要
一批授权加固:JWT 解析复核 disabled、OTP 失败纳入防爆破计数、目录级写 ACL 真正执行、归档读取走 meta ACL、ZIP 条目路径清理、上传大小按实际字节数复核、S3 网关补权限位与用户根目录隔离、WebAuthn challenge/origin/UV 校验。
🧭 整体方案
每个修复点都标注了「历史缺陷 + 实测复现」,用 isWithinUserRoot 统一收口路径越界,方案方向正确。问题在于大小限制这条线只修了一半。
📊 变更统计
8+ 个文件 | 功能 ⭐⭐⭐⭐ | 最小改动 ⭐⭐⭐⭐ | 前向兼容 ⭐⭐⭐⭐ | 方案设计 ⭐⭐⭐
🚨 关键问题
P1(建议修复后再合并):
⚠️ server/fs.ts:1655-1673(/fs/multipart/part)仍只做Content-Length预检,无该头时直接Buffer.from(await c.req.arrayBuffer())——readBodyWithinLimit想封死的「省略头绕过上限」路径在这里依然可用。该函数只接入了/fs/put(L974)和/fs/upload/part(L1104)。建议本路由同样改用readBodyWithinLimit(c, true),并把 L1655 的预检删掉换成统一入口。⚠️ server/s3.ts:206(PutObject)Buffer.from(await c.req.arrayBuffer())之前没有任何大小限制——MAX_UPLOAD/MAX_UPPART都不生效,超大请求体可整包读入内存。与/fs/put的防护形成明显不对称。建议复用getUploadSizeLimit(或新增MAX_S3_PUT)+ 实际字节数复核。
P2(可选):
- 💡
s3.ts:206PutObject 的ETag用Date.now().toString(16)生成,不是内容的真实哈希;对做分片续传/幂等判断的 S3 客户端可能产生误导,是否考虑至少返回空 ETag 或内容 md5? - 💡
safeArchiveEntryPath拒绝了../盘符/NUL,覆盖到位;/fs/other签发直传 URL 后的写入路径不经过本 PR 的大小限制(预失效 #75 已单独处理失效),边界行为建议在 PR 描述中注明。
📂 逐文件分析
src/backend/server/fs.ts
改动意图:写 ACL 执行点 + 归档 ACL + 条目清理 + 上传限流。
问题分析:deniedByMetaWriteAcl / deniedByMetaReadAcl 管理员放行、meta 缺失时放行的语义与 pkg/meta 约定一致;readBodyWithinLimit 的实际字节数复核思路正确——只是接入点不全(见 P1)。
src/backend/server/s3.ts
改动意图:S3 权限位 + base_path 隔离。
问题分析:canWrite/canRemove 权限位、isWithinUserRoot 收口、authUserFromReq 复检 disabled 都已到位;s3VirtualPath 对受限用户把 bucket 解释为自己根下的一层目录,与 /api/fs 视图一致。缺的只有大小限制(见 P1)。
src/backend/pkg/permission.ts
改动意图:normalizeSegments + isWithinUserRoot。
问题分析:.. 越根钳制、重复 / 合并正确;建议补一条「normalizeSegments('/a/../../b') === '/b'」的显式测试。
其余文件(auth.ts / webauthn.ts 等)无重大问题。
✅ 待处理清单
- [P1]
/fs/multipart/part改用readBodyWithinLimit,封死无 Content-Length 绕过 - [P1] S3 PutObject 增加大小限制 + 实际字节数复核
- [P2] S3 ETag 语义(可选)
- [P2]
normalizeSegments补越根钳制测试(可选)
🎯 结论:🔄 Request Changes — 权限加固值得合并,但上传限流修复不完整,两条 P1 补齐后即可通过。
fix(authz): 强化授权校验、令牌吊销、S3 权限与用户根目录隔离
一、背景与动机
一次针对本仓库的安全评审(代码走查 + 隔离环境 PoC 实测)确认了若干授权与
隔离类缺陷。它们的共同特征是:「签发时刻合法」被当成了「当前仍然合法」,
以及**「路径字符串相等」被当成了「权限边界成立」**。
这些缺陷不会导致数据泄露到公网,但会在多用户部署下破坏权限模型:
已注销/已降权的管理员仍持有后台权限、禁用用户的令牌继续可用、只读用户可写入、
受限用户可越出
base_path读写他人目录。本 PR 只修复不改变正常接口结构的部分(无需迁移、无需前端改动),
把需要兼容性设计的问题留作后续(见 §七)。
二、修复内容
1.
checkAdminAuth—— 管理后台授权改以数据库为权威文件:
src/backend/pkg/utils.ts缺陷:只读取 JWT 里的
payload.role,且完全不检查jti。后果是「退出登录」和「把管理员降权为普通用户」都不会立即生效——旧令牌在
7 天有效期内继续拥有完整后台权限。
实测:注销后
/api/admin/*仍返回 200;把role改为 0 后仍返回 200。修复:管理员授权必须同时满足四条不变量:
HS256,alg 已 pin);jti不在吊销名单);role仍是管理员。静态 API Token(
settings.token)分支保持不变,仍在函数开头提前返回。2.
authUserFromReq—— 身份解析复检禁用状态文件:
src/backend/server/auth.ts缺陷:只验证 JWT 签名并查找用户,缺少
disabled判断。核心 API 走getUserFromContext(有该检查),但 S3 网关直接复用本函数,因此绕过了限制。修复:
if (!user || user.disabled) return null。3. S3 网关 —— 补权限位与用户根目录隔离
文件:
src/backend/server/s3.ts缺陷:路由只判断「是否登录」,随后直接操作全局虚拟路径
/${bucket}/${key}。两条后果:permission=0的只读用户、已禁用用户)都能上传/删除;base_path的用户可以读写其他用户的目录。修复:
PutObjectWRITE_CONTENT(canWrite),管理员放行DeleteObjectDELETE(canRemove),管理员放行/bucket/key<base_path>/bucket/key,并二次isWithinUserRoot校验bucket/keyisValidS3Bucket拒绝.、..、含/\\0新增
s3VirtualPath()统一做路径映射,新增outOfScope()统一返回 403(不暴露目标是否存在)。
getCtx现在透传env,保证多环境下驱动解析正确。兼容性:
base_path为/的(默认)用户路径映射完全不变,bucket仍即存储挂载点首段。4.
getActualPath—— 折叠点段,用户根不可越过文件:
src/backend/pkg/permission.ts缺陷:只做字符串拼接,
..段留给下游resolvePath()折叠。于是/photos/alice+/../bob/x会解析成/photos/bob/x——用户根被越过。实测:受限用户通过
/fs/get的路径参数取得其他用户目录下文件的元信息,明文与百分号编码两种写法均成立。
修复:新增
normalizeSegments()(栈折叠..,空栈时钳制在根),getActualPath先折叠再拼接,从源头保证返回值只可能等于base_path或位于其下。同时新增导出
isWithinUserRoot()供网关类入口做二次确认。刻意不做的事:不增加
decodeURIComponent次数。多次解码会改变合法%2f/百分号字面量文件名的含义,与 Go 的EncodePath契约冲突。5. OTP 失败计入防爆破计数
文件:
src/backend/server/auth.ts缺陷:
/login与/login/hash的 OTP 分支直接return,不调用recordLoginFailure。攻击者只要先拿到正确密码,就能以无限次尝试爆破6 位 TOTP,而账号永远不会被锁定。
实测:连续 7 次错误 OTP 后仍可继续提交。
修复:OTP 失败调用
recordLoginFailure(c, username, c.env),与密码失败共用同一套 IP+用户名 / 全局限定计数与指数退避。
三、验证
自动化测试
结果:225 项全部通过,0 失败(基线
2da4256)。静态检查
node_modules未安装typescript,tsx仅转译不做类型检查。合入前请先跑pnpm run lint(=tsc --noEmit)。PoC 实测口径(复现条件说明)
评审阶段在隔离环境中验证上述行为,全部使用:合成密钥、注入式内存后端、
工作区临时文件、被禁止联网的
fetch。未触碰真实账号、真实网盘、生产数据库或私网资源。 相关临时测试文件已删除,未进入本提交。
四、兼容性与影响面
需要关注的行为变化
WRITE_CONTENTDELETE位用户经 S3 删除DELETEbase_path用户的 S3 路径bucket现为其顶层目录base_path="/"用户的 S3 路径getActualPath含..的请求明确未改动的契约(避免误伤)
/d、/p、/sd增加登录要求——签名直链要保持可被<video src>、播放器、下载器匿名消费;base_path(官方前端已在 URL 中携带);customize_head/customize_body(这是功能);文档
已核对
OpenList-Docs的pages/guide/advanced/s3.md:该文档仅描述「存储桶 ↔ 目录映射」的配置方式,未描述 S3 网关的认证与权限语义,
因此本次权限收紧不存在需要同步的文档描述。若后续补充 S3 网关权限说明,
需明确:需要
WRITE_CONTENT/DELETE位、对象路径相对base_path、且不识别静态 API Token。
五、已知遗留问题(本 PR 未包含)
以下来自同一轮评审,因需要兼容性设计或更大改动面,未在本 PR 修复:
/fs/archive/list、/fs/archive/decompress不做源路径的元信息密码校验、不检查
READ_ARCHIVES/DECOMPRESS位;解压目标未做base_path映射;ZIP 条目中的../可逃出目标目录(仍受存储物理根限制)。map格式把 JSON 解析失败转成null,随后/api/public/init/setup会重新开放初始化并覆盖配置。应区分「不存在 / 空库 / 损坏 / 读取失败」。
Content-Length,缺失该头时不做拦截。isSafeUrl:规范化后的 IPv4-mapped IPv6 私网地址(如http://[::ffff:127.0.0.1]/)未被识别。rename/remove/move/copy仍统一使用canWrite,未使用已定义的RENAME/DELETE/MOVE/COPY位;导致「只有写权限也能删除」而「只有删除权限反被拒」。
write_usersACL:仅影响/fs/list返回的write字段提示,写接口未在执行时重新校验。
path+size复用,无 owner 绑定,跨用户可能复用同一会话;
/fs/multipart/status无需认证。D1-only 环境下不保证可靠持久化与即时一致性。
map全量快照的 last-writer-wins、key格式「先删后写」失败后旧记录丢失、D1 多批提交无整体原子性、持久化失败后
内存缓存仍被更新。
六、建议的验收要点
base_path非根用户的签名下载继续正常(不得 401);DELETE位用户 DELETE → 403;默认
base_path="/"用户路径映射与修复前一致;..(含百分号编码形式)的请求被钳制在用户根内,且合法文件名、中文路径、既有编码链接不受影响;
七、提交信息