Merge PR #80: feat(perm) fine-grained account permissions

Conflict resolution + cross-PR integration:
- player.ts: kept #88's POST /:botId/fm route AND gated it with
  requirePermission('player.control') so the new control endpoint honors #80's
  permission model (it was added without gating).
- bot.ts: kept #81's relocated /settings routes (the relocation fixes the GET
  /settings shadow bug) and dropped #80's now-duplicate bottom copy; gated
  POST /settings with requirePermission('bot.manage').
- Navbar.vue: composed #82's dedicated-link scope with #80's permission filter —
  displayedBots is now the INTERSECTION (scope ∩ controllable allow-list).
- database.ts: kept BOTH new table sets (#87 favorite_playlists + #80
  user_permissions/user_bot_access).
- bot.test.ts: updated to createRequireAuth(sessions, permissions) for #80's new
  two-arg signature.

#80 review fixes (credential exposure / IDOR, adversarially verified):
- GET /:id/config now requires bot.manage + bot access AND redacts ts6ApiKey +
  identity from the response (was readable by any authenticated member).
- GET /:id and GET /:id/avatar now require bot access (were ungated read oracles).
This commit is contained in:
saopig1 committed 2026-06-16 15:05:57 +08:00
commit bea2f92508
32 files changed
+2225 -137

No files matched your search

+3 -1
View File
@@ -5,6 +5,7 @@ import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "./requireAuth.js";
import { requireAdmin } from "./requireAdmin.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
@@ -19,13 +20,14 @@ describe("requireAdmin middleware", () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const admin = await users.createUser("admin", "pw-admin-pw", "admin");
const member = await users.createUser("member", "pw-member-pw", "member");
adminCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
memberCookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(member.id).token}`;
app = express();
app.use(cookieParser());
app.use(createRequireAuth(sessions));
app.use(createRequireAuth(sessions, permissions));
app.use(requireAdmin);
app.get("/admin-only", (_req, res) => res.json({ ok: true }));
});
+3 -1
View File
@@ -5,6 +5,7 @@ import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "./requireAuth.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
@@ -17,12 +18,13 @@ describe("requireAuth middleware", () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const u = await users.createUser("alice", "pw-alice", "admin");
validToken = sessions.createSession(u.id).token;
app = express();
app.use(cookieParser());
app.use(createRequireAuth(sessions));
app.use(createRequireAuth(sessions, permissions));
app.get("/protected", (req, res) => {
res.json({ ok: true, user: (req as any).user });
});
+17 -3
View File
@@ -1,6 +1,7 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
import type { SessionStore } from "../../data/sessions.js";
import { SESSION_TTL_MS } from "../../data/sessions.js";
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
import {
validateSessionFromHeaders,
extractSessionToken,
@@ -9,11 +10,17 @@ import {
declare module "express-serve-static-core" {
interface Request {
user?: { id: string; username: string; role: "admin" | "member" };
user?: {
id: string;
username: string;
role: "admin" | "member";
capabilities?: Set<string>;
bots?: "all" | Set<string>;
};
}
}
export function createRequireAuth(sessions: SessionStore): RequestHandler {
export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler {
return function requireAuth(req: Request, res: Response, next: NextFunction) {
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) {
@@ -21,7 +28,14 @@ export function createRequireAuth(sessions: SessionStore): RequestHandler {
res.status(401).json({ error: "unauthenticated" });
return;
}
req.user = { id: result.userId, username: result.username, role: result.role };
const ctx = resolvePermissionContext(result.role, result.userId, permissions);
req.user = {
id: result.userId,
username: result.username,
role: result.role,
capabilities: ctx.capabilities,
bots: ctx.bots,
};
const token = extractSessionToken(req.headers.cookie);
if (token) {
res.cookie(SESSION_COOKIE_NAME, token, {
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
import { requirePermission, requireBotAccess } from "./requirePermission.js";
function appWith(user: any) {
const app = express();
app.use((req, _res, next) => { (req as any).user = user; next(); });
app.post("/cap", requirePermission("quality"), (_req, res) => res.json({ ok: true }));
app.post("/bot/:botId", requireBotAccess("botId"), (_req, res) => res.json({ ok: true }));
return app;
}
const member = (caps: string[], bots: "all" | string[]) => ({
id: "u1", username: "a", role: "member",
capabilities: new Set(caps), bots: bots === "all" ? "all" : new Set(bots),
});
const admin = { id: "a", username: "admin", role: "admin", capabilities: new Set(), bots: "all" };
describe("requirePermission", () => {
it("401 when unauthenticated", async () => {
const app = express();
app.post("/cap", requirePermission("quality"), (_r, res) => res.json({ ok: true }));
expect((await request(app).post("/cap")).status).toBe(401);
});
it("403 when member lacks the capability", async () => {
expect((await request(appWith(member([], "all"))).post("/cap")).status).toBe(403);
});
it("200 when member has the capability", async () => {
expect((await request(appWith(member(["quality"], "all"))).post("/cap")).status).toBe(200);
});
it("200 for admin regardless of capabilities", async () => {
expect((await request(appWith(admin)).post("/cap")).status).toBe(200);
});
});
describe("requireBotAccess", () => {
it("200 when bots = all", async () => {
expect((await request(appWith(member([], "all"))).post("/bot/b1")).status).toBe(200);
});
it("200 when botId in allow-list", async () => {
expect((await request(appWith(member([], ["b1"]))).post("/bot/b1")).status).toBe(200);
});
it("403 when botId not in allow-list", async () => {
expect((await request(appWith(member([], ["b2"]))).post("/bot/b1")).status).toBe(403);
});
it("200 for admin", async () => {
expect((await request(appWith(admin)).post("/bot/b1")).status).toBe(200);
});
it("401 when unauthenticated", async () => {
const app = express();
app.post("/bot/:botId", requireBotAccess("botId"), (_r, res) => res.json({ ok: true }));
expect((await request(app).post("/bot/b1")).status).toBe(401);
});
it("403 when the route param is absent", async () => {
const app = express();
app.use((req, _res, next) => { (req as any).user = member([], ["b1"]); next(); });
app.post("/bot/:botId", requireBotAccess("nope"), (_r, res) => res.json({ ok: true }));
expect((await request(app).post("/bot/b1")).status).toBe(403);
});
});
+24
View File
@@ -0,0 +1,24 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
// Generic over the route-param shape (`P`) so Express can keep inferring
// `req.params` from the route string (e.g. `/:id` → `{ id: string }`) when
// these are passed as a per-route middleware argument. Pinning the default
// `ParamsDictionary` here would otherwise force the broad
// `string | string[]` param overload on every route they guard.
export function requirePermission<P = Record<string, string>>(capability: string): RequestHandler<P> {
return (req: Request<P>, res: Response, next: NextFunction) => {
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (req.user.role === "admin" || req.user.capabilities?.has(capability)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}
export function requireBotAccess<P = Record<string, string>>(paramName = "botId"): RequestHandler<P> {
return (req: Request<P>, res: Response, next: NextFunction) => {
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (req.user.role === "admin" || req.user.bots === "all") { next(); return; }
const botId = (req.params as Record<string, string | undefined>)[paramName];
if (typeof botId === "string" && req.user.bots instanceof Set && req.user.bots.has(botId)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}