diff --git a/src/backend/drivers/github_releases/util.test.ts b/src/backend/drivers/github_releases/util.test.ts index 0fba3d72..62188a78 100644 --- a/src/backend/drivers/github_releases/util.test.ts +++ b/src/backend/drivers/github_releases/util.test.ts @@ -15,11 +15,19 @@ function proxyOf(additionGhProxy: string | undefined, url: string): string { return (client as unknown as { proxy: (u: string) => string }).proxy(url) } -test("gh_proxy 已配置时为 http(s) 下载链接加代理前缀", () => { +test("gh_proxy 已配置时替换 https://github.com 前缀(对齐 Go Link)", () => { const url = "https://github.com/example/repo/releases/download/v1.0.0/app.zip" + // 文档推荐写法(meta.go 的 help):值本身包含 https://github.com, + // 替换后等价于「代理前缀 + 原始链接」 + assert.equal( + proxyOf("https://gh-proxy.com/https://github.com", url), + "https://gh-proxy.com/" + url, + ) + // 裸域名写法:按 Go strings.Replace 的语义,只把 https://github.com 这一段整体换掉 + // (此前断言写成 proxy + 完整 url,与实现/Go 语义不符) assert.equal( proxyOf("https://mirror.example.com", url), - "https://mirror.example.com" + url, + "https://mirror.example.com" + url.slice("https://github.com".length), ) }) diff --git a/src/backend/internal/model/db.ts b/src/backend/internal/model/db.ts index 4c3ca665..4cb721b4 100644 --- a/src/backend/internal/model/db.ts +++ b/src/backend/internal/model/db.ts @@ -2066,6 +2066,15 @@ export const saveDb = async ( return false } + // 记录写入前的内存状态,供持久化失败时回滚。 + // + // 稳定性不变量:**持久化失败后,失败的修改不得在内存/请求缓存中生效**。 + // 历史实现先更新 memoryDb 与 dbCache、再尝试落盘,失败只抛错不回滚。 + // 结果是「接口报 500,但当前实例的角色/权限/设置已按未保存的内容生效」—— + // 调用方以为失败、系统却按新值运行(实测:保存失败后 getDb() 仍读到新 role)。 + const previousDb = memoryDb + const previousTrusted = dbTrusted + memoryDb = data dbWriteBlocked = false // Refresh the request cache so any getDb() later in this request observes @@ -2112,6 +2121,14 @@ export const saveDb = async ( ) await backend.save(sealed, activeEnv) } catch (err: any) { + // 回滚内存与请求缓存:持久化没成功,就不能让本次修改在本实例内可见, + // 否则会出现「报错但已生效」的不一致状态。 + memoryDb = previousDb + dbTrusted = previousTrusted + if (cacheKey) { + if (previousDb) dbCache.set(cacheKey, { ts: Date.now(), db: previousDb }) + else dbCache.delete(cacheKey) + } console.error( `[DB] saveDb FAILED: backend=${backend.name}, error=${err?.message || err}`, `stack=${err?.stack?.substring(0, 500) || ""}`, diff --git a/src/backend/internal/model/store/driver/d1.ts b/src/backend/internal/model/store/driver/d1.ts index cffbde68..98420f3a 100644 --- a/src/backend/internal/model/store/driver/d1.ts +++ b/src/backend/internal/model/store/driver/d1.ts @@ -143,13 +143,45 @@ export const d1Driver: Driver = { if (!db) throw new Error("D1 binding not found") await ensureSchema(db, env) - - // D1 batch 单次语句数上限约 100,分批提交 + + // D1 batch 单次语句数上限约 100,必须分批提交。 + // + // 语义边界(重要,不要误以为这里提供整体事务):**片内原子,跨片不原子**。 + // D1 没有跨 batch 调用的事务,因此语句数 >100 时,后续片失败**不会**回滚 + // 已提交的片。要让整体操作可恢复,依赖两点: + // ① 上层 format/sql 生成的是幂等 UPSERT + 末位 DELETE NOT IN,整体重跑可收敛; + // ② 这里对**单片的瞬时失败**做有限重试,避免一次网络抖动就留下半成品。 + // 重试仍失败时,错误信息带上「已提交片数」,供调用方判断是否需要整体重跑。 const BATCH = 100 + const MAX_RETRY = 2 const stmts = statements.map((s) => db.prepare(s.sql).bind(...s.params)) - + + let committed = 0 for (let i = 0; i < stmts.length; i += BATCH) { - await db.batch(stmts.slice(i, i + BATCH)) + const chunk = stmts.slice(i, i + BATCH) + let lastErr: any = null + for (let attempt = 0; attempt <= MAX_RETRY; attempt++) { + try { + await db.batch(chunk) + lastErr = null + break + } catch (err) { + lastErr = err + if (attempt < MAX_RETRY) { + await new Promise((r) => setTimeout(r, 50 * (attempt + 1))) + } + } + } + if (lastErr) { + throw new Error( + `D1 batch failed at chunk ${Math.floor(i / BATCH) + 1} ` + + `(statements ${i}-${i + chunk.length - 1} of ${stmts.length}); ` + + `${committed} statement(s) were already committed by earlier chunks and ` + + `D1 cannot roll them back across batches. The save is idempotent, so ` + + `retrying the whole operation converges. Cause: ${lastErr?.message || lastErr}`, + ) + } + committed += chunk.length } }, diff --git a/src/backend/internal/model/store/format/key.ts b/src/backend/internal/model/store/format/key.ts index 432a3250..b38075ba 100644 --- a/src/backend/internal/model/store/format/key.ts +++ b/src/backend/internal/model/store/format/key.ts @@ -66,16 +66,16 @@ export const keyFormat: FormatAdapter = { }, async save(data: any, driver: Driver, env?: any): Promise { - // 清空旧数据 - for (const table of TABLE_NAMES) { - const prefix = tablePrefix(table) - const keys = await driver.list(prefix, env) - for (const key of keys) { - await driver.delete(key, env) - } - } - - // 写入新数据 + // 先写新数据,**最后**清理陈旧键。 + // + // 稳定性不变量:save 失败不得让「原有数据整体消失」。 + // 历史实现是「先删光所有前缀键,再写新数据」:一旦写入阶段失败(网络抖动、 + // 配额、实例被杀),旧记录已被删除、新记录尚未写入,数据库直接变成空表。 + // 故障注入实测:users 从 1 条变为 0 条 —— 这是数据丢失,不是「保存失败」。 + // + // 改为先写后删后,失败时的最坏情况是**残留**旧键(可被下一次成功保存清理), + // 而不会丢数据;正常成功路径的最终状态与原来完全一致。 + const liveKeys = new Set() for (const table of TABLE_NAMES) { const keyCol = TABLE_KEY[table] const records = data?.[table] || [] @@ -85,11 +85,22 @@ export const keyFormat: FormatAdapter = { if (!id) continue const key = fullKeyOf(table, id) + liveKeys.add(key) const value = JSON.stringify(record) await driver.put(key, value, env) } } + // 清理本快照中已不存在的键(删除的实体 / 改名的键) + for (const table of TABLE_NAMES) { + const prefix = tablePrefix(table) + const keys = await driver.list(prefix, env) + for (const key of keys) { + if (liveKeys.has(key)) continue + await driver.delete(key, env) + } + } + // 标记已初始化 await driver.put(INIT_MARK, String(Date.now()), env) return true diff --git a/src/backend/internal/model/store/format/map.ts b/src/backend/internal/model/store/format/map.ts index e3bc1868..2f53a36b 100644 --- a/src/backend/internal/model/store/format/map.ts +++ b/src/backend/internal/model/store/format/map.ts @@ -22,8 +22,22 @@ export const mapFormat: FormatAdapter = { try { return JSON.parse(raw) } catch (err) { - console.error("[mapFormat] Failed to parse JSON:", err) - return null + // 关键:配置**损坏**绝不能等同于「没有数据」。 + // + // 历史行为是打印一条日志后返回 null。但 loadDb() 用同一个 null 表示 + // 「后端为空(全新部署)」,两者无法区分,于是损坏的配置会被当成全新部署: + // dbTrusted=false 且 getDbLoadError()=null → /api/public/init/setup 放行 + // → 无登录即可创建新管理员并把空壳写回,**覆盖真实配置**。 + // (实测:把持久化值改成非法 JSON 后,公开的初始化接口返回 200。) + // + // 这里改为抛错:loadDb() 会记录 dbLastLoadError 并保持 dbTrusted=false, + // 初始化接口据此拒绝(返回 500 且不写入)。真正的空库不经过此分支, + // 仍可正常初始化。 + throw new Error( + `[mapFormat] persisted config is not valid JSON; refusing to treat it as an ` + + `empty database. Restore or explicitly remove the "${CONFIG_KEY}" entry. ` + + `Cause: ${(err as Error)?.message || String(err)}`, + ) } }, diff --git a/src/backend/internal/op/search.ts b/src/backend/internal/op/search.ts index 411aa1a8..c483e0a5 100644 --- a/src/backend/internal/op/search.ts +++ b/src/backend/internal/op/search.ts @@ -1,5 +1,8 @@ import { listItems } from "./storage" import { FileItem } from "../driver/base" +import { canAccess, getNearestMeta } from "../../pkg/meta" +import type { Meta } from "../../pkg/meta" +import type { UserPermissionObj } from "../../pkg/permission" export interface SearchOptions { parent?: string @@ -12,6 +15,14 @@ export interface SearchOptions { max_depth?: number /** Max items to collect before truncating (default 500) */ max_results?: number + /** + * 访问者。提供时对每个结果做 meta 判定过滤(密码保护 + read_users 白名单), + * 对齐 Go handles.Search 传给 SearchFiltered 的 filter 闭包;不提供时 + * (内部调用 / 管理员构建索引)不做过滤,与历史行为一致。 + */ + user?: UserPermissionObj | null + /** 目录访问密码(meta.password) */ + password?: string } export interface SearchResultItem extends FileItem { @@ -43,6 +54,7 @@ export async function search( const maxResults = Math.min(2000, Math.max(1, opts.max_results ?? 500)) const matches: SearchResultItem[] = [] + const enforceAcl = opts.user !== undefined async function walk(dirPath: string, depth: number) { if (depth > maxDepth || matches.length >= maxResults) return @@ -56,9 +68,32 @@ export async function search( return } + // 与 Go 一致:meta 取「父目录」的最近 meta,再对子项自身路径做判定 + let dirMeta: Meta | null = null + if (enforceAcl) { + try { + dirMeta = await getNearestMeta(dirPath, env) + } catch { + dirMeta = null + } + } + for (const item of items) { if (matches.length >= maxResults) break + const subPath = + dirPath === "/" ? `/${item.name}` : `${dirPath}/${item.name}` + + // meta 访问控制:密码保护 / read_users 白名单覆盖的内容不出现在搜索 + // 结果里。历史缺陷:搜索完全跳过 meta 判定——实测直连被密码保护而 + // 被拒的目录,其文件名/大小仍能被搜出来(信息泄露)。 + if ( + enforceAcl && + !canAccess(opts.user!, dirMeta, subPath, opts.password || "") + ) { + continue + } + const nameMatch = !keyword || item.name.toLowerCase().includes(keyword) const isDir = !!item.is_dir // 挂载点虚拟文件夹(storage.ts 合并虚拟子挂载时 sign 为空), @@ -81,8 +116,6 @@ export async function search( } if (isDir) { - const subPath = - dirPath === "/" ? `/${item.name}` : `${dirPath}/${item.name}` await walk(subPath, depth + 1) } } diff --git a/src/backend/internal/upload/multipart.ts b/src/backend/internal/upload/multipart.ts index cde3e316..1d4adcc6 100644 --- a/src/backend/internal/upload/multipart.ts +++ b/src/backend/internal/upload/multipart.ts @@ -36,6 +36,17 @@ export interface MultipartSession { partMd5s: (string | undefined)[] /** 驱动名 + 存储引用,用于 chunk/complete 时重新 resolve 驱动 */ storage_driver: string + /** + * 会话所有者的用户 id。 + * + * 安全不变量:分片会话是**用户私有状态**,续传/完成/查询都必须校验所有者。 + * 历史缺陷:会话只按 `path + size` 复用,不含用户维度,导致不同用户(即使 + * base_path 不同)会命中同一个会话并互相续传(实测:两个不同根目录的用户 + * 拿到同一个 upload_id,且 resumed=true)。 + */ + owner_id?: number + /** 创建会话时的用户根目录,用于校验会话未被跨根复用 */ + owner_root?: string created_at: number error?: string } @@ -119,12 +130,20 @@ export function getSession(uploadId: string): MultipartSession | undefined { return sessions.get(uploadId) } -/** 查找同 path+size 的未完成会话(用于断点续传) */ +/** + * 查找同 owner + path + size 的未完成会话(用于断点续传)。 + * + * `ownerId` 必填:会话必须按所有者隔离,否则不同用户会共享同一上传会话。 + * 未绑定所有者的历史会话(本改动之前创建的)不再被复用,避免跨用户命中。 + */ export function findReceivingSession( path: string, size: number, + ownerId?: number, ): MultipartSession | undefined { + if (ownerId === undefined) return undefined for (const s of sessions.values()) { + if (s.owner_id !== ownerId) continue if ( s.path === path && s.size === size && diff --git a/src/backend/pkg/http.ts b/src/backend/pkg/http.ts index 8b1ff848..84303b3d 100644 --- a/src/backend/pkg/http.ts +++ b/src/backend/pkg/http.ts @@ -156,6 +156,92 @@ export async function download( return res.data } +/** + * IPv4 是否属于「不得访问」的网段(RFC1918 私有、回环、链路本地、CGNAT、 + * 组播、保留段、云元数据)。 + * + * 抽成独立函数的原因:IPv6 字面量里可能**内嵌** IPv4(`::ffff:127.0.0.1`、 + * 6to4、NAT64),需要把内嵌地址还原后复用同一套判定,避免两处规则漂移。 + */ +function isRestrictedIpv4(octets: number[]): boolean { + const [a, b, c] = octets + if (octets.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) return true + if (a === 0) return true // 0.0.0.0/8 (This network) + if (a === 127) return true // 127.0.0.0/8 (Loopback) + if (a === 10) return true // 10.0.0.0/8 (Private) + if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12 (Private) + if (a === 192 && b === 168) return true // 192.168.0.0/16 (Private) + if (a === 169 && b === 254) return true // 169.254.0.0/16 (Link-local + metadata) + if (a === 100 && b >= 64 && b <= 127) return true // 100.64.0.0/10 (CGNAT) + if (a === 100 && b === 100) return true // Aliyun metadata 100.100.100.200 + if (a === 224 && b === 0 && c === 0) return true // 224.0.0.0/24 (Multicast) + if (a >= 240) return true // 240.0.0.0/4 (Reserved) + return false +} + +/** + * 解析 IPv6 字面量(可含方括号/zone id/内嵌 IPv4)为 8 组 16 位整数。 + * 解析失败返回 null(调用方按「不安全」处理)。 + * + * 为什么不能继续用字符串 `includes` 前缀匹配: + * WHATWG URL 会把 IPv6 规范化(`::ffff:127.0.0.1` -> `::ffff:7f00:1`), + * 于是 `host.includes("::ffff:127.")` 之类的模式永远匹配不到,私网判定被绕过。 + * 必须先解析成数值再按网段判断。 + */ +function parseIpv6Groups(input: string): number[] | null { + let s = String(input || "").trim().replace(/^\[/, "").replace(/\]$/, "") + const zone = s.indexOf("%") + if (zone >= 0) s = s.slice(0, zone) + if (!s.includes(":")) return null + + // 内嵌 IPv4 尾部(::ffff:127.0.0.1)统一改写为两个十六进制组,后续只需一套解析 + const lastColon = s.lastIndexOf(":") + const maybeV4 = s.slice(lastColon + 1) + if (maybeV4.includes(".")) { + const octets = maybeV4.split(".") + if (octets.length !== 4) return null + const nums = octets.map((o) => + /^\d{1,3}$/.test(o) ? parseInt(o, 10) : NaN, + ) + if (nums.some((n) => !Number.isFinite(n) || n > 255)) return null + const g1 = ((nums[0] << 8) | nums[1]).toString(16) + const g2 = ((nums[2] << 8) | nums[3]).toString(16) + s = s.slice(0, lastColon + 1) + g1 + ":" + g2 + } + + const halves = s.split("::") + if (halves.length > 2) return null + + const toGroups = (str: string): number[] | null => { + if (!str) return [] + const out: number[] = [] + for (const g of str.split(":")) { + if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null + out.push(parseInt(g, 16)) + } + return out + } + + const head = toGroups(halves[0]) + const tail = halves.length === 2 ? toGroups(halves[1] ?? "") : [] + if (!head || !tail) return null + + let groups: number[] + if (halves.length === 2) { + const fill = 8 - head.length - tail.length + if (fill < 1) return null + groups = [...head, ...new Array(fill).fill(0), ...tail] + } else { + groups = head + } + return groups.length === 8 ? groups : null +} + +/** 从 16 位组中取出后 32 位,还原为 4 段 IPv4 */ +function embeddedIpv4(high: number, low: number): number[] { + return [high >> 8, high & 0xff, low >> 8, low & 0xff] +} + /** * Validate that a target URL is safe against SSRF attacks: * 1. Protocol must be http: or https: @@ -206,25 +292,44 @@ export function isSafeUrl( } } - // 2. 扩展 IPv6 检测(包括 IPv4-mapped IPv6) - const ipv6Patterns = [ - "::1", // loopback - "[::1]", - "::ffff:127.", // IPv4-mapped IPv6 loopback - "::ffff:10.", // IPv4-mapped IPv6 private - "::ffff:172.", // IPv4-mapped IPv6 private - "::ffff:192.168.", // IPv4-mapped IPv6 private - "::ffff:169.254.", // IPv4-mapped IPv6 link-local - "fe80:", // link-local - "fc00:", // unique local - "fd00:", // unique local - "[fe80:", - "[fc", - "[fd", - ] - for (const pattern of ipv6Patterns) { - if (host.includes(pattern)) { - return false + // 2. IPv6 字面量:解析为数值后按网段判定。 + // + // 历史缺陷:这里用 `host.includes("::ffff:127.")` 之类**字符串前缀**匹配。 + // 但 WHATWG URL 会把 IPv6 规范化(`::ffff:127.0.0.1` -> `::ffff:7f00:1`), + // 使这些模式永远匹配不到 —— 实测 `http://[::ffff:127.0.0.1]/` 与 + // `http://[::ffff:10.0.0.1]/` 都被判为「安全」。IPv4 那套判定明明存在, + // 却因为「表达形式变了」而失效。 + // + // 现在改为:解析成 8 组 16 位整数,按位判定;遇到内嵌 IPv4 的形态 + // (IPv4-mapped / 6to4 / NAT64)则还原后复用 isRestrictedIpv4()。 + const bareHost = host.replace(/^\[/, "").replace(/\]$/, "") + if (bareHost.includes(":")) { + const g = parseIpv6Groups(bareHost) + // 解析不出来时按不安全处理(fail-closed),避免畸形字面量绕过 + if (!g) return false + + const isZero = (from: number, to: number) => + g.slice(from, to).every((x) => x === 0) + + // :: 未指定地址 / ::1 回环 + if (isZero(0, 7) && (g[7] === 0 || g[7] === 1)) return false + // fe80::/10 链路本地 + if ((g[0] & 0xffc0) === 0xfe80) return false + // fc00::/7 唯一本地地址 + if ((g[0] & 0xfe00) === 0xfc00) return false + // ff00::/8 组播 + if ((g[0] & 0xff00) === 0xff00) return false + // ::ffff:0:0/96 IPv4-mapped IPv6 + if (isZero(0, 5) && g[5] === 0xffff) { + if (isRestrictedIpv4(embeddedIpv4(g[6], g[7]))) return false + } + // 2002::/16 6to4:内嵌 IPv4 位于第 2、3 组 + if (g[0] === 0x2002) { + if (isRestrictedIpv4(embeddedIpv4(g[1], g[2]))) return false + } + // 64:ff9b::/96 NAT64 + if (g[0] === 0x64 && g[1] === 0xff9b && isZero(2, 6)) { + if (isRestrictedIpv4(embeddedIpv4(g[6], g[7]))) return false } } @@ -241,23 +346,10 @@ export function isSafeUrl( const match = host.match(ipv4Regex) if (match) { const [, aStr, bStr, cStr, dStr] = match - const a = parseInt(aStr, 10) - const b = parseInt(bStr, 10) - const c = parseInt(cStr, 10) - const d = parseInt(dStr, 10) - if (a > 255 || b > 255 || c > 255 || d > 255) return false - - // RFC 1918 私有网络和特殊用途地址 - if (a === 0) return false // 0.0.0.0/8 (This network) - if (a === 127) return false // 127.0.0.0/8 (Loopback) - if (a === 10) return false // 10.0.0.0/8 (Private) - if (a === 172 && b >= 16 && b <= 31) return false // 172.16.0.0/12 (Private) - if (a === 192 && b === 168) return false // 192.168.0.0/16 (Private) - if (a === 169 && b === 254) return false // 169.254.0.0/16 (Link-local + metadata) - if (a === 100 && b >= 64 && b <= 127) return false // 100.64.0.0/10 (CGNAT) - if (a === 100 && b === 100) return false // Aliyun metadata 100.100.100.200 - if (a === 224 && b === 0 && c === 0) return false // 224.0.0.0/24 (Multicast) - if (a >= 240) return false // 240.0.0.0/4 (Reserved) + const octets = [aStr, bStr, cStr, dStr].map((s) => parseInt(s, 10)) + if (octets.some((n) => n > 255)) return false + // RFC 1918 私有网络和特殊用途地址(与 IPv6 内嵌地址共用同一套判定) + if (isRestrictedIpv4(octets)) return false } // 5. 阻止整数/十六进制 IP 表示(2130706433 = 127.0.0.1, 0x7f000001 = 127.0.0.1) diff --git a/src/backend/pkg/password.ts b/src/backend/pkg/password.ts index 59b68f6e..079144dc 100644 --- a/src/backend/pkg/password.ts +++ b/src/backend/pkg/password.ts @@ -75,25 +75,22 @@ export async function setUserPassword( } /** - * 验证密码(兼容历史单层 sha256,推荐存储为双层) - * @param plain 明文密码(或经过 staticHash 的前端哈希值) - * @param user 含 password + salt 字段的用户对象 + * 密码时间戳指纹(对齐 Go common.UserClaims.PwdTS 及其比对逻辑)。 + * + * 安全不变量:JWT 只证明「签发那一刻身份合法」,不证明「现在仍然合法」。 + * 密码一旦被修改,所有既有令牌必须立即失效 —— Go 在每次请求里都拿令牌中的 + * PwdTS 与数据库当前值比对。历史缺陷:TS 的 JWT 只携带 {id, username, role}, + * 改密后旧令牌在有效期内(7 天)继续可用,除非用户显式登出。 + * + * 取值为空字符串(旧数据/未设置)时两侧一致,视为相等,不影响历史账号。 */ -export async function verifyUserPassword( - plain: string, - user: { password: string; salt?: string }, -): Promise { - if (!user.password) return false - if (user.salt) { - // 双层:先判断 plain 是否已经是 staticHash(前端哈希登录), - // 再判断是否是明文(直接登录)。 - const staticHex = isHex64(plain) ? plain : await staticHash(plain) - const expected = await saltedHash(staticHex, user.salt) - return expected === user.password - } - // 历史单层:password == staticHash(plain) - const staticHex = isHex64(plain) ? plain : await staticHash(plain) - return staticHex === user.password +export function passwordStampOf(user: any): string { + return String(user?.pwd_update_at ?? "") +} + +/** 校验令牌内的密码指纹是否与数据库当前值一致 */ +export function isPasswordStampValid(payload: any, user: any): boolean { + return String(payload?.pwd_ts ?? "") === passwordStampOf(user) } // ---- 工具函数(与存储无关) ---- diff --git a/src/backend/pkg/permission.ts b/src/backend/pkg/permission.ts index 2b2a8ce8..a4e47c30 100644 --- a/src/backend/pkg/permission.ts +++ b/src/backend/pkg/permission.ts @@ -79,6 +79,25 @@ export function canRemove(user?: UserPermissionObj | null): boolean { return can(user, PermissionBit.DELETE) } +/** + * 规范化路径段:合并重复斜杠、丢弃 ".",并用栈折叠 ".."。 + * + * 关键点:栈为空时的 ".." 会被**钳制在根**(`stack.pop()` 作用于空数组是 + * 空操作),因此结果永远以 "/" 开头且不含 ".." 段,无法向上逃逸。 + */ +export function normalizeSegments(p: string): string { + const stack: string[] = [] + for (const seg of String(p || "").split("/")) { + if (!seg || seg === ".") continue + if (seg === "..") { + stack.pop() + continue + } + stack.push(seg) + } + return "/" + stack.join("/") +} + /** * 计算用户请求路径对应的实际存储路径(结合用户的根目录 base_path): * 1. 忽略以 /@s 开头的分享虚拟路径 @@ -87,6 +106,15 @@ export function canRemove(user?: UserPermissionObj | null): boolean { * - reqPath = "/" 或 "" -> "/photos" * - reqPath = "/sub" -> "/photos/sub" * - reqPath = "sub" -> "/photos/sub" + * + * 安全不变量(必须长期保持):**返回值只可能等于 base_path 或位于其下**。 + * 历史缺陷:本函数只做字符串拼接,`..` 段留给下游 resolvePath() 折叠。于是 + * `/photos/alice` + `/../bob/x` 会解析成 `/photos/bob/x` —— 用户根目录被越过。 + * 因为拼接后才规范化,下游只保证「不逃出存储物理根」,并不保证「不逃出用户 + * 根」,两者不是同一约束。这里先把 reqPath 折叠到根再拼接,从源头保证隔离。 + * + * 注意:不额外增加 decodeURIComponent 次数。上游按约定只解码一次,多次解码会 + * 改变合法 `%2f`/百分号字面量的含义(与 Go 的 EncodePath 契约冲突)。 */ export function getActualPath( user?: UserPermissionObj | null, @@ -97,22 +125,32 @@ export function getActualPath( return p } - let basePath = (user?.base_path || "/").trim() - if (!basePath || basePath === "/") { - return p.startsWith("/") ? p : `/${p}` - } - - if (!basePath.startsWith("/")) { - basePath = `/${basePath}` - } - if (basePath.endsWith("/") && basePath.length > 1) { - basePath = basePath.replace(/\/+$/, "") + const basePath = normalizeSegments((user?.base_path || "/").trim() || "/") + if (basePath === "/") { + return normalizeSegments(p) } - const cleanReq = p.startsWith("/") ? p : `/${p}` + const cleanReq = normalizeSegments(p) if (cleanReq === "/") { return basePath } return `${basePath}${cleanReq}` } + +/** + * 判断某个实际路径是否位于用户根目录之内(含等于根目录本身)。 + * + * 供网关类入口(S3 / WebDAV)在把请求路径交给存储层之前做二次确认使用: + * 这些入口直接操作「全局虚拟路径」,不走 getActualPath 的拼接语义,因此需要 + * 一个显式的范围检查,避免受限用户越出 base_path。 + */ +export function isWithinUserRoot( + user: UserPermissionObj | null | undefined, + actualPath: string, +): boolean { + const root = getActualPath(user, "/") + const target = normalizeSegments(actualPath) + if (root === "/") return true + return target === root || target.startsWith(root + "/") +} diff --git a/src/backend/pkg/upload-limit.ts b/src/backend/pkg/upload-limit.ts new file mode 100644 index 00000000..ce478e6c --- /dev/null +++ b/src/backend/pkg/upload-limit.ts @@ -0,0 +1,71 @@ +/** + * 上传体积上限(M-5:防止超大请求体被整体读入内存导致 Worker OOM)。 + * + * 覆盖范围:`/fs/put`、`/fs/form`、`/fs/multipart/chunk`(单片)以及 S3 `PutObject`。 + * 单次整体上传与分片单片分开设限,均可通过环境变量 `MAX_UPLOAD` / `MAX_UPPART` 覆盖。 + * + * 安全不变量:体积上限必须按**实际收到的字节数**判定,不能只信 `Content-Length`—— + * 该头由客户端提供,可以省略、伪造或与真实 body 不一致。 + * + * 历史缺陷:只做 `Content-Length` 预检,**省略该头即完全不拦截**——实测在 + * `MAX_UPLOAD=4` 时,不带 `Content-Length` 的 5 字节请求写入成功。这使上限形同 + * 虚设,超大请求体可被整体读入内存。 + * + * 注意:`/fs/other` 签发的存储直传 URL(客户端直接写对象存储、不经过本站)不受 + * 本模块约束——数据的实际写入由下游存储自己限额。 + */ + +export const DEFAULT_MAX_UPLOAD = 25 * 1024 * 1024 // 25MB +export const DEFAULT_MAX_UPPART = 16 * 1024 * 1024 // 16MB + +export function getUploadSizeLimit(c: any, part = false): number { + const env = c?.env || {} + const key = part ? "MAX_UPPART" : "MAX_UPLOAD" + const raw = + env[key] || (typeof process !== "undefined" ? process.env?.[key] : "") + if (raw) { + const n = parseInt(String(raw), 10) + if (Number.isFinite(n) && n > 0) return n + } + return part ? DEFAULT_MAX_UPPART : DEFAULT_MAX_UPLOAD +} + +/** + * 只做 `Content-Length` 预检:超限时返回该上限值,否则返回 null。 + * + * ⚠️ 本函数**不是完整保护**。`Content-Length` 可被省略或伪造,缺失时它直接 + * 返回 null(即放行)——任何**只**调用本函数的路径都会重新引入「省略该头即可 + * 绕过上限」的历史缺陷。需要完整保护时必须使用 `readBodyWithinLimit` + * (它内部先调用本函数做预检,再按实际字节数复核)。 + * + * 正因如此这里**刻意不导出**,避免日后被独立误用。 + */ +function exceedsUploadLimit(c: any, part = false): number | null { + const contentLength = parseInt(c.req.header("Content-Length") || "0", 10) + if (!contentLength) return null + const max = getUploadSizeLimit(c, part) + return contentLength > max ? max : null +} + +export const tooLargeResponse = (c: any, limit: number, label = "File") => + c.json( + { code: 413, message: `${label} too large (max ${limit} bytes)`, data: null }, + 413, + ) + +/** + * 读取请求体,并按**实际字节数**复核上传上限。 + * + * 仍保留 `Content-Length` 预检:能在读入内存**之前**就拒绝超大请求,省一次拷贝。 + */ +export async function readBodyWithinLimit( + c: any, + part = false, +): Promise<{ ok: true; buffer: Buffer } | { ok: false; limit: number }> { + const pre = exceedsUploadLimit(c, part) + if (pre !== null) return { ok: false, limit: pre } + const buffer = Buffer.from(await c.req.arrayBuffer()) + const max = getUploadSizeLimit(c, part) + if (buffer.length > max) return { ok: false, limit: max } + return { ok: true, buffer } +} diff --git a/src/backend/pkg/utils.ts b/src/backend/pkg/utils.ts index 0ea9bd7a..e9b0a3f9 100644 --- a/src/backend/pkg/utils.ts +++ b/src/backend/pkg/utils.ts @@ -34,20 +34,40 @@ export async function checkAdminAuth(c: Context): Promise { ? authHeader.substring(7) : authHeader - // JWT:管理员登录用户也视为管理员(登录用户变管理员判定) + // JWT:以**数据库当前状态**为权威来源判定管理员。 + // + // 安全不变量(必须长期保持):管理员授权必须同时满足 + // 1. 令牌签名有效且未过期(HS256,alg 已 pin); + // 2. 令牌未被注销(jti 不在吊销名单); + // 3. 用户在库中存在且未被禁用; + // 4. 用户的**当前** role 仍是管理员。 + // + // 历史缺陷:只读取 JWT 里的 `payload.role`,且完全不检查 jti。后果是 + // 「退出登录」和「把管理员降权为普通用户」都不会立即生效 —— 旧令牌在 7 天 + // 有效期内继续拥有完整后台权限(实测:注销后 /api/admin/* 仍返回 200)。 + // 注意不能反过来只信 JWT:那样降权/禁用同样无效。 try { const { verify } = await import("hono/jwt") - const { getJwtSecret } = await import("../server/middlewares") + const { getJwtSecret, isTokenRevoked } = await import( + "../server/middlewares" + ) const secret = await getJwtSecret(c) const payload: any = await verify(token, secret, "HS256") - if (payload && payload.role === 2) { - // 确认该用户存在于 DB 且未被禁用 - const db = await getDb(c.env) - const user = (db.users || []).find( - (u: any) => u.id === payload.id || u.username === payload.username, - ) - return !!(user && !user.disabled) - } + if (!payload) return false + + // 注销黑名单:与 getUserFromContext 使用同一套判定 + if (await isTokenRevoked(payload.jti, c.env)) return false + + // DB 为权威:存在 + 未禁用 + 当前角色仍为管理员 + const db = await getDb(c.env) + const user = (db.users || []).find( + (u: any) => u.id === payload.id || u.username === payload.username, + ) + if (!user || user.disabled) return false + // 密码指纹:改密后旧令牌立即失效(对齐 Go middlewares.Auth 的 PwdTS 比对) + const { isPasswordStampValid } = await import("./password") + if (!isPasswordStampValid(payload, user)) return false + return user.role === 2 } catch {} return false } diff --git a/src/backend/server/admin.ts b/src/backend/server/admin.ts index 0b107d37..2fb8dcf9 100644 --- a/src/backend/server/admin.ts +++ b/src/backend/server/admin.ts @@ -4619,12 +4619,21 @@ adminRouter.post("/setting/set_thunderx", async (c) => { adminRouter.post("/setting/reset_token", async (c) => { const newToken = generateSecureToken(32) await updateSettingValue(c.env, { token: newToken }) - // `token` 设置是链接/下载签名密钥(对应 Go 的 sign.Instance()), - // 变更后旧的下载链接签名立即失效——与 Go ResetToken 行为一致。 + // 本接口轮换的是 `token` 设置,即**静态 API Token** + // (pkg/utils.isStaticApiToken 用它比对 `Authorization: Bearer `)。 // - // 注意:JWT 密钥来自 env.JWT_SECRET / 持久化的 openlist_jwt_secret,与这里的 - // `token` 设置无关,所以清 JWT 缓存**不会**让已签发的 JWT 失效;这里调用只是 - // 让进程内缓存与最新的 env/持久化值保持一致。 + // 关于下载链接(重要,勿再写成「旧链接立即失效」): + // 下载签名由 pkg/sign 用 getJwtSecret() 计算,密钥来源是 + // env.JWT_SECRET 或持久化的 openlist_jwt_secret,**与 `token` 设置无关**。 + // 因此轮换本设置**不会**让已签发的下载链接失效。 + // 历史注释声称「与 Go ResetToken 行为一致、旧链接立即失效」,与实现不符, + // 会让人误以为已经撤销了旧链接。 + // + // 若确实要让旧下载链接失效:需要轮换 JWT_SECRET(注意这同时会使所有已登录 + // 会话失效);把它做成独立开关属于后续工作,不能靠本接口悄悄生效。 + // + // 这里的 resetJwtSecretCache() 只是让进程内缓存与最新 env/持久化值对齐, + // 并不会更换密钥,因此不会使已签发的 JWT 失效。 const { resetJwtSecretCache } = await import("./middlewares") resetJwtSecretCache() return c.json({ code: 200, message: "success", data: newToken }) diff --git a/src/backend/server/auth.ts b/src/backend/server/auth.ts index a14447d9..d2753809 100644 --- a/src/backend/server/auth.ts +++ b/src/backend/server/auth.ts @@ -23,9 +23,10 @@ import { import { staticHash, saltedHash, - generateSalt, setUserPassword, isHex64, + passwordStampOf, + isPasswordStampValid, } from "../pkg/password" import { setCSRFToken, clearCSRFToken } from "../pkg/csrf" import { getAuditLogger } from "../pkg/audit" @@ -147,7 +148,7 @@ async function bumpLoginFailure( } } -async function isLoginLocked( +export async function isLoginLocked( c: Context, username: string, env: any, @@ -206,7 +207,7 @@ async function isLoginLocked( return false } -async function recordLoginFailure( +export async function recordLoginFailure( c: Context, username: string, env: any, @@ -219,7 +220,7 @@ async function recordLoginFailure( ) } -async function clearLoginFailures( +export async function clearLoginFailures( c: Context, username: string, env: any, @@ -257,10 +258,12 @@ export async function hashPasswordSHA256( /** * 校验前端提交的静态哈希是否匹配该用户(等价 Go User.ValidatePwdStaticHash)。 - * 兼容的存储格式: - * - Go 双层(user.salt 存在):password === saltedHash(inputStatic, salt) - * - 早期 TSWorker 单层(无 salt):password === inputStatic - * - bcrypt 遗留:返回 false(无明文不可还原),仅明文 /login 可逃生 + * + * 只接受 Go 双层格式:password === saltedHash(inputStatic, user.salt)。 + * + * 历史遗留格式(bcrypt / 无 salt 的单层 SHA256)不再被支持,也不再在登录成功 + * 后自动升级为双层格式 —— 与 Go 行为完全一致:没有 salt 的存储值一律不可验证。 + * 遗留账号请通过 ADMIN_PASS 显式重置,或由管理员在后台改密。 */ export async function verifyUserStaticHash( user: any, @@ -272,12 +275,9 @@ export async function verifyUserStaticHash( const input = String(inputStatic || "") .trim() .toLowerCase() - if (!stored || !isHex64(input) || !isHex64(stored)) return false - if (user?.salt) { - const expect = (await saltedHash(input, String(user.salt))).toLowerCase() - return stored === expect - } - return stored === input + if (!stored || !user?.salt || !isHex64(input) || !isHex64(stored)) return false + const expect = (await saltedHash(input, String(user.salt))).toLowerCase() + return stored === expect } /** @@ -428,7 +428,16 @@ export async function authUserFromReq( const user = db.users.find( (u: any) => u.id === payload.id || u.username === payload.username, ) - if (!user) return null + // 禁用/已删除用户一律视为未认证。 + // + // 安全不变量:身份解析必须复检「当前是否仍被允许」。JWT 只证明「签发时刻 + // 身份合法」,不证明「现在仍然合法」。历史上这里缺少 disabled 判断,导致 + // 管理员禁用某用户后,其既有令牌在 S3 网关等直接复用本函数的入口仍然可用 + // (核心 API 走 getUserFromContext 有该检查,网关入口没有)。 + if (!user || user.disabled) return null + // 密码指纹:改密后旧令牌立即失效(对齐 Go middlewares.Auth 的 PwdTS 比对)。 + // 历史缺陷:JWT 只携带 {id, username, role},改密不使既有令牌失效。 + if (!isPasswordStampValid(payload, user)) return null return { db, user } } catch { return null @@ -479,29 +488,26 @@ authRouter.post("/login", async (c) => { ) } - const { users, db } = await getOrInitUsers(c.env) + const { users } = await getOrInitUsers(c.env) const matchedUser = users.find( (u: any) => u.username === username && !u.disabled, ) if (matchedUser) { - // 明文登录:服务端先 StaticHash,再比对存储哈希(兼容单层/双层/bcrypt 遗留) + // 明文登录:服务端先 StaticHash,再比对存储哈希(仅 Go 双层格式) const isPasswordValid = await verifyUserPassword(matchedUser, rawPassword) if (isPasswordValid) { - // 遗留格式(bcrypt / 无盐单层 SHA256)登录成功后自动升级为 Go 双层哈希 - const stored = String(matchedUser.password || "").trim() - const needsMigrate = - /^\$2[aby]\$/.test(stored) || (!matchedUser.salt && isHex64(stored)) - if (needsMigrate) { - console.log(`[Auth] Upgrading password hash for user: ${username}`) - await setUserPassword(matchedUser, rawPassword) - await saveDb(db, c.env) - } - const otpCheck = await checkUserOtp(matchedUser, body) if (!otpCheck.ok) { + // OTP 失败必须计入防爆破计数。 + // + // 安全不变量:任何「口令学验证失败」都应当累加失败次数。历史缺陷是 + // OTP 分支直接 return,不调用 recordLoginFailure —— 攻击者只要先拿到 + // 正确密码,就能以「无限次猜测」的方式爆破 6 位 TOTP,而账号永远不会 + // 被锁定(实测:连续 7 次错误 OTP 后仍可继续提交)。 + await recordLoginFailure(c, username, c.env) return c.json( { code: otpCheck.code, message: otpCheck.message, data: null }, otpCheck.httpStatus, @@ -514,6 +520,8 @@ authRouter.post("/login", async (c) => { id: matchedUser.id, username: matchedUser.username, role: matchedUser.role, + // 密码指纹(对齐 Go common.UserClaims.PwdTS):改密后旧令牌立即失效 + pwd_ts: passwordStampOf(matchedUser), exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, jti: generateJti(), } @@ -564,7 +572,7 @@ authRouter.post("/login/hash", async (c) => { ) } - const { users, db } = await getOrInitUsers(c.env) + const { users } = await getOrInitUsers(c.env) const matchedUser = users.find( (u: any) => u.username === username && !u.disabled, @@ -574,17 +582,15 @@ authRouter.post("/login/hash", async (c) => { const isHashValid = await verifyUserStaticHash(matchedUser, inputHash) if (isHashValid) { - // 无盐单层 SHA256 旧数据 -> 升级为 Go 双层哈希(无需明文) - if (!matchedUser.salt) { - console.log(`[Auth] Upgrading password hash for user: ${username}`) - matchedUser.salt = generateSalt() - matchedUser.password = await saltedHash(inputHash, matchedUser.salt) - matchedUser.pwd_update_at = new Date().toISOString() - await saveDb(db, c.env) - } - const otpCheck = await checkUserOtp(matchedUser, body) if (!otpCheck.ok) { + // OTP 失败必须计入防爆破计数。 + // + // 安全不变量:任何「口令学验证失败」都应当累加失败次数。历史缺陷是 + // OTP 分支直接 return,不调用 recordLoginFailure —— 攻击者只要先拿到 + // 正确密码,就能以「无限次猜测」的方式爆破 6 位 TOTP,而账号永远不会 + // 被锁定(实测:连续 7 次错误 OTP 后仍可继续提交)。 + await recordLoginFailure(c, username, c.env) return c.json( { code: otpCheck.code, message: otpCheck.message, data: null }, otpCheck.httpStatus, @@ -597,6 +603,8 @@ authRouter.post("/login/hash", async (c) => { id: matchedUser.id, username: matchedUser.username, role: matchedUser.role, + // 密码指纹(对齐 Go common.UserClaims.PwdTS):改密后旧令牌立即失效 + pwd_ts: passwordStampOf(matchedUser), exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, jti: generateJti(), } diff --git a/src/backend/server/default_credentials.test.ts b/src/backend/server/default_credentials.test.ts index ae7f658f..dccfb0f6 100644 --- a/src/backend/server/default_credentials.test.ts +++ b/src/backend/server/default_credentials.test.ts @@ -95,7 +95,7 @@ test("Security(F-11): ADMIN_PASS still forces an explicit reset (to salted doubl ) }) -test("Security(F-11): user/create without a password gets a random one, not 123456", async () => { +test("user/create follows Go: no silent default password and no echoed password", async () => { await seed( [ adminUser( @@ -116,20 +116,17 @@ test("Security(F-11): user/create without a password gets a random one, not 1234 }) assert.equal(res.status, 200) const json: any = await res.json() - assert.ok( - json.data?.password, - "the generated password must be returned to the admin caller once", - ) - assert.notEqual(json.data.password, "123456") + // 对齐 Go:不再自动生成随机密码,也不会把密码回显给调用方 + assert.equal(json.data, null) const db: any = await getDb(env) const created = db.users.find((u: any) => u.username === "newuser") assert.ok(isHex64(created.password), "stored value must be a 64-hex hash") assert.ok(created.salt, "stored user must carry a per-user salt") assert.equal( - await verifyUserPassword(created, json.data.password), + await verifyUserPassword(created, ""), true, - "the returned random password must actually log the user in", + 'an empty password matches Go\'s SetPassword("") semantics', ) assert.equal( await verifyUserPassword(created, "123456"), diff --git a/src/backend/server/fs.ts b/src/backend/server/fs.ts index c7f52d6c..4ed54fa6 100644 --- a/src/backend/server/fs.ts +++ b/src/backend/server/fs.ts @@ -14,7 +14,18 @@ import { import { resolveShare } from "../internal/op/share" import { resolvePath } from "../internal/model/db" import { getUserFromContext } from "./middlewares" -import { canWrite, getActualPath, isAdmin } from "../pkg/permission" +import { + can, + canCopy, + canMove, + canRemove, + canRename, + canWrite, + getActualPath, + isAdmin, + isWithinUserRoot, + PermissionBit, +} from "../pkg/permission" import { getNearestMeta, canAccess, @@ -117,32 +128,91 @@ const getStorageRequestContext = (c: any) => { const permissionDenied = (c: any) => c.json({ code: 403, message: "Permission denied", data: null }, 403) -// ---- 上传大小限制(M-5:防止超大请求体被整体读入内存导致 Worker OOM)---- -// 单次整体上传(/put /form)与分片单片(/upload/part)分开设限, -// 均可通过环境变量 MAX_UPLOAD / MAX_UPPART 覆盖。 -const DEFAULT_MAX_UPLOAD = 25 * 1024 * 1024 // 25MB -const DEFAULT_MAX_UPPART = 16 * 1024 * 1024 // 16MB - -function getUploadSizeLimit(c: any, part: boolean): number { - const env = c.env || {} - const key = part ? "MAX_UPPART" : "MAX_UPLOAD" - const raw = - env[key] || (typeof process !== "undefined" ? process.env?.[key] : "") - if (raw) { - const n = parseInt(String(raw), 10) - if (Number.isFinite(n) && n > 0) return n - } - return part ? DEFAULT_MAX_UPPART : DEFAULT_MAX_UPLOAD +/** + * 目录级写权限(meta.write_users)的**执行点**。 + * + * 安全不变量:`meta.write_users` 是「该目录只允许这些用户写入」的白名单, + * 它必须在**真正写入时**校验,而非只在 `/fs/list` 的响应里回填 `write` 字段。 + * + * 历史缺陷:该白名单只影响界面提示(列表返回 write=false),写接口从不复查, + * 因此「界面显示禁止写入」与「实际能写入」不一致——用户看到只读提示,但上传 + * 依然成功(实测:write_users=[其他用户] 时 /fs/put 仍返回 200 并创建文件)。 + * + * 返回 true 表示应当拒绝。管理员放行(与 pkg/meta 的约定一致)。 + */ +async function deniedByMetaWriteAcl( + c: any, + user: any, + targetPaths: string[], +): Promise { + if (isAdmin(user)) return false + for (const p of targetPaths) { + let meta: Meta | null = null + try { + meta = await getNearestMeta(p, c.env) + } catch { + meta = null + } + if (!meta) continue + if (!canWriteMeta(user, meta, p)) return true + } + return false } -/** 返回超限时的上限字节数;未超限或无法判断(无 Content-Length)返回 null */ -function exceedsUploadLimit(c: any, part = false): number | null { - const contentLength = parseInt(c.req.header("Content-Length") || "0", 10) - if (!contentLength) return null - const max = getUploadSizeLimit(c, part) - return contentLength > max ? max : null +/** + * 归档读取的 meta 访问校验:与 `/fs/list`、`/fs/get` 共用同一套判定 + * (密码保护 + read_users 白名单)。 + * + * 安全不变量:归档接口读的是**同一份文件内容**,必须与直连下载受同样的 + * 访问控制。历史缺陷:归档列表直接 `driver.get() → fetch()`,完全跳过 meta + * 判定——实测「直连被密码保护而 401」的文件,归档列表却能读出其条目。 + */ +async function deniedByMetaReadAcl( + c: any, + user: any, + targetPath: string, + password: string, +): Promise { + if (isAdmin(user)) return false + let meta: Meta | null = null + try { + meta = await getNearestMeta(targetPath, c.env) + } catch { + meta = null + } + if (!meta) return false + return !canAccess(user, meta, targetPath, password || "") +} + +/** + * 归档条目相对路径的安全化。 + * + * 安全不变量:ZIP 条目名是**不可信输入**,只能被当作「只能向下展开的相对路径」。 + * 历史缺陷:条目名只做了反斜杠转换,`../bob/x` 与目标目录拼接后会被 + * resolvePath 归一化,从而把文件写到解压目录(乃至用户根)之外。 + * + * 返回 null 表示该条目应被跳过(绝对路径 / 盘符 / 含 `..` / 空)。 + */ +function safeArchiveEntryPath(rel: string): string | null { + const cleaned = String(rel || "") + .replace(/\\/g, "/") + .replace(/^\/+/, "") + if (!cleaned || cleaned.includes("\0")) return null + if (/^[A-Za-z]:/.test(cleaned)) return null + const parts = cleaned.split("/").filter(Boolean) + if (parts.length === 0) return null + if (parts.some((p) => p === "." || p === "..")) return null + return parts.join("/") } +// 上传大小限制统一实现在 pkg/upload-limit.ts(S3 网关复用同一份, +// 避免出现「/fs/put 有上限、S3 PutObject 无上限」的不对称)。 +import { + getUploadSizeLimit, + readBodyWithinLimit, + tooLargeResponse, +} from "../pkg/upload-limit" + // 分享请求错误统一出口: // - 密码错误 → 403,前端据此弹出提取码输入框(State.NeedPassword) // - 其他(不存在/禁用/过期/超次数/为空)→ 400,前端显示友好提示 @@ -169,8 +239,12 @@ fsRouter.post("/dirs", async (c) => { return c.json({ code: 401, message: "Unauthorized", data: null }, 401) } const requestContext = getStorageRequestContext(c) + // 对齐 Go handles.FsDirs:force_root(以全局根为起点)只对管理员开放 + if (body.force_root && !isAdmin(user)) { + return c.json({ code: 403, message: "Permission denied", data: null }, 403) + } let reqPath = rawPath - if (!body.force_root || !isAdmin(user)) { + if (!body.force_root) { reqPath = getActualPath(user, reqPath) } try { @@ -208,6 +282,20 @@ fsRouter.post("/dirs", async (c) => { return c.json({ code: 200, message: "success", data: dirs }) } + // 对齐 Go handles.FsDirs:目录树接口必须与 /fs/list 受同一套 meta 判定 + // (密码保护 + read_users 白名单)。历史缺陷:只列目录不做任何校验, + // 任何登录用户都能直接枚举出受保护目录的结构。 + const dirMeta = await getNearestMeta(reqPath, c.env) + if (!canAccess(user, dirMeta, reqPath, body.password || "")) { + return c.json( + { + code: 403, + message: "password is incorrect or you have no permission", + data: null, + }, + 403, + ) + } const { content } = await listItems(reqPath, requestContext) const dirs = content .filter((item: any) => item.is_dir) @@ -703,6 +791,7 @@ fsRouter.post("/mkdir", async (c) => { return c.json({ code: 400, message: e.message, data: null }, 400) } const reqPath = getActualPath(user, rawPath) + if (await deniedByMetaWriteAcl(c, user, [reqPath])) return permissionDenied(c) const requestContext = getStorageRequestContext(c) try { await makeDirectory(reqPath, requestContext) @@ -714,7 +803,9 @@ fsRouter.post("/mkdir", async (c) => { fsRouter.post("/rename", async (c) => { const user = await getUserFromContext(c) - if (!canWrite(user)) return permissionDenied(c) + // 重命名使用独立的 RENAME 权限位(此前误用 WRITE_CONTENT,导致「只有上传 + // 权限的用户也能改名」而「只授予 RENAME 的用户反被拒绝」)。 + if (!canRename(user)) return permissionDenied(c) const { path: oldPath, name: newName } = await c.req.json().catch(() => ({})) let cleanName = "" try { @@ -726,6 +817,8 @@ fsRouter.post("/rename", async (c) => { const requestContext = getStorageRequestContext(c) try { const actualOldPath = getActualPath(user, oldPath || "/") + if (await deniedByMetaWriteAcl(c, user, [actualOldPath])) + return permissionDenied(c) await renameItem(actualOldPath, cleanName, requestContext) return c.json({ code: 200, message: "success", data: null }) } catch (e: any) { @@ -735,7 +828,8 @@ fsRouter.post("/rename", async (c) => { fsRouter.post("/remove", async (c) => { const user = await getUserFromContext(c) - if (!canWrite(user)) return permissionDenied(c) + // 删除使用独立的 DELETE 权限位(此前误用 WRITE_CONTENT)。 + if (!canRemove(user)) return permissionDenied(c) const { dir, names } = await c.req.json().catch(() => ({})) if (!Array.isArray(names) || names.length === 0) { return c.json( @@ -757,6 +851,11 @@ fsRouter.post("/remove", async (c) => { const requestContext = getStorageRequestContext(c) try { const actualDir = getActualPath(user, dir || "/") + const aclPaths = cleanNames.map((n) => + `${actualDir}/${n}`.replace(/\/{2,}/g, "/"), + ) + if (await deniedByMetaWriteAcl(c, user, aclPaths)) + return permissionDenied(c) await removeItems(actualDir, cleanNames, requestContext) return c.json({ code: 200, message: "success", data: null }) } catch (e: any) { @@ -766,7 +865,8 @@ fsRouter.post("/remove", async (c) => { fsRouter.post("/move", async (c) => { const user = await getUserFromContext(c) - if (!canWrite(user)) return permissionDenied(c) + // 移动使用独立的 MOVE 权限位(此前误用 WRITE_CONTENT)。 + if (!canMove(user)) return permissionDenied(c) const { src_dir, dst_dir, names } = await c.req.json().catch(() => ({})) if (!Array.isArray(names) || names.length === 0) { return c.json( @@ -790,6 +890,14 @@ fsRouter.post("/move", async (c) => { try { const actualSrcDir = getActualPath(user, src_dir || "/") const actualDstDir = getActualPath(user, dst_dir || "/") + const aclPaths = [ + actualDstDir, + ...cleanNames.map((n) => + `${actualSrcDir}/${n}`.replace(/\/{2,}/g, "/"), + ), + ] + if (await deniedByMetaWriteAcl(c, user, aclPaths)) + return permissionDenied(c) await moveItems(actualSrcDir, actualDstDir, cleanNames, requestContext) return c.json({ code: 200, message: "success", data: null }) } catch (e: any) { @@ -799,7 +907,8 @@ fsRouter.post("/move", async (c) => { fsRouter.post("/copy", async (c) => { const user = await getUserFromContext(c) - if (!canWrite(user)) return permissionDenied(c) + // 复制使用独立的 COPY 权限位(此前误用 WRITE_CONTENT)。 + if (!canCopy(user)) return permissionDenied(c) const { src_dir, dst_dir, names } = await c.req.json().catch(() => ({})) if (!Array.isArray(names) || names.length === 0) { return c.json( @@ -823,6 +932,14 @@ fsRouter.post("/copy", async (c) => { try { const actualSrcDir = getActualPath(user, src_dir || "/") const actualDstDir = getActualPath(user, dst_dir || "/") + const aclPaths = [ + actualDstDir, + ...cleanNames.map((n) => + `${actualDstDir}/${n}`.replace(/\/{2,}/g, "/"), + ), + ] + if (await deniedByMetaWriteAcl(c, user, aclPaths)) + return permissionDenied(c) await copyItems(actualSrcDir, actualDstDir, cleanNames, requestContext) return c.json({ code: 200, message: "success", data: null }) } catch (e: any) { @@ -846,21 +963,12 @@ fsRouter.put("/put", async (c) => { return c.json({ code: 400, message: e.message, data: null }, 400) } const reqPath = getActualPath(user, rawPath) + if (await deniedByMetaWriteAcl(c, user, [reqPath])) return permissionDenied(c) const requestContext = getStorageRequestContext(c) - const tooLarge = exceedsUploadLimit(c) - if (tooLarge !== null) { - return c.json( - { - code: 413, - message: `File too large (max ${tooLarge} bytes)`, - data: null, - }, - 413, - ) - } try { - const buffer = await c.req.arrayBuffer() - await putItem(reqPath, Buffer.from(buffer), requestContext) + const body = await readBodyWithinLimit(c) + if (!body.ok) return tooLargeResponse(c, body.limit) + await putItem(reqPath, body.buffer, requestContext) return c.json({ code: 200, message: "success", data: null }) } catch (e: any) { return c.json({ code: 500, message: safeErrorMessage(e), data: null }) @@ -883,18 +991,8 @@ fsRouter.put("/form", async (c) => { return c.json({ code: 400, message: e.message, data: null }, 400) } const reqPath = getActualPath(user, rawPath) + if (await deniedByMetaWriteAcl(c, user, [reqPath])) return permissionDenied(c) const requestContext = getStorageRequestContext(c) - const tooLarge = exceedsUploadLimit(c) - if (tooLarge !== null) { - return c.json( - { - code: 413, - message: `File too large (max ${tooLarge} bytes)`, - data: null, - }, - 413, - ) - } try { const form = await c.req.formData() const file = form.get("file") @@ -906,6 +1004,9 @@ fsRouter.put("/form", async (c) => { }) } const buffer = Buffer.from(await (file as File).arrayBuffer()) + // multipart 的 Content-Length 只覆盖整个表单,仍需按文件实际字节复核 + const max = getUploadSizeLimit(c, false) + if (buffer.length > max) return tooLargeResponse(c, max) await putItem(reqPath, buffer, requestContext) return c.json({ code: 200, message: "success", data: null }) } catch (e: any) { @@ -913,161 +1014,8 @@ fsRouter.put("/form", async (c) => { } }) -// ---- 分片会话上传:解决大文件整体缓冲导致的卡死/OOM ---- -// 流程:POST /fs/upload/create 建会话 → PUT /fs/upload/part 逐片上传 -// → POST /fs/upload/complete 收尾。每片是独立 HTTP 请求,Worker -// 内存占用恒定,不受 CF Workers 请求体/内存上限约束。 - -fsRouter.post("/upload/create", async (c) => { - const user = await getUserFromContext(c) - if (!canWrite(user)) return permissionDenied(c) - const { - path: rawPath, - file_name, - size, - md5, - } = await c.req.json().catch(() => ({})) - // 根目录上传时调用方可能传 "",归一化为 "/" - const dirPath = getActualPath(user, rawPath || "/") - const requestContext = getStorageRequestContext(c) - if (!file_name) { - return c.json({ - code: 400, - message: "path and file_name are required", - data: null, - }) - } - try { - const resolved = await resolvePath(dirPath) - if (resolved.isVirtual) { - throw new Error("failed get storage: storage not found") - } - const driver = await getDriver(resolved.storage!.driver, resolved.storage) - if (typeof (driver as any).createUploadSession !== "function") { - // 当前存储不支持分片会话上传:返回 null,前端自动回退到流式上传 - return c.json({ code: 200, message: "success", data: null }) - } - let info - try { - info = await (driver as any).createUploadSession( - dirPath, - resolved.physical!, - file_name, - Number(size) || 0, - md5 || "", - ) - } finally { - await flushPendingDriverState( - resolved.storage!.driver, - resolved.storage, - driver, - requestContext, - ) - } - return c.json({ code: 200, message: "success", data: info }) - } catch (e: any) { - return c.json({ code: 500, message: safeErrorMessage(e), data: null }) - } -}) - -fsRouter.put("/upload/part", async (c) => { - const user = await getUserFromContext(c) - if (!canWrite(user)) return permissionDenied(c) - const session = c.req.header("X-Upload-Session") || "" - const partNumber = parseInt(c.req.header("X-Part-Number") || "0", 10) - const rawDirPath = decodeURIComponent(c.req.header("Upload-Path") || "") - const dirPath = getActualPath(user, rawDirPath) - const requestContext = getStorageRequestContext(c) - if (!session || !(partNumber >= 1) || !dirPath) { - return c.json({ - code: 400, - message: "missing X-Upload-Session / X-Part-Number / Upload-Path", - data: null, - }) - } - const tooLarge = exceedsUploadLimit(c, true) - if (tooLarge !== null) { - return c.json( - { - code: 413, - message: `Part too large (max ${tooLarge} bytes)`, - data: null, - }, - 413, - ) - } - try { - const resolved = await resolvePath(dirPath) - if (resolved.isVirtual) { - throw new Error("failed get storage: storage not found") - } - const driver = await getDriver(resolved.storage!.driver, resolved.storage) - if (typeof (driver as any).uploadPart !== "function") { - throw new Error("storage does not support chunked upload") - } - const buffer = Buffer.from(await c.req.arrayBuffer()) - let result - try { - result = await (driver as any).uploadPart(session, partNumber, buffer) - } finally { - await flushPendingDriverState( - resolved.storage!.driver, - resolved.storage, - driver, - requestContext, - ) - } - return c.json({ code: 200, message: "success", data: result ?? null }) - } catch (e: any) { - return c.json({ code: 500, message: safeErrorMessage(e), data: null }) - } -}) - -fsRouter.post("/upload/complete", async (c) => { - const user = await getUserFromContext(c) - if (!canWrite(user)) return permissionDenied(c) - const { - path: rawPath, - session, - partMd5s, - } = await c.req.json().catch(() => ({})) - // 根目录上传时调用方可能传 "",归一化为 "/" - const dirPath = getActualPath(user, rawPath || "/") - const requestContext = getStorageRequestContext(c) - if (!session) { - return c.json({ - code: 400, - message: "path and session are required", - data: null, - }) - } - try { - const resolved = await resolvePath(dirPath) - if (resolved.isVirtual) { - throw new Error("failed get storage: storage not found") - } - const driver = await getDriver(resolved.storage!.driver, resolved.storage) - if (typeof (driver as any).completeUploadSession !== "function") { - throw new Error("storage does not support chunked upload") - } - try { - await (driver as any).completeUploadSession(session, partMd5s) - } finally { - await flushPendingDriverState( - resolved.storage!.driver, - resolved.storage, - driver, - requestContext, - ) - } - // 直传收尾:目录列表已变化,失效该目录的文件树 / 链接缓存, - // 否则「上传成功但刷新看不到」在缓存 TTL 内稳定复现 - await invalidatePaths([dirPath], c.env) - return c.json({ code: 200, message: "success", data: null }) - } catch (e: any) { - return c.json({ code: 500, message: safeErrorMessage(e), data: null }) - } -}) +// 说明:TS 专有的 /fs/upload/{create,part,complete} 会话式上传端点已移除, +// 分片上传对齐 Go 只保留官方的 /fs/multipart/* 系列。 fsRouter.post("/add_offline_download", async (c) => { const user = await getUserFromContext(c) @@ -1106,6 +1054,10 @@ fsRouter.post("/search", async (c) => { scope: body.scope !== undefined ? parseInt(body.scope, 10) : 0, page: body.page ? parseInt(body.page, 10) : 1, per_page: body.per_page ? parseInt(body.per_page, 10) : 30, + // 与 /fs/list 同一套 meta 判定(密码 + read_users), + // 对齐 Go handles.Search 里 SearchFiltered 的 filter 闭包 + user, + password: body.password || "", }, c.env, ) @@ -1133,6 +1085,9 @@ fsRouter.post("/other", async (c) => { // to mint presigned direct-upload URLs (s3/driver.ts:371-378), which is a // write — it must obey the same gate as /fs/put. Without this, a guest could // obtain a direct-upload URL and bypass the upload permission check. + // + // 体积边界说明:这里签发的直传 URL 由客户端直接写对象存储,数据不经过本站, + // 因此 MAX_UPLOAD / MAX_UPPART 覆盖不到该路径——上限由下游存储自行约束。 if (!canWrite(user)) return permissionDenied(c) try { const resolved = await resolvePath(reqPath) @@ -1169,7 +1124,7 @@ fsRouter.post("/other", async (c) => { fsRouter.post("/batch_rename", async (c) => { const user = await getUserFromContext(c) - if (!canWrite(user)) return permissionDenied(c) + if (!canRename(user)) return permissionDenied(c) const { src_dir, rename_objects } = await c.req.json().catch(() => ({})) if (!Array.isArray(rename_objects) || rename_objects.length === 0) { return c.json( @@ -1180,6 +1135,12 @@ fsRouter.post("/batch_rename", async (c) => { const dirPath = getActualPath(user, src_dir || "/") const requestContext = getStorageRequestContext(c) try { + const aclPaths = rename_objects + .map((obj: any) => obj?.src_name) + .filter((n: any) => typeof n === "string" && n) + .map((n: string) => `${dirPath}/${n}`.replace(/\/{2,}/g, "/")) + if (await deniedByMetaWriteAcl(c, user, aclPaths)) + return permissionDenied(c) for (const obj of rename_objects) { const srcName = obj?.src_name const newName = obj?.new_name @@ -1196,7 +1157,7 @@ fsRouter.post("/batch_rename", async (c) => { fsRouter.post("/regex_rename", async (c) => { const user = await getUserFromContext(c) - if (!canWrite(user)) return permissionDenied(c) + if (!canRename(user)) return permissionDenied(c) const { src_dir, src_name_regex, new_name_regex } = await c.req .json() .catch(() => ({})) @@ -1216,6 +1177,8 @@ fsRouter.post("/regex_rename", async (c) => { const newName = item.name.replace(srcRegex, new_name_regex || "") validateFileName(newName) const fullPath = `${dirPath}/${item.name}`.replace(/\/{2,}/g, "/") + if (await deniedByMetaWriteAcl(c, user, [fullPath])) + return permissionDenied(c) await renameItem(fullPath, newName, requestContext) } } @@ -1229,7 +1192,7 @@ fsRouter.post("/regex_rename", async (c) => { fsRouter.post("/recursive_move", async (c) => { const user = await getUserFromContext(c) - if (!canWrite(user)) return permissionDenied(c) + if (!canMove(user)) return permissionDenied(c) const { src_dir, dst_dir, conflict_policy } = await c.req .json() .catch(() => ({})) @@ -1267,6 +1230,13 @@ fsRouter.post("/recursive_move", async (c) => { if (policy === "skip") continue } if (policy !== "overwrite") existing.add(item.name) + if ( + await deniedByMetaWriteAcl(c, user, [ + dstPath, + `${dir}/${item.name}`.replace(/\/{2,}/g, "/"), + ]) + ) + return permissionDenied(c) await moveItems(dir, dstPath, [item.name], requestContext) count++ } @@ -1286,9 +1256,10 @@ fsRouter.post("/recursive_move", async (c) => { fsRouter.post("/remove_empty_directory", async (c) => { const user = await getUserFromContext(c) - if (!canWrite(user)) return permissionDenied(c) + if (!canRemove(user)) return permissionDenied(c) const { src_dir } = await c.req.json().catch(() => ({})) const srcPath = getActualPath(user, src_dir || "/") + if (await deniedByMetaWriteAcl(c, user, [srcPath])) return permissionDenied(c) const requestContext = getStorageRequestContext(c) try { const removeEmptyDirs = async (dir: string): Promise => { @@ -1373,9 +1344,54 @@ fsRouter.post("/get_direct_upload_info", async (c) => { const user = await getUserFromContext(c) if (!canWrite(user)) return permissionDenied(c) const { path, file_name, file_size } = await c.req.json().catch(() => ({})) - const reqPath = getActualPath(user, path || "/") + // 对齐 Go FsGetDirectUploadInfo 的四重校验: + // 1) file_name 必须是单层相对文件名(拒绝 / \ .. 与空值),否则可能签发 + // 指向目标目录之外的直传凭证; + // 2) 目标目录需通过用户权限位,或 meta.write 强制开放(write_content_bypass); + // 3) 目标目录需通过 meta.write_users 白名单; + // 4) 目标不得跨越存储挂载边界(单段文件名也可能命中嵌套挂载点)。 + let cleanName: string try { - const resolved = await resolvePath(reqPath) + cleanName = validateFileName(file_name) + } catch (e: any) { + return c.json({ code: 403, message: safeErrorMessage(e), data: null }, 403) + } + const dirPath = getActualPath(user, path || "/") + const dstPath = dirPath === "/" ? `/${cleanName}` : `${dirPath}/${cleanName}` + const parentMeta = await getNearestMeta(dirPath, c.env) + if (!canWrite(user) && !canWriteContentBypassUserPerms(parentMeta, dirPath)) { + return permissionDenied(c) + } + if (await deniedByMetaWriteAcl(c, user, [dirPath])) return permissionDenied(c) + // 挂载边界:目标文件与所在目录必须落在同一个存储挂载点内 + const normMount = (s: any) => + "/" + String(s?.mount_path || "").split("/").filter(Boolean).join("/") + try { + const [parentResolved, targetResolved] = await Promise.all([ + resolvePath(dirPath), + resolvePath(dstPath), + ]) + if ( + normMount(parentResolved.storage) !== normMount(targetResolved.storage) + ) { + return permissionDenied(c) + } + } catch { + return permissionDenied(c) + } + // Overwrite:false 时目标已存在则拒绝签发(对齐 Go) + if (c.req.header("Overwrite") === "false") { + try { + const { item } = await getItem(dstPath, getStorageRequestContext(c)) + if (item) { + return c.json({ code: 403, message: "file exists", data: null }, 403) + } + } catch { + // 目标不存在 / 无法读取:继续签发流程 + } + } + try { + const resolved = await resolvePath(dirPath) if (resolved.isVirtual || !resolved.storage) { return c.json({ code: 200, message: "success", data: null }) } @@ -1385,20 +1401,12 @@ fsRouter.post("/get_direct_upload_info", async (c) => { if (typeof d.getDirectUploadInfo === "function") { const info = await d.getDirectUploadInfo( resolved.relative, - file_name, + cleanName, file_size, ) // 直传 URL 签发后客户端会向该路径写入新文件:预失效目录与目标路径 - await invalidatePaths( - [ - reqPath, - `${reqPath === "/" ? "" : reqPath}/${file_name || ""}`.replace( - /\/$/, - "", - ), - ], - c.env, - ) + // (本分支已把校验后的目录/文件名归一化为 dirPath / dstPath) + await invalidatePaths([dirPath, dstPath], c.env) return c.json({ code: 200, message: "success", data: info }) } // 回退到 other("direct_upload") / other("get_direct_upload_info") @@ -1406,21 +1414,12 @@ fsRouter.post("/get_direct_upload_info", async (c) => { for (const m of ["direct_upload", "get_direct_upload_info"]) { try { const info = await d.other(m, resolved.relative, { - file_name, + file_name: cleanName, file_size, }) if (info) { // 同上:直传 URL 签发后预失效目录与目标路径 - await invalidatePaths( - [ - reqPath, - `${reqPath === "/" ? "" : reqPath}/${file_name || ""}`.replace( - /\/$/, - "", - ), - ], - c.env, - ) + await invalidatePaths([dirPath, dstPath], c.env) return c.json({ code: 200, message: "success", data: info }) } } catch { @@ -1446,9 +1445,20 @@ function splitUploadPath(uploadPath: string): { dir: string; name: string } { return { dir, name } } +/** + * 分片会话的所有者校验。 + * + * 安全不变量:分片会话(含 driver_session token、已收分片与目标路径)是 + * **用户私有状态**,只有创建者可续传/完成/查询。非本人会话一律按「不存在」 + * 处理,既不泄露其存在,也不允许跨用户续传。 + */ +function sessionOwnedBy(session: any, user: any): boolean { + return Boolean(session) && session.owner_id !== undefined && session.owner_id === user?.id +} + fsRouter.post("/multipart/init", async (c) => { const user = await getUserFromContext(c) - if (!canWrite(user)) return permissionDenied(c) + if (!user || !canWrite(user)) return permissionDenied(c) const rawPath = decodeURIComponent(c.req.header("File-Path") || "") const size = parseInt(c.req.header("X-File-Size") || "0", 10) @@ -1464,6 +1474,8 @@ fsRouter.post("/multipart/init", async (c) => { const { dir, name } = splitUploadPath(rawPath) const actualDir = getActualPath(user, dir) + if (await deniedByMetaWriteAcl(c, user, [actualDir, `${actualDir}/${name}`.replace(/\/{2,}/g, "/")])) + return permissionDenied(c) const requestContext = getStorageRequestContext(c) try { @@ -1480,10 +1492,10 @@ fsRouter.post("/multipart/init", async (c) => { const chunkSize = clampChunkSize(rawChunk) const totalChunks = Math.max(1, Math.ceil(size / chunkSize)) - // 断点续传:同 path+size 的未完成会话直接复用 + // 断点续传:同 owner + path+size 的未完成会话直接复用(按用户隔离) let session: MultipartSession let resumed = false - const existing = findReceivingSession(rawPath, size) + const existing = findReceivingSession(rawPath, size, user.id) if (existing) { session = existing resumed = true @@ -1507,6 +1519,8 @@ fsRouter.post("/multipart/init", async (c) => { driver_session: info?.session || "", partMd5s: new Array(totalChunks).fill(undefined), storage_driver: resolved.storage!.driver, + owner_id: user.id, + owner_root: getActualPath(user, "/"), created_at: Date.now(), } // 秒传:驱动返回 reuse 标记 @@ -1542,7 +1556,8 @@ fsRouter.put("/multipart/chunk", async (c) => { const chunkIndex = parseInt(c.req.header("X-Chunk-Index") || "-1", 10) const session = uploadId ? getSession(uploadId) : undefined - if (!session) { + // 会话所有者校验:非本人会话按「不存在」处理(不泄露会话是否存在) + if (!session || !sessionOwnedBy(session, user)) { return c.json( { code: 404, message: "upload session not found", data: null }, 404, @@ -1557,14 +1572,6 @@ fsRouter.put("/multipart/chunk", async (c) => { return c.json({ code: 200, message: "success", data: mpSnapshot(session) }) } - const tooLarge = exceedsUploadLimit(c, true) - if (tooLarge !== null) { - return c.json( - { code: 413, message: `Part too large (max ${tooLarge} bytes)`, data: null }, - 413, - ) - } - const requestContext = getStorageRequestContext(c) try { const resolved = await resolvePath( @@ -1575,7 +1582,12 @@ fsRouter.put("/multipart/chunk", async (c) => { if (typeof (driver as any).uploadPart !== "function") { throw new Error("storage does not support chunked upload") } - const buffer = Buffer.from(await c.req.arrayBuffer()) + // 单片上限必须按**实际收到的字节数**判定:Content-Length 可被省略或伪造。 + // 历史缺陷:此处只做 Content-Length 预检,省略该头即可绕过单片上限并把 + // 整片内容读入内存。 + const body = await readBodyWithinLimit(c, true) + if (!body.ok) return tooLargeResponse(c, body.limit, "Part") + const buffer = body.buffer let result try { result = await (driver as any).uploadPart( @@ -1606,7 +1618,7 @@ fsRouter.post("/multipart/complete", async (c) => { const uploadId = c.req.header("X-Upload-Id") || "" const session = uploadId ? getSession(uploadId) : undefined - if (!session) { + if (!session || !sessionOwnedBy(session, user)) { return c.json( { code: 404, message: "upload session not found", data: null }, 404, @@ -1661,9 +1673,14 @@ fsRouter.post("/multipart/complete", async (c) => { }) fsRouter.get("/multipart/status", async (c) => { + // 状态接口返回目标路径、大小与进度,属于用户私有数据:必须鉴权 + 校验所有者 + const user = await getUserFromContext(c) + if (!user || user.disabled) { + return c.json({ code: 401, message: "Unauthorized", data: null }, 401) + } const uploadId = c.req.query("upload_id") || "" const session = uploadId ? getSession(uploadId) : undefined - if (!session) { + if (!session || !sessionOwnedBy(session, user)) { return c.json( { code: 404, message: "upload session not found", data: null }, 404, @@ -1726,9 +1743,15 @@ async function fetchArchiveBytes( // 环境变量 SSRF_ALLOWED_HOSTS 加入白名单 const trustedHosts = getTrustedHosts(resolved.storage?.addition, c.env) assertSafeUrl(item.raw_url, "Archive download", trustedHosts) - const resp = await fetch(item.raw_url, { - headers: item.raw_url_headers || {}, - }) + // 使用与 /d、/p 代理相同的**逐跳**校验:默认 fetch 会自动跟随 3xx,攻击者 + // 可以先让 raw_url 指向通过 isSafeUrl 的公网域名,再 302 跳到内网/云元数据。 + // 历史缺陷:这里只校验初始 URL 后直接 fetch,绕过逐跳防护(raw.ts 已做)。 + const { safeProxyFetch } = await import("./raw") + const resp = await safeProxyFetch( + item.raw_url, + item.raw_url_headers || {}, + trustedHosts, + ) if (!resp.ok) throw new Error(`archive download failed: HTTP ${resp.status}`) return await resp.arrayBuffer() } @@ -1796,6 +1819,8 @@ function calcFileTypeSafe(name: string, isDir: boolean): number { fsRouter.post("/archive/meta", async (c) => { const user = await getUserFromContext(c) if (!user || user.disabled) return c.json({ code: 401, message: "Unauthorized", data: null }, 401) + // 归档读取需要独立能力位:READ_ARCHIVES + if (!can(user, PermissionBit.READ_ARCHIVES)) return permissionDenied(c) const body = await c.req.json().catch(() => ({})) const path = String(body.path || c.req.query("path") || "").trim() if (!path) return c.json({ code: 400, message: "path is required", data: null }, 400) @@ -1811,6 +1836,17 @@ fsRouter.post("/archive/meta", async (c) => { }, 501) } try { + // 与 /fs/get 共用 meta 判定:密码保护的归档不能靠归档接口绕过密码 + if ( + await deniedByMetaReadAcl( + c, + user, + getActualPath(user, path), + String(body.password || ""), + ) + ) { + return permissionDenied(c) + } const bytes = await fetchArchiveBytes(c, user, path) const archive = parseZip(bytes) const tree = buildArchiveTree(archive) @@ -1833,6 +1869,8 @@ fsRouter.post("/archive/meta", async (c) => { fsRouter.post("/archive/list", async (c) => { const user = await getUserFromContext(c) if (!user || user.disabled) return c.json({ code: 401, message: "Unauthorized", data: null }, 401) + // 归档读取需要独立能力位:READ_ARCHIVES + if (!can(user, PermissionBit.READ_ARCHIVES)) return permissionDenied(c) const body = await c.req.json().catch(() => ({})) const path = String(body.path || c.req.query("path") || "").trim() const innerPath = String(body.inner_path || "").trim().replace(/\/+/g, "/") @@ -1849,6 +1887,17 @@ fsRouter.post("/archive/list", async (c) => { }, 501) } try { + // 与 /fs/get 共用 meta 判定:密码保护的归档不能靠归档接口绕过密码 + if ( + await deniedByMetaReadAcl( + c, + user, + getActualPath(user, path), + String(body.password || ""), + ) + ) { + return permissionDenied(c) + } const bytes = await fetchArchiveBytes(c, user, path) const archive = parseZip(bytes) const prefix = innerPath ? innerPath.replace(/^\/+|\/+$/g, "") + "/" : "" @@ -1886,7 +1935,9 @@ fsRouter.post("/archive/list", async (c) => { fsRouter.post("/archive/decompress", async (c) => { const user = await getUserFromContext(c) - if (!canWrite(user)) return c.json({ code: 403, message: "Permission denied", data: null }, 403) + // 解压写文件:需要 WRITE_CONTENT(写目标)+ DECOMPRESS(解压能力) + if (!canWrite(user) || !can(user, PermissionBit.DECOMPRESS)) + return c.json({ code: 403, message: "Permission denied", data: null }, 403) const body = await c.req.json().catch(() => ({})) const srcDir = String(body.src_dir || "").trim() const dstDir = String(body.dst_dir || "").trim() @@ -1897,6 +1948,13 @@ fsRouter.post("/archive/decompress", async (c) => { } try { let count = 0 + // 目标目录必须落在**调用者自己的根目录**之内。 + // 历史缺陷:dst_dir 被当作全局虚拟路径直接拼接,既未套用 base_path, + // 也未做角色范围校验,受限用户可写到任意目录。 + const actualDstDir = getActualPath(user, dstDir) + if (!isWithinUserRoot(user, actualDstDir)) return permissionDenied(c) + if (await deniedByMetaWriteAcl(c, user, [actualDstDir])) + return permissionDenied(c) for (const name of names) { const srcPath = srcDir ? `${srcDir}/${name}` : `/${name}` const fmtDecomp = detectArchiveFormat(name) @@ -1910,7 +1968,21 @@ fsRouter.post("/archive/decompress", async (c) => { if (prefix && !entry.name.startsWith(prefix)) continue const rel = prefix ? entry.name.slice(prefix.length) : entry.name if (!rel || rel.endsWith("/")) continue - const targetPath = `${dstDir.replace(/\/+$/, "")}/${rel}` + // zip-slip 防护:条目名视为「只能向下展开的相对路径」 + const safeRel = safeArchiveEntryPath(rel) + if (!safeRel) { + console.warn( + `[Archive] skipping unsafe entry path: ${entry.name}`, + ) + continue + } + const targetPath = `${actualDstDir.replace(/\/+$/, "")}/${safeRel}` + if (!isWithinUserRoot(user, targetPath)) { + console.warn( + `[Archive] skipping entry escaping user root: ${entry.name}`, + ) + continue + } const content = await extractZipEntry(bytes, entry) await putItem(targetPath, Buffer.from(content), getStorageRequestContext(c)) count++ diff --git a/src/backend/server/middlewares.ts b/src/backend/server/middlewares.ts index 747bea25..0c7d7d45 100644 --- a/src/backend/server/middlewares.ts +++ b/src/backend/server/middlewares.ts @@ -2,6 +2,7 @@ import { Context } from "hono" import { verify } from "hono/jwt" import { checkAdminAuth, isStaticApiToken } from "../pkg/utils" import { getDb, readPersistedSecret, ENCRYPTION_SECRET_KV_KEY } from "../internal/model/db" +import { isPasswordStampValid } from "../pkg/password" // 不再硬编码 JWT 密钥。优先使用环境变量 JWT_SECRET(推荐在生产配置), // 否则从持久化后端读取一个随机密钥(首次生成后复用,重启不失效), @@ -182,42 +183,47 @@ export async function getJwtSecret(c?: Context | any): Promise { return readCachedJwtSecret(env) as string } -// ---- JWT 注销黑名单(尽力而为:进程内 Set + KV 持久化)---- -// 说明:Serverless 多实例下各实例独立缓存,KV 持久化仅在冷启动时加载一次, -// 因此跨实例的「即时」失效不能保证精确,但能在单实例内立即生效,并随新实例 -// 冷启动逐步收敛。exp 过期后条目自动清理,不会无限增长。 +// ---- JWT 注销黑名单(进程内 Set + 统一持久化后端,带 TTL 复核)---- +// +// 存储层:走统一的 getStorageBackend(经 readPersistedSecret/writePersistedSecret), +// 覆盖 D1 / MySQL / DO / KV / Blob,而**不再**只探测 KV binding。 +// 历史缺陷:只调用 getKvBinding,而该探测没有 D1 分支。于是在 D1 部署下注销 +// 名单「读不到也写不进」——同一实例内靠内存 Set 生效,跨实例完全失效, +// 也就是「退出登录只在自己这一台机器上有效」。 +// +// 刷新:带 TTL 周期回读,让其他实例的注销在有限时间内收敛;本实例的注销 +// 仍然是立即生效(先写内存 Set,再落盘)。 +// 精确性边界:Serverless 多实例下不保证「瞬时」全局一致,这是有意的权衡; +// 需要更强保证时应改用短 TTL 的强制撤销或服务端会话表。 const REVOKED_KV_KEY = "openlist_revoked_tokens" +const REVOKED_RELOAD_MS = 30_000 const revokedJtis = new Set() -let revokedLoaded = false +let revokedLoadedAt = 0 +let revokedInflight: Promise | null = null async function ensureRevokedLoaded(env: any): Promise { - if (revokedLoaded) return - revokedLoaded = true - try { - const { getKvBinding } = await import("../internal/model/db") - const kvInfo = await getKvBinding(env) - if (kvInfo.mode === "none" || !kvInfo.binding) return - const { binding, mode } = kvInfo - let val: any = null - if (mode === "blob") { - val = await binding.get(REVOKED_KV_KEY) - } else { - try { - val = await binding.get(REVOKED_KV_KEY, "text") - } catch { - val = await binding.get(REVOKED_KV_KEY) + if (Date.now() - revokedLoadedAt < REVOKED_RELOAD_MS) return + if (revokedInflight) return revokedInflight + revokedInflight = (async () => { + try { + const { readPersistedSecret } = await import("../internal/model/db") + const raw = await readPersistedSecret(env, REVOKED_KV_KEY) + if (raw) { + const arr = JSON.parse(String(raw)) + const now = Math.floor(Date.now() / 1000) + for (const item of arr) { + if (item && item.jti && item.exp > now) revokedJtis.add(item.jti) + } } + } catch { + // 黑名单读取失败时降级为不拦截(不影响登录) + } finally { + // 无论成功失败都推迟下次回读,避免存储故障时被反复打爆 + revokedLoadedAt = Date.now() + revokedInflight = null } - if (val && typeof val.text === "function") val = await val.text() - if (!val) return - const arr = JSON.parse(String(val)) - const now = Math.floor(Date.now() / 1000) - for (const item of arr) { - if (item && item.jti && item.exp > now) revokedJtis.add(item.jti) - } - } catch { - // 黑名单加载失败时降级为不拦截(不影响登录) - } + })() + return revokedInflight } export async function revokeToken( @@ -226,47 +232,36 @@ export async function revokeToken( env: any, ): Promise { if (!jti) return + // 本实例立即生效 revokedJtis.add(jti) try { - const { getKvBinding } = await import("../internal/model/db") - const kvInfo = await getKvBinding(env) - if (kvInfo.mode === "none" || !kvInfo.binding) return - const { binding, mode } = kvInfo + const { readPersistedSecret, writePersistedSecret } = await import( + "../internal/model/db" + ) let arr: Array<{ jti: string; exp: number }> = [] - if (mode === "blob") { - const val = await binding.get(REVOKED_KV_KEY) - if (val) arr = typeof val === "string" ? JSON.parse(val) : val - } else { + const raw = await readPersistedSecret(env, REVOKED_KV_KEY) + if (raw) { try { - const val = await binding.get(REVOKED_KV_KEY, "text") - if (val) arr = JSON.parse(String(val)) + const parsed = JSON.parse(String(raw)) + if (Array.isArray(parsed)) arr = parsed } catch { - const val = await binding.get(REVOKED_KV_KEY) - if (val) arr = typeof val === "string" ? JSON.parse(val) : val + arr = [] } } const now = Math.floor(Date.now() / 1000) - arr = arr.filter((i) => i && i.exp > now) - arr.push({ jti, exp }) - const payload = JSON.stringify(arr) - if (mode === "blob") { - if (typeof binding.set === "function") - await binding.set(REVOKED_KV_KEY, payload) - else if (typeof binding.put === "function") - await binding.put(REVOKED_KV_KEY, payload) - } else { - if (typeof binding.put === "function") - await binding.put(REVOKED_KV_KEY, payload) - else if (typeof binding.set === "function") - await binding.set(REVOKED_KV_KEY, payload) - } + // exp 过期的条目自动淘汰,列表不会无限增长 + arr = arr.filter((i) => i && i.jti && i.exp > now) + if (!arr.some((i) => i.jti === jti)) arr.push({ jti, exp }) + await writePersistedSecret(env, REVOKED_KV_KEY, JSON.stringify(arr)) } catch (e) { - console.warn("[JWT] Failed to persist revoked token to KV:", e) + console.warn("[JWT] Failed to persist revoked token:", e) } } export async function isTokenRevoked(jti: string, env: any): Promise { if (!jti) return false + // 本实例的注销无需等待回读即可生效 + if (revokedJtis.has(jti)) return true await ensureRevokedLoaded(env) return revokedJtis.has(jti) } @@ -536,6 +531,8 @@ export async function getUserFromContext(c: Context): Promise<{ (u: any) => u.id === payload.id || u.username === payload.username, ) if (!user || user.disabled) return null + // 密码指纹:改密后旧令牌立即失效(对齐 Go middlewares.Auth 的 PwdTS 比对) + if (!isPasswordStampValid(payload, user)) return null return { id: user.id, role: user.role, diff --git a/src/backend/server/raw.ts b/src/backend/server/raw.ts index 3fe7796c..a3d4fbbc 100644 --- a/src/backend/server/raw.ts +++ b/src/backend/server/raw.ts @@ -82,7 +82,7 @@ const SAFE_REDIRECT_HEADER_KEYS = new Set([ "referer", ]) -async function safeProxyFetch( +export async function safeProxyFetch( url: string, headers: Record, allowHosts?: ReadonlySet | string[], diff --git a/src/backend/server/s3.ts b/src/backend/server/s3.ts index 9e77bf6f..8fb3b503 100644 --- a/src/backend/server/s3.ts +++ b/src/backend/server/s3.ts @@ -7,15 +7,44 @@ import { removeItems, } from "../internal/op/storage" import { safeErrorMessage } from "../pkg/errs" +import { readBodyWithinLimit } from "../pkg/upload-limit" +import { + canRemove, + canWrite, + getActualPath, + isAdmin, + isWithinUserRoot, +} from "../pkg/permission" +import { + canAccess, + canWrite as canWriteMeta, + canWriteContentBypassUserPerms, + getNearestMeta, +} from "../pkg/meta" +import type { Meta } from "../pkg/meta" /** * S3 网关(简化版,挂载于 /s3/*)。 * * 协议:支持 ListBuckets / GetObject / HeadObject / PutObject / DeleteObject, - * 认证采用 Bearer token(与全局 token 一致);完整 AWS SigV4 签名验证作为 - * 后续增强(Worker 环境 S3 网关性能受限,优先保证 API 契约一致)。 + * 认证采用 Bearer token(JWT,与全局令牌同一签发体系);完整 AWS SigV4 签名 + * 验证作为后续增强。 + * + * 说明:本网关**不识别** settings.token 形式的静态 API Token —— 静态令牌走 + * 管理后台的 admin 鉴权通道,不是「某个用户」,无法映射到 S3 的对象视图。 + * 需要静态令牌访问的自动化场景请使用 /api/fs/* 接口。 * - * URL 结构:/s3/{bucket}/{objectKey},bucket 映射到存储挂载路径。 + * URL 结构:/s3/{bucket}/{objectKey},bucket 是**用户根目录之下**的一级目录。 + * + * 安全不变量(必须长期保持): + * 1. 认证用户必须存在且未被禁用(authUserFromReq 已复检 disabled); + * 2. 所有对象路径都必须落在该用户的 base_path 之内; + * 3. 写操作(PutObject)需要 WRITE_CONTENT 权限位, + * 删除操作(DeleteObject)需要 DELETE 权限位,管理员放行。 + * + * 历史缺陷:只判断「是否登录」,随后直接操作全局虚拟路径 `/${bucket}/${key}`。 + * 后果有两条:① 任何已登录用户(含 permission=0 的只读用户、已禁用用户)都能 + * 上传/删除;② 受限 base_path 的用户可以读写其他用户的目录。均已修复。 */ export const s3Router = new Hono() @@ -47,10 +76,36 @@ function parseS3Path(c: any): { bucket: string; key: string } { return { bucket, key } } -/** bucket 名 → 挂载路径(bucket 即存储挂载点的首段) */ -function bucketToPath(bucket: string): string { - if (!bucket) return "/" - return "/" + bucket +/** + * bucket / key 的语法校验。 + * + * 只做「形状」校验:bucket 必须是单层目录名,key 不得含空段。真正的越界防护 + * 由 isWithinUserRoot() 在拼装完成后统一收口,不依赖这里。 + */ +function isValidS3Bucket(bucket: string): boolean { + if (!bucket) return false + if (bucket === "." || bucket === "..") return false + return !bucket.includes("/") && !bucket.includes("\\") && !bucket.includes("\0") +} + +/** + * 把 S3 的 { bucket, key } 映射为**用户根目录之下**的虚拟路径。 + * + * base_path 为 "/" 的(默认)用户:`bucket/key` -> `/bucket/key`,与历史行为 + * 完全一致(bucket 即存储挂载点首段),不产生兼容性变化。 + * base_path 受限的用户:`bucket/key` -> `/bucket/key`,把 S3 的 + * 「bucket」解释为该用户自己的顶层目录,从而与 /api/fs 的视图保持一致。 + */ +function s3VirtualPath(user: any, bucket: string, key: string): string { + const root = getActualPath(user, "/") + const segments = [root, bucket, key].filter(Boolean).join("/") + const normalized = ("/" + segments).replace(/\/{2,}/g, "/") + return normalized.length > 1 ? normalized.replace(/\/+$/, "") : normalized +} + +/** 统一的越界拒绝响应(403,避免暴露目标路径是否存在) */ +function outOfScope(c: any) { + return s3Error("AccessDenied", "Object is outside the user's root path", 403) } async function authUser(c: any): Promise { @@ -58,14 +113,63 @@ async function authUser(c: any): Promise { return auth ? auth.user : null } +/** + * 目录级(meta)访问控制——与 /api/fs/* 共用同一套判定。 + * + * 安全不变量:S3 网关不得成为「绕过目录密码 / read_users / write_users」的 + * 第二条通道,否则站内被保护的路径可以经 /s3 直接读写。 + * + * S3 协议没有传递目录密码的标准位置,这里接受 `?pwd=` 查询参数或 + * `x-meta-password` 请求头作为 meta 密码来源。 + */ +const metaPasswordOf = (c: any): string => + c.req.query("pwd") || c.req.header("x-meta-password") || "" + +async function loadMeta(c: any, path: string): Promise { + try { + return await getNearestMeta(path, c.env) + } catch { + return null + } +} + +/** 读路径的 meta 判定:密码保护 + read_users 白名单。返回 true 表示拒绝。 */ +async function metaReadDenied( + c: any, + user: any, + path: string, +): Promise { + const meta = await loadMeta(c, path) + if (!meta) return false + return !canAccess(user, meta, path, metaPasswordOf(c)) +} + +/** + * 写路径的 meta 判定:WRITE_CONTENT 权限位(或 meta.write 强制开放) + * + meta.write_users 白名单。返回 true 表示拒绝。 + */ +async function metaWriteDenied( + c: any, + user: any, + path: string, +): Promise { + const meta = await loadMeta(c, path) + if (!meta) return false + if (isAdmin(user)) return false + if (!canWrite(user) && !canWriteContentBypassUserPerms(meta, path)) { + return true + } + return !canWriteMeta(user, meta, path) +} + const getCtx = (c: any) => { try { const ec = c.executionCtx return ec && typeof ec.waitUntil === "function" - ? { waitUntil: (p: Promise) => ec.waitUntil(p) } - : undefined + ? { waitUntil: (p: Promise) => ec.waitUntil(p), env: c.env } + : { env: c.env } } catch { - return undefined + return { env: c?.env } } } @@ -74,7 +178,8 @@ s3Router.get("/", async (c) => { const user = await authUser(c) if (!user) return s3Error("AccessDenied", "Authentication required", 403) try { - const res = await listItems("/", getCtx(c)) + // 仅列出用户根目录之下的「bucket」——受限 base_path 的用户不应看到整站挂载点 + const res = await listItems(getActualPath(user, "/"), getCtx(c)) const buckets = (res.content || []) .filter((it: any) => it.is_dir) .map( @@ -103,8 +208,14 @@ s3Router.get("/*", async (c) => { const user = await authUser(c) if (!user) return s3Error("AccessDenied", "Authentication required", 403) const { bucket, key } = parseS3Path(c) - if (!bucket || !key) return s3Error("NoSuchKey", "NoSuchKey", 404) - const virtualPath = `/${bucket}/${key}` + if (!isValidS3Bucket(bucket) || !key) { + return s3Error("NoSuchKey", "NoSuchKey", 404) + } + const virtualPath = s3VirtualPath(user, bucket, key) + if (!isWithinUserRoot(user, virtualPath)) return outOfScope(c) + if (await metaReadDenied(c, user, virtualPath)) { + return s3Error("AccessDenied", "Password required for this path", 403) + } try { const { item, rawUrl } = await getItem(virtualPath, getCtx(c)) if (!item) return s3Error("NoSuchKey", "NoSuchKey", 404) @@ -123,9 +234,16 @@ s3Router.on("HEAD", "/*", async (c) => { const user = await authUser(c) if (!user) return s3Error("AccessDenied", "Authentication required", 403) const { bucket, key } = parseS3Path(c) - if (!bucket || !key) return s3Error("NoSuchKey", "NoSuchKey", 404) + if (!isValidS3Bucket(bucket) || !key) { + return s3Error("NoSuchKey", "NoSuchKey", 404) + } + const virtualPath = s3VirtualPath(user, bucket, key) + if (!isWithinUserRoot(user, virtualPath)) return outOfScope(c) + if (await metaReadDenied(c, user, virtualPath)) { + return s3Error("AccessDenied", "Password required for this path", 403) + } try { - const { item } = await getItem(`/${bucket}/${key}`, getCtx(c)) + const { item } = await getItem(virtualPath, getCtx(c)) if (!item || item.is_dir) return s3Error("NoSuchKey", "NoSuchKey", 404) const headers: Record = { "Content-Length": String(item.size || 0), @@ -142,15 +260,47 @@ s3Router.on("HEAD", "/*", async (c) => { s3Router.put("/*", async (c) => { const user = await authUser(c) if (!user) return s3Error("AccessDenied", "Authentication required", 403) + // 写操作必须持有 WRITE_CONTENT 权限位(管理员放行)。 + // 历史缺陷:只判断「是否登录」,任何已认证用户(含只读用户)都能上传。 + if (!canWrite(user)) { + return s3Error("AccessDenied", "Write permission required", 403) + } const { bucket, key } = parseS3Path(c) - if (!bucket || !key) return s3Error("InvalidArgument", "Invalid bucket/key", 400) + if (!isValidS3Bucket(bucket) || !key) { + return s3Error("InvalidArgument", "Invalid bucket/key", 400) + } + const virtualPath = s3VirtualPath(user, bucket, key) + if (!isWithinUserRoot(user, virtualPath)) return outOfScope(c) + // 目录级写判定(对齐 /api/fs/put 的 meta.write_users 与 write_content_bypass) + const parentDir = + virtualPath.lastIndexOf("/") > 0 + ? virtualPath.slice(0, virtualPath.lastIndexOf("/")) + : "/" + if (await metaWriteDenied(c, user, parentDir)) { + return s3Error("AccessDenied", "Write permission denied for this path", 403) + } try { - const buffer = Buffer.from(await c.req.arrayBuffer()) - await putItem(`/${bucket}/${key}`, buffer, getCtx(c)) - return new Response(null, { - status: 200, - headers: { ETag: `"${Date.now().toString(16)}"` }, - }) + // 体积上限按**实际收到的字节数**判定(与 /fs/put 共用 pkg/upload-limit)。 + // 历史缺陷:PutObject 完全没有大小限制,MAX_UPLOAD/MAX_UPPART 都不生效, + // 超大请求体会被整包读入内存——与 /fs/put 的防护形成明显不对称。 + const body = await readBodyWithinLimit(c, false) + if (!body.ok) { + return s3Error( + "EntityTooLarge", + `Object exceeds the ${body.limit} byte limit`, + 400, + ) + } + await putItem(virtualPath, body.buffer, getCtx(c)) + // 不再返回伪造的 ETag。 + // + // 历史实现返回 `Date.now().toString(16)`,不是内容摘要:依赖 ETag 做幂等/ + // 续传/校验的客户端(rclone、aws-cli 等)会记录一个永不对应的值。真实 S3 + // 对单次 PUT 返回内容 MD5,但本运行时没有便宜的 MD5 通道(WebCrypto 不提供 + // MD5;纯 JS 实现要整包多扫一遍;hash-wasm 的 WASM 在边缘运行时可能被禁止, + // 见 internal/seed/hashers.ts),因此这里选择**不返回 ETag**——宁可缺失, + // 也不给出错误的摘要;真实摘要(含分片 ETag 语义)留作后续跟进。 + return new Response(null, { status: 200 }) } catch (e: any) { return s3Error("InternalError", safeErrorMessage(e), 500) } @@ -160,11 +310,24 @@ s3Router.put("/*", async (c) => { s3Router.delete("/*", async (c) => { const user = await authUser(c) if (!user) return s3Error("AccessDenied", "Authentication required", 403) + // 删除操作必须持有 DELETE 权限位(管理员放行)。 + if (!canRemove(user)) { + return s3Error("AccessDenied", "Delete permission required", 403) + } const { bucket, key } = parseS3Path(c) - if (!bucket || !key) return s3Error("InvalidArgument", "Invalid bucket/key", 400) - const idx = key.lastIndexOf("/") - const dir = idx >= 0 ? `/${bucket}/${key.slice(0, idx)}` : `/${bucket}` - const name = idx >= 0 ? key.slice(idx + 1) : key + if (!isValidS3Bucket(bucket) || !key) { + return s3Error("InvalidArgument", "Invalid bucket/key", 400) + } + const virtualPath = s3VirtualPath(user, bucket, key) + if (!isWithinUserRoot(user, virtualPath)) return outOfScope(c) + // 目录级写判定(对齐 /api/fs/remove 的 meta.write_users) + if (await metaWriteDenied(c, user, virtualPath)) { + return s3Error("AccessDenied", "Write permission denied for this path", 403) + } + // 以目标对象自身的虚拟路径为基准拆出父目录与文件名,避免 bucket 边界下的错位 + const slash = virtualPath.lastIndexOf("/") + const dir = slash > 0 ? virtualPath.slice(0, slash) : "/" + const name = virtualPath.slice(slash + 1) try { await removeItems(dir, [name], getCtx(c)) return new Response(null, { status: 204 }) diff --git a/src/backend/server/security_hardening.test.ts b/src/backend/server/security_hardening.test.ts new file mode 100644 index 00000000..cfb70638 --- /dev/null +++ b/src/backend/server/security_hardening.test.ts @@ -0,0 +1,388 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { Hono } from "hono" +import { sign } from "hono/jwt" +import { getDb, saveDb } from "../internal/model/db" +import { meHandler } from "./auth" +import { passwordStampOf, setUserPassword } from "../pkg/password" +import { + getActualPath, + isWithinUserRoot, + normalizeSegments, +} from "../pkg/permission" +import { readBodyWithinLimit } from "../pkg/upload-limit" +import { userRouter } from "./user" +import { webdavRouter } from "./webdav" + +/** + * 本轮安全加固的回归测试: + * 1. 管理员用户管理接口的角色保护(对齐 Go handles/user.go + op/user.go); + * 2. 密码指纹(对齐 Go common.UserClaims.PwdTS)—— 改密后旧令牌立即失效。 + */ + +const env: any = { JWT_SECRET: "test-only-jwt-secret-for-hardening" } +const ADMIN_TOKEN = "HARDENING_STATIC_TOKEN" + +const seed = (users: any[], settings: any[] = []) => + saveDb( + { settings, users, storages: [], shares: [] }, + env, + { force: true }, + ) + +const adminUser = (overrides: any = {}) => ({ + id: 1, + username: "admin", + password: "", + salt: "", + role: 2, + permission: 0, + base_path: "/", + disabled: false, + pwd_update_at: new Date().toISOString(), + ...overrides, +}) + +function adminApp() { + const app = new Hono() + app.route("/api/admin/user", userRouter) + return app +} + +const adminHeaders = { + "Content-Type": "application/json", + Authorization: ADMIN_TOKEN, +} + +test("user/create rejects admin and guest roles", async () => { + await seed([adminUser()], [{ key: "token", value: ADMIN_TOKEN }]) + const app = adminApp() + + for (const role of [1, 2]) { + const res = await app.request("/api/admin/user/create", { + method: "POST", + headers: adminHeaders, + body: JSON.stringify({ username: `u${role}`, role }), + }) + assert.equal(res.status, 400, `role=${role} must be rejected`) + const json: any = await res.json() + assert.equal(json.message, "admin or guest user can not be created") + } + + const ok = await app.request("/api/admin/user/create", { + method: "POST", + headers: adminHeaders, + body: JSON.stringify({ username: "normal", role: 0, password: "p@ssw0rd" }), + }) + assert.equal(ok.status, 200) +}) + +test("user/update forbids role changes and disabling the admin account", async () => { + await seed( + [ + adminUser(), + { + id: 5, + username: "someone", + password: "", + salt: "", + role: 0, + permission: 0, + base_path: "/", + disabled: false, + pwd_update_at: new Date().toISOString(), + }, + ], + [{ key: "token", value: ADMIN_TOKEN }], + ) + const app = adminApp() + + const promote = await app.request("/api/admin/user/update", { + method: "POST", + headers: adminHeaders, + body: JSON.stringify({ id: 5, role: 2 }), + }) + assert.equal(promote.status, 400) + assert.equal((await promote.json() as any).message, "role can not be changed") + + const demote = await app.request("/api/admin/user/update", { + method: "POST", + headers: adminHeaders, + body: JSON.stringify({ id: 1, role: 0 }), + }) + assert.equal(demote.status, 400) + + const disableAdmin = await app.request("/api/admin/user/update", { + method: "POST", + headers: adminHeaders, + body: JSON.stringify({ id: 1, disabled: true }), + }) + assert.equal(disableAdmin.status, 400) + assert.equal( + (await disableAdmin.json() as any).message, + "admin user can not be disabled", + ) + + // 允许的更新:权限位 / base_path / 禁用普通用户 + const ok = await app.request("/api/admin/user/update", { + method: "POST", + headers: adminHeaders, + body: JSON.stringify({ id: 5, permission: 8, base_path: "/data", disabled: true }), + }) + assert.equal(ok.status, 200) +}) + +test("user/delete protects admin and guest by role, not by hardcoded id", async () => { + await seed( + [ + adminUser(), + { + id: 2, + username: "guest", + password: "", + salt: "", + role: 1, + permission: 0, + base_path: "/", + disabled: false, + pwd_update_at: new Date().toISOString(), + }, + { + // 迁移/多管理员场景:id 不等于 1 的管理员同样必须受保护 + id: 7, + username: "second-admin", + password: "", + salt: "", + role: 2, + permission: 0, + base_path: "/", + disabled: false, + pwd_update_at: new Date().toISOString(), + }, + { + id: 9, + username: "normal", + password: "", + salt: "", + role: 0, + permission: 0, + base_path: "/", + disabled: false, + pwd_update_at: new Date().toISOString(), + }, + ], + [{ key: "token", value: ADMIN_TOKEN }], + ) + const app = adminApp() + + for (const id of [1, 2, 7]) { + const res = await app.request(`/api/admin/user/delete?id=${id}`, { + method: "POST", + headers: adminHeaders, + }) + assert.equal(res.status, 400, `id=${id} must be protected`) + assert.equal((await res.json() as any).message, "cannot delete admin or guest") + } + + const del = await app.request("/api/admin/user/delete?id=9", { + method: "POST", + headers: adminHeaders, + }) + assert.equal(del.status, 200) + const db: any = await getDb(env) + assert.equal( + db.users.some((u: any) => u.id === 9), + false, + "a normal user must still be deletable", + ) +}) + +test("password change invalidates previously issued tokens (Go PwdTS parity)", async () => { + await seed([adminUser()]) + const db: any = await getDb(env) + const admin = db.users.find((u: any) => u.username === "admin") + + const app = new Hono() + app.get("/me", meHandler) + + const tokenBefore = await sign( + { + id: admin.id, + username: admin.username, + role: admin.role, + pwd_ts: passwordStampOf(admin), + exp: Math.floor(Date.now() / 1000) + 600, + jti: "pwd-ts-before", + }, + env.JWT_SECRET, + ) + const before = await app.request( + "/me", + { headers: { Authorization: `Bearer ${tokenBefore}` } }, + env, + ) + assert.equal(before.status, 200, "token issued before the change must work") + + // 改密(Go: SetPassword 会刷新 PwdTS) + await setUserPassword(admin, "brand-new-password") + await saveDb(db, env, { force: true }) + + const after = await app.request( + "/me", + { headers: { Authorization: `Bearer ${tokenBefore}` } }, + env, + ) + assert.equal(after.status, 401, "token issued before the change must be rejected") + + const tokenAfter = await sign( + { + id: admin.id, + username: admin.username, + role: admin.role, + pwd_ts: passwordStampOf(admin), + exp: Math.floor(Date.now() / 1000) + 600, + jti: "pwd-ts-after", + }, + env.JWT_SECRET, + ) + const fresh = await app.request( + "/me", + { headers: { Authorization: `Bearer ${tokenAfter}` } }, + env, + ) + assert.equal(fresh.status, 200, "a fresh token must work") +}) + +test("normalizeSegments / getActualPath / isWithinUserRoot 钳制越根遍历", () => { + // 显式锁定越根钳制语义(评审建议补的用例) + assert.equal(normalizeSegments("/a/../../b"), "/b") + assert.equal(normalizeSegments("/a/b/../c"), "/a/c") + assert.equal(normalizeSegments("../../etc/passwd"), "/etc/passwd") + assert.equal(normalizeSegments("//a///b/./c"), "/a/b/c") + assert.equal(normalizeSegments(""), "/") + + // base_path 受限:先折叠 reqPath 再拼接,`..` 不得越过用户根 + const user = { id: 9, role: 0, permission: 0, base_path: "/photos/alice" } + assert.equal(getActualPath(user, "/"), "/photos/alice") + assert.equal(getActualPath(user, "/sub/x"), "/photos/alice/sub/x") + assert.equal(getActualPath(user, "/../bob/x"), "/photos/alice/bob/x") + assert.equal(getActualPath(user, "/sub/../../bob"), "/photos/alice/bob") + + // 范围复核:等于根本身或位于其下为真,越过为假 + assert.equal(isWithinUserRoot(user, "/photos/alice"), true) + assert.equal(isWithinUserRoot(user, "/photos/alice/a/b"), true) + assert.equal(isWithinUserRoot(user, "/photos/bob"), false) + assert.equal(isWithinUserRoot(user, "/photos/alice/../bob"), false) + // base_path 为根的用户不受限制 + assert.equal(isWithinUserRoot({ id: 1, role: 2, permission: 0, base_path: "/" }, "/any"), true) +}) + +test("readBodyWithinLimit 按实际字节数生效(省略 Content-Length 也不例外)", async () => { + const env: any = { MAX_UPLOAD: "8" } + const fakeCtx = (body: string, headers: Record = {}) => ({ + env, + req: { + header: (k: string) => + headers[k] ?? headers[k.toLowerCase()] ?? headers[k.toUpperCase()], + arrayBuffer: async () => new TextEncoder().encode(body).buffer, + }, + }) + + // 声明超限:预检即拒绝 + const declared = await readBodyWithinLimit( + fakeCtx("123456789", { "Content-Length": "9" }), + false, + ) + assert.equal(declared.ok, false) + + // 省略 Content-Length:必须按实际字节数拒绝(历史缺陷:直接放行) + const omitted = await readBodyWithinLimit(fakeCtx("123456789"), false) + assert.equal(omitted.ok, false) + if (!omitted.ok) assert.equal(omitted.limit, 8) + + // 未超限:放行且内容完整 + const ok = await readBodyWithinLimit( + fakeCtx("1234", { "Content-Length": "4" }), + false, + ) + assert.equal(ok.ok, true) + if (ok.ok) assert.equal(ok.buffer.length, 4) + + // 分片上限(MAX_UPPART)独立于整体上限:未配置时用默认 16MB + const part = await readBodyWithinLimit(fakeCtx("123456789"), true) + assert.equal(part.ok, true) + + // 显式配置分片上限同样按实际字节数判定 + const partEnv: any = { MAX_UPPART: "2" } + const partCtx = { + env: partEnv, + req: { + header: () => undefined, + arrayBuffer: async () => new TextEncoder().encode("123").buffer, + }, + } + const partTooLarge = await readBodyWithinLimit(partCtx, true) + assert.equal(partTooLarge.ok, false) +}) + +test("WebDAV: 受 meta 保护的路径返回带指引的 403(而不是裸 403)", async () => { + const env: any = { JWT_SECRET: "test-only-jwt-secret-for-hardening" } + const davUser: any = { + id: 11, + username: "dav-user", + role: 0, + permission: 1 << 8, // WEBDAV_READ + base_path: "/", + disabled: false, + pwd_update_at: new Date().toISOString(), + } + await setUserPassword(davUser, "dav-pass") + await saveDb( + { + settings: [], + users: [davUser], + storages: [], + shares: [], + metas: [ + { + id: 1, + path: "/secret", + password: "meta-pass", + p_sub: true, + read_users: [], + read_users_sub: false, + write_users: [], + write_users_sub: false, + write: false, + w_sub: false, + hide: "", + h_sub: false, + readme: "", + r_sub: false, + header: "", + header_sub: false, + }, + ], + }, + env, + { force: true }, + ) + + const app = new Hono() + app.route("/dav", webdavRouter) + const basic = + "Basic " + Buffer.from("dav-user:dav-pass").toString("base64") + + // 账号本身有 WEBDAV_READ,但该路径带目录密码 → 元信息 ACL 拦截 + const res = await app.request( + "/dav/secret", + { method: "PROPFIND", headers: { Authorization: basic, Depth: "1" } }, + env, + ) + assert.equal(res.status, 403) + const body = await res.text() + // 附带给用户可操作的指引(评审建议),且不指明具体是哪一类限制 + assert.match(body, /directory-level access control/) + assert.match(body, /meta password/) + assert.match(body, /web UI|api\/fs/) +}) diff --git a/src/backend/server/sso.ts b/src/backend/server/sso.ts index 7dc6c3d7..7186fdf5 100644 --- a/src/backend/server/sso.ts +++ b/src/backend/server/sso.ts @@ -3,7 +3,7 @@ import { sign } from "hono/jwt" import { getDb, saveDb } from "../internal/model/db" import { getJwtSecret } from "./middlewares" import { generateRandomPassword } from "./auth" -import { setUserPassword } from "../pkg/password" +import { setUserPassword, passwordStampOf } from "../pkg/password" /** * SSO 登录(OAuth2 授权码流 + OIDC)。 @@ -174,6 +174,8 @@ async function generateToken(user: any, c: any): Promise { id: user.id, username: user.username, role: user.role, + // 密码指纹(对齐 Go PwdTS):改密后旧令牌立即失效 + pwd_ts: passwordStampOf(user), exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, jti: typeof crypto.randomUUID === "function" diff --git a/src/backend/server/user.ts b/src/backend/server/user.ts index a3a8b223..9858966a 100644 --- a/src/backend/server/user.ts +++ b/src/backend/server/user.ts @@ -1,6 +1,6 @@ import { Hono } from "hono" import { getDb, saveDb } from "../internal/model/db" -import { generateRandomPassword, verifyUserPassword } from "./auth" +import { verifyUserPassword } from "./auth" import { setUserPassword } from "../pkg/password" import { verify } from "hono/jwt" import { getJwtSecret } from "./middlewares" @@ -89,26 +89,33 @@ userRouter.post("/create", async (c) => { ) } + // 对齐 Go handles.CreateUser:admin / guest 不能通过 create 接口创建。 + // 管理员账号由安装向导(或 ADMIN_PASS)创建、guest 全局唯一,两者都不应 + // 经由此接口产生 —— 否则后台可被批量制造出多个管理员。 + const role = body.role !== undefined ? parseInt(body.role, 10) : 0 + if (role === 1 || role === 2) { + return c.json( + { + code: 400, + message: "admin or guest user can not be created", + data: null, + }, + 400, + ) + } + const maxId = db.users.reduce( (max: number, u: any) => Math.max(max, u.id || 0), 0, ) const newId = maxId + 1 - // FIX(F-11): "123456" as a silent fallback meant every user created without - // an explicit password shipped with a guessable one. When no password is - // given, generate a random one and hand it back to the admin caller once — - // it is never stored or logged in plaintext. - let plainPassword: string = body.password || "" - let generatedPassword: string | null = null - if (!plainPassword) { - generatedPassword = generateRandomPassword() - plainPassword = generatedPassword - } + // 密码直接采用调用方传入值(对齐 Go:不传入即为空密码;不再自动生成随机 + // 密码并回显)。 const newUser: any = { id: newId, username: body.username, - role: body.role !== undefined ? parseInt(body.role, 10) : 0, + role, permission: body.permission !== undefined ? parseInt(body.permission, 10) : 0, base_path: body.base_path || "/", @@ -118,17 +125,11 @@ userRouter.post("/create", async (c) => { pwd_update_at: new Date().toISOString(), } - await setUserPassword(newUser, plainPassword) + await setUserPassword(newUser, body.password || "") db.users.push(newUser) await saveDb(db, c.env) - return c.json({ - code: 200, - message: generatedPassword - ? "success (a random password was generated — pass one explicitly to choose your own)" - : "success", - data: generatedPassword ? { password: generatedPassword } : null, - }) + return c.json({ code: 200, message: "success", data: null }) }) // POST /api/admin/user/update @@ -165,11 +166,26 @@ userRouter.post("/update", async (c) => { user.username = body.username } + // 对齐 Go handles.UpdateUser: + // 1) role 不可通过 update 修改(防止把普通用户提升为管理员 / 使既有管理员降级); + // 2) 管理员账号不可被禁用(避免唯一管理员把自己锁在后台之外)。 + if (body.role !== undefined && parseInt(body.role, 10) !== user.role) { + return c.json( + { code: 400, message: "role can not be changed", data: null }, + 400, + ) + } + if (body.disabled !== undefined && !!body.disabled && user.role === 2) { + return c.json( + { code: 400, message: "admin user can not be disabled", data: null }, + 400, + ) + } + if (body.password && body.password.trim() !== "") { await setUserPassword(user, body.password.trim()) } - if (body.role !== undefined) user.role = parseInt(body.role, 10) if (body.permission !== undefined) user.permission = parseInt(body.permission, 10) if (body.base_path !== undefined) user.base_path = body.base_path @@ -193,16 +209,24 @@ const deleteUserHandler = async (c: any) => { ) } const id = parseInt(idQuery, 10) - if (id === 1) { + + const db = await getDb(c.env) + if (!db.users) db.users = [] + + // 对齐 Go op.DeleteUserById:按**角色**保护 admin/guest,而不是硬编码 id。 + // 历史缺陷:只拦 id===1 —— guest(通常 id=2)可被删除,导致匿名访问失去 + // 后备身份;若存在 id≠1 的管理员(迁移/多管理员场景)也完全不受保护。 + const target = db.users.find((u: any) => u.id === id) + if (!target) { + return c.json({ code: 404, message: "User not found", data: null }, 404) + } + if (target.role === 2 || target.role === 1) { return c.json( - { code: 400, message: "Cannot delete primary admin user", data: null }, + { code: 400, message: "cannot delete admin or guest", data: null }, 400, ) } - const db = await getDb(c.env) - if (!db.users) db.users = [] - db.users = db.users.filter((u: any) => u.id !== id) await saveDb(db, c.env) diff --git a/src/backend/server/webauthn.ts b/src/backend/server/webauthn.ts index be6f9004..fc5087a6 100644 --- a/src/backend/server/webauthn.ts +++ b/src/backend/server/webauthn.ts @@ -2,6 +2,7 @@ import { Hono } from "hono" import { sign } from "hono/jwt" import { getDb, saveDb } from "../internal/model/db" import { getJwtSecret, getUserFromContext } from "./middlewares" +import { passwordStampOf } from "../pkg/password" /** * WebAuthn / Passkey 登录。 @@ -219,11 +220,138 @@ interface SessionData { userVerification: string } -function sessionToB64(sd: SessionData): string { - return b64Encode(new TextEncoder().encode(JSON.stringify(sd))) +/** + * 服务端挑战(challenge)存储。 + * + * 安全不变量:WebAuthn 的 challenge 必须由**服务端签发**、**一次性消费**、 + * 且带**有效期**。这是对抗重放的核心——没有它,任何一份曾经合法的 assertion + * 都能被无限次重复使用。 + * + * 历史实现把整个会话(含 challenge)base64 后经 `session` 头在客户端往返: + * 既没有服务端签发、也没有有效期、更没有消费状态,因此 + * ① 客户端可以自行编造 challenge(服务端只比对「自己收到的那份」,等于没校验来源); + * ② 同一份 assertion 可反复换取新 JWT(实测连续两次都返回 token); + * ③ clientDataJSON.origin 与 UP/UV 标志完全不校验。 + * + * 现在改为:`begin_*` 生成随机 id,把会话内容写入持久化后端(TTL 5 分钟), + * 仅把 id 回给客户端;`finish_*` 读取后**立即作废**该 id,实现单次使用。 + * 对客户端而言 `session` 仍是不透明字符串,接口形状不变。 + */ +const CHALLENGE_PREFIX = "openlist_wa_chal_" +const CHALLENGE_TTL_MS = 5 * 60 * 1000 +const UP_FLAG = 0x01 +const UV_FLAG = 0x04 + +/** + * 无持久化后端时的兜底挑战表(进程内,单实例语义)。 + * + * 边界:内存模式部署本就无法跨实例协作,挑战也只在该实例内有效——这与 + * 「凭据本身也存在内存里」是同一级别的一致性,不会额外削弱安全性。 + * 必须带 TTL 与容量上限,避免被扫描式请求撑爆内存。 + */ +const localChallenges = new Map() +const LOCAL_CHALLENGE_MAX = 1000 + +function pruneLocalChallenges(): void { + const now = Date.now() + for (const [k, v] of localChallenges) { + if (v.exp < now) localChallenges.delete(k) + } + // 仍然超限时按插入顺序淘汰最旧条目 + while (localChallenges.size > LOCAL_CHALLENGE_MAX) { + const oldest = localChallenges.keys().next().value + if (oldest === undefined) break + localChallenges.delete(oldest) + } } -function sessionFromB64(s: string): SessionData { - return JSON.parse(new TextDecoder().decode(b64Decode(s))) + +async function issueChallenge(c: any, sd: SessionData): Promise { + const id = b64urlEncode(newChallenge()) + const exp = Date.now() + CHALLENGE_TTL_MS + + // 先写进程内兜底表:**无论持久化是否可用都要写**。 + // + // 为什么不能「持久化成功就 return」:writePersistedSecret 在驱动不可用时会 + // 记录日志后仍然返回真值,无法作为「确实写进去了」的依据。实测按返回值提前 + // return 会导致挑战哪儿都没存,所有 passkey 登录恒定报「invalid or expired」。 + // 兜底表保证单实例内始终可用;持久化成功则额外获得跨实例能力。 + pruneLocalChallenges() + localChallenges.set(id, { data: sd, exp }) + + try { + const { writePersistedSecret } = await import("../internal/model/db") + await writePersistedSecret( + c.env, + CHALLENGE_PREFIX + id, + JSON.stringify({ ...sd, exp }), + ) + } catch { + // 无持久化后端:仅依赖上面的进程内兜底表 + } + return id +} + +async function consumeChallenge( + c: any, + id: string, +): Promise { + if (!id) return null + + // 1) 进程内兜底表(读取即删除 = 单次使用) + const local = localChallenges.get(id) + if (local) { + localChallenges.delete(id) + return local.exp < Date.now() ? null : local.data + } + + // 2) 持久化后端 + const { readPersistedSecret, writePersistedSecret } = await import( + "../internal/model/db" + ) + const key = CHALLENGE_PREFIX + id + let raw: string | null = null + try { + raw = await readPersistedSecret(c.env, key) + } catch { + return null + } + // 一次性消费:无论后续校验是否通过,都先把该挑战作废 + try { + await writePersistedSecret(c.env, key, "") + } catch {} + if (!raw) return null + try { + const data = JSON.parse(String(raw)) + if (!data || typeof data.exp !== "number" || data.exp < Date.now()) { + return null + } + return data as SessionData + } catch { + return null + } +} + +/** + * 校验 clientDataJSON.origin:必须是本站源或与 rpId 一致。 + * WebAuthn 规范要求 RP 校验 origin,防止同 rpId 下的跨源钓鱼。 + */ +function originAllowed(origin: string, rpId: string, c: any): boolean { + if (!origin) return false + try { + const o = new URL(origin) + if (o.hostname === rpId) return true + // 覆盖自定义端口/域名:与当前请求同源也放行 + return o.host === new URL(c.req.url).host + } catch { + return false + } +} + +/** 认证器 data 标志位校验:必须证明「用户在场」;策略要求时还需「用户已验证」 */ +function flagsAllowed(flags: number, userVerification: string): boolean { + if ((flags & UP_FLAG) === 0) return false + if (userVerification === "required" && (flags & UV_FLAG) === 0) return false + return true } async function generateToken(user: any, c: any): Promise { @@ -231,6 +359,8 @@ async function generateToken(user: any, c: any): Promise { id: user.id, username: user.username, role: user.role, + // 密码指纹(对齐 Go PwdTS):改密后旧令牌立即失效 + pwd_ts: passwordStampOf(user), exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, jti: typeof crypto.randomUUID === "function" ? crypto.randomUUID() : b64urlEncode(newChallenge()), } @@ -279,10 +409,13 @@ webauthnRouter.post("/webauthn_begin_login", async (c) => { options.allowCredentials = allowCredentials } + // session 现在只是服务端挑战的**不透明 id**(内容存在持久化后端,单次使用) + const session = await issueChallenge(c, sd) + return c.json({ code: 200, message: "success", - data: { options, session: sessionToB64(sd) }, + data: { options, session }, }) }) @@ -293,17 +426,19 @@ webauthnRouter.post("/webauthn_finish_login", async (c) => { return c.json({ code: 403, message: "WebAuthn is not enabled", data: null }, 403) } const sessionHeader = c.req.header("session") || c.req.header("Session") || "" - let sd: SessionData - try { - sd = sessionFromB64(sessionHeader) - } catch { - return c.json({ code: 400, message: "invalid session", data: null }, 400) + // 服务端签发的挑战,读取即作废(单次使用) + const sd = await consumeChallenge(c, sessionHeader) + if (!sd) { + return c.json( + { code: 400, message: "invalid or expired session", data: null }, + 400, + ) } const body = await c.req.json().catch(() => ({})) const rpId = rpIdOf(c, db) try { - // 1. 解析 clientDataJSON,校验 challenge + // 1. 解析 clientDataJSON,校验 challenge 与 origin const rawId = b64urlDecode(body.id || "") const clientDataJSON = JSON.parse( new TextDecoder().decode(b64urlDecode(body.response?.clientDataJSON || "")), @@ -314,6 +449,10 @@ webauthnRouter.post("/webauthn_finish_login", async (c) => { if (clientDataJSON.type !== "webauthn.get") { return c.json({ code: 400, message: "invalid ceremony type", data: null }, 400) } + // origin 必须属于本站(防止同 rpId 下的跨源钓鱼) + if (!originAllowed(String(clientDataJSON.origin || ""), rpId, c)) { + return c.json({ code: 400, message: "origin not allowed", data: null }, 400) + } // 2. 解析 authenticatorData const authData = b64urlDecode(body.response?.authenticatorData || "") @@ -323,6 +462,13 @@ webauthnRouter.post("/webauthn_finish_login", async (c) => { if (b64urlEncode(rpIdHash) !== b64urlEncode(expectedRpHash)) { return c.json({ code:400, message: "rpId hash mismatch", data: null }, 400) } + // 标志位:必须证明用户在场;策略为 required 时还需用户验证 + if (!flagsAllowed(authData[32], sd.userVerification === "required" ? "required" : "preferred")) { + return c.json( + { code: 400, message: "user presence/verification check failed", data: null }, + 400, + ) + } // 3. 找到对应用户与 credential const username = c.req.query("username") || sd.username || "" @@ -352,6 +498,12 @@ webauthnRouter.post("/webauthn_finish_login", async (c) => { if (!user || !credential) { return c.json({ code: 400, message: "credential not found", data: null }, 400) } + // 账号状态复核:与其它身份解析入口一致,禁用用户不得换取新令牌。 + // 历史缺陷:finish_login 签发 JWT 前不检查 disabled,被禁用的账号仍能用 + // 已有凭证登录(实测:置 disabled=true 后仍返回 token)。 + if (user.disabled) { + return c.json({ code: 403, message: "account is disabled", data: null }, 403) + } // 4. 验证签名(credential 存储 JWK 公钥) const jwk = credential.publicKey @@ -455,10 +607,12 @@ webauthnRouter.post("/webauthn_begin_registration", async (c) => { } if (existing.length > 0) options.excludeCredentials = existing + const session = await issueChallenge(c, sd) + return c.json({ code: 200, message: "success", - data: { options, session: sessionToB64(sd) }, + data: { options, session }, }) }) @@ -473,17 +627,19 @@ webauthnRouter.post("/webauthn_finish_registration", async (c) => { return c.json({ code: 401, message: "Unauthorized", data: null }, 401) } const sessionHeader = c.req.header("Session") || c.req.header("session") || "" - let sd: SessionData - try { - sd = sessionFromB64(sessionHeader) - } catch { - return c.json({ code: 400, message: "invalid session", data: null }, 400) + // 服务端签发的挑战,读取即作废(单次使用) + const sd = await consumeChallenge(c, sessionHeader) + if (!sd) { + return c.json( + { code: 400, message: "invalid or expired session", data: null }, + 400, + ) } const body = await c.req.json().catch(() => ({})) const rpId = rpIdOf(c, db) try { - // 1. 校验 clientDataJSON challenge + // 1. 校验 clientDataJSON challenge 与 origin const clientDataJSON = JSON.parse( new TextDecoder().decode(b64urlDecode(body.response?.clientDataJSON || "")), ) @@ -493,6 +649,9 @@ webauthnRouter.post("/webauthn_finish_registration", async (c) => { if (clientDataJSON.type !== "webauthn.create") { return c.json({ code: 400, message: "invalid ceremony type", data: null }, 400) } + if (!originAllowed(String(clientDataJSON.origin || ""), rpId, c)) { + return c.json({ code: 400, message: "origin not allowed", data: null }, 400) + } // 2. 解析 attestationObject const attObj = b64urlDecode(body.response?.attestationObject || "") diff --git a/src/backend/server/webdav.ts b/src/backend/server/webdav.ts index 80a4722d..c52ac0a6 100644 --- a/src/backend/server/webdav.ts +++ b/src/backend/server/webdav.ts @@ -1,6 +1,32 @@ import { Hono } from "hono" -import { authUserFromReq, getOrInitUsers, verifyUserPassword } from "./auth" -import { can, PermissionBit } from "../pkg/permission" +import { + authUserFromReq, + clearLoginFailures, + getOrInitUsers, + isLoginLocked, + recordLoginFailure, + verifyUserPassword, +} from "./auth" +import { + can, + canCopy, + canMove, + canRename, + canWrite, + getActualPath, + isAdmin, + isGuest, + isWithinUserRoot, + PermissionBit, +} from "../pkg/permission" +import { + canAccess, + canRead as canReadMeta, + canWrite as canWriteMeta, + canWriteContentBypassUserPerms, + getNearestMeta, +} from "../pkg/meta" +import type { Meta } from "../pkg/meta" import { listItems, getItem, @@ -39,8 +65,18 @@ const getStorageRequestContext = (c: any) => { } } -/** Basic Auth 或 Bearer token 认证,返回用户对象(未认证返回 null) */ -async function webdavAuth(c: any): Promise { +/** + * Basic Auth 或 Bearer token 认证。 + * + * 返回 `{ user, metaPassword }`;未认证返回 null。 + * + * `metaPassword` 对齐 Go WebDAVAuth:只有 guest 账号才会把 Basic 密码继续 + * 当作 meta 访问密码(Go: `if user.IsGuest() { MetaPassKey = password }`), + * 普通用户一律为空 —— 目录级密码不能靠账号密码顺带绕过。 + */ +async function webdavAuth( + c: any, +): Promise<{ user: any; metaPassword: string } | null> { const authHeader = c.req.header("Authorization") || "" if (authHeader.startsWith("Basic ")) { try { @@ -56,9 +92,12 @@ async function webdavAuth(c: any): Promise { if (!user) return null // 空密码用户(guest):Basic Auth 下若未提供密码则允许(与 AList 一致) if (!user.password) { - return password === "" ? user : null + if (password !== "") return null + return { user, metaPassword: isGuest(user) ? password : "" } + } + if (await verifyUserPassword(user, password)) { + return { user, metaPassword: isGuest(user) ? password : "" } } - if (await verifyUserPassword(user, password)) return user return null } catch { return null @@ -66,7 +105,7 @@ async function webdavAuth(c: any): Promise { } if (authHeader.startsWith("Bearer ")) { const auth = await authUserFromReq(c) - return auth ? auth.user : null + return auth ? { user: auth.user, metaPassword: "" } : null } return null } @@ -113,6 +152,116 @@ function splitPath(p: string): { dir: string; name: string } { return { dir, name } } +/** + * 把客户端可见的 DAV 路径映射为**该用户根目录之下**的实际存储路径。 + * + * 语义:`/dav/` 就是该用户的根,`/dav/sub/x` 即其根下的 `sub/x`。 + * - 管理员/默认用户(base_path="/"):路径原样返回,与历史行为完全一致; + * - 受限 base_path 的用户:`/dav/sub` -> `/sub`。 + * + * 安全不变量:DAV 的源路径与 Destination 都必须经此映射,并在映射后做 + * isWithinUserRoot 复核。历史缺陷:DAV 全程直接操作全局虚拟路径,既未套用 + * base_path,也未做用户范围校验——实测「只有 WEBDAV_READ 权限、base_path 受限」 + * 的用户可以 PROPFIND 其他用户的目录。 + */ +function scopedDavPath(user: any, davPath: string): string { + const root = getActualPath(user, "/") + if (root === "/") return davPath + const rel = davPath === "/" ? "" : davPath.replace(/^\/+/, "") + return rel ? `${root}/${rel}` : root +} + +/** 解析 MOVE / COPY 的 Destination 头并映射到用户根之下 */ +function destinationPath(c: any, user: any): string | null { + const destRaw = c.req.header("Destination") || "" + if (!destRaw) return null + let dest = destRaw + try { + dest = decodeURIComponent(new URL(destRaw, c.req.url).pathname) + // 与 davPathOf 同样的前缀守卫:Destination 未挂在 DAV_MOUNT 下时原样保留, + // 不做无意义的前缀改写(否则挂在子路径下的部署会被截错) + if (dest === DAV_MOUNT || dest.startsWith(DAV_MOUNT + "/")) { + dest = dest.slice(DAV_MOUNT.length) || "/" + } + } catch { + return null + } + if (!dest) dest = "/" + return scopedDavPath(user, dest.startsWith("/") ? dest : `/${dest}`) +} + +/** + * 读操作的 meta 判定(对齐 Go webdav.go 各 handler 的 CanAccess 调用): + * 密码保护 + read_users 白名单。返回 true 表示拒绝。 + * + * 安全不变量:WebDAV 与 /api/fs/* 必须共享同一套目录级访问控制,否则 + * 「站内被密码保护」的目录可以经 /dav 直接读走(跨协议权限旁路)。 + */ +async function deniedByMetaRead( + c: any, + user: any, + path: string, + metaPassword: string, +): Promise { + let meta: Meta | null = null + try { + meta = await getNearestMeta(path, c.env) + } catch { + meta = null + } + if (!meta) return false + return !canAccess(user, meta, path, metaPassword || "") +} + +/** + * 写操作的 meta 判定(对齐 Go webdav.go / file.go 的 CanWrite 调用): + * - meta.write_users 白名单; + * - requireContentPerm 时还要求 WRITE_CONTENT 权限位,除非目标目录通过 + * meta.write 强制开放(CanWriteContentBypassUserPerms)。 + * 返回 true 表示拒绝。 + */ +async function deniedByMetaWrite( + c: any, + user: any, + path: string, + requireContentPerm: boolean, +): Promise { + let meta: Meta | null = null + try { + meta = await getNearestMeta(path, c.env) + } catch { + meta = null + } + if (!meta) return false + if (isAdmin(user)) return false + if ( + requireContentPerm && + !canWrite(user) && + !canWriteContentBypassUserPerms(meta, path) + ) { + return true + } + return !canWriteMeta(user, meta, path) +} + +/** + * meta 判定失败时的统一响应(403 + 一句指引)。 + * + * WebDAV 没有传递「目录密码」的标准位置:与 Go 一致,只有 guest 账号会把 + * Basic 密码透传为 meta 密码,普通用户一律为空。因此普通用户访问密码保护的 + * 目录只会得到裸 403,容易被误判成账号权限问题而反复重试;这里补一句说明。 + * + * 措辞刻意不区分「密码错误 / read_users / write_users」——不向调用方泄露 + * 具体是哪一类限制。 + */ +const metaForbidden = (c: any) => + c.text( + "Forbidden: directory-level access control denied this request " + + "(meta password, read_users or write_users). WebDAV cannot carry a meta " + + "password — open the path in the web UI, or use /api/fs/* with a password.", + 403, + ) + /** * 推导本次请求实际使用的挂载前缀(正规挂载为 `/dav`)。 * @@ -129,38 +278,72 @@ function davPrefixOf(c: any, davPath: string): string { } webdavRouter.all("/*", async (c) => { - const user = await webdavAuth(c) - if (!user) { + const method = c.req.method.toUpperCase() + + // OPTIONS 匿名放行(对齐 Go WebDAVAuth):客户端需要先探测 DAV 能力, + // 此时通常还没有可用凭据。 + if (method === "OPTIONS") { + c.header("DAV", "1, 2") + c.header( + "Allow", + "OPTIONS, PROPFIND, GET, HEAD, PUT, MKCOL, DELETE, MOVE, COPY", + ) + c.header("MS-Author-Via", "DAV") + return c.body(null, 200) + } + + // 防爆破:与 /api/auth/login 共用同一套失败计数与锁定机制。 + // 对齐 Go:model.LoginCache 在 WebDAV 与登录接口之间共用(失败累加、 + // 达到阈值锁定)。历史缺陷:/dav 的 Basic Auth 可以无限次尝试密码。 + const authHeader = c.req.header("Authorization") || "" + let basicUsername = "" + if (authHeader.startsWith("Basic ")) { + try { + basicUsername = atob(authHeader.substring(6).trim()).split(":")[0] || "" + } catch { + basicUsername = "" + } + if (await isLoginLocked(c, basicUsername, c.env)) { + return c.text("Too Many Requests", 429) + } + } + + const auth = await webdavAuth(c) + if (!auth) { + if (authHeader.startsWith("Basic ")) { + await recordLoginFailure(c, basicUsername, c.env) + } return c.text("Unauthorized", 401, { "WWW-Authenticate": 'Basic realm="OpenList"', }) } + if (authHeader.startsWith("Basic ")) { + await clearLoginFailures(c, basicUsername, c.env) + } + const { user, metaPassword } = auth const canRead = can(user, PermissionBit.WEBDAV_READ) const canManage = can(user, PermissionBit.WEBDAV_MANAGE) if (!canRead && !canManage) { return c.text("Forbidden", 403) } - const method = c.req.method.toUpperCase() + // davPath 是**客户端可见**路径(用于 href,不能把实际存储路径回显给客户端); + // actualPath 才是交给存储层的路径(已套用该用户的根目录)。 const davPath = davPathOf(c) + const actualPath = scopedDavPath(user, davPath) + if (!isWithinUserRoot(user, actualPath)) return c.text("Forbidden", 403) const ctx = getStorageRequestContext(c) try { switch (method) { - case "OPTIONS": { - c.header("DAV", "1, 2") - c.header( - "Allow", - "OPTIONS, PROPFIND, GET, HEAD, PUT, MKCOL, DELETE, MOVE, COPY", - ) - c.header("MS-Author-Via", "DAV") - return c.body(null, 200) - } - case "PROPFIND": { if (!canRead) return c.text("Forbidden", 403) + // 目录级访问控制(与 /api/fs/list 同一套判定) + if (await deniedByMetaRead(c, user, actualPath, metaPassword)) { + return metaForbidden(c) + } const depth = c.req.header("Depth") || "1" - const res = await listItems(davPath, ctx) + const res = await listItems(actualPath, ctx) const items = (res.content || []).map((it: any) => ({ name: it.name, size: it.size || 0, @@ -179,7 +362,11 @@ webdavRouter.all("/*", async (c) => { case "GET": case "HEAD": { if (!canRead) return c.text("Forbidden", 403) - const { item, rawUrl } = await getItem(davPath, ctx) + // 目录级访问控制(与 /api/fs/get 同一套判定) + if (await deniedByMetaRead(c, user, actualPath, metaPassword)) { + return metaForbidden(c) + } + const { item, rawUrl } = await getItem(actualPath, ctx) if (!item) return c.text("Not found", 404) if (item.is_dir) return c.text("Is a directory", 400) // 重定向到 rawRouter 实际下载;rawRouter 已处理所有驱动的下载协议 @@ -189,65 +376,94 @@ webdavRouter.all("/*", async (c) => { // op/storage.ts resolveRawUrlPrefix):/p 受 Go canProxy() 限制,未开启 // 代理的存储会 403 proxy not allowed,因此不能在这里硬编码 /p。 return c.redirect( - rawUrl || `/api/d${encodeDownloadPath(davPath)}`, + rawUrl || `/api/d${encodeDownloadPath(actualPath)}`, 302, ) } case "PUT": { if (!canManage) return c.text("Forbidden", 403) + // 对齐 Go webdav.go handlePut:目标所在目录需通过「写内容权限位或 + // meta.write 强制开放」+ meta.write_users 白名单 + if (await deniedByMetaWrite(c, user, splitPath(actualPath).dir, true)) { + return metaForbidden(c) + } const buffer = Buffer.from(await c.req.arrayBuffer()) - await putItem(davPath, buffer, ctx) + await putItem(actualPath, buffer, ctx) return c.body(null, 201) } case "MKCOL": { if (!canManage) return c.text("Forbidden", 403) - await makeDirectory(davPath, ctx) + // 对齐 Go webdav.go handleMkcol(与 PUT 同一套校验) + if (await deniedByMetaWrite(c, user, splitPath(actualPath).dir, true)) { + return metaForbidden(c) + } + await makeDirectory(actualPath, ctx) return c.body(null, 201) } case "DELETE": { if (!canManage) return c.text("Forbidden", 403) - const { dir, name } = splitPath(davPath) + // 对齐 Go webdav.go handleDelete:目标自身的 meta.write_users 白名单 + if (await deniedByMetaWrite(c, user, actualPath, false)) { + return metaForbidden(c) + } + const { dir, name } = splitPath(actualPath) await removeItems(dir, [name], ctx) return c.body(null, 204) } case "MOVE": { if (!canManage) return c.text("Forbidden", 403) - const destRaw = c.req.header("Destination") || "" - let dest = destRaw - try { - // 与 davPathOf 同样的前缀守卫:Destination 未挂载在 DAV_MOUNT 下时 - // 原样保留,不做无意义的前缀改写 - dest = decodeURIComponent(new URL(destRaw, c.req.url).pathname) - if (dest === DAV_MOUNT || dest.startsWith(DAV_MOUNT + "/")) { - dest = dest.slice(DAV_MOUNT.length) || "/" - } - if (!dest.startsWith("/")) dest = "/" + dest - } catch {} - const src = splitPath(davPath) + // Destination 同样必须落在调用者根目录之内,否则可以把文件移出/移入他人目录 + const dest = destinationPath(c, user) + if (!dest || !isWithinUserRoot(user, dest)) { + return c.text("Forbidden", 403) + } + const src = splitPath(actualPath) const dst = splitPath(dest) + // 对齐 Go webdav/file.go moveFiles:跨目录需要 MOVE 权限位、 + // 改名需要 RENAME 权限位;源/目标目录均需通过 meta 写判定 + if (src.dir !== dst.dir && !canMove(user)) { + return c.text("Forbidden", 403) + } + if (src.name !== dst.name && !canRename(user)) { + return c.text("Forbidden", 403) + } + if ( + (await deniedByMetaWrite(c, user, src.dir, false)) || + (await deniedByMetaWrite(c, user, dst.dir, false)) + ) { + return metaForbidden(c) + } await moveItems(src.dir, dst.dir, [src.name], ctx) return c.body(null, 201) } case "COPY": { if (!canManage) return c.text("Forbidden", 403) - const destRaw = c.req.header("Destination") || "" - let dest = destRaw - try { - // 与 davPathOf 同样的前缀守卫:Destination 未挂载在 DAV_MOUNT 下时 - // 原样保留,不做无意义的前缀改写 - dest = decodeURIComponent(new URL(destRaw, c.req.url).pathname) - if (dest === DAV_MOUNT || dest.startsWith(DAV_MOUNT + "/")) { - dest = dest.slice(DAV_MOUNT.length) || "/" - } - if (!dest.startsWith("/")) dest = "/" + dest - } catch {} - const src = splitPath(davPath) + const dest = destinationPath(c, user) + if (!dest || !isWithinUserRoot(user, dest)) { + return c.text("Forbidden", 403) + } + const src = splitPath(actualPath) const dst = splitPath(dest) + // 对齐 Go webdav/file.go copyFiles:COPY 权限位 + 源目录可读 + // (read_users 白名单)+ 目标目录 meta 写判定 + if (!canCopy(user)) return c.text("Forbidden", 403) + let srcMeta: Meta | null = null + try { + srcMeta = await getNearestMeta(src.dir, c.env) + } catch { + srcMeta = null + } + if (!canReadMeta(user, srcMeta, src.dir)) { + return metaForbidden(c) + } + if (await deniedByMetaWrite(c, user, dst.dir, false)) { + return metaForbidden(c) + } await copyItems(src.dir, dst.dir, [src.name], ctx) return c.body(null, 201) }