From 66230e6b431b419ea369ba08772ab205f5a164df Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 25 Jun 2026 15:59:42 +0800 Subject: [PATCH] fix(guest): normalize guestMode config on load + strict-boolean authorize gate loadConfig now sanitizes guestMode the same way the write path does: bots is coerced to "all" | string[] (numbers/objects/missing fall back to the default "all"), and permissions are rebuilt from defaults with each known flag strict-coerced to a boolean so a hand-edited/legacy/corrupt config.json can no longer crash the gate or leak garbage index keys. The authorize guest gate now uses === true instead of a truthy check. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/data/config.test.ts | 46 ++++++++++++++++++++++++++++ src/data/config.ts | 41 ++++++++++++++++++++----- src/web/middleware/authorize.test.ts | 4 +++ src/web/middleware/authorize.ts | 2 +- 4 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/data/config.test.ts b/src/data/config.test.ts index 17c1c89..692ab14 100644 --- a/src/data/config.test.ts +++ b/src/data/config.test.ts @@ -130,4 +130,50 @@ describe("guestMode config", () => { expect(c.guestMode.permissions.skip).toBe(false); // back-filled default rmSync(dir, { recursive: true, force: true }); }); + + // --- B1: loadConfig must sanitize a hand-edited/legacy/corrupt guestMode --- + + function loadGuestMode(raw: unknown) { + const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-")); + const p = join(dir, "config.json"); + writeFileSync(p, JSON.stringify(raw)); + try { + return loadConfig(p).guestMode; + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + describe("bots normalization", () => { + it("a numeric bots value falls back to the default \"all\" (no crash)", () => { + const gm = loadGuestMode({ guestMode: { bots: 5 } }); + expect(gm.bots).toBe("all"); + }); + it("an array bots value is filtered to strings only", () => { + const gm = loadGuestMode({ guestMode: { bots: ["a", 2, "b"] } }); + expect(gm.bots).toEqual(["a", "b"]); + }); + it("the literal \"all\" is preserved", () => { + const gm = loadGuestMode({ guestMode: { bots: "all" } }); + expect(gm.bots).toBe("all"); + }); + }); + + describe("permissions coercion", () => { + it("a non-boolean truthy flag is coerced to false; a real true stays true", () => { + const gm = loadGuestMode({ guestMode: { permissions: { skip: 1, playNext: true } } }); + expect(gm.permissions.skip).toBe(false); + expect(gm.permissions.playNext).toBe(true); + }); + it("a string permissions value yields defaults with no numeric index keys", () => { + const gm = loadGuestMode({ guestMode: { permissions: "hacked" } }); + // 7 known flags present at their defaults + expect(gm.permissions).toEqual({ + addToQueue: true, playNext: false, playNow: false, + skip: false, transport: false, removeClear: false, playMode: false, + }); + // no garbage index keys leaked from spreading a string + expect((gm.permissions as unknown as Record)["0"]).toBeUndefined(); + }); + }); }); diff --git a/src/data/config.ts b/src/data/config.ts index cb8a4a7..2c2291d 100755 --- a/src/data/config.ts +++ b/src/data/config.ts @@ -1,6 +1,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from "node:fs"; import { dirname } from "node:path"; import type { BotAccess, GuestPermissions } from "./permissions.js"; +import { GUEST_PERMISSION_FLAGS } from "./permissions.js"; export interface GuestModeConfig { enabled: boolean; @@ -72,17 +73,41 @@ export function loadConfig(path: string): BotConfig { try { const raw = readFileSync(path, "utf-8"); const partial = JSON.parse(raw) as Partial; + + // Normalize/sanitize guestMode on load. The WRITE path (POST /api/bot/settings) + // sanitizes too, but a hand-edited/legacy/corrupt config.json reaches the gate + // directly — so coerce it here as well, mirroring that write-path logic. + const partialGm = (partial.guestMode ?? {}) as Partial; + const gm: GuestModeConfig = { + ...defaults.guestMode, + ...partialGm, + // bots → "all" | string[]; anything else falls back to the default ("all"). + bots: + partialGm.bots === "all" + ? "all" + : Array.isArray(partialGm.bots) + ? partialGm.bots.filter((id): id is string => typeof id === "string") + : defaults.guestMode.bots, + // permissions → defaults, then spread ONLY a plain object, then strict-coerce + // each known flag to a boolean (drops index keys + non-boolean values). + permissions: { ...defaults.guestMode.permissions }, + }; + const partialPerms = partialGm.permissions; + if ( + partialPerms !== null && + typeof partialPerms === "object" && + !Array.isArray(partialPerms) + ) { + Object.assign(gm.permissions, partialPerms); + } + for (const f of GUEST_PERMISSION_FLAGS) { + gm.permissions[f] = gm.permissions[f] === true; + } + return { ...defaults, ...partial, - guestMode: { - ...defaults.guestMode, - ...(partial.guestMode ?? {}), - permissions: { - ...defaults.guestMode.permissions, - ...(partial.guestMode?.permissions ?? {}), - }, - }, + guestMode: gm, }; } catch { return defaults; diff --git a/src/web/middleware/authorize.test.ts b/src/web/middleware/authorize.test.ts index 15e3586..6e6e74e 100644 --- a/src/web/middleware/authorize.test.ts +++ b/src/web/middleware/authorize.test.ts @@ -30,4 +30,8 @@ describe("authorize", () => { it("guest is denied on routes with no guestFlag (e.g. play-song)", () => { expect(run({ role: "guest", guest: { addToQueue: true } }, { capability: "player.control" }).res.statusCode).toBe(403); }); + it("guest with a non-boolean truthy flag value (1) is denied (strict-boolean gate)", () => { + expect(run({ role: "guest", guest: { playNext: 1 } as any }, { guestFlag: "playNext" }).res.statusCode).toBe(403); + expect(run({ role: "guest", guest: { playNext: true } }, { guestFlag: "playNext" }).next).toHaveBeenCalled(); + }); }); diff --git a/src/web/middleware/authorize.ts b/src/web/middleware/authorize.ts index d5e39cb..6830607 100644 --- a/src/web/middleware/authorize.ts +++ b/src/web/middleware/authorize.ts @@ -19,7 +19,7 @@ export function authorize

>(opts: { if (!user) { res.status(401).json({ error: "unauthenticated" }); return; } if (user.role === "admin") { next(); return; } if (user.role === "guest") { - if (opts.guestFlag && user.guest?.[opts.guestFlag]) { next(); return; } + if (opts.guestFlag && user.guest?.[opts.guestFlag] === true) { next(); return; } res.status(403).json({ error: "forbidden" }); return; }