diff --git a/src/web/api/auth.ts b/src/web/api/auth.ts index 16e3d1f..9bfc0a0 100644 --- a/src/web/api/auth.ts +++ b/src/web/api/auth.ts @@ -4,6 +4,7 @@ import { YouTubeProvider } from "../../music/youtube.js"; import type { CookieStore } from "../../music/auth.js"; import type { Logger } from "../../logger.js"; import { requirePermission } from "../middleware/requirePermission.js"; +import { requireNotGuest } from "../middleware/requireNotGuest.js"; export function createAuthRouter( neteaseProvider: MusicProvider, @@ -23,7 +24,7 @@ export function createAuthRouter( return platform === "qq" ? qqProvider : neteaseProvider; } - router.get("/status", async (req, res) => { + router.get("/status", requireNotGuest, async (req, res) => { try { const platform = req.query.platform as string; const provider = getProvider(platform); @@ -49,7 +50,7 @@ export function createAuthRouter( } }); - router.get("/qrcode/status", async (req, res) => { + router.get("/qrcode/status", requireNotGuest, async (req, res) => { try { const { key, platform } = req.query; if (!key) { diff --git a/src/web/api/permissions-enforcement.test.ts b/src/web/api/permissions-enforcement.test.ts index 4593393..c669cdb 100644 --- a/src/web/api/permissions-enforcement.test.ts +++ b/src/web/api/permissions-enforcement.test.ts @@ -6,6 +6,8 @@ import { createPlayerRouter } from "./player.js"; import { createBotRouter } from "./bot.js"; import { createAuthRouter } from "./auth.js"; import { createMusicRouter } from "./music.js"; +import { createFavoritesRouter } from "./favorites.js"; +import { requireNotGuest } from "../middleware/requireNotGuest.js"; const logger = pino({ level: "silent" }); @@ -204,7 +206,7 @@ describe("permission enforcement on action routes", () => { expect(res.status).not.toBe(403); }); - it("GET /api/music/quality not gated", async () => { + it("GET /api/music/quality readable by members, denied to guests", async () => { const app = makeApp(member([], "all")); const res = await request(app).get("/api/music/quality"); expect(res.status).not.toBe(403); @@ -215,6 +217,12 @@ describe("permission enforcement on action routes", () => { const res = await request(app).get("/api/bot"); expect(res.status).not.toBe(403); }); + + it("GET /api/auth/status and /api/auth/qrcode/status are 403 for guests", async () => { + const app = makeApp(guest()); + expect((await request(app).get("/api/auth/status")).status).toBe(403); + expect((await request(app).get("/api/auth/qrcode/status?key=k")).status).toBe(403); + }); }); describe("admin bypasses every gate", () => { @@ -368,3 +376,48 @@ describe("guest enforcement on player routes", () => { expect((await request(m).post(`/api/player/${ALLOWED_BOT}/add-song`).send({ song: SONG })).status).not.toBe(403); }); }); + +// -------------------------------------------------------------------------- +// Favorites are member-only: the router keys everything off req.user.id and +// all guests share the __guest__ principal, so a guest must never reach it. +// server.ts gates the mount with requireNotGuest; we mirror that mount here +// and assert a guest gets 403 (the requireNotGuest guard runs before any +// handler, so the fake database is never touched). +// -------------------------------------------------------------------------- + +function makeFavoritesApp(user: any) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { (req as any).user = user; next(); }); + const fakeDb = { + getFavorites: () => [], + addFavorite: () => {}, + removeFavorite: () => {}, + isFavorited: () => false, + } as any; + app.use("/api/favorites", requireNotGuest, createFavoritesRouter(fakeDb, logger)); + return app; +} + +describe("favorites are denied to guests", () => { + it("403 for a guest on GET /api/favorites", async () => { + const app = makeFavoritesApp(guest()); + expect((await request(app).get("/api/favorites")).status).toBe(403); + }); + + it("403 for a guest on GET /api/favorites/check", async () => { + const app = makeFavoritesApp(guest()); + expect((await request(app).get("/api/favorites/check?platform=netease&playlistId=x")).status).toBe(403); + }); + + it("403 for a guest on POST /api/favorites", async () => { + const app = makeFavoritesApp(guest()); + const res = await request(app).post("/api/favorites").send({ platform: "netease", playlistId: "x", name: "n" }); + expect(res.status).toBe(403); + }); + + it("NOT 403 for a member on GET /api/favorites", async () => { + const app = makeFavoritesApp(member([], "all")); + expect((await request(app).get("/api/favorites")).status).not.toBe(403); + }); +}); diff --git a/src/web/api/session.test.ts b/src/web/api/session.test.ts index f327685..e26b42d 100644 --- a/src/web/api/session.test.ts +++ b/src/web/api/session.test.ts @@ -236,4 +236,37 @@ describe("session router — guest mode", () => { const res = await request(app).get("/api/session/needs-setup"); expect(res.body.guestAllowed).toBe(true); }); + + it("GET /me returns 401 for a guest session once guest mode is disabled", async () => { + // Build an app whose guest config can be toggled at runtime, mirroring an + // admin flipping the setting mid-session (requireAuthInline must reject). + botDb = createDatabase(":memory:"); + const users = createUserStore(botDb.db); + const sessions = createSessionStore(botDb.db); + const audit = createAuditStore(botDb.db); + const permissions = createPermissionStore(botDb.db); + const guestCfg: GuestModeConfig = { + enabled: true, + bots: getDefaultConfig().guestMode.bots, + permissions: getDefaultConfig().guestMode.permissions, + }; + const app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use( + "/api/session", + createSessionRouter(users, sessions, audit, pino({ level: "silent" }), permissions, () => guestCfg) + ); + + const login = await request(app).post("/api/session/guest"); + expect(login.status).toBe(200); + const cookie = login.headers["set-cookie"]; + + // While enabled, /me works for the guest. + expect((await request(app).get("/api/session/me").set("Cookie", cookie)).status).toBe(200); + + // Admin disables guest mode → the in-flight guest session is now invalid. + guestCfg.enabled = false; + expect((await request(app).get("/api/session/me").set("Cookie", cookie)).status).toBe(401); + }); }); diff --git a/src/web/api/session.ts b/src/web/api/session.ts index 1d67b89..beaef21 100644 --- a/src/web/api/session.ts +++ b/src/web/api/session.ts @@ -65,6 +65,13 @@ export function createSessionRouter( res.status(401).json({ error: "unauthenticated" }); return; } + // A guest session is only valid while guest mode is enabled. Disabling it + // immediately invalidates any in-flight guest sessions (mirrors createRequireAuth). + if (result.role === "guest" && !getGuestConfig().enabled) { + clearSessionCookie(res); + res.status(401).json({ error: "unauthenticated" }); + return; + } req.user = { id: result.userId, username: result.username, role: result.role }; const token = extractSessionToken(req.headers.cookie); if (token) setSessionCookie(res, token); diff --git a/src/web/server.ts b/src/web/server.ts index ac609b4..da5258f 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -25,6 +25,7 @@ import { createSessionStore } from "../data/sessions.js"; import { createPermissionStore } from "../data/permissions.js"; import { createRequireAuth } from "./middleware/requireAuth.js"; import { requireAdmin } from "./middleware/requireAdmin.js"; +import { requireNotGuest } from "./middleware/requireNotGuest.js"; import { csrfOriginCheck } from "./middleware/csrf.js"; import { createRateLimit } from "./middleware/rateLimit.js"; import { validateSessionFromHeaders } from "./auth/validateSession.js"; @@ -126,7 +127,7 @@ export function createWebServer(options: WebServerOptions): WebServer { "/api/auth", createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore) ); - app.use("/api/favorites", createFavoritesRouter(options.database, logger)); + app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger)); // admin-only routes app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions)); diff --git a/web/src/App.vue b/web/src/App.vue index 1031cae..e90c4dc 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -19,22 +19,22 @@