diff --git a/esa-entry.ts b/esa-entry.ts index b98e104b..3d2e912b 100644 --- a/esa-entry.ts +++ b/esa-entry.ts @@ -92,6 +92,17 @@ function deleteModuleKvCache(key: string): void { moduleKvCache.delete(key) } +/** + * 模块级 KV 探测结果缓存。 + * + * probe 的目的只是诊断 KV 可用性(结果仅用于日志),但裸调 edgeKv.get() + * 每个请求都会烧掉 1 次 KV 子请求配额(ESA 每请求限 8 次)。 + * 这里将探测结果缓存 60 秒:函数实例存活期内后续请求直接复用结果, + * 既保留故障可见性,又不与业务 get 争抢配额。 + */ +const KV_PROBE_TTL_MS = 60_000 +let kvProbeCache: { ok: boolean; err: string | null; at: number } | null = null + /** * 包装 ESA EdgeKV 为项目通用的 { get, put, delete } 接口。 * @param edgeKv 原始 EdgeKV 实例 @@ -152,8 +163,13 @@ function wrapEsaEdgeKV(edgeKv: any, cache?: Map) { async delete(key: string): Promise { try { await edgeKv.delete(key) - } catch {} - // delete 后使缓存失效 + } catch (e: any) { + // 删除失败必须抛出(与 put 一致):吞掉错误后再失效本地缓存, + // 会让调用方误以为删除成功,而下次 get 又读到旧值(数据"删不掉"且无日志)。 + console.error(`[ESA/KV] delete failed key=${key}:`, e?.message || e) + throw e + } + // delete 成功后才使缓存失效 if (cache) cache.delete(key) deleteModuleKvCache(key) }, @@ -182,14 +198,26 @@ export default { if (edgeKvCtor) { try { const edgeKv = new edgeKvCtor({ namespace }) - // probe 本身也走缓存,避免和业务 get 争抢配额 + // probe 走模块级缓存(60s TTL):探测是诊断行为,结果仅用于日志, + // 不应每请求都烧 1 次 KV 子请求配额。注意必须裸调 edgeKv 而非 wrappedKv, + // 否则包装层吞错后 probe 永远报 ok,失去故障可见性。 let kvTestOk = false let kvTestErr: string | null = null - try { - await edgeKv.get("__openlist_probe__") - kvTestOk = true - } catch (e: any) { - kvTestErr = e?.message || String(e) + if ( + kvProbeCache && + Date.now() - kvProbeCache.at <= KV_PROBE_TTL_MS + ) { + kvTestOk = kvProbeCache.ok + kvTestErr = kvProbeCache.err + } else { + try { + await edgeKv.get("__openlist_probe__") + kvTestOk = true + kvProbeCache = { ok: true, err: null, at: Date.now() } + } catch (e: any) { + kvTestErr = e?.message || String(e) + kvProbeCache = { ok: false, err: kvTestErr, at: Date.now() } + } } // 传入请求级缓存 const wrappedKv = wrapEsaEdgeKV(edgeKv, kvCache) diff --git a/loadEnv.js b/loadEnv.js index 66d673a5..869907e3 100644 --- a/loadEnv.js +++ b/loadEnv.js @@ -3,11 +3,19 @@ import path from "path"; try { const envPath = path.resolve(process.cwd(), ".env"); const envFile = fs.readFileSync(envPath, "utf-8"); - envFile.split("\n").forEach(line => { + envFile.split(/\r?\n/).forEach(line => { const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/); if (match) { + // 剥离 CRLF 残留与成对引号:JWT_SECRET="abc" 应得到 abc 而不是 "abc"(含引号 8 字符) + let value = (match[2] ?? "").trim(); + if ( + (value.startsWith('"') && value.endsWith('"') && value.length >= 2) || + (value.startsWith("'") && value.endsWith("'") && value.length >= 2) + ) { + value = value.slice(1, -1); + } if (!process.env[match[1]]) { - process.env[match[1]] = match[2]; + process.env[match[1]] = value; } } }); diff --git a/middleware.js b/middleware.js index b4969f8d..3f6789b5 100644 --- a/middleware.js +++ b/middleware.js @@ -16,8 +16,14 @@ export function middleware(context) { const { pathname } = new URL(request.url) const accept = request.headers.get("accept") || "" + // /dav(WebDAV)与 /s3(S3 网关)也是后端路由(src/backend/index.ts 挂载), + // 必须放行到云函数:否则浏览器以 text/html 访问 /dav/*、/s3/* 时会被改写为 + // /index.html,返回 SPA 壳而非后端响应。 const isBackend = - pathname === "/health" || /^\/(api|d|p|sd|kv-get|kv-put|kv-delete|kv-list)(\/|$)/.test(pathname) + pathname === "/health" || + /^\/(api|d|p|sd|dav|s3|kv-get|kv-put|kv-delete|kv-list)(\/|$)/.test( + pathname, + ) if ( !isBackend && diff --git a/src/backend/drivers/s3/util.ts b/src/backend/drivers/s3/util.ts index 96f55c23..bbb02b7f 100644 --- a/src/backend/drivers/s3/util.ts +++ b/src/backend/drivers/s3/util.ts @@ -128,7 +128,11 @@ export function parseListObjectsV1( const size = parseInt(parseXmlTag(block, "Size") || "0", 10) const modified = parseXmlTag(block, "LastModified") || new Date().toISOString() - const etag = parseXmlTag(block, "ETag")?.replace(/"/g, "") + // ETag 在 XML 里形如 "hex",必须先反转义再剥引号,否则 etag 值带实体前缀 + const etag = unescapeXml(parseXmlTag(block, "ETag") || "").replace( + /"/g, + "", + ) files.push({ name, @@ -199,7 +203,11 @@ export function parseListObjectsV2( const size = parseInt(parseXmlTag(block, "Size") || "0", 10) const modified = parseXmlTag(block, "LastModified") || new Date().toISOString() - const etag = parseXmlTag(block, "ETag")?.replace(/"/g, "") + // 同 V1:ETag 需先反转义再剥引号 + const etag = unescapeXml(parseXmlTag(block, "ETag") || "").replace( + /"/g, + "", + ) files.push({ name, @@ -467,7 +475,11 @@ export class S3Client { if (result.nextMarker) { marker = result.nextMarker } else if (result.files.length > 0) { - marker = result.files[result.files.length - 1].path + // 兜底 marker 必须是 S3 对象键:files[].path 是虚拟路径,文件夹还缺 + // 尾斜杠,直接当 marker 用会导致续列重复/死循环。经 getKey 转换: + // 去前导斜杠 + 文件夹补尾斜杠。 + const last = result.files[result.files.length - 1] + marker = getKey(last.path, last.isFolder) } else { break } diff --git a/src/backend/drivers/webdav/util.ts b/src/backend/drivers/webdav/util.ts index 6420946c..a6733777 100644 --- a/src/backend/drivers/webdav/util.ts +++ b/src/backend/drivers/webdav/util.ts @@ -31,6 +31,16 @@ export function pathEscape(p: string): string { .join("/") } +/** 反转义 XML 实体(& 最后替换,避免二次解码) */ +function unescapeXmlEntities(s: string): string { + return s + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&") +} + /** * Robust WebDAV XML Multistatus parser. * Handles diverse namespace prefixes (d:, D:, a:, xmlns="DAV:") and case variations. @@ -105,7 +115,9 @@ export function parseMultistatusXml( /<(?:[a-zA-Z0-9_-]+:)?displayname\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9_-]+:)?displayname>/i.exec( propContent, ) - let displayName = dnMatch ? dnMatch[1].trim() : "" + // displayname 是 XML 文本节点:文件名含 & < > ' 时服务端返回的是实体 + // (如 a&b.txt),必须反转义,否则列表展示错误且后续 rename/move/get 404 + let displayName = dnMatch ? unescapeXmlEntities(dnMatch[1].trim()) : "" // Derive name from href if displayname is missing or is pure path const cleanHref = decodedHref.replace(/\/+$/, "") @@ -162,14 +174,20 @@ export function parseMultistatusXml( } // Check if this response represents the directory itself + // 注意:只能用精确匹配。旧实现 `normHref.endsWith(normTarget)` 有两个坑: + // 1) normTarget 为空串时 endsWith("") 恒真,multistatus 中第一个 + // (若服务器把子项排在目录之前)会被误判为 self 而丢条目; + // 2) 子目录与父目录同名(/movies/movies)也会命中 endsWith 被吞。 + // href 可能是完整 URL(http://host/dav/path),先剥离 scheme://host 再比较。 const normTarget = targetPath.replace(/\/+$/, "").toLowerCase() - const normHref = cleanHref.toLowerCase() + const normHref = cleanHref + .toLowerCase() + .replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+/, "") if ( !selfItem && (normHref === normTarget || - normHref.endsWith(normTarget) || - (normTarget === "" && normHref === "")) + (normTarget === "" && (normHref === "" || normHref === "/"))) ) { selfItem = file } else { diff --git a/src/backend/internal/archive/zip.ts b/src/backend/internal/archive/zip.ts index 72b56cb9..39348fd1 100644 --- a/src/backend/internal/archive/zip.ts +++ b/src/backend/internal/archive/zip.ts @@ -120,8 +120,10 @@ export async function extractZipEntry( } const ds = new DecompressionStream("deflate-raw") const writer = ds.writable.getWriter() - writer.write(compressed) - writer.close() + // write/close 必须等待:否则流中途出错(坏数据等)会变成 unhandled rejection, + // 而不是在本次解压的 await 链路上传播,调用方拿到截断结果而无明确报错。 + await writer.write(compressed) + await writer.close() const out = await new Response(ds.readable).arrayBuffer() return new Uint8Array(out) } diff --git a/src/backend/internal/stream/stream.ts b/src/backend/internal/stream/stream.ts index 2cd7c037..e9cf3db7 100644 --- a/src/backend/internal/stream/stream.ts +++ b/src/backend/internal/stream/stream.ts @@ -6,16 +6,60 @@ export interface RangeParams { chunksize: number } -// Parses the standard Range header +/** + * 解析标准 Range header(RFC 7233)。 + * 支持 `bytes=start-end` / `bytes=start-` / `bytes=-suffix`(末尾 N 字节); + * end 超出文件大小时按规范收敛到 fileSize-1。 + * 非法 / 多段 / 不可满足的 Range 返回 null,调用方应回退为全量 200 响应。 + * + * 此前的实现对 `bytes=-N`(部分播放器拖动进度条时发送)会解析出 start=NaN, + * 直接传给 fs.createReadStream 会同步抛 ERR_OUT_OF_RANGE 导致下载 500。 + */ export function parseRangeHeader( rangeHeader: string, fileSize: number, -): RangeParams { - const parts = rangeHeader.replace(/bytes=/, "").split("-") - const start = parseInt(parts[0], 10) - const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1 - const chunksize = end - start + 1 - return { start, end, chunksize } +): RangeParams | null { + if (!rangeHeader || !Number.isFinite(fileSize) || fileSize <= 0) { + return null + } + + const match = rangeHeader.trim().match(/^bytes=(\d*)-(\d*)$/) + if (!match) { + return null + } + + let start: number + let end: number + + if (match[1] === "" && match[2] === "") { + // "bytes=-" 无法确定任何区间 + return null + } + + if (match[1] === "") { + // 后缀形式 "bytes=-N":取文件末尾 N 字节 + const suffix = parseInt(match[2], 10) + if (isNaN(suffix) || suffix <= 0) { + return null + } + start = Math.max(0, fileSize - suffix) + end = fileSize - 1 + } else { + start = parseInt(match[1], 10) + end = match[2] === "" ? fileSize - 1 : parseInt(match[2], 10) + if (isNaN(start) || isNaN(end)) { + return null + } + if (end >= fileSize) { + end = fileSize - 1 // 按规范收敛,而不是返回错误切片 + } + } + + if (start >= fileSize || start > end) { + return null + } + + return { start, end, chunksize: end - start + 1 } } let fs: any = null diff --git a/src/backend/internal/upload/multipart.ts b/src/backend/internal/upload/multipart.ts index cde3e316..063dd0ca 100644 --- a/src/backend/internal/upload/multipart.ts +++ b/src/backend/internal/upload/multipart.ts @@ -88,7 +88,14 @@ function intervalsOf(set: Set): [number, number][] { export function snapshot(s: MultipartSession): MultipartSnapshot { const intervals = intervalsOf(s.received) - const receivedBytes = s.received.size * s.chunk_size + // received_bytes 必须按每个分片的实际字节数累加:末分片在 size 不整除 + // chunk_size 时更小,直接用 received.size * chunk_size 会超算 + //(例如 15MB 文件、10MB 分片:2 片全收后应为 15MB 而非 20MB)。 + let receivedBytes = 0 + for (const idx of s.received) { + const offset = idx * s.chunk_size + receivedBytes += Math.max(0, Math.min(s.chunk_size, s.size - offset)) + } // frontier:连续已收的最大 index + 1(驱动顺序写入进度) let frontier = 0 for (let i = 0; i < s.total_chunks; i++) { diff --git a/src/backend/pkg/csrf.ts b/src/backend/pkg/csrf.ts index eff3c1e2..e0bc1cb1 100644 --- a/src/backend/pkg/csrf.ts +++ b/src/backend/pkg/csrf.ts @@ -225,8 +225,18 @@ function getCookie(c: Context, name: string): string | undefined { const cookies = cookieHeader.split(";").map((c) => c.trim()) for (const cookie of cookies) { - const [key, value] = cookie.split("=") - if (key === name) return decodeURIComponent(value) + const eq = cookie.indexOf("=") + if (eq === -1) continue + const key = cookie.slice(0, eq).trim() + const rawValue = cookie.slice(eq + 1) + if (key !== name) continue + // 值含非法百分号序列(如 %zz)时 decodeURIComponent 会抛 URIError, + // 导致中间件 500;此时退回原值交由后续比对自然失败。 + try { + return decodeURIComponent(rawValue) + } catch { + return rawValue + } } return undefined } diff --git a/src/backend/pkg/http.ts b/src/backend/pkg/http.ts index 8b1ff848..e5093428 100644 --- a/src/backend/pkg/http.ts +++ b/src/backend/pkg/http.ts @@ -24,7 +24,15 @@ const DEFAULT_TIMEOUT = 30_000 function buildUrl(url: string, params?: Record): string { if (!params || Object.keys(params).length === 0) return url const qs = new URLSearchParams(params).toString() - return `${url}${url.includes("?") ? "&" : "?"}${qs}` + // 参数必须拼进 query(第一个 ? 之后),而不能落入 fragment(# 之后)—— + // 否则 `buildUrl("http://x/a#f", {k:"1"})` 会产出 `http://x/a#f?k=1`,服务端收不到参数。 + const hashIndex = url.indexOf("#") + if (hashIndex === -1) { + return `${url}${url.includes("?") ? "&" : "?"}${qs}` + } + const base = url.slice(0, hashIndex) + const fragment = url.slice(hashIndex) + return `${base}${base.includes("?") ? "&" : "?"}${qs}${fragment}` } async function fetchWithTimeout( @@ -34,8 +42,15 @@ async function fetchWithTimeout( ): Promise { const controller = new AbortController() const id = setTimeout(() => controller.abort(), timeout) + // 调用方传入的 signal 不能被超时 signal 无条件覆盖:两者任一触发都应中止请求。 + // AbortSignal.any 在旧运行时可能不存在,此时退回旧行为(仅超时信号)。 + const callerSignal = init.signal as AbortSignal | undefined + let signal: AbortSignal = controller.signal + if (callerSignal && typeof (AbortSignal as any)?.any === "function") { + signal = (AbortSignal as any).any([controller.signal, callerSignal]) + } try { - return await fetch(url, { ...init, signal: controller.signal }) + return await fetch(url, { ...init, signal }) } finally { clearTimeout(id) } @@ -85,7 +100,7 @@ export async function get( const finalUrl = buildUrl(url, config?.params) const res = await fetchWithTimeout( finalUrl, - { method: "GET", headers: config?.headers }, + { method: "GET", headers: config?.headers, signal: config?.signal }, config?.timeout ?? DEFAULT_TIMEOUT, ) return parseResponse(res, config?.responseType) @@ -103,7 +118,7 @@ export async function post( const body = typeof data === "string" ? data : JSON.stringify(data) const res = await fetchWithTimeout( url, - { method: "POST", headers, body }, + { method: "POST", headers, body, signal: config?.signal }, config?.timeout ?? DEFAULT_TIMEOUT, ) return parseResponse(res, config?.responseType) @@ -116,6 +131,7 @@ export async function request(config: { headers?: Record params?: Record timeout?: number + signal?: AbortSignal responseType?: string }): Promise> { const finalUrl = buildUrl(config.url, config.params) @@ -131,7 +147,12 @@ export async function request(config: { } const res = await fetchWithTimeout( finalUrl, - { method: config.method.toUpperCase(), headers, body }, + { + method: config.method.toUpperCase(), + headers, + body, + signal: config.signal, + }, config.timeout ?? DEFAULT_TIMEOUT, ) return parseResponse(res, config.responseType) @@ -274,10 +295,11 @@ export function isSafeUrl( /(^|\.)\d{1,3}-\d{1,3}-\d{1,3}-\d{1,3}(\.|$)/, // 127-0-0-1.example.com /(^|\.)0x[0-9a-f]{6,8}(\.|$)/i, // 0x7f000001.example.com (十六进制IP label) /(^|\.)\d{8,}(\.|$)/, // 2130706433.example.com (整数IP label) - /(^|\.)127\.0\.0\.1\.nip\.io$/, // nip.io DNS rebinding service + /\.nip\.io$/i, // nip.io 全域封禁:<任意IP>.nip.io 均解析到该 IP,仅封 127.0.0.1 可被绕过 + /\.sslip\.io$/i, // sslip.io 同类服务(.sslip.io / ..sslip.io) /(^|\.)localtest\.me$/, // localtest.me resolves to 127.0.0.1 /(^|\.)vcap\.me$/, // vcap.me resolves to 127.0.0.1 - /(^|\.)xip\.io$/, // xip.io DNS rebinding service + /\.xip\.io$/i, // xip.io DNS rebinding service ] for (const pattern of dnsRebindPatterns) { if (pattern.test(host)) { diff --git a/src/backend/pkg/stream.ts b/src/backend/pkg/stream.ts index 0851050b..d841d903 100644 --- a/src/backend/pkg/stream.ts +++ b/src/backend/pkg/stream.ts @@ -12,6 +12,11 @@ export interface RangeParams { /** * Parse Range header + * 支持 RFC 7233 标准形式: + * - `bytes=start-end`(闭区间,end 超出文件大小时按规范收敛到 total-1) + * - `bytes=start-`(从 start 到末尾) + * - `bytes=-suffix`(取文件末尾 suffix 字节) + * 非法/多段/不可满足的 Range 返回 undefined(调用方应回退为全量 200 响应) * @param rangeHeader Range header string * @param total Total file size */ @@ -19,15 +24,43 @@ export function parseRange( rangeHeader: string | undefined | null, total: number, ): RangeParams | undefined { - if (!rangeHeader || !rangeHeader.startsWith("bytes=")) { + if (!rangeHeader || total <= 0) { return undefined } - const parts = rangeHeader.replace(/bytes=/, "").split("-") - const start = parseInt(parts[0], 10) - const end = parts[1] ? parseInt(parts[1], 10) : total - 1 + const match = rangeHeader.trim().match(/^bytes=(\d*)-(\d*)$/) + if (!match) { + return undefined + } + + let start: number + let end: number + + if (match[1] === "" && match[2] === "") { + // "bytes=-" 无法确定任何区间 + return undefined + } + + if (match[1] === "") { + // 后缀形式 "bytes=-N":取文件末尾 N 字节 + const suffix = parseInt(match[2], 10) + if (isNaN(suffix) || suffix <= 0) { + return undefined + } + start = Math.max(0, total - suffix) + end = total - 1 + } else { + start = parseInt(match[1], 10) + end = match[2] === "" ? total - 1 : parseInt(match[2], 10) + if (isNaN(start) || isNaN(end)) { + return undefined + } + if (end >= total) { + end = total - 1 // 按规范收敛,而不是拒绝 + } + } - if (isNaN(start) || start >= total || end >= total || start > end) { + if (start >= total || start > end) { return undefined } diff --git a/src/backend/pkg/totp.ts b/src/backend/pkg/totp.ts index 7cad2f4d..a7534402 100644 --- a/src/backend/pkg/totp.ts +++ b/src/backend/pkg/totp.ts @@ -114,10 +114,12 @@ export function verifyBackupCode( inputCode: string, storedCodes: string[], ): boolean { - const normalized = inputCode.trim().toUpperCase().replace(/\s/g, "") - return storedCodes.some( - (code) => code.toUpperCase().replace(/\s/g, "") === normalized, - ) + // 归一化必须同时去掉空白与连字符:generateBackupCodes 产出 `XXXX-XXXX`, + // 用户可能输入 `XXXXXXXX` / `XXXX XXXX` / `xxxx-xxxx`,若只去空白则永不匹配。 + const normalize = (code: string) => + code.trim().toUpperCase().replace(/[-\s]/g, "") + const normalized = normalize(inputCode) + return storedCodes.some((code) => normalize(code) === normalized) } // ============ 兼容旧代码的函数别名 ============ diff --git a/src/backend/pkg/utils.ts b/src/backend/pkg/utils.ts index 0ea9bd7a..7592a722 100644 --- a/src/backend/pkg/utils.ts +++ b/src/backend/pkg/utils.ts @@ -15,11 +15,16 @@ export * from "./stream" // Format byte sizes to human-readable strings export function formatBytes(bytes: number, decimals = 2): string { + // 非法输入(NaN/Infinity/负数)按 0 处理,避免返回 "NaN undefined" + if (!Number.isFinite(bytes) || bytes < 0) return "0 Bytes" if (bytes === 0) return "0 Bytes" const k = 1024 const dm = decimals < 0 ? 0 : decimals const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB"] - const i = Math.floor(Math.log(bytes) / Math.log(k)) + let i = Math.floor(Math.log(bytes) / Math.log(k)) + // bytes >= 1024PB 时 i 会越界到 sizes.length 之外,需钳制到最后一档 + if (i < 0) i = 0 + if (i >= sizes.length) i = sizes.length - 1 return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i] } diff --git a/src/backend/pkg/validators.ts b/src/backend/pkg/validators.ts index f16d162e..7df957d9 100644 --- a/src/backend/pkg/validators.ts +++ b/src/backend/pkg/validators.ts @@ -84,7 +84,9 @@ export const loginRequestSchema = z.object({ export const createUserRequestSchema = z.object({ username: usernameSchema, password: passwordSchema, - role: z.number().int().min(0).max(3).default(1), // 0=超级管理员, 1=管理员, 2=普通用户, 3=访客 + // 对齐 pkg/permission.ts 的 UserRole 枚举:0=GENERAL(普通用户), 1=GUEST(访客), 2=ADMIN(管理员)。 + // 此前注释与默认值均与实际枚举相反(default 1 会默认建出访客、传 2 想建"普通用户"实际是管理员)。 + role: z.number().int().min(0).max(2).default(0), permission: z.number().int().min(0).default(0), disabled: z.boolean().optional().default(false), base_path: pathSchema.optional(), @@ -95,7 +97,7 @@ export const createUserRequestSchema = z.object({ */ export const updateUserRequestSchema = z.object({ password: passwordSchema.optional(), - role: z.number().int().min(0).max(3).optional(), + role: z.number().int().min(0).max(2).optional(), permission: z.number().int().min(0).optional(), disabled: z.boolean().optional(), base_path: pathSchema.optional(), diff --git a/src/backend/pkg/xml.ts b/src/backend/pkg/xml.ts index 565617bd..de6e65d1 100644 --- a/src/backend/pkg/xml.ts +++ b/src/backend/pkg/xml.ts @@ -2,6 +2,11 @@ * XML generation utilities for OpenList protocols (WebDAV, S3). */ +/** XML 文本转义:href/路径等动态值必须转义后再插入,否则含 & < > 的目录名会产生非法 XML */ +function xmlEscape(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">") +} + export function generateWebDavXml( path: string, items: Array<{ @@ -16,7 +21,7 @@ export function generateWebDavXml( // Current folder description xml += ` \n` - xml += ` ${path}\n` + xml += ` ${xmlEscape(path)}\n` xml += ` \n` xml += ` \n` xml += ` \n` @@ -30,7 +35,7 @@ export function generateWebDavXml( for (const item of items) { const itemHref = `${path}${path.endsWith("/") ? "" : "/"}${encodeURIComponent(item.name)}` xml += ` \n` - xml += ` ${itemHref}\n` + xml += ` ${xmlEscape(itemHref)}\n` xml += ` \n` xml += ` \n` if (item.isFolder) { diff --git a/src/backend/server/admin.ts b/src/backend/server/admin.ts index a23583a5..8330d20c 100644 --- a/src/backend/server/admin.ts +++ b/src/backend/server/admin.ts @@ -25,7 +25,9 @@ adminRouter.use("*", async (c, next) => { // (role===2 且 DB 中存在未禁用用户)都视为管理员。 const isAdmin = await checkAdminAuth(c) if (!isAdmin) { - return c.json({ code: 401, message: "Unauthorized", data: null }) + // 必须返回真正的 HTTP 401 状态码:缺省第二个参数时 Hono 返回 200, + // 网关/监控/按 res.status 判断的客户端会把未授权当成功。 + return c.json({ code: 401, message: "Unauthorized", data: null }, 401) } await next() }) diff --git a/src/backend/server/assets.ts b/src/backend/server/assets.ts index df9b297f..3ed5a9d1 100644 --- a/src/backend/server/assets.ts +++ b/src/backend/server/assets.ts @@ -45,9 +45,11 @@ async function resolveCdnBase(cdnUrl: string): Promise { let version = "latest" try { const db = await getDb() - const versionItem = db.get( - "SELECT * FROM x_settings WHERE key = 'version'", - ) as any + // TS 版 getDb() 返回普通对象(settings 数组),不存在 Go 风格的 db.get(sql) API; + // 旧写法必然 TypeError 并被空 catch 吞掉,导致 $version 恒为 latest。 + const versionItem = (db.settings || []).find( + (s: any) => s.key === "version", + ) if (versionItem && versionItem.value) { // 从版本字符串提取 frontend 版本,如 // "v4.2.3 (Commit: xxx) - Frontend: v1.0.0 - Build at: xxx" diff --git a/src/backend/server/auth.ts b/src/backend/server/auth.ts index e903f475..4d9f7481 100644 --- a/src/backend/server/auth.ts +++ b/src/backend/server/auth.ts @@ -477,6 +477,11 @@ authRouter.post("/login", async (c) => { const otpCheck = await checkUserOtp(matchedUser, body) if (!otpCheck.ok) { + // 密码正确但 OTP 错误/缺失时同样计入防爆破计数:否则攻击者可用正确密码 + // 对 6 位 TOTP 无限高频尝试,绕过 5 次/IP 锁定。 + const auditLogger = getAuditLogger() + await auditLogger.logLoginFailure(c, username, otpCheck.message) + await recordLoginFailure(c, username, c.env) return c.json( { code: otpCheck.code, message: otpCheck.message, data: null }, otpCheck.httpStatus, @@ -560,6 +565,10 @@ authRouter.post("/login/hash", async (c) => { const otpCheck = await checkUserOtp(matchedUser, body) if (!otpCheck.ok) { + // 同 /login:OTP 失败也必须计入防爆破计数 + const auditLogger = getAuditLogger() + await auditLogger.logLoginFailure(c, username, otpCheck.message) + await recordLoginFailure(c, username, c.env) return c.json( { code: otpCheck.code, message: otpCheck.message, data: null }, otpCheck.httpStatus, diff --git a/src/backend/server/fs.ts b/src/backend/server/fs.ts index ac90cef6..41c30f10 100644 --- a/src/backend/server/fs.ts +++ b/src/backend/server/fs.ts @@ -524,7 +524,10 @@ fsRouter.post("/get", async (c) => { sign: item.sign || "", thumb: (item as any).thumb || "", type: item.type ?? 0, - raw_url: `/api/sd/${shareId}${subPath}`, + // 子路径必须逐段编码:分享文件名含 ?、#、% 时,裸拼接的 raw_url 会被截断或报 400/500 + raw_url: `/api/sd/${encodeURIComponent(shareId)}${ + subPath && subPath !== "/" ? encodeDownloadPath(subPath) : "" + }`, readme: shareRes.share.readme || "", header: shareRes.share.header || "", provider, @@ -805,10 +808,30 @@ fsRouter.post("/copy", async (c) => { } }) +/** + * 安全解码上传路径 header:值含非法百分号序列(如 /a%zz.txt)时 + * decodeURIComponent 会抛 URIError,且不在 try 块内 → Hono 兑底 500。 + * 这里统一返回 null 交由调用方返回 400(对齐 raw.ts 对同类问题的处理)。 + */ +function decodeUploadHeader(value: string | undefined): string | null { + if (!value) return "" + try { + return decodeURIComponent(value) + } catch { + return null + } +} + fsRouter.put("/put", async (c) => { const user = await getUserFromContext(c) if (!canWrite(user)) return permissionDenied(c) - const rawPath = decodeURIComponent(c.req.header("File-Path") || "") + const rawPath = decodeUploadHeader(c.req.header("File-Path")) + if (rawPath === null) { + return c.json( + { code: 400, message: "Malformed File-Path header encoding", data: null }, + 400, + ) + } if (!rawPath.trim()) { return c.json( { code: 400, message: "Missing File-Path header", data: null }, @@ -845,7 +868,13 @@ fsRouter.put("/put", async (c) => { fsRouter.put("/form", async (c) => { const user = await getUserFromContext(c) if (!canWrite(user)) return permissionDenied(c) - const rawPath = decodeURIComponent(c.req.header("File-Path") || "") + const rawPath = decodeUploadHeader(c.req.header("File-Path")) + if (rawPath === null) { + return c.json( + { code: 400, message: "Malformed File-Path header encoding", data: null }, + 400, + ) + } if (!rawPath.trim()) { return c.json( { code: 400, message: "Missing File-Path header", data: null }, @@ -950,7 +979,17 @@ fsRouter.put("/upload/part", async (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 rawDirPath = decodeUploadHeader(c.req.header("Upload-Path")) + if (rawDirPath === null) { + return c.json( + { + code: 400, + message: "Malformed Upload-Path header encoding", + data: null, + }, + 400, + ) + } const dirPath = getActualPath(user, rawDirPath) const requestContext = getStorageRequestContext(c) if (!session || !(partNumber >= 1) || !dirPath) { @@ -1049,7 +1088,7 @@ fsRouter.post("/add_offline_download", async (c) => { const { path: rawPath, urls } = await c.req.json().catch(() => ({})) const reqPath = getActualPath(user, rawPath || "/") if (!urls || urls.length === 0) { - return c.json({ code: 400, message: "No URLs provided" }) + return c.json({ code: 400, message: "No URLs provided" }, 400) } return c.json( @@ -1390,7 +1429,13 @@ fsRouter.post("/multipart/init", async (c) => { const user = await getUserFromContext(c) if (!canWrite(user)) return permissionDenied(c) - const rawPath = decodeURIComponent(c.req.header("File-Path") || "") + const rawPath = decodeUploadHeader(c.req.header("File-Path")) + if (rawPath === null) { + return c.json( + { code: 400, message: "Malformed File-Path header encoding", data: null }, + 400, + ) + } const size = parseInt(c.req.header("X-File-Size") || "0", 10) const rawChunk = parseInt(c.req.header("X-Chunk-Size") || "0", 10) const md5 = c.req.header("X-File-Md5") || "" @@ -1641,7 +1686,10 @@ async function fetchArchiveBytes( const driver = await getDriver(resolved.storage!.driver, resolved.storage) let item: any try { - item = await driver.get(virtualPath, resolved.physical!) + // 与 getItem / /fs/link 的约定一致:第一个参数传已拼 base_path 的实际路径, + // 而不是未处理的 virtualPath,否则依赖路径参数的驱动会把 base_path 用户的 + // 归档解析到错误对象。 + item = await driver.get(actual, resolved.physical!) } finally { await flushPendingDriverState( resolved.storage!.driver, diff --git a/src/backend/server/proxy_request.ts b/src/backend/server/proxy_request.ts index 3de7173d..8ff0a343 100644 --- a/src/backend/server/proxy_request.ts +++ b/src/backend/server/proxy_request.ts @@ -225,7 +225,9 @@ export function exceedsProxyPayloadLimit( if (!Number.isFinite(size) || size <= 0) return false if (range) { try { - return parseRangeHeader(range, size).chunksize > limit + const parsed = parseRangeHeader(range, size) + // 非法 Range 会回退为全量内容传输,因此按完整大小判断限额 + return parsed ? parsed.chunksize > limit : size > limit } catch { return false } diff --git a/src/backend/server/raw.ts b/src/backend/server/raw.ts index f1894ffa..c301aa6e 100644 --- a/src/backend/server/raw.ts +++ b/src/backend/server/raw.ts @@ -613,11 +613,12 @@ rawRouter.get("/*", async (c) => { c.header("Access-Control-Allow-Origin", "*") const size = fileItem.size || 0 const rangeHeader = c.req.header("Range") - if (rangeHeader && size > 0) { - const { start, end, chunksize } = parseRangeHeader( - rangeHeader, - size, - ) + const parsedRange = + rangeHeader && size > 0 + ? parseRangeHeader(rangeHeader, size) + : null + if (parsedRange) { + const { start, end, chunksize } = parsedRange const stream = await (driver as any).createReadStream( resolved.physical, { start, end }, @@ -687,8 +688,13 @@ rawRouter.get("/*", async (c) => { c.header("Access-Control-Allow-Origin", "*") const rangeHeader = c.req.header("Range") - if (rangeHeader) { - const { start, end, chunksize } = parseRangeHeader(rangeHeader, stat.size) + // 非法/不可满足的 Range 解析为 null,回退为全量 200 响应(RFC 允许忽略 Range), + // 避免把 NaN 切片传给 createReadStream 导致 ERR_OUT_OF_RANGE 500。 + const parsedRange = rangeHeader + ? parseRangeHeader(rangeHeader, stat.size) + : null + if (parsedRange) { + const { start, end, chunksize } = parsedRange const stream = createReadStream(resolved.physical, { start, end }) c.header("Content-Range", `bytes ${start}-${end}/${stat.size}`) diff --git a/src/backend/server/sso.ts b/src/backend/server/sso.ts index 7dc6c3d7..8660f389 100644 --- a/src/backend/server/sso.ts +++ b/src/backend/server/sso.ts @@ -185,7 +185,12 @@ async function generateToken(user: any, c: any): Promise { } /** SSO 自动注册 */ -async function autoRegister(db: any, username: string, ssoId: string): Promise { +async function autoRegister( + db: any, + env: any, + username: string, + ssoId: string, +): Promise { let uname = username || ssoId if (db.users.some((u: any) => u.username === uname)) { uname = `${uname}_${ssoId}` @@ -208,12 +213,23 @@ async function autoRegister(db: any, username: string, ssoId: string): Promise/g, "\\u003e") + // 注入防护:value 来自 IdP 返回的 id/sub 等字段,可能包含双引号/反斜杠/换行。 + // 只转义 <> 不足以防止跳出 JS 字符串字面量(值含 `"` 时可注入任意脚本)。 + const safe = String(value) + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(//g, "\\u003e") + .replace(/\r/g, "\\r") + .replace(/\n/g, "\\n") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029") return ` @@ -436,7 +452,7 @@ ssoRouter.get("/sso_callback", async (c) => { return c.text("user not found and auto register is disabled", 400) } const username = String(userInfo?.login ?? userInfo?.[usernameField] ?? userInfo?.name ?? "") - user = await autoRegister(db, username || userID, userID) + user = await autoRegister(db, c.env, username || userID, userID) } const token = await generateToken(user, c) if (useCompat) { diff --git a/src/backend/server/user.ts b/src/backend/server/user.ts index a3a8b223..a39edcc2 100644 --- a/src/backend/server/user.ts +++ b/src/backend/server/user.ts @@ -3,7 +3,7 @@ import { getDb, saveDb } from "../internal/model/db" import { generateRandomPassword, verifyUserPassword } from "./auth" import { setUserPassword } from "../pkg/password" import { verify } from "hono/jwt" -import { getJwtSecret } from "./middlewares" +import { getJwtSecret, isTokenRevoked } from "./middlewares" import { listUserSshKeys, deleteUserSshKey } from "../internal/op/sshkey" export const userRouter = new Hono() @@ -276,6 +276,14 @@ export const updatePwdHandler = async (c: any) => { try { const secret = await getJwtSecret(c) const payload = await verify(token, secret, "HS256") + // 与 authUserFromReq 对齐:仅验签还不够,还必须拒绝已吊销(注销拉黑)的 token, + // 否则 logout 黑名单对本接口无效,已注销/被风控的 token 仍可改密码。 + if ( + (payload as any)?.jti && + (await isTokenRevoked((payload as any).jti as string, c.env)) + ) { + return c.json({ code: 401, message: "Token revoked", data: null }, 401) + } const body = await c.req.json().catch(() => ({})) const oldPassword = body.old_password || "" const newPassword = body.new_password || "" @@ -298,6 +306,10 @@ export const updatePwdHandler = async (c: any) => { } const user = db.users[userIdx] + // 被禁用的账号不允许修改密码(对齐 getUserFromContext 的 disabled 语义) + if (user.disabled) { + return c.json({ code: 403, message: "User disabled", data: null }, 403) + } if (!user.password || !(await verifyUserPassword(user, oldPassword))) { return c.json( { code: 400, message: "Incorrect old password", data: null }, diff --git a/src/backend/server/webdav.ts b/src/backend/server/webdav.ts index eefbcff8..367efa40 100644 --- a/src/backend/server/webdav.ts +++ b/src/backend/server/webdav.ts @@ -137,8 +137,14 @@ webdavRouter.all("/*", async (c) => { : davPath.endsWith("/") ? davPath : davPath + "/" - const xml = buildWebDavPropfindResponse(href, items) - return c.body(xml, depth === "0" ? 207 : 207, { + // RFC 4918:Depth: 0 只返回集合自身,不得附带子项; + // 之前 depth 变量读而不用的写法(两个分支同为 207 且都带全部子项)既违反 + // 规范,也让部分客户端(依赖 Depth: 0 探测目录)拿到错误结果。 + const xml = buildWebDavPropfindResponse( + href, + depth === "0" ? [] : items, + ) + return c.body(xml, 207, { "Content-Type": "application/xml; charset=utf-8", }) }