From 4cc1e2f34a06918693d8ae237dc792ba44fb09b1 Mon Sep 17 00:00:00 2001 From: Wudarensheng Date: Thu, 22 Oct 2026 15:10:46 +0800 Subject: [PATCH 1/3] fix(drivers/lanzou): support new /fn download page structure Lanzou moved the share download params into the /fn? inner page, and the download API is now an absolute URL (https://apifile.woozooo.com/ajaxfile.php?file=N) that requires sign params (wp_sign / ajaxdata, action=downprocess, websign=2, kd=1, ves=1). The old relative-path regex ('/ajaxm.php?file=N') no longer matches, so parsing silently fell back to the wrong host with an incomplete form. - add parseFnPage() to extract the apifile URL and sign form, and use it when the legacy regex does not match (legacy flow kept untouched) - keep the previous lenient extraction as a last-resort fallback - fix removeJSComment() treating the '//' in https:// as a line comment, which truncated absolute URLs on the share page - use an absolute iframe src as-is instead of prefixing the share domain Adds unit tests for the new parser and the driver-level flow, and fixes the stale mock in scripts/test-lanzou.mts (it matched ajaxm.php while the driver posts to ajaxfile.php, so that case never actually ran). Ports OpenListTeam/OpenList@e1d88b0 (fix(drivers/lanzou): support new /fn download page structure, #3157). --- scripts/test-lanzou.mts | 78 ++++++++++- src/backend/drivers/lanzou/help.ts | 4 +- src/backend/drivers/lanzou/util.test.ts | 173 ++++++++++++++++++++++++ src/backend/drivers/lanzou/util.ts | 123 +++++++++++++---- 4 files changed, 352 insertions(+), 26 deletions(-) create mode 100644 src/backend/drivers/lanzou/util.test.ts diff --git a/scripts/test-lanzou.mts b/scripts/test-lanzou.mts index 2dc3b9f4..8174cf07 100644 --- a/scripts/test-lanzou.mts +++ b/scripts/test-lanzou.mts @@ -120,7 +120,7 @@ await test("Lanzou Driver get() 直链与请求头验证(纯 User-Agent,无 try { (globalThis as any).fetch = async (url: string, init?: RequestInit) => { const urlStr = String(url) - if (urlStr.includes("ajaxm.php")) { + if (urlStr.includes("ajaxfile.php") || urlStr.includes("ajaxm.php")) { return new Response(JSON.stringify({ zt: 1, dom: "https://developer.lanzoui.com", @@ -161,6 +161,82 @@ await test("Lanzou Driver get() 直链与请求头验证(纯 User-Agent,无 } }) +// 5. Test 2026-10 改版 /fn 内页(apifile 绝对地址 + 签名) +await test("Lanzou Driver get() 适配改版 /fn 内页(apifile 绝对地址)", async () => { + const driver = new LanzouDriver({ + type: "url", + root_folder_id: "b00test", + shareUrl: "https://pan.lanzoui.com", + }) + + const mockFilePage = ` + + sample.zip - 蓝奏云 +
大小:10.5 M
+ + + ` + // 改版后:下载接口是 apifile 绝对地址,签名放在 wp_sign / ajaxdata + const mockIframe = ` + + + + ` + + let posted: { url: string; form: URLSearchParams } | null = null + const originalFetch = globalThis.fetch + try { + (globalThis as any).fetch = async (url: string, init: RequestInit = {}) => { + const urlStr = String(url) + const method = (init.method || "GET").toUpperCase() + if (urlStr.includes("/fn?")) return new Response(mockIframe) + if (method === "POST") { + posted = { + url: urlStr, + form: new URLSearchParams(String(init.body || "")), + } + return new Response( + JSON.stringify({ + zt: 1, + dom: "https://developer.lanzoui.com", + url: "token_new", + }), + ) + } + if (urlStr.includes("developer.lanzoui.com/file")) { + return new Response(null, { + status: 302, + headers: { location: "https://cdn.example.com/new.zip" }, + }) + } + if (urlStr.includes("pan.lanzoui.com/b00test")) { + return new Response(mockFilePage) + } + return new Response("Not Found", { status: 404 }) + } + + const item = await driver.get("/b00test", "/sample.zip") + const got = posted as { url: string; form: URLSearchParams } | null + if (!got) throw new Error("下载接口未被调用") + if (!got.url.startsWith("https://apifile.woozooo.com/ajaxfile.php?file=987654")) { + throw new Error(`下载接口地址错误: ${got.url}`) + } + if (got.form.get("sign") !== "NEWSIGN") throw new Error("sign 未透传") + if (got.form.get("signs") !== "NEWDATA") throw new Error("signs 未透传") + if (got.form.get("kd") !== "1") throw new Error("kd 未按改版结构传 1") + if (item.raw_url !== "https://cdn.example.com/new.zip") { + throw new Error(`直链错误: ${item.raw_url}`) + } + } finally { + globalThis.fetch = originalFetch + } +}) + // Clean up scratch files try { const fs = await import("fs") diff --git a/src/backend/drivers/lanzou/help.ts b/src/backend/drivers/lanzou/help.ts index 3c3d275d..5d0da90c 100644 --- a/src/backend/drivers/lanzou/help.ts +++ b/src/backend/drivers/lanzou/help.ts @@ -117,7 +117,9 @@ export function removeJSComment(data: string): string { inComment = true i++ continue - } else if (nextChar === "/") { + } else if (nextChar === "/" && !result.endsWith(":")) { + // 注意:'https://' 里的 '//' 不是注释(改版后页面大量使用绝对地址), + // 只有前一个字符不是 ':' 时才按行注释处理 inSingleLineComment = true i++ continue diff --git a/src/backend/drivers/lanzou/util.test.ts b/src/backend/drivers/lanzou/util.test.ts new file mode 100644 index 00000000..42030233 --- /dev/null +++ b/src/backend/drivers/lanzou/util.test.ts @@ -0,0 +1,173 @@ +import assert from "node:assert/strict" +import { afterEach, test } from "node:test" + +import { LanzouClient, parseFnPage } from "./util" +import { removeJSComment } from "./help" + +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +const SHARE_DOMAIN = "https://pan.lanzoui.com" +const SHARE_ID = "b00test" + +const SHARE_PAGE = `测试文件.zip - 蓝奏云 + +` + +/** 2026-10 改版 /fn 内页:下载接口为 apifile 绝对地址 + 签名参数 */ +const FN_PAGE = `` + +/** 旧版 /fn 内页:相对路径 + data 对象 */ +const LEGACY_FN_PAGE = `` + +interface CapturedPost { + url: string + form: URLSearchParams + headers: Record +} + +/** 安装 fetch 桩:/fn 内页返回 fnPage,下载接口返回 JSON,直链探测 302 到 CDN */ +function installFetch(fnPage: string): { + posts: CapturedPost[] + gets: string[] +} { + const posts: CapturedPost[] = [] + const gets: string[] = [] + globalThis.fetch = (async (input: any, init: any = {}) => { + const url = typeof input === "string" ? input : input.url + const method = (init.method || "GET").toUpperCase() + + if (url.includes("/fn?")) { + gets.push(url) + return new Response(fnPage, { status: 200 }) + } + if (method === "POST") { + posts.push({ + url, + form: new URLSearchParams(String(init.body || "")), + headers: init.headers || {}, + }) + return new Response( + JSON.stringify({ zt: 1, dom: "https://dev.lanzou.com", url: "abcdef" }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + } + if (url.includes("/file/")) { + return new Response(null, { + status: 302, + headers: { location: "https://cdn.example.com/real.zip" }, + }) + } + throw new Error(`unexpected request: ${method} ${url}`) + }) as typeof fetch + return { posts, gets } +} + +function newClient(): LanzouClient { + return new LanzouClient({ type: "url", shareUrl: SHARE_DOMAIN }) +} + +test("parseFnPage extracts apifile url and sign form", () => { + const parsed = parseFnPage(FN_PAGE) + assert.ok(parsed) + assert.equal( + parsed!.url, + "https://apifile.woozooo.com/ajaxfile.php?file=1234567", + ) + assert.equal(parsed!.fileId, "1234567") + assert.deepEqual(parsed!.form, { + action: "downprocess", + websignkey: "DATAVALUE", + signs: "DATAVALUE", + sign: "SIGNVALUE", + websign: "2", + kd: "1", + ves: "1", + }) +}) + +test("parseFnPage returns null for legacy page", () => { + assert.equal(parseFnPage(LEGACY_FN_PAGE), null) +}) + +test("removeJSComment keeps url '//' but strips real comments", () => { + const src = [ + "var a = 'https://apifile.woozooo.com/ajaxfile.php?file=1' // 行注释", + "/* 块注释 */ var b = 1;", + ].join("\n") + const out = removeJSComment(src) + assert.ok(out.includes("https://apifile.woozooo.com/ajaxfile.php?file=1")) + assert.ok(!out.includes("行注释")) + assert.ok(!out.includes("块注释")) + assert.ok(out.includes("var b = 1;")) +}) + +test("new /fn structure posts to absolute apifile url with sign form", async () => { + const { posts, gets } = installFetch(FN_PAGE) + const file = await newClient().getFilesByShareUrl( + SHARE_ID, + "", + SHARE_PAGE, + SHARE_DOMAIN, + ) + + assert.deepEqual(gets, [`${SHARE_DOMAIN}/fn?abcd1234`]) + assert.equal(posts.length, 1) + assert.equal( + posts[0].url, + "https://apifile.woozooo.com/ajaxfile.php?file=1234567", + ) + assert.equal(posts[0].form.get("action"), "downprocess") + assert.equal(posts[0].form.get("websignkey"), "DATAVALUE") + assert.equal(posts[0].form.get("signs"), "DATAVALUE") + assert.equal(posts[0].form.get("sign"), "SIGNVALUE") + assert.equal(posts[0].form.get("websign"), "2") + // 与上游 Go 版一致:跨域 POST 的 Referer 回落为 baseUrl + assert.equal(posts[0].headers["Referer"], "https://pc.woozooo.com") + + assert.equal(file.name_all, "测试文件.zip") + assert.equal(file.url, "https://cdn.example.com/real.zip") +}) + +test("legacy /fn structure still posts to share-domain ajaxfile.php", async () => { + const { posts } = installFetch(LEGACY_FN_PAGE) + const file = await newClient().getFilesByShareUrl( + SHARE_ID, + "", + SHARE_PAGE, + SHARE_DOMAIN, + ) + + assert.equal(posts.length, 1) + assert.equal(`${SHARE_DOMAIN}/ajaxfile.php?file=888`, posts[0].url) + assert.equal(posts[0].form.get("signs"), "OLDSIGN") + assert.equal(file.url, "https://cdn.example.com/real.zip") +}) + +test("absolute iframe src is used as-is instead of being prefixed", async () => { + const { gets, posts } = installFetch(FN_PAGE) + const subDomain = "https://user.lanzn.com" + const page = `` + + await newClient().getFilesByShareUrl(SHARE_ID, "", page, subDomain) + + assert.deepEqual(gets, [`${subDomain}/fn?abcd1234`]) + assert.equal( + posts[0].url, + "https://apifile.woozooo.com/ajaxfile.php?file=1234567", + ) +}) diff --git a/src/backend/drivers/lanzou/util.ts b/src/backend/drivers/lanzou/util.ts index 42e8d7a5..0af56218 100644 --- a/src/backend/drivers/lanzou/util.ts +++ b/src/backend/drivers/lanzou/util.ts @@ -27,6 +27,45 @@ const LANZOU_SHARE_DOMAINS = [ /** 蓝奏云 Cookie 过期提示文案(cookie 有效期约 15 天) */ const COOKIE_EXPIRED_MSG = "[Lanzou] Cookie 已过期或失效,请在管理后台重新填写 Cookie(有效期约 15 天)" +/** 旧版分享下载页结构:相对路径 '/ajaxm.php?file=N'(带引号) */ +const LEGACY_FILE_ID_RE = /'\/ajax(?:file|m)\.php\?file=(\d+)'/ + +/** 2026-10 改版:文件页将下载参数移入 /fn? 内页,接口变为 apifile 绝对地址并携带签名 */ +const FN_DOMAIN_RE = /var\s+domain[12]\s*=\s*'([^']*(?:ajaxfile|ajaxm)\.php\?file=(\d+)[^']*)'/ +const FN_SIGN_RE = /var\s+wp_sign\s*=\s*'([^']*)'/ +const FN_AJAXDATA_RE = /var\s+ajaxdata\s*=\s*'([^']*)'/ + +/** + * 从改版后的 /fn? 内页提取下载接口地址与签名表单。 + * 对应页面 JS:POST domain1 {'action':'downprocess','websignkey':ajaxdata, + * 'signs':ajaxdata,'sign':wp_sign,'websign':'2','kd':kdns,'ves':1} + * 未命中改版结构时返回 null(调用方回退到旧版解析)。 + */ +export function parseFnPage(pageData: string): { + url: string + fileId: string + form: Record +} | null { + const domainMatch = pageData.match(FN_DOMAIN_RE) + if (!domainMatch) return null + const signMatch = pageData.match(FN_SIGN_RE) + const ajaxDataMatch = pageData.match(FN_AJAXDATA_RE) + if (!signMatch || !ajaxDataMatch) return null + return { + url: domainMatch[1], + fileId: domainMatch[2], + form: { + action: "downprocess", + websignkey: ajaxDataMatch[1], + signs: ajaxDataMatch[1], + sign: signMatch[1], + websign: "2", + kd: "1", + ves: "1", + }, + } +} + export class LanzouClient { private addition: LanzouAddition private cookie: string = "" @@ -667,6 +706,31 @@ export class LanzouClient { return files } + /** + * POST 分享下载接口并解析响应。 + * 旧版走分享域相对地址(ajaxfile.php?file=N),2026-10 改版走 apifile 绝对地址 + 签名表单。 + * @param referer 省略时使用 baseUrl(与上游 Go 版一致) + */ + private async postShareAjax( + url: string, + form: Record, + referer?: string, + ): Promise { + const resStr = await this.request(url, "POST", form, referer) + let resp: LanzouShareResp + try { + resp = JSON.parse(resStr) + } catch { + throw new Error(`[Lanzou] 下载接口响应格式错误: ${resStr}`) + } + if (resp.zt !== 1) { + throw new Error( + resp.info || resp.text || `[Lanzou] 提取链接失败 (zt=${resp.zt})`, + ) + } + return resp + } + /** * 解析单文件分享页面并提取下载直链 */ @@ -749,7 +813,11 @@ export class LanzouClient { } const iframePath = iframeMatch[1] - const iframeFullUrl = `${shareBaseDomain}${iframePath.startsWith("/") ? "" : "/"}${iframePath}` + // 改版后 iframe 可能直接给绝对地址(如 https://xxx.lanzn.com/fn?token), + // 此时不能再拼 shareBaseDomain,否则会拼成 https://hosthttps://host/fn?... + const iframeFullUrl = /^https?:\/\//i.test(iframePath) + ? iframePath + : `${shareBaseDomain}${iframePath.startsWith("/") ? "" : "/"}${iframePath}` const nextPageData = await this.request( iframeFullUrl, "GET", @@ -757,32 +825,39 @@ export class LanzouClient { sharePageUrl, ) const cleanNextPage = removeNotes(nextPageData) - param = htmlJsonToMap(cleanNextPage, cleanNextPage) - const fileIdMatch = - cleanNextPage.match(/['"]?\/?ajax(?:file|m)\.php\?file=(\d+)['"]?/) || - cleanNextPage.match(/file\s*[:=]\s*['"]?(\d+)['"]?/) || - cleanNextPage.match(/file=(\d+)/) || - cleanNextPage.match(/var\s+file_id\s*=\s*['"]?(\d+)['"]?/) - const fileId = fileIdMatch ? fileIdMatch[1] : "" - if (!fileId) throw new Error("[Lanzou] 未找到文件 ID") - - const resStr = await this.request( - `${shareBaseDomain}/ajaxfile.php?file=${fileId}`, - "POST", - param, - iframeFullUrl, - ) let resp: LanzouShareResp - try { - resp = JSON.parse(resStr) - } catch { - throw new Error(`[Lanzou] ajaxfile.php 响应格式错误: ${resStr}`) - } - if (resp.zt !== 1) { - throw new Error( - resp.info || resp.text || `[Lanzou] 提取链接失败 (zt=${resp.zt})`, + const legacyIdMatch = cleanNextPage.match(LEGACY_FILE_ID_RE) + if (legacyIdMatch) { + // 旧版结构:相对路径 /ajaxm.php?file=N + param = htmlJsonToMap(cleanNextPage, cleanNextPage) + resp = await this.postShareAjax( + `${shareBaseDomain}/ajaxfile.php?file=${legacyIdMatch[1]}`, + param, + iframeFullUrl, ) + } else { + const fnPage = parseFnPage(cleanNextPage) + if (fnPage) { + // 2026-10 改版结构:/fn? 内页携带 apifile 绝对地址与签名参数, + // 必须原样 POST 到该地址(签名与 file=N 绑定),不能再拼成分享域相对路径 + resp = await this.postShareAjax(fnPage.url, fnPage.form) + } else { + // 兜底:页面变体(无引号 / 无前导斜杠),沿用原有宽松提取 + param = htmlJsonToMap(cleanNextPage, cleanNextPage) + const fileIdMatch = + cleanNextPage.match(/['"]?\/?ajax(?:file|m)\.php\?file=(\d+)['"]?/) || + cleanNextPage.match(/file\s*[:=]\s*['"]?(\d+)['"]?/) || + cleanNextPage.match(/file=(\d+)/) || + cleanNextPage.match(/var\s+file_id\s*=\s*['"]?(\d+)['"]?/) + const fileId = fileIdMatch ? fileIdMatch[1] : "" + if (!fileId) throw new Error("[Lanzou] 未找到文件 ID") + resp = await this.postShareAjax( + `${shareBaseDomain}/ajaxfile.php?file=${fileId}`, + param, + iframeFullUrl, + ) + } } baseUrl = `${resp.dom}/file` From e3bc0bfe7d7362b99bf0a5735903bc730de283d8 Mon Sep 17 00:00:00 2001 From: Wudarensheng Date: Mon, 26 Oct 2026 18:22:10 +0800 Subject: [PATCH 2/3] refactor(drivers/lanzou): address review feedback Review follow-ups for the /fn page structure fix: - document parseFnPage()'s return contract: null means "not a 2026-10 /fn page", not an error, and the caller must fall back to the legacy parse - report an actionable error when the download page matches neither the legacy nor the /fn structure (previously "not find file id", which reads like a parse bug rather than "Lanzou changed again") - tests: parseFnPage returns null when wp_sign/ajaxdata are missing, legacy markers take priority on a page carrying both structures, unrecognized page yields the new error, and a driver-level legacy regression case so backward compatibility is asserted explicitly No behavior change for legacy pages. --- scripts/test-lanzou.mts | 74 +++++++++++++++++++++++++ src/backend/drivers/lanzou/util.test.ts | 46 +++++++++++++++ src/backend/drivers/lanzou/util.ts | 16 +++++- 3 files changed, 133 insertions(+), 3 deletions(-) diff --git a/scripts/test-lanzou.mts b/scripts/test-lanzou.mts index 8174cf07..e5f0edf9 100644 --- a/scripts/test-lanzou.mts +++ b/scripts/test-lanzou.mts @@ -237,6 +237,80 @@ await test("Lanzou Driver get() 适配改版 /fn 内页(apifile 绝对地址 } }) +// 6. 旧版 /fn 内页回归:确认改版适配没有破坏原有结构 +await test("Lanzou Driver get() 保持旧版 /fn 内页兼容性", async () => { + const driver = new LanzouDriver({ + type: "url", + root_folder_id: "b00test", + shareUrl: "https://pan.lanzoui.com", + }) + + const mockFilePage = ` + + legacy.zip - 蓝奏云 +
大小:1.2 M
+ + + ` + // 旧版:相对路径 + data 对象(改版前一直用的结构) + const mockIframe = ` + + + + ` + + let posted: { url: string; form: URLSearchParams } | null = null + const originalFetch = globalThis.fetch + try { + (globalThis as any).fetch = async (url: string, init: RequestInit = {}) => { + const urlStr = String(url) + const method = (init.method || "GET").toUpperCase() + if (urlStr.includes("/fn?")) return new Response(mockIframe) + if (method === "POST") { + posted = { url: urlStr, form: new URLSearchParams(String(init.body || "")) } + return new Response( + JSON.stringify({ zt: 1, dom: "https://developer.lanzoui.com", url: "token_old" }), + ) + } + if (urlStr.includes("developer.lanzoui.com/file")) { + return new Response(null, { + status: 302, + headers: { location: "https://cdn.example.com/legacy.zip" }, + }) + } + if (urlStr.includes("pan.lanzoui.com/b00test")) { + return new Response(mockFilePage) + } + return new Response("Not Found", { status: 404 }) + } + + const item = await driver.get("/b00test", "/legacy.zip") + const got = posted as { url: string; form: URLSearchParams } | null + if (!got) throw new Error("下载接口未被调用") + // 旧版仍应 POST 到分享域相对地址,而不是 apifile + if (got.url !== "https://pan.lanzoui.com/ajaxfile.php?file=555") { + throw new Error(`旧版下载接口地址错误: ${got.url}`) + } + if (got.form.get("signs") !== "OLD_AJAX") throw new Error("signs 未透传") + if (got.form.get("sign") !== "OLD_SIGN") throw new Error("sign 未透传") + if (item.raw_url !== "https://cdn.example.com/legacy.zip") { + throw new Error(`直链错误: ${item.raw_url}`) + } + } finally { + globalThis.fetch = originalFetch + } +}) + // Clean up scratch files try { const fs = await import("fs") diff --git a/src/backend/drivers/lanzou/util.test.ts b/src/backend/drivers/lanzou/util.test.ts index 42030233..6d5d07e0 100644 --- a/src/backend/drivers/lanzou/util.test.ts +++ b/src/backend/drivers/lanzou/util.test.ts @@ -104,6 +104,18 @@ test("parseFnPage returns null for legacy page", () => { assert.equal(parseFnPage(LEGACY_FN_PAGE), null) }) +test("parseFnPage returns null when sign vars are missing", () => { + // 只有 domain1、缺 wp_sign / ajaxdata:不算改版结构,交给旧版兜底 + const partial = `` + assert.equal(parseFnPage(partial), null) + + const noAjaxData = `` + assert.equal(parseFnPage(noAjaxData), null) +}) + test("removeJSComment keeps url '//' but strips real comments", () => { const src = [ "var a = 'https://apifile.woozooo.com/ajaxfile.php?file=1' // 行注释", @@ -171,3 +183,37 @@ test("absolute iframe src is used as-is instead of being prefixed", async () => "https://apifile.woozooo.com/ajaxfile.php?file=1234567", ) }) + +test("legacy markers win when the page carries both legacy and fn structure", async () => { + // 优先级约定:旧版严格正则先命中就不再看 /fn 结构(与上游 Go 版一致) + const mixed = `` + + const { posts } = installFetch(mixed) + await newClient().getFilesByShareUrl(SHARE_ID, "", SHARE_PAGE, SHARE_DOMAIN) + + assert.equal(posts.length, 1) + assert.equal(`${SHARE_DOMAIN}/ajaxfile.php?file=777`, posts[0].url) + assert.equal(posts[0].form.get("signs"), "OLDSIGN") +}) + +test("unrecognized page reports an actionable error instead of 'not find file id'", async () => { + const unknown = `` + + installFetch(unknown) + await assert.rejects( + () => + newClient().getFilesByShareUrl(SHARE_ID, "", SHARE_PAGE, SHARE_DOMAIN), + /未能识别下载页结构/, + ) +}) diff --git a/src/backend/drivers/lanzou/util.ts b/src/backend/drivers/lanzou/util.ts index 0af56218..0a1a903b 100644 --- a/src/backend/drivers/lanzou/util.ts +++ b/src/backend/drivers/lanzou/util.ts @@ -35,11 +35,17 @@ const FN_DOMAIN_RE = /var\s+domain[12]\s*=\s*'([^']*(?:ajaxfile|ajaxm)\.php\?fil const FN_SIGN_RE = /var\s+wp_sign\s*=\s*'([^']*)'/ const FN_AJAXDATA_RE = /var\s+ajaxdata\s*=\s*'([^']*)'/ +/** 新旧结构都不匹配时的提示:说明页面可能又改版了,而不是笼统的"未找到文件 ID" */ +const UNKNOWN_SHARE_PAGE_MSG = + "[Lanzou] 未能识别下载页结构(旧版 /ajaxm.php 与 2026-10 的 /fn 结构都不匹配),蓝奏云可能再次改版,需要更新驱动" + /** * 从改版后的 /fn? 内页提取下载接口地址与签名表单。 * 对应页面 JS:POST domain1 {'action':'downprocess','websignkey':ajaxdata, * 'signs':ajaxdata,'sign':wp_sign,'websign':'2','kd':kdns,'ves':1} - * 未命中改版结构时返回 null(调用方回退到旧版解析)。 + * @param pageData /fn 内页 HTML(建议先过 removeNotes) + * @returns 解析成功返回 { url, fileId, form };未命中改版结构(缺 domain1/wp_sign/ajaxdata) + * 返回 null —— 这不是错误,调用方应回退到旧版解析 */ export function parseFnPage(pageData: string): { url: string @@ -844,14 +850,18 @@ export class LanzouClient { resp = await this.postShareAjax(fnPage.url, fnPage.form) } else { // 兜底:页面变体(无引号 / 无前导斜杠),沿用原有宽松提取 - param = htmlJsonToMap(cleanNextPage, cleanNextPage) + try { + param = htmlJsonToMap(cleanNextPage, cleanNextPage) + } catch { + throw new Error(UNKNOWN_SHARE_PAGE_MSG) + } const fileIdMatch = cleanNextPage.match(/['"]?\/?ajax(?:file|m)\.php\?file=(\d+)['"]?/) || cleanNextPage.match(/file\s*[:=]\s*['"]?(\d+)['"]?/) || cleanNextPage.match(/file=(\d+)/) || cleanNextPage.match(/var\s+file_id\s*=\s*['"]?(\d+)['"]?/) const fileId = fileIdMatch ? fileIdMatch[1] : "" - if (!fileId) throw new Error("[Lanzou] 未找到文件 ID") + if (!fileId) throw new Error(UNKNOWN_SHARE_PAGE_MSG) resp = await this.postShareAjax( `${shareBaseDomain}/ajaxfile.php?file=${fileId}`, param, From 87e91f1ce93b51b3c6299169e913efe3dd39317f Mon Sep 17 00:00:00 2001 From: Wudarensheng Date: Tue, 10 Nov 2026 18:13:38 +0800 Subject: [PATCH 3/3] fix(drivers/lanzou): validate /fn api url against lanzou host allowlist Review follow-up (P1): parseFnPage() extracts the download API URL from the remote page text, and postShareAjax() POSTed to it verbatim. A tampered/injected page could therefore redirect the server-side request to any host (SSRF / intranet probing) and carry the driver cookie along. - add isTrustedLanzouApiUrl(): https only, hostname must belong to the lanzou host suffix allowlist (woozooo.com / lanzn.com / lanzouX.com family); rejects invalid URLs, non-https, loopback/private IPs, lookalike domains (evilwoozooo.com) and userinfo tricks (apifile.woozooo.com@evil.com) - reject untrusted URLs with an actionable error before issuing any request; the driver cookie can now only reach allowlisted lanzou hosts - drop the bare `file=(\d+)` / `file: N` guesses from the lenient fallback: they could "match" the file id inside an apifile URL and fire a doomed request to the wrong host; unrecognized pages now fail explicitly with UNKNOWN_SHARE_PAGE_MSG - removeJSComment(): also treat '//' immediately after a string-literal quote as URL content, so protocol-relative src="//cdn..." is no longer truncated - tests: allowlist unit cases, plus integration rejection without issuing any request for evil.example.com / 127.0.0.1 / 169.254.169.254 / http test:drivers 130 pass / 0 fail; lanzou util.test.ts 12 pass / 0 fail; scripts/test-lanzou.mts 6 pass / 0 fail --- src/backend/drivers/lanzou/help.ts | 20 +++++- src/backend/drivers/lanzou/util.test.ts | 84 ++++++++++++++++++++++++- src/backend/drivers/lanzou/util.ts | 53 ++++++++++++++-- 3 files changed, 149 insertions(+), 8 deletions(-) diff --git a/src/backend/drivers/lanzou/help.ts b/src/backend/drivers/lanzou/help.ts index 5d0da90c..56e48e33 100644 --- a/src/backend/drivers/lanzou/help.ts +++ b/src/backend/drivers/lanzou/help.ts @@ -117,9 +117,7 @@ export function removeJSComment(data: string): string { inComment = true i++ continue - } else if (nextChar === "/" && !result.endsWith(":")) { - // 注意:'https://' 里的 '//' 不是注释(改版后页面大量使用绝对地址), - // 只有前一个字符不是 ':' 时才按行注释处理 + } else if (nextChar === "/" && !isUrlDoubleSlash(result)) { inSingleLineComment = true i++ continue @@ -131,6 +129,22 @@ export function removeJSComment(data: string): string { return result } +/** + * 判断紧随其后的 '//' 是否为 URL 的一部分(而非行注释)。 + * 两类已知误伤都要放过: + * 1. 'https://' —— scheme 冒号紧跟 '//'(改版后页面大量使用绝对地址); + * 2. 协议相对地址 '//'cdn...'' —— 出现在字符串字面量里,前一个已输出字符是引号。 + * 宁多保留、不误删:多留一段注释文本无害,截断真实 URL 致命。 + */ +function isUrlDoubleSlash(emitted: string): boolean { + return ( + emitted.endsWith(":") || + emitted.endsWith("'") || + emitted.endsWith('"') || + emitted.endsWith("`") + ) +} + function unbox(hexStr: string): string { const box = [ 6, 28, 34, 31, 33, 18, 30, 23, 9, 8, 19, 38, 17, 24, 0, 5, 32, 21, 10, 22, diff --git a/src/backend/drivers/lanzou/util.test.ts b/src/backend/drivers/lanzou/util.test.ts index 6d5d07e0..4fbed019 100644 --- a/src/backend/drivers/lanzou/util.test.ts +++ b/src/backend/drivers/lanzou/util.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { afterEach, test } from "node:test" -import { LanzouClient, parseFnPage } from "./util" +import { isTrustedLanzouApiUrl, LanzouClient, parseFnPage } from "./util" import { removeJSComment } from "./help" const originalFetch = globalThis.fetch @@ -128,6 +128,68 @@ test("removeJSComment keeps url '//' but strips real comments", () => { assert.ok(out.includes("var b = 1;")) }) +test("removeJSComment keeps protocol-relative urls inside strings", () => { + // 评审 P2:src="//cdn..." 的前两个字符也是 '//' 且前面是引号,不能当行注释截断 + const src = ` var b = 1 // 真注释` + const out = removeJSComment(src) + assert.ok(out.includes("//cdn.woozooo.com/x.js")) + assert.ok(!out.includes("真注释")) + assert.ok(out.includes("var b = 1")) +}) + +test("isTrustedLanzouApiUrl only allows https lanzou hosts", () => { + // 可信:蓝奏云自家域(含子域) + assert.equal( + isTrustedLanzouApiUrl("https://apifile.woozooo.com/ajaxfile.php?file=1"), + true, + ) + assert.equal(isTrustedLanzouApiUrl("https://user.lanzn.com/fn?x=1"), true) + assert.equal( + isTrustedLanzouApiUrl("https://pan.lanzoui.com/ajaxfile.php?file=1"), + true, + ) + // 不可信:非 https + assert.equal( + isTrustedLanzouApiUrl("http://apifile.woozooo.com/ajaxfile.php?file=1"), + false, + ) + // 不可信:近似域(evilwoozooo.com 不是 .woozooo.com 的子域) + assert.equal( + isTrustedLanzouApiUrl( + "https://apifile.evilwoozooo.com/ajaxfile.php?file=1", + ), + false, + ) + assert.equal( + isTrustedLanzouApiUrl("https://apifile.woozooo.com.evil.com/ajaxfile.php"), + false, + ) + // 不可信:userinfo 伪装(apifile.woozooo.com@evil.com 的主机是 evil.com) + assert.equal( + isTrustedLanzouApiUrl("https://apifile.woozooo.com@evil.com/ajaxfile.php"), + false, + ) + // 不可信:loopback / 私网 / 内网元数据地址 + assert.equal( + isTrustedLanzouApiUrl("https://127.0.0.1/ajaxfile.php?file=1"), + false, + ) + assert.equal( + isTrustedLanzouApiUrl("https://169.254.169.254/ajaxfile.php?file=1"), + false, + ) + assert.equal( + isTrustedLanzouApiUrl("https://10.0.0.5/ajaxfile.php?file=1"), + false, + ) + assert.equal( + isTrustedLanzouApiUrl("https://[::1]/ajaxfile.php?file=1"), + false, + ) + // 不可信:非法 URL + assert.equal(isTrustedLanzouApiUrl("not a url"), false) +}) + test("new /fn structure posts to absolute apifile url with sign form", async () => { const { posts, gets } = installFetch(FN_PAGE) const file = await newClient().getFilesByShareUrl( @@ -217,3 +279,23 @@ $("#go").click(function(){ /未能识别下载页结构/, ) }) + +test("fn page pointing outside lanzou domains is rejected (SSRF guard)", async () => { + // 评审 P1:domain1 由远端页面内容决定,指向非蓝奏云域时必须拒绝, + // 且不能发出任何请求( Cookie 不得外送) + for (const badHost of [ + "https://evil.example.com", + "https://127.0.0.1", + "https://169.254.169.254", + "http://apifile.woozooo.com", + ]) { + const evil = FN_PAGE.replace("https://apifile.woozooo.com", badHost) + const { posts } = installFetch(evil) + await assert.rejects( + () => + newClient().getFilesByShareUrl(SHARE_ID, "", SHARE_PAGE, SHARE_DOMAIN), + /可信域名/, + ) + assert.equal(posts.length, 0, `${badHost} 不应发出任何请求`) + } +}) diff --git a/src/backend/drivers/lanzou/util.ts b/src/backend/drivers/lanzou/util.ts index 0a1a903b..12d1914e 100644 --- a/src/backend/drivers/lanzou/util.ts +++ b/src/backend/drivers/lanzou/util.ts @@ -39,6 +39,45 @@ const FN_AJAXDATA_RE = /var\s+ajaxdata\s*=\s*'([^']*)'/ const UNKNOWN_SHARE_PAGE_MSG = "[Lanzou] 未能识别下载页结构(旧版 /ajaxm.php 与 2026-10 的 /fn 结构都不匹配),蓝奏云可能再次改版,需要更新驱动" +/** /fn 页面给出的接口地址不在可信域名内的提示(SSRF / Cookie 外送防护) */ +const UNTRUSTED_API_URL_MSG = + "[Lanzou] /fn 页面给出的下载接口地址不在蓝奏云可信域名内,已拒绝请求(防止 SSRF)。" + + "如蓝奏云更换接口域名,请更新驱动的可信域名列表" + +/** + * 下载接口允许的域名后缀(后缀匹配,近似域如 evilwoozooo.com 不会命中)。 + * /fn 页面给出的绝对地址只允许发往蓝奏云自家域:既防止被篡改/注入的页面把 + * 服务端请求引向任意主机(SSRF / 内网探测),也保证驱动 Cookie 不会带出蓝奏云域。 + */ +const TRUSTED_API_HOST_SUFFIXES = [ + "woozooo.com", // apifile.woozooo.com(2026-10 改版的下载接口) + "lanzn.com", // 蓝奏云账号分享子域(xxx.lanzn.com) + "lanzoui.com", // 以下为蓝奏云轮换使用的分享/接口域家族 + "lanzouw.com", + "lanzoux.com", + "lanzouy.com", + "lanzou.com", +] + +/** + * 校验 /fn 页面解析出的下载接口地址是否可信。 + * @returns 仅当协议为 https 且主机属于蓝奏云可信域(精确后缀匹配,含子域)时返回 true; + * 非法 URL、非 https、loopback/私网 IP、近似域、userinfo 伪装一律 false + */ +export function isTrustedLanzouApiUrl(raw: string): boolean { + let url: URL + try { + url = new URL(raw) + } catch { + return false + } + if (url.protocol !== "https:") return false + const host = url.hostname.toLowerCase() + return TRUSTED_API_HOST_SUFFIXES.some( + (s) => host === s || host.endsWith(`.${s}`), + ) +} + /** * 从改版后的 /fn? 内页提取下载接口地址与签名表单。 * 对应页面 JS:POST domain1 {'action':'downprocess','websignkey':ajaxdata, @@ -846,10 +885,18 @@ export class LanzouClient { const fnPage = parseFnPage(cleanNextPage) if (fnPage) { // 2026-10 改版结构:/fn? 内页携带 apifile 绝对地址与签名参数, - // 必须原样 POST 到该地址(签名与 file=N 绑定),不能再拼成分享域相对路径 + // 必须原样 POST 到该地址(签名与 file=N 绑定),不能再拼成分享域相对路径。 + // 接口地址来自远端页面文本,先做可信域校验(评审 P1:防 SSRF / Cookie 外送) + if (!isTrustedLanzouApiUrl(fnPage.url)) { + throw new Error( + `${UNTRUSTED_API_URL_MSG}(host: ${safeHost(fnPage.url)})`, + ) + } resp = await this.postShareAjax(fnPage.url, fnPage.form) } else { - // 兜底:页面变体(无引号 / 无前导斜杠),沿用原有宽松提取 + // 兜底:页面变体(无引号 / 无前导斜杠),沿用原有宽松提取。 + // 注意:不放行裸 `file=(\d+)` / `file: 123` 这类猜测 —— 它们会在 apifile + // 绝对地址上"蒙对" file ID 然后发一个注定失败的请求;识别不了就显式失败 try { param = htmlJsonToMap(cleanNextPage, cleanNextPage) } catch { @@ -857,8 +904,6 @@ export class LanzouClient { } const fileIdMatch = cleanNextPage.match(/['"]?\/?ajax(?:file|m)\.php\?file=(\d+)['"]?/) || - cleanNextPage.match(/file\s*[:=]\s*['"]?(\d+)['"]?/) || - cleanNextPage.match(/file=(\d+)/) || cleanNextPage.match(/var\s+file_id\s*=\s*['"]?(\d+)['"]?/) const fileId = fileIdMatch ? fileIdMatch[1] : "" if (!fileId) throw new Error(UNKNOWN_SHARE_PAGE_MSG)