From e171db6d5f03a32cad18e7b7d561cf973812bef8 Mon Sep 17 00:00:00 2001 From: Pikachu Ren Date: Sun, 1 Nov 2026 22:41:42 +0800 Subject: [PATCH 1/4] fix(auth): honor disabled guest access Issue #133 reports that unauthenticated visitors can browse mounted content despite expecting sign-in. The public settings endpoint already exposes allow_guest=false, but getUserFromContext previously fell back to any active guest account without enforcing that setting. When no Authorization header or query token is present, return null before guest fallback if allow_guest is false. Preserve the current default guest behavior when the setting is absent. Add a regression test proving default fallback remains enabled and that disabling it makes anonymous /api/fs/list return 401. Document that guest access is an OpenList application setting, not a Cloudflare environment variable. Fixes #133 Validation: git diff --cached --check passed. Targeted runtime test is pending dependency installation in an isolated verification copy. --- README.md | 1 + readmes/README_en.md | 1 + src/backend/server/guest_auth.test.ts | 67 +++++++++++++++++++++++++++ src/backend/server/middlewares.ts | 9 +++- 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b466ac7b..6e9845c7 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ OpenList-Worker 是官方 [OpenListTeam/OpenList](https://github.com/OpenListTea ### 权限管理 - **权限管理**:基于角色的访问控制(RBAC),支持用户分组、目录级读写权限与配额。 +- **访客访问**:默认启用 guest 访客回退,未登录请求会按 guest 用户权限访问。若要要求登录后才能浏览,请在应用管理界面关闭“访客访问”(`allow_guest=false`);这是 OpenList 的应用设置,不是在 Cloudflare 控制台配置的环境变量。 - **认证方式**:内置账号密码,支持TOTP验证、WebAuthn/FIDO登录、SSO单点登录与 LDAP 目录认证。 - **安全加固**:JWT 会话、CSRF 防护、点击劫持防护(X-Frame-Options)、内容安全策略(CSP)。 - **健康检查**:提供 `/health` 存活探针与 `/healthz` 就绪探针,可用于监控与告警。 diff --git a/readmes/README_en.md b/readmes/README_en.md index bf35d179..9fc8f87b 100644 --- a/readmes/README_en.md +++ b/readmes/README_en.md @@ -80,6 +80,7 @@ In addition to the real storages above, virtual/functional drivers such as `Loca ### Access Control - **Permissions**: role-based access control (RBAC), supporting user groups, directory-level read/write permissions and quotas. +- **Guest access**: guest fallback is enabled by default, so unauthenticated requests use the guest user's permissions. To require sign-in before browsing, turn off guest access (`allow_guest=false`) in the OpenList application settings; this is not a Cloudflare environment variable. - **Authentication**: built-in account passwords with TOTP verification, WebAuthn/FIDO login, SSO single sign-on and LDAP directory authentication. - **Security hardening**: JWT sessions, CSRF protection, clickjacking protection (X-Frame-Options), Content Security Policy (CSP). - **Health checks**: provide `/health` liveness probe and `/healthz` readiness probe for monitoring and alerting. diff --git a/src/backend/server/guest_auth.test.ts b/src/backend/server/guest_auth.test.ts index 12256f09..0a2add83 100644 --- a/src/backend/server/guest_auth.test.ts +++ b/src/backend/server/guest_auth.test.ts @@ -77,6 +77,73 @@ test("Security: getUserFromContext returns null when guest is deleted or disable ) }) +test("Security: allow_guest=false blocks anonymous guest fallback", async () => { + const env: any = {} + const admin = { + id: 1, + username: "admin", + password: await hashPasswordSHA256("admin123"), + role: 2, + permission: 0, + base_path: "/", + disabled: false, + } + const guest = { + id: 2, + username: "guest", + password: "", + role: 1, + permission: 0, + base_path: "/", + disabled: false, + } + const users = [admin, guest] + const context: any = { + req: { + header: () => undefined, + query: () => undefined, + }, + env, + } + + await saveDb({ settings: [], users, storages: [], shares: [] }, env) + assert.equal( + (await getUserFromContext(context))?.id, + guest.id, + "Guest fallback remains enabled by default", + ) + + await saveDb( + { + settings: [{ key: "allow_guest", value: "false" }], + users, + storages: [], + shares: [], + }, + env, + ) + assert.equal( + await getUserFromContext(context), + null, + "allow_guest=false must disable anonymous guest fallback", + ) + + const app = new Hono() + app.route("/api/fs", fsRouter) + const response = await app.request( + "/api/fs/list", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "/" }), + }, + env, + ) + assert.equal(response.status, 401) + const responseBody: any = await response.json() + assert.equal(responseBody.code, 401) +}) + test("Security: /api/me returns 401 when unauthenticated and guest is deleted or disabled", async () => { const env: any = {} const adminHash = await hashPasswordSHA256("admin123") diff --git a/src/backend/server/middlewares.ts b/src/backend/server/middlewares.ts index 0c7d7d45..dbfdc2b4 100644 --- a/src/backend/server/middlewares.ts +++ b/src/backend/server/middlewares.ts @@ -494,11 +494,16 @@ export async function getUserFromContext(c: Context): Promise<{ } } - // 无 Authorization header:尝试使用 guest 用户作为后备 - // 注意:guest 不可用时返回 null 是合理的,调用方应该正确处理 + // 无 Authorization header:仅在允许访客访问时使用 guest 用户作为后备。 + // allow_guest 是应用层安全设置,必须由后端鉴权路径执行,不能只作为前端显示提示。 if (!authHeader) { try { const db = await getDb(c.env) + const allowGuest = (db.settings || []).find( + (setting: any) => setting.key === "allow_guest", + )?.value + if (allowGuest === "false") return null + const guest = (db.users || []).find((u: any) => u.username === "guest") if (guest && !guest.disabled) { return { From c56b8e682c04140f81ddff8bc92eb14e855294d9 Mon Sep 17 00:00:00 2001 From: pikachuren Date: Mon, 9 Nov 2026 17:43:16 +0800 Subject: [PATCH 2/4] fix(auth): unify allow_guest check and cover Emby anonymous entry - Add pkg/permission.isGuestAllowed(): tolerates boolean false / "False" / padded values, and is shared by /api/public/settings and every auth path so display and enforcement cannot drift apart - getUserFromContext uses isGuestAllowed (bare === "false" failed open) - Emby anonymous cover entry (guestUserOf) now honors allow_guest too - Regression tests: boolean/case/padded settings values and Emby anonymous cover returning 401; both verified by mutation Co-Authored-By: CodeBuddy --- src/backend/pkg/permission.ts | 23 ++++++++ src/backend/server/emby.ts | 11 +++- src/backend/server/emby_protocol.test.ts | 62 +++++++++++++++++++++ src/backend/server/guest_auth.test.ts | 70 ++++++++++++++++++++++++ src/backend/server/middlewares.ts | 8 +-- src/backend/server/public.ts | 5 +- 6 files changed, 172 insertions(+), 7 deletions(-) diff --git a/src/backend/pkg/permission.ts b/src/backend/pkg/permission.ts index a4e47c30..92746af2 100644 --- a/src/backend/pkg/permission.ts +++ b/src/backend/pkg/permission.ts @@ -154,3 +154,26 @@ export function isWithinUserRoot( if (root === "/") return true return target === root || target.startsWith(root + "/") } + +/** + * 访客访问开关(应用设置 `allow_guest`)。 + * + * 设置值来自应用设置(`db.settings`)。历史数据与第三方写入可能是字符串、 + * 布尔值,或带空白/大小写差异(`"false"` / `"False"` / `false`)。统一按 + * 「字符串化后 trim + 小写 === "false"」判定;缺失或其它值一律视为允许, + * 保持「未配置 = 旧行为」的兼容性。 + * + * 为什么必须集中在这里:展示端(`/api/public/settings`)与鉴权端 + * (`getUserFromContext`、Emby 匿名封面入口)各写一套比较时,只要一边改动, + * 就会出现「设置页显示已关闭、接口仍放行」的漂移。 + */ +export function isGuestAllowed(settings: any): boolean { + let raw: any + if (Array.isArray(settings)) { + raw = settings.find((s: any) => s && s.key === "allow_guest")?.value + } else if (settings && typeof settings === "object") { + raw = settings.allow_guest + } + if (raw === undefined || raw === null) return true + return String(raw).trim().toLowerCase() !== "false" +} diff --git a/src/backend/server/emby.ts b/src/backend/server/emby.ts index 64bb94b7..91177882 100644 --- a/src/backend/server/emby.ts +++ b/src/backend/server/emby.ts @@ -30,7 +30,7 @@ import { type MediaDb, type MediaItem, } from "../internal/media/model" -import { isWithinUserRoot } from "../pkg/permission" +import { isWithinUserRoot, isGuestAllowed } from "../pkg/permission" import { createPlaylist, deletePlaylist, @@ -174,9 +174,16 @@ function baseVisible(db: MediaDb, user: any, item: MediaItem) { } } -/** 取 guest 用户(匿名入口用);不存在或被禁用返回 null */ +/** + * 取 guest 用户(匿名入口用)。 + * + * 匿名请求只有在「访客访问已开启」且「guest 存在且未禁用」时才回退成 guest。 + * 历史缺陷:这里只看用户状态,不看 allow_guest —— 与 getUserFromContext 同类的 + * fail-open:管理员把访客访问关掉后,Emby 封面等匿名入口仍按 guest 权限出数据。 + */ async function guestUserOf(env: any) { const db = await getDb(env) + if (!isGuestAllowed(db.settings)) return null const guest = (db.users || []).find((u: any) => u.username === "guest") return guest && !guest.disabled ? guest : null } diff --git a/src/backend/server/emby_protocol.test.ts b/src/backend/server/emby_protocol.test.ts index ff205046..1af47a09 100644 --- a/src/backend/server/emby_protocol.test.ts +++ b/src/backend/server/emby_protocol.test.ts @@ -333,3 +333,65 @@ test("Emby 封面:剧集封面同样经过 meta 校验", async () => { const denied = await app.request(path, {}, {}) assert.equal(denied.status, 404) }) + +/** + * 写入/删除 allow_guest 设置行。 + * + * 该键在默认设置里没有对应条目(由应用设置动态产生),因此测试里需要 + * 自行插入,结束后再删除,避免影响同文件其它用例。 + */ +async function setAllowGuestRow(value: any) { + const db = await getDb() + if (!db.settings) db.settings = [] + const row = (db.settings || []).find((s: any) => s.key === "allow_guest") + if (value === undefined) { + db.settings = (db.settings || []).filter((s: any) => s.key !== "allow_guest") + } else if (row) { + row.value = value + } else { + db.settings.push({ + key: "allow_guest", + value, + type: "bool", + help: "", + group: 0, + flag: 0, + } as any) + } + await saveDb(db, undefined, { force: true } as any) +} + +test("Emby 封面:allow_guest=false 时匿名封面请求返回 401", async () => { + await seedMediaEnv([ + mediaItem({ + id: 1, + media_type: "video", + folder_path: "/public", + file_name: "ok.mkv", + cover: "https://example.com/ok.jpg", + }), + ]) + const app = buildApp() + const path = "/@emby/Items/mv-1/Images/Primary" + + // 基线:访客访问开启时匿名可取封面(历史行为) + const allowed = await app.request(path, {}, {}) + assert.equal(allowed.status, 302) + + try { + for (const value of ["false", false, "False"]) { + await setAllowGuestRow(value) + const res = await app.request(path, {}, {}) + assert.equal( + res.status, + 401, + `allow_guest=${JSON.stringify(value)} 时匿名封面入口必须拒绝`, + ) + } + } finally { + await setAllowGuestRow(undefined) + } + + const restored = await app.request(path, {}, {}) + assert.equal(restored.status, 302) +}) diff --git a/src/backend/server/guest_auth.test.ts b/src/backend/server/guest_auth.test.ts index 0a2add83..ea433ee1 100644 --- a/src/backend/server/guest_auth.test.ts +++ b/src/backend/server/guest_auth.test.ts @@ -322,3 +322,73 @@ test("/raw/* returns 401 without sign when sign_all is enabled (parity with Go n const res = await app.request("/raw/some/file.txt", { method: "GET" }) assert.equal(res.status, 401, "sign_all enabled must require a valid sign") }) + +test("Security: allow_guest 的布尔/大小写/空白写法同样关闭访客", async () => { + const env: any = {} + const users = [ + { + id: 1, + username: "admin", + password: await hashPasswordSHA256("admin123"), + role: 2, + permission: 0, + base_path: "/", + disabled: false, + }, + { + id: 2, + username: "guest", + password: "", + role: 1, + permission: 0, + base_path: "/", + disabled: false, + }, + ] + const context: any = { + req: { header: () => undefined, query: () => undefined }, + env, + } + const app = new Hono() + app.route("/api/fs", fsRouter) + app.route("/api/public", publicRouter) + + // 设置行可能由其它入口/历史版本写成布尔值或大小写不同的字符串, + // 这些写法都必须与 "false" 等价关闭访客,否则就是 fail-open。 + for (const value of [false, "False", " FALSE "]) { + await saveDb( + { + settings: [{ key: "allow_guest", value }], + users: JSON.parse(JSON.stringify(users)), + storages: [], + shares: [], + } as any, + env, + ) + + assert.equal( + await getUserFromContext(context), + null, + `allow_guest=${JSON.stringify(value)} 时不应回退为 guest`, + ) + + const list = await app.request( + "/api/fs/list", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "/" }), + }, + env, + ) + assert.equal(list.status, 401, `allow_guest=${JSON.stringify(value)}`) + + const settings = await app.request("/api/public/settings", {}, env) + const settingsBody: any = await settings.json() + assert.equal( + settingsBody.data.allow_guest, + "false", + `allow_guest=${JSON.stringify(value)} 的展示值必须与鉴权一致`, + ) + } +}) diff --git a/src/backend/server/middlewares.ts b/src/backend/server/middlewares.ts index dbfdc2b4..a5e9f6ca 100644 --- a/src/backend/server/middlewares.ts +++ b/src/backend/server/middlewares.ts @@ -1,6 +1,7 @@ import { Context } from "hono" import { verify } from "hono/jwt" import { checkAdminAuth, isStaticApiToken } from "../pkg/utils" +import { isGuestAllowed } from "../pkg/permission" import { getDb, readPersistedSecret, ENCRYPTION_SECRET_KV_KEY } from "../internal/model/db" import { isPasswordStampValid } from "../pkg/password" @@ -499,10 +500,9 @@ export async function getUserFromContext(c: Context): Promise<{ if (!authHeader) { try { const db = await getDb(c.env) - const allowGuest = (db.settings || []).find( - (setting: any) => setting.key === "allow_guest", - )?.value - if (allowGuest === "false") return null + // 统一走 isGuestAllowed:与 /api/public/settings 的展示规则同源, + // 并兼容布尔 false / 大小写等历史写法(裸 === "false" 会 fail-open)。 + if (!isGuestAllowed(db.settings)) return null const guest = (db.users || []).find((u: any) => u.username === "guest") if (guest && !guest.disabled) { diff --git a/src/backend/server/public.ts b/src/backend/server/public.ts index 10132e5c..018b3154 100644 --- a/src/backend/server/public.ts +++ b/src/backend/server/public.ts @@ -17,6 +17,7 @@ import { readFormat, } from "../internal/model/store/backend" import { setUserPassword } from "../pkg/password" +import { isGuestAllowed } from "../pkg/permission" // 脱敏 / 截断:与全局 503 拦截(index.ts)共用同一套规则 import { reasonLines, redact } from "./storage-error" @@ -551,7 +552,9 @@ publicRouter.get("/settings", async (c) => { // 动态检查是否存在且启用了 guest 账号 const guest = (db.users || []).find((u: any) => u.username === "guest") const isGuestActive = Boolean(guest && !guest.disabled) - if (!isGuestActive || settingsObj.allow_guest === "false") { + // 与鉴权路径(getUserFromContext / Emby 匿名入口)共用 isGuestAllowed, + // 避免「展示已关闭、实际仍放行」的规则漂移(布尔 false / "False" 等写法)。 + if (!isGuestActive || !isGuestAllowed(settingsObj)) { settingsObj.allow_guest = "false" } else { settingsObj.allow_guest = "true" From 23ea82a28ab50c6ed99494ab7b23e193d9fb130d Mon Sep 17 00:00:00 2001 From: pikachuren Date: Mon, 9 Nov 2026 17:58:06 +0800 Subject: [PATCH 3/4] feat(auth)!: require sign-in on fresh installs - defaultDb: disable the guest account by default (issue #133) - defaultDb: make allow_guest a real site setting (value "true" on insert so existing deployments are not silently switched to "must sign in") - init/setup and ADMIN_PASS first boot write allow_guest=false for new installs - docs: two-step opt-in for anonymous read-only browsing - tests: fresh install (both init paths) is blocked and persisted; an existing deployment without the setting keeps its previous behavior Co-Authored-By: CodeBuddy --- README.md | 2 +- readmes/README_en.md | 2 +- src/backend/internal/model/db.ts | 50 ++++++- src/backend/server/auth.ts | 23 ++- src/backend/server/emby_protocol.test.ts | 6 + src/backend/server/guest_defaults.test.ts | 162 ++++++++++++++++++++++ src/backend/server/public.ts | 12 ++ 7 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 src/backend/server/guest_defaults.test.ts diff --git a/README.md b/README.md index 964cfc82..29f61cda 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ OpenList-Worker 是官方 [OpenListTeam/OpenList](https://github.com/OpenListTea - **文件管理**:统一目录树浏览,图片 / 视频 / 音频 / 文档 / 代码 / 压缩包在线预览;跨存储上传、批量下载、流式传输与直链跳转;带有效期、密码与权限控制的分享链接。 - **检索与任务**:已索引存储的全文搜索,后台任务队列支持批量与异步处理。 - **外部接口**:WebDAV、S3 兼容协议,以及可被 AI 助手集成的 MCP 端点。 -- **访客访问**:默认启用 guest 访客回退,未登录请求会按 guest 用户权限访问。若要要求登录后才能浏览,请关闭访客访问(`allow_guest=false`);这是 OpenList 的应用设置,不是在 Cloudflare 控制台配置的环境变量。 +- **访客访问**:新装默认要求登录(`guest` 账号默认禁用,且 `allow_guest=false`)。需要匿名只读时,在「站点设置」打开「允许访客访问」,并在用户管理中启用 `guest`(两步都要);存量部署升级后保持原状,可随时在站点设置里关闭。这是 OpenList 的应用设置,不是在 Cloudflare 控制台配置的环境变量。 - **权限与认证**:RBAC(用户分组、目录级读写权限、配额),账号密码 + TOTP / WebAuthn / SSO / LDAP。 - **安全与运维**:JWT 会话、CSRF、点击劫持(X-Frame-Options)与 CSP 防护,`/health`、`/healthz` 健康探针。 - **并发去重**:相同目录列表 / 元信息请求只向上游网盘发起一次,其余请求复用结果,减少限流与 CPU 消耗。 diff --git a/readmes/README_en.md b/readmes/README_en.md index 54f86d66..e7bc59b4 100644 --- a/readmes/README_en.md +++ b/readmes/README_en.md @@ -73,7 +73,7 @@ In addition to the real storages above, virtual/functional drivers such as `Loca - **File management**: unified directory tree browsing with online preview for images, videos, audio, documents, code and archives; cross-storage upload, batch download, streaming and direct-link redirect; share links with expiry, password and permission control. - **Search & tasks**: full-text search across indexed storages; background task queue for batch and async processing. - **External interfaces**: WebDAV, S3-compatible protocol, and an MCP endpoint for AI assistants. -- **Guest access**: guest fallback is enabled by default, so unauthenticated requests use the guest user's permissions. To require sign-in before browsing, turn off guest access (`allow_guest=false`) in the OpenList application settings; this is not a Cloudflare environment variable. +- **Guest access**: fresh installs require sign-in by default (the `guest` account is disabled and `allow_guest=false`). To allow anonymous read-only browsing, turn on "Allow Guest Access" in the site settings **and** enable the `guest` user (both steps); existing deployments keep their current behavior and can turn it off in the site settings at any time. This is an OpenList application setting, not a Cloudflare environment variable. - **Access & auth**: RBAC (user groups, directory-level read/write permissions, quotas), account passwords plus TOTP / WebAuthn / SSO / LDAP. - **Security & ops**: JWT sessions, CSRF, clickjacking (X-Frame-Options) and CSP protection, `/health` and `/healthz` probes. - **Concurrency deduplication**: identical directory listing / metadata requests hit the upstream drive only once; the rest reuse the result, reducing rate limits and CPU usage. diff --git a/src/backend/internal/model/db.ts b/src/backend/internal/model/db.ts index 38894380..5a55963b 100644 --- a/src/backend/internal/model/db.ts +++ b/src/backend/internal/model/db.ts @@ -119,6 +119,20 @@ export const defaultDb = { group: 1, flag: 0, }, + { + // 访客访问(匿名浏览)开关。 + // + // 这里必须保留 "true":ensureDefaultSettings() 会把缺失的默认项补进**存量** + // 数据库,若默认写成 "false",升级瞬间所有老部署都会被静默改成「必须登录」。 + // 新装由初始化的两条路径(init/setup、ADMIN_PASS 首启)显式写成 "false", + // 见 setSettingValue() 的调用点。 + key: "allow_guest", + value: "true", + type: "bool", + help: "Allow Anonymous Visitors to Browse as Guest", + group: 1, + flag: 0, + }, { key: "robots_txt", value: "User-agent: *\nDisallow: /", @@ -1029,7 +1043,10 @@ export const defaultDb = { role: 1, permission: 0, base_path: "/", - disabled: false, + // 新装默认不启用访客:旧行为是 guest 默认启用、匿名请求直接按 guest 浏览, + // 与「打开网页就要登录」的预期相反(issue #133)。存量部署的用户表存在 + // 持久化存储里,不受这里影响;需要匿名只读时在用户管理中显式启用。 + disabled: true, sso_id: "", allow_ldap: false, pwd_update_at: new Date().toISOString(), @@ -1177,6 +1194,37 @@ const LEGACY_SETTING_MIGRATIONS: Record = { }, } +/** + * 写入/新增一个设置项(仅用于初始化路径,不落盘)。 + * + * 为什么需要它:`allow_guest` 在 defaultDb 里必须是 "true"(否则存量部署升级时 + * 会被 ensureDefaultSettings 静默改成「必须登录」),而新装又必须默认关闭访客。 + * 两条初始化路径(`init/setup`、ADMIN_PASS 首启)因此需要显式把该行改成 + * "false",统一走这个函数避免两处写法漂移。 + */ +export function setSettingValue( + db: any, + key: string, + value: string, + meta: { type?: string; help?: string; group?: number; flag?: number } = {}, +) { + if (!db) return + if (!Array.isArray(db.settings)) db.settings = [] + const row = db.settings.find((s: any) => s && s.key === key) + if (row) { + row.value = value + return + } + db.settings.push({ + key, + value, + type: meta.type || "string", + help: meta.help || "", + group: typeof meta.group === "number" ? meta.group : 0, + flag: typeof meta.flag === "number" ? meta.flag : 0, + }) +} + const ensureDefaultSettings = (db: any) => { if (!db) return if (!db.settings) { diff --git a/src/backend/server/auth.ts b/src/backend/server/auth.ts index d2753809..2d20d12c 100644 --- a/src/backend/server/auth.ts +++ b/src/backend/server/auth.ts @@ -1,6 +1,6 @@ import { Hono, type Context } from "hono" import { sign, verify } from "hono/jwt" -import { getDb, saveDb } from "../internal/model/db" +import { getDb, saveDb, setSettingValue } from "../internal/model/db" import { getJwtSecret, getUserFromContext, @@ -332,7 +332,8 @@ export async function getOrInitUsers(envCtx: any) { role: 1, permission: 0, base_path: "/", - disabled: false, + // 与 defaultDb.users 保持一致:新装默认不启用访客(issue #133)。 + disabled: true, sso_id: "", allow_ldap: false, pwd_update_at: new Date().toISOString(), @@ -362,6 +363,14 @@ export async function getOrInitUsers(envCtx: any) { // 未初始化:仅创建 guest,admin 由 Web 安装向导(POST /api/public/init/setup)创建 db.users = [guest] } + // 新装默认要求登录(issue #133):关闭匿名访客浏览。 + // 只在这些「初始化」分支写入,存量库不会经过这里。 + setSettingValue(db, "allow_guest", "false", { + type: "bool", + help: "Allow Anonymous Visitors to Browse as Guest", + group: 1, + flag: 0, + }) // 只有配置了 ADMIN_PASS(或已存在真实管理员)时,这份库才不是空壳, // 才应该落盘。未配置时应等待 init/setup 完成初始化,而不是抢先写入一个 // 未初始化的占位库(会被 saveDb 的写前守卫拦截)。 @@ -391,6 +400,16 @@ export async function getOrInitUsers(envCtx: any) { "" if (envPass) { await setUserPassword(adminUser, envPass) + if (!adminPass) { + // ADMIN_PASS 首启自动建号 = 新装:与 init/setup 一致,默认关闭匿名访客。 + // 已有密码的库(含历史哈希)不在此改动,避免升级时静默要求登录。 + setSettingValue(db, "allow_guest", "false", { + type: "bool", + help: "Allow Anonymous Visitors to Browse as Guest", + group: 1, + flag: 0, + }) + } await saveDb(db, envCtx) } else if (!adminPass) { // 未初始化:不再自动生成随机密码,交由 Web 安装向导(POST /api/public/init/setup)完成。 diff --git a/src/backend/server/emby_protocol.test.ts b/src/backend/server/emby_protocol.test.ts index 1af47a09..9c77ef6c 100644 --- a/src/backend/server/emby_protocol.test.ts +++ b/src/backend/server/emby_protocol.test.ts @@ -18,6 +18,7 @@ function buildApp() { const original = new Map() let originalGuestBasePath: string | null = null +let originalGuestDisabled: boolean | null = null let originalMetas: any[] | null = null async function setSetting(key: string, value: string) { @@ -43,6 +44,7 @@ after(async () => { } const guest = (db.users || []).find((u: any) => u.username === "guest") if (guest && originalGuestBasePath !== null) guest.base_path = originalGuestBasePath + if (guest && originalGuestDisabled !== null) guest.disabled = originalGuestDisabled if (originalMetas !== null) db.metas = originalMetas await saveDb(db, undefined, { force: true } as any) }) @@ -101,6 +103,10 @@ async function seedMediaEnv(items: MediaItem[], metas: any[] = [], enabled = tru originalGuestBasePath = String(guest.base_path || "/") } guest.base_path = "/public" + // 新装默认禁用访客(issue #133),本文件验证的是「访客开启时」的封面语义, + // 因此显式启用;allow_guest 由 defaultDb 的默认行保持为 true。 + if (originalGuestDisabled === null) originalGuestDisabled = !!guest.disabled + guest.disabled = false } if (originalMetas === null) originalMetas = JSON.parse(JSON.stringify(userDb.metas || [])) userDb.metas = metas diff --git a/src/backend/server/guest_defaults.test.ts b/src/backend/server/guest_defaults.test.ts new file mode 100644 index 00000000..2ebef71d --- /dev/null +++ b/src/backend/server/guest_defaults.test.ts @@ -0,0 +1,162 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { Hono } from "hono" +import { + __resetDbCacheForTest, + defaultDb, + getDb, + saveDb, +} from "../internal/model/db" +import { memoryDriver } from "../internal/model/store/driver/memory" +import { getOrInitUsers } from "./auth" +import { getUserFromContext } from "./middlewares" +import { fsRouter } from "./fs" +import { publicRouter } from "./public" +import { isGuestAllowed } from "../pkg/permission" + +const ADMIN_HASH = "a".repeat(64) + +async function freshEnv() { + for (const key of await memoryDriver.list("openlist_")) { + await memoryDriver.delete(key) + } + __resetDbCacheForTest() + return {} as any +} + +function anonContext(env: any) { + return { + req: { header: () => undefined, query: () => undefined }, + env, + } as any +} + +test("默认库:guest 默认禁用,但 allow_guest 默认行仍为 true(存量兼容)", () => { + const guest = defaultDb.users.find((u: any) => u.username === "guest") + assert.equal(guest?.disabled, true, "新装默认不应启用 guest") + + const row = defaultDb.settings.find((s: any) => s.key === "allow_guest") + assert.ok(row, "allow_guest 必须作为正式设置项存在(否则管理界面无法开关)") + assert.equal(row.value, "true", "存量库补该行时不能被静默改成必须登录") + assert.equal(row.group, 1) + assert.equal(row.type, "bool") +}) + +test("新装(ADMIN_PASS 首启):guest 禁用 + allow_guest=false,匿名 401", async () => { + const env = await freshEnv() + env.ADMIN_PASS = "admin123" + + const { db } = await getOrInitUsers(env) + const guest = (db.users || []).find((u: any) => u.username === "guest") + assert.equal(guest?.disabled, true) + assert.equal(isGuestAllowed(db.settings), false, "新装应默认关闭匿名访客") + + // 必须真的落盘:清掉模块缓存后重新读取,不能只是内存里的默认值 + __resetDbCacheForTest() + const reloaded = await getDb(env) + assert.equal( + isGuestAllowed(reloaded.settings), + false, + "初始化写入的 allow_guest=false 必须持久化", + ) + + const app = new Hono() + app.route("/api/fs", fsRouter) + app.route("/api/public", publicRouter) + + const list = await app.request( + "/api/fs/list", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "/" }), + }, + env, + ) + assert.equal(list.status, 401) + + const settings = await app.request("/api/public/settings", {}, env) + const body: any = await settings.json() + assert.equal(body.data.allow_guest, "false") + + assert.equal(await getUserFromContext(anonContext(env)), null) +}) + +test("存量库升级(无 allow_guest 行、guest 启用):仍按允许处理,不被强制登录", async () => { + const env = await freshEnv() + await saveDb( + { + settings: [], + users: [ + { + id: 1, + username: "admin", + password: ADMIN_HASH, + role: 2, + permission: 0, + base_path: "/", + disabled: false, + }, + { + id: 2, + username: "guest", + password: "", + role: 1, + permission: 0, + base_path: "/", + disabled: false, + }, + ], + storages: [], + shares: [], + metas: [], + } as any, + env, + ) + __resetDbCacheForTest() + + const db = await getDb(env) + assert.equal(isGuestAllowed(db.settings), true, "存量库升级后不应被静默改成必须登录") + assert.equal((await getUserFromContext(anonContext(env)))?.username, "guest") +}) + +test("新装(安装向导 init/setup):完成后 allow_guest=false,匿名 401", async () => { + const env = await freshEnv() + const app = new Hono() + app.route("/api/public", publicRouter) + app.route("/api/fs", fsRouter) + + const res = await app.request( + "/api/public/init/setup", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "admin", + password: "admin123", + site_title: "Test", + }), + }, + env, + ) + assert.equal(res.status, 200) + + __resetDbCacheForTest() + const db = await getDb(env) + assert.equal(isGuestAllowed(db.settings), false, "setup 完成后必须关闭匿名访客") + assert.equal( + (db.users || []).find((u: any) => u.username === "guest")?.disabled, + true, + ) + + const list = await app.request( + "/api/fs/list", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "/" }), + }, + env, + ) + assert.equal(list.status, 401) +}) diff --git a/src/backend/server/public.ts b/src/backend/server/public.ts index 018b3154..fa32424f 100644 --- a/src/backend/server/public.ts +++ b/src/backend/server/public.ts @@ -7,6 +7,7 @@ import { isDbTrusted, isEncryptionReady, saveDb, + setSettingValue, } from "../internal/model/db" import { getStoreConfigErrorDetail, @@ -824,6 +825,17 @@ publicRouter.post("/init/setup", async (c) => { } } + // 新装默认要求登录:关闭匿名访客浏览。 + // + // 只在初始化(本请求)里写 "false":存量部署不会走到这里,因此升级不会被 + // 静默改成「必须登录」;管理员之后可在「站点设置 → 允许访客访问」打开。 + setSettingValue(db, "allow_guest", "false", { + type: "bool", + help: "Allow Anonymous Visitors to Browse as Guest", + group: 1, + flag: 0, + }) + // 初始化是唯一允许「在一次成功读取(确认存储为空)之后写入空壳」的场景, // 因此显式 force;此时 db 已包含新建的管理员用户,本身也不再是空壳。 await saveDb(db, c.env, { force: true }) From 90bdd8a8e825f70e2bfc8ab33e037f4503fd6e74 Mon Sep 17 00:00:00 2001 From: pikachuren Date: Mon, 9 Nov 2026 18:06:10 +0800 Subject: [PATCH 4/4] refactor(auth)!: drop allow_guest to match the Go backend Go has no allow_guest setting: anonymous access is decided only by whether the guest account exists and is disabled (server/middlewares/auth.go Auth(false)). The TS-only switch added earlier was therefore a divergence, not a fix. - remove the default setting row and the init-time writes - remove isGuestAllowed and the checks in getUserFromContext / Emby guestUserOf - stop echoing allow_guest from /api/public/settings (the frontend never read it) - keep the actual #133 fix: fresh installs create a disabled guest, so anonymous browsing requires an explicit opt-in by enabling the guest user - tests assert parity: no allow_guest in defaults or public settings, fresh install returns 401, an existing deployment is left untouched BREAKING CHANGE: /api/public/settings no longer returns allow_guest. Co-Authored-By: CodeBuddy --- README.md | 2 +- readmes/README_en.md | 2 +- src/backend/internal/model/db.ts | 45 ------- src/backend/pkg/permission.ts | 23 ---- src/backend/server/auth.ts | 22 +--- src/backend/server/emby.ts | 11 +- src/backend/server/emby_protocol.test.ts | 63 +-------- src/backend/server/guest_auth.test.ts | 146 +-------------------- src/backend/server/guest_defaults.test.ts | 87 ++++++------ src/backend/server/middlewares.ts | 10 +- src/backend/server/public.ts | 28 +--- src/backend/server/public_settings.test.ts | 4 +- 12 files changed, 63 insertions(+), 380 deletions(-) diff --git a/README.md b/README.md index 29f61cda..bca3fac3 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ OpenList-Worker 是官方 [OpenListTeam/OpenList](https://github.com/OpenListTea - **文件管理**:统一目录树浏览,图片 / 视频 / 音频 / 文档 / 代码 / 压缩包在线预览;跨存储上传、批量下载、流式传输与直链跳转;带有效期、密码与权限控制的分享链接。 - **检索与任务**:已索引存储的全文搜索,后台任务队列支持批量与异步处理。 - **外部接口**:WebDAV、S3 兼容协议,以及可被 AI 助手集成的 MCP 端点。 -- **访客访问**:新装默认要求登录(`guest` 账号默认禁用,且 `allow_guest=false`)。需要匿名只读时,在「站点设置」打开「允许访客访问」,并在用户管理中启用 `guest`(两步都要);存量部署升级后保持原状,可随时在站点设置里关闭。这是 OpenList 的应用设置,不是在 Cloudflare 控制台配置的环境变量。 +- **访客访问**:新装默认要求登录(`guest` 账号默认禁用)。需要匿名只读时,在「用户管理」里启用 `guest` 即可;存量部署升级后保持原状。是否允许匿名只取决于 guest 账号本身是否启用(与 Go 版一致),没有额外的站点开关,也不是 Cloudflare 控制台的环境变量。 - **权限与认证**:RBAC(用户分组、目录级读写权限、配额),账号密码 + TOTP / WebAuthn / SSO / LDAP。 - **安全与运维**:JWT 会话、CSRF、点击劫持(X-Frame-Options)与 CSP 防护,`/health`、`/healthz` 健康探针。 - **并发去重**:相同目录列表 / 元信息请求只向上游网盘发起一次,其余请求复用结果,减少限流与 CPU 消耗。 diff --git a/readmes/README_en.md b/readmes/README_en.md index e7bc59b4..f013e124 100644 --- a/readmes/README_en.md +++ b/readmes/README_en.md @@ -73,7 +73,7 @@ In addition to the real storages above, virtual/functional drivers such as `Loca - **File management**: unified directory tree browsing with online preview for images, videos, audio, documents, code and archives; cross-storage upload, batch download, streaming and direct-link redirect; share links with expiry, password and permission control. - **Search & tasks**: full-text search across indexed storages; background task queue for batch and async processing. - **External interfaces**: WebDAV, S3-compatible protocol, and an MCP endpoint for AI assistants. -- **Guest access**: fresh installs require sign-in by default (the `guest` account is disabled and `allow_guest=false`). To allow anonymous read-only browsing, turn on "Allow Guest Access" in the site settings **and** enable the `guest` user (both steps); existing deployments keep their current behavior and can turn it off in the site settings at any time. This is an OpenList application setting, not a Cloudflare environment variable. +- **Guest access**: fresh installs require sign-in by default (the `guest` account is disabled). To allow anonymous read-only browsing, enable the `guest` user; existing deployments keep their current behavior. Anonymous access is controlled solely by the guest account state (same as the Go backend) — there is no extra site setting, and it is not a Cloudflare environment variable. - **Access & auth**: RBAC (user groups, directory-level read/write permissions, quotas), account passwords plus TOTP / WebAuthn / SSO / LDAP. - **Security & ops**: JWT sessions, CSRF, clickjacking (X-Frame-Options) and CSP protection, `/health` and `/healthz` probes. - **Concurrency deduplication**: identical directory listing / metadata requests hit the upstream drive only once; the rest reuse the result, reducing rate limits and CPU usage. diff --git a/src/backend/internal/model/db.ts b/src/backend/internal/model/db.ts index 5a55963b..e07a9c36 100644 --- a/src/backend/internal/model/db.ts +++ b/src/backend/internal/model/db.ts @@ -119,20 +119,6 @@ export const defaultDb = { group: 1, flag: 0, }, - { - // 访客访问(匿名浏览)开关。 - // - // 这里必须保留 "true":ensureDefaultSettings() 会把缺失的默认项补进**存量** - // 数据库,若默认写成 "false",升级瞬间所有老部署都会被静默改成「必须登录」。 - // 新装由初始化的两条路径(init/setup、ADMIN_PASS 首启)显式写成 "false", - // 见 setSettingValue() 的调用点。 - key: "allow_guest", - value: "true", - type: "bool", - help: "Allow Anonymous Visitors to Browse as Guest", - group: 1, - flag: 0, - }, { key: "robots_txt", value: "User-agent: *\nDisallow: /", @@ -1194,37 +1180,6 @@ const LEGACY_SETTING_MIGRATIONS: Record = { }, } -/** - * 写入/新增一个设置项(仅用于初始化路径,不落盘)。 - * - * 为什么需要它:`allow_guest` 在 defaultDb 里必须是 "true"(否则存量部署升级时 - * 会被 ensureDefaultSettings 静默改成「必须登录」),而新装又必须默认关闭访客。 - * 两条初始化路径(`init/setup`、ADMIN_PASS 首启)因此需要显式把该行改成 - * "false",统一走这个函数避免两处写法漂移。 - */ -export function setSettingValue( - db: any, - key: string, - value: string, - meta: { type?: string; help?: string; group?: number; flag?: number } = {}, -) { - if (!db) return - if (!Array.isArray(db.settings)) db.settings = [] - const row = db.settings.find((s: any) => s && s.key === key) - if (row) { - row.value = value - return - } - db.settings.push({ - key, - value, - type: meta.type || "string", - help: meta.help || "", - group: typeof meta.group === "number" ? meta.group : 0, - flag: typeof meta.flag === "number" ? meta.flag : 0, - }) -} - const ensureDefaultSettings = (db: any) => { if (!db) return if (!db.settings) { diff --git a/src/backend/pkg/permission.ts b/src/backend/pkg/permission.ts index 92746af2..a4e47c30 100644 --- a/src/backend/pkg/permission.ts +++ b/src/backend/pkg/permission.ts @@ -154,26 +154,3 @@ export function isWithinUserRoot( if (root === "/") return true return target === root || target.startsWith(root + "/") } - -/** - * 访客访问开关(应用设置 `allow_guest`)。 - * - * 设置值来自应用设置(`db.settings`)。历史数据与第三方写入可能是字符串、 - * 布尔值,或带空白/大小写差异(`"false"` / `"False"` / `false`)。统一按 - * 「字符串化后 trim + 小写 === "false"」判定;缺失或其它值一律视为允许, - * 保持「未配置 = 旧行为」的兼容性。 - * - * 为什么必须集中在这里:展示端(`/api/public/settings`)与鉴权端 - * (`getUserFromContext`、Emby 匿名封面入口)各写一套比较时,只要一边改动, - * 就会出现「设置页显示已关闭、接口仍放行」的漂移。 - */ -export function isGuestAllowed(settings: any): boolean { - let raw: any - if (Array.isArray(settings)) { - raw = settings.find((s: any) => s && s.key === "allow_guest")?.value - } else if (settings && typeof settings === "object") { - raw = settings.allow_guest - } - if (raw === undefined || raw === null) return true - return String(raw).trim().toLowerCase() !== "false" -} diff --git a/src/backend/server/auth.ts b/src/backend/server/auth.ts index 2d20d12c..775bded7 100644 --- a/src/backend/server/auth.ts +++ b/src/backend/server/auth.ts @@ -1,6 +1,6 @@ import { Hono, type Context } from "hono" import { sign, verify } from "hono/jwt" -import { getDb, saveDb, setSettingValue } from "../internal/model/db" +import { getDb, saveDb } from "../internal/model/db" import { getJwtSecret, getUserFromContext, @@ -363,14 +363,8 @@ export async function getOrInitUsers(envCtx: any) { // 未初始化:仅创建 guest,admin 由 Web 安装向导(POST /api/public/init/setup)创建 db.users = [guest] } - // 新装默认要求登录(issue #133):关闭匿名访客浏览。 - // 只在这些「初始化」分支写入,存量库不会经过这里。 - setSettingValue(db, "allow_guest", "false", { - type: "bool", - help: "Allow Anonymous Visitors to Browse as Guest", - group: 1, - flag: 0, - }) + // 新装默认要求登录(issue #133):由上面的「guest 默认禁用」实现, + // 与 Go 版一致 —— Go 没有 allow_guest 设置项,只有 guest 账号的启用状态。 // 只有配置了 ADMIN_PASS(或已存在真实管理员)时,这份库才不是空壳, // 才应该落盘。未配置时应等待 init/setup 完成初始化,而不是抢先写入一个 // 未初始化的占位库(会被 saveDb 的写前守卫拦截)。 @@ -400,16 +394,6 @@ export async function getOrInitUsers(envCtx: any) { "" if (envPass) { await setUserPassword(adminUser, envPass) - if (!adminPass) { - // ADMIN_PASS 首启自动建号 = 新装:与 init/setup 一致,默认关闭匿名访客。 - // 已有密码的库(含历史哈希)不在此改动,避免升级时静默要求登录。 - setSettingValue(db, "allow_guest", "false", { - type: "bool", - help: "Allow Anonymous Visitors to Browse as Guest", - group: 1, - flag: 0, - }) - } await saveDb(db, envCtx) } else if (!adminPass) { // 未初始化:不再自动生成随机密码,交由 Web 安装向导(POST /api/public/init/setup)完成。 diff --git a/src/backend/server/emby.ts b/src/backend/server/emby.ts index 91177882..64bb94b7 100644 --- a/src/backend/server/emby.ts +++ b/src/backend/server/emby.ts @@ -30,7 +30,7 @@ import { type MediaDb, type MediaItem, } from "../internal/media/model" -import { isWithinUserRoot, isGuestAllowed } from "../pkg/permission" +import { isWithinUserRoot } from "../pkg/permission" import { createPlaylist, deletePlaylist, @@ -174,16 +174,9 @@ function baseVisible(db: MediaDb, user: any, item: MediaItem) { } } -/** - * 取 guest 用户(匿名入口用)。 - * - * 匿名请求只有在「访客访问已开启」且「guest 存在且未禁用」时才回退成 guest。 - * 历史缺陷:这里只看用户状态,不看 allow_guest —— 与 getUserFromContext 同类的 - * fail-open:管理员把访客访问关掉后,Emby 封面等匿名入口仍按 guest 权限出数据。 - */ +/** 取 guest 用户(匿名入口用);不存在或被禁用返回 null */ async function guestUserOf(env: any) { const db = await getDb(env) - if (!isGuestAllowed(db.settings)) return null const guest = (db.users || []).find((u: any) => u.username === "guest") return guest && !guest.disabled ? guest : null } diff --git a/src/backend/server/emby_protocol.test.ts b/src/backend/server/emby_protocol.test.ts index 9c77ef6c..402bdfb0 100644 --- a/src/backend/server/emby_protocol.test.ts +++ b/src/backend/server/emby_protocol.test.ts @@ -104,7 +104,7 @@ async function seedMediaEnv(items: MediaItem[], metas: any[] = [], enabled = tru } guest.base_path = "/public" // 新装默认禁用访客(issue #133),本文件验证的是「访客开启时」的封面语义, - // 因此显式启用;allow_guest 由 defaultDb 的默认行保持为 true。 + // 因此显式启用;是否允许匿名只取决于 guest 账号状态(与 Go 一致)。 if (originalGuestDisabled === null) originalGuestDisabled = !!guest.disabled guest.disabled = false } @@ -340,64 +340,3 @@ test("Emby 封面:剧集封面同样经过 meta 校验", async () => { assert.equal(denied.status, 404) }) -/** - * 写入/删除 allow_guest 设置行。 - * - * 该键在默认设置里没有对应条目(由应用设置动态产生),因此测试里需要 - * 自行插入,结束后再删除,避免影响同文件其它用例。 - */ -async function setAllowGuestRow(value: any) { - const db = await getDb() - if (!db.settings) db.settings = [] - const row = (db.settings || []).find((s: any) => s.key === "allow_guest") - if (value === undefined) { - db.settings = (db.settings || []).filter((s: any) => s.key !== "allow_guest") - } else if (row) { - row.value = value - } else { - db.settings.push({ - key: "allow_guest", - value, - type: "bool", - help: "", - group: 0, - flag: 0, - } as any) - } - await saveDb(db, undefined, { force: true } as any) -} - -test("Emby 封面:allow_guest=false 时匿名封面请求返回 401", async () => { - await seedMediaEnv([ - mediaItem({ - id: 1, - media_type: "video", - folder_path: "/public", - file_name: "ok.mkv", - cover: "https://example.com/ok.jpg", - }), - ]) - const app = buildApp() - const path = "/@emby/Items/mv-1/Images/Primary" - - // 基线:访客访问开启时匿名可取封面(历史行为) - const allowed = await app.request(path, {}, {}) - assert.equal(allowed.status, 302) - - try { - for (const value of ["false", false, "False"]) { - await setAllowGuestRow(value) - const res = await app.request(path, {}, {}) - assert.equal( - res.status, - 401, - `allow_guest=${JSON.stringify(value)} 时匿名封面入口必须拒绝`, - ) - } - } finally { - await setAllowGuestRow(undefined) - } - - const restored = await app.request(path, {}, {}) - assert.equal(restored.status, 302) -}) diff --git a/src/backend/server/guest_auth.test.ts b/src/backend/server/guest_auth.test.ts index ea433ee1..ddb982ca 100644 --- a/src/backend/server/guest_auth.test.ts +++ b/src/backend/server/guest_auth.test.ts @@ -77,73 +77,6 @@ test("Security: getUserFromContext returns null when guest is deleted or disable ) }) -test("Security: allow_guest=false blocks anonymous guest fallback", async () => { - const env: any = {} - const admin = { - id: 1, - username: "admin", - password: await hashPasswordSHA256("admin123"), - role: 2, - permission: 0, - base_path: "/", - disabled: false, - } - const guest = { - id: 2, - username: "guest", - password: "", - role: 1, - permission: 0, - base_path: "/", - disabled: false, - } - const users = [admin, guest] - const context: any = { - req: { - header: () => undefined, - query: () => undefined, - }, - env, - } - - await saveDb({ settings: [], users, storages: [], shares: [] }, env) - assert.equal( - (await getUserFromContext(context))?.id, - guest.id, - "Guest fallback remains enabled by default", - ) - - await saveDb( - { - settings: [{ key: "allow_guest", value: "false" }], - users, - storages: [], - shares: [], - }, - env, - ) - assert.equal( - await getUserFromContext(context), - null, - "allow_guest=false must disable anonymous guest fallback", - ) - - const app = new Hono() - app.route("/api/fs", fsRouter) - const response = await app.request( - "/api/fs/list", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path: "/" }), - }, - env, - ) - assert.equal(response.status, 401) - const responseBody: any = await response.json() - assert.equal(responseBody.code, 401) -}) - test("Security: /api/me returns 401 when unauthenticated and guest is deleted or disabled", async () => { const env: any = {} const adminHash = await hashPasswordSHA256("admin123") @@ -178,7 +111,7 @@ test("Security: /api/me returns 401 when unauthenticated and guest is deleted or assert.equal(json.code, 401) }) -test("Security: /api/public/settings returns allow_guest 'false' when guest is deleted or disabled", async () => { +test("Security: /api/public/settings does not expose allow_guest (parity with Go)", async () => { const env: any = {} await saveDb( { @@ -203,15 +136,13 @@ test("Security: /api/public/settings returns allow_guest 'false' when guest is d const app = new Hono() app.route("/api/public", publicRouter) - const res = await app.request("/api/public/settings", { - method: "GET", - }) + const res = await app.request("/api/public/settings", { method: "GET" }) assert.equal(res.status, 200) const json: any = await res.json() assert.equal( json.data.allow_guest, - "false", - "allow_guest must be false when guest user is deleted", + undefined, + "Go 版公开设置里没有 allow_guest,这里也不应输出", ) }) @@ -323,72 +254,3 @@ test("/raw/* returns 401 without sign when sign_all is enabled (parity with Go n assert.equal(res.status, 401, "sign_all enabled must require a valid sign") }) -test("Security: allow_guest 的布尔/大小写/空白写法同样关闭访客", async () => { - const env: any = {} - const users = [ - { - id: 1, - username: "admin", - password: await hashPasswordSHA256("admin123"), - role: 2, - permission: 0, - base_path: "/", - disabled: false, - }, - { - id: 2, - username: "guest", - password: "", - role: 1, - permission: 0, - base_path: "/", - disabled: false, - }, - ] - const context: any = { - req: { header: () => undefined, query: () => undefined }, - env, - } - const app = new Hono() - app.route("/api/fs", fsRouter) - app.route("/api/public", publicRouter) - - // 设置行可能由其它入口/历史版本写成布尔值或大小写不同的字符串, - // 这些写法都必须与 "false" 等价关闭访客,否则就是 fail-open。 - for (const value of [false, "False", " FALSE "]) { - await saveDb( - { - settings: [{ key: "allow_guest", value }], - users: JSON.parse(JSON.stringify(users)), - storages: [], - shares: [], - } as any, - env, - ) - - assert.equal( - await getUserFromContext(context), - null, - `allow_guest=${JSON.stringify(value)} 时不应回退为 guest`, - ) - - const list = await app.request( - "/api/fs/list", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path: "/" }), - }, - env, - ) - assert.equal(list.status, 401, `allow_guest=${JSON.stringify(value)}`) - - const settings = await app.request("/api/public/settings", {}, env) - const settingsBody: any = await settings.json() - assert.equal( - settingsBody.data.allow_guest, - "false", - `allow_guest=${JSON.stringify(value)} 的展示值必须与鉴权一致`, - ) - } -}) diff --git a/src/backend/server/guest_defaults.test.ts b/src/backend/server/guest_defaults.test.ts index 2ebef71d..6e3dd104 100644 --- a/src/backend/server/guest_defaults.test.ts +++ b/src/backend/server/guest_defaults.test.ts @@ -12,7 +12,6 @@ import { getOrInitUsers } from "./auth" import { getUserFromContext } from "./middlewares" import { fsRouter } from "./fs" import { publicRouter } from "./public" -import { isGuestAllowed } from "../pkg/permission" const ADMIN_HASH = "a".repeat(64) @@ -31,58 +30,64 @@ function anonContext(env: any) { } as any } -test("默认库:guest 默认禁用,但 allow_guest 默认行仍为 true(存量兼容)", () => { +function buildApp() { + const app = new Hono() + app.route("/api/fs", fsRouter) + app.route("/api/public", publicRouter) + return app +} + +async function anonList(env: any) { + const res = await buildApp().request( + "/api/fs/list", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "/" }), + }, + env, + ) + return res.status +} + +test("默认库:guest 默认禁用,且不引入 Go 没有的 allow_guest 设置项", () => { const guest = defaultDb.users.find((u: any) => u.username === "guest") assert.equal(guest?.disabled, true, "新装默认不应启用 guest") const row = defaultDb.settings.find((s: any) => s.key === "allow_guest") - assert.ok(row, "allow_guest 必须作为正式设置项存在(否则管理界面无法开关)") - assert.equal(row.value, "true", "存量库补该行时不能被静默改成必须登录") - assert.equal(row.group, 1) - assert.equal(row.type, "bool") + assert.equal(row, undefined, "Go 版没有 allow_guest,默认设置里也不应有") }) -test("新装(ADMIN_PASS 首启):guest 禁用 + allow_guest=false,匿名 401", async () => { +test("新装(ADMIN_PASS 首启):guest 禁用,匿名 401", async () => { const env = await freshEnv() env.ADMIN_PASS = "admin123" const { db } = await getOrInitUsers(env) const guest = (db.users || []).find((u: any) => u.username === "guest") assert.equal(guest?.disabled, true) - assert.equal(isGuestAllowed(db.settings), false, "新装应默认关闭匿名访客") // 必须真的落盘:清掉模块缓存后重新读取,不能只是内存里的默认值 __resetDbCacheForTest() const reloaded = await getDb(env) assert.equal( - isGuestAllowed(reloaded.settings), - false, - "初始化写入的 allow_guest=false 必须持久化", + (reloaded.users || []).find((u: any) => u.username === "guest")?.disabled, + true, + "新装的 guest 禁用状态必须持久化", ) - const app = new Hono() - app.route("/api/fs", fsRouter) - app.route("/api/public", publicRouter) + assert.equal(await anonList(env), 401) + assert.equal(await getUserFromContext(anonContext(env)), null) - const list = await app.request( - "/api/fs/list", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path: "/" }), - }, + const settings = await buildApp().request( + "/api/public/settings", + { method: "GET" }, env, ) - assert.equal(list.status, 401) - - const settings = await app.request("/api/public/settings", {}, env) const body: any = await settings.json() - assert.equal(body.data.allow_guest, "false") - - assert.equal(await getUserFromContext(anonContext(env)), null) + assert.equal(body.data.allow_guest, undefined, "公开设置不应输出 allow_guest") }) -test("存量库升级(无 allow_guest 行、guest 启用):仍按允许处理,不被强制登录", async () => { +test("存量库升级(guest 启用):不会被静默改成必须登录", async () => { const env = await freshEnv() await saveDb( { @@ -116,17 +121,18 @@ test("存量库升级(无 allow_guest 行、guest 启用):仍按允许处 __resetDbCacheForTest() const db = await getDb(env) - assert.equal(isGuestAllowed(db.settings), true, "存量库升级后不应被静默改成必须登录") + assert.equal( + (db.users || []).find((u: any) => u.username === "guest")?.disabled, + false, + "存量库的 guest 状态不应被升级改动", + ) assert.equal((await getUserFromContext(anonContext(env)))?.username, "guest") }) -test("新装(安装向导 init/setup):完成后 allow_guest=false,匿名 401", async () => { +test("新装(安装向导 init/setup):完成后 guest 仍禁用,匿名 401", async () => { const env = await freshEnv() - const app = new Hono() - app.route("/api/public", publicRouter) - app.route("/api/fs", fsRouter) - const res = await app.request( + const res = await buildApp().request( "/api/public/init/setup", { method: "POST", @@ -143,20 +149,9 @@ test("新装(安装向导 init/setup):完成后 allow_guest=false,匿名 __resetDbCacheForTest() const db = await getDb(env) - assert.equal(isGuestAllowed(db.settings), false, "setup 完成后必须关闭匿名访客") assert.equal( (db.users || []).find((u: any) => u.username === "guest")?.disabled, true, ) - - const list = await app.request( - "/api/fs/list", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ path: "/" }), - }, - env, - ) - assert.equal(list.status, 401) + assert.equal(await anonList(env), 401) }) diff --git a/src/backend/server/middlewares.ts b/src/backend/server/middlewares.ts index a5e9f6ca..b61d3b4b 100644 --- a/src/backend/server/middlewares.ts +++ b/src/backend/server/middlewares.ts @@ -1,7 +1,6 @@ import { Context } from "hono" import { verify } from "hono/jwt" import { checkAdminAuth, isStaticApiToken } from "../pkg/utils" -import { isGuestAllowed } from "../pkg/permission" import { getDb, readPersistedSecret, ENCRYPTION_SECRET_KV_KEY } from "../internal/model/db" import { isPasswordStampValid } from "../pkg/password" @@ -495,15 +494,12 @@ export async function getUserFromContext(c: Context): Promise<{ } } - // 无 Authorization header:仅在允许访客访问时使用 guest 用户作为后备。 - // allow_guest 是应用层安全设置,必须由后端鉴权路径执行,不能只作为前端显示提示。 + // 无 Authorization header:尝试使用 guest 用户作为后备。 + // 与 Go 版 server/middlewares/auth.go 的 Auth(false) 一致:是否允许匿名访问 + // 只取决于 guest 账号本身是否存在/被禁用,没有额外的站点开关。 if (!authHeader) { try { const db = await getDb(c.env) - // 统一走 isGuestAllowed:与 /api/public/settings 的展示规则同源, - // 并兼容布尔 false / 大小写等历史写法(裸 === "false" 会 fail-open)。 - if (!isGuestAllowed(db.settings)) return null - const guest = (db.users || []).find((u: any) => u.username === "guest") if (guest && !guest.disabled) { return { diff --git a/src/backend/server/public.ts b/src/backend/server/public.ts index fa32424f..a91995b0 100644 --- a/src/backend/server/public.ts +++ b/src/backend/server/public.ts @@ -7,7 +7,6 @@ import { isDbTrusted, isEncryptionReady, saveDb, - setSettingValue, } from "../internal/model/db" import { getStoreConfigErrorDetail, @@ -18,7 +17,6 @@ import { readFormat, } from "../internal/model/store/backend" import { setUserPassword } from "../pkg/password" -import { isGuestAllowed } from "../pkg/permission" // 脱敏 / 截断:与全局 503 拦截(index.ts)共用同一套规则 import { reasonLines, redact } from "./storage-error" @@ -492,7 +490,6 @@ publicRouter.get("/settings", async (c) => { check_update: "false", // --- Auth --- - allow_guest: "true", webauthn_login_enabled: "false", sso_login_enabled: "false", sso_compatibility_mode: "false", @@ -550,16 +547,9 @@ publicRouter.get("/settings", async (c) => { } }) - // 动态检查是否存在且启用了 guest 账号 - const guest = (db.users || []).find((u: any) => u.username === "guest") - const isGuestActive = Boolean(guest && !guest.disabled) - // 与鉴权路径(getUserFromContext / Emby 匿名入口)共用 isGuestAllowed, - // 避免「展示已关闭、实际仍放行」的规则漂移(布尔 false / "False" 等写法)。 - if (!isGuestActive || !isGuestAllowed(settingsObj)) { - settingsObj.allow_guest = "false" - } else { - settingsObj.allow_guest = "true" - } + // 注意:不再输出 allow_guest。Go 版没有这个键,匿名访问是否放行完全由 + // guest 账号是否存在/被禁用决定(server/middlewares/auth.go 的 Auth(false))。 + // 前端也不读取该字段,删掉它是为了和 Go 的公开设置保持一致。 return c.json({ code: 200, @@ -825,16 +815,8 @@ publicRouter.post("/init/setup", async (c) => { } } - // 新装默认要求登录:关闭匿名访客浏览。 - // - // 只在初始化(本请求)里写 "false":存量部署不会走到这里,因此升级不会被 - // 静默改成「必须登录」;管理员之后可在「站点设置 → 允许访客访问」打开。 - setSettingValue(db, "allow_guest", "false", { - type: "bool", - help: "Allow Anonymous Visitors to Browse as Guest", - group: 1, - flag: 0, - }) + // 注意:新装默认要求登录由「guest 账号默认禁用」实现(defaultDb.users), + // 与 Go 保持同一套语义,不再写 allow_guest 设置项。 // 初始化是唯一允许「在一次成功读取(确认存储为空)之后写入空壳」的场景, // 因此显式 force;此时 db 已包含新建的管理员用户,本身也不再是空壳。 diff --git a/src/backend/server/public_settings.test.ts b/src/backend/server/public_settings.test.ts index 3b28aa63..1d3f2fa6 100644 --- a/src/backend/server/public_settings.test.ts +++ b/src/backend/server/public_settings.test.ts @@ -59,8 +59,8 @@ test("Security(C-1): public settings still returns display options (no regressio assert.equal(json.data.webauthn_login_enabled, "false") assert.equal(json.data.sign_all, "true") assert.equal(json.data.site_title, "My Site") - // NOTE: allow_guest is deliberately not asserted here — it is derived from - // whether a guest account exists (see guest_auth.test.ts), not a plain echo. + // NOTE: allow_guest is not part of the public response — the Go backend has no + // such key; anonymous access is controlled by the guest account state only. }) test("Security(F-14): unknown keys fail closed — they are not echoed", async () => {