diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 92a8ab5..92a82e3 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -62,6 +62,7 @@ "react-dom": "^19.1.0", "react-i18next": "^15.5.2", "recharts": "^3.8.0", + "safe-regex": "^2.1.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "zod": "^3.25.17" @@ -71,6 +72,7 @@ "@tailwindcss/vite": "^4.1.7", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.6", + "@types/safe-regex": "^1.1.6", "@vitejs/plugin-react": "^4.5.2", "@vitest/coverage-v8": "^4.0.18", "concurrently": "^9.1.2", diff --git a/apps/dashboard/src/server/features/actions/routes.ts b/apps/dashboard/src/server/features/actions/routes.ts index d99b102..28592b1 100644 --- a/apps/dashboard/src/server/features/actions/routes.ts +++ b/apps/dashboard/src/server/features/actions/routes.ts @@ -28,6 +28,7 @@ import { } from "@fluxcore/systems/actions/constants"; import type { ActionEventType, ActionType, RuleStep } from "@fluxcore/systems/actions/types"; import { channelExistsInGuild } from "../../shared/discordApi.js"; +import { logger } from "@fluxcore/utils"; const validEventTypes = new Set(Object.keys(EVENT_TYPES)); const validActionTypes = new Set(Object.keys(ACTION_TYPES)); @@ -228,8 +229,17 @@ export function registerActionRoutes(app: FastifyInstance): void { }); await notifyCacheInvalidation(guildId); reply.send(updated); - } catch { - reply.code(404).send({ error: "Rule not found" }); + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "P2025") { + reply.code(404).send({ error: "Rule not found" }); + return; + } + logger.error( + `Failed to update action rule ${ruleId} in guild ${guildId}`, + err instanceof Error ? err : new Error(String(err)), + ); + reply.code(500).send({ error: "Failed to update rule" }); } }, ); diff --git a/apps/dashboard/src/server/features/commands/routes.ts b/apps/dashboard/src/server/features/commands/routes.ts index 5173c2f..c7e1876 100644 --- a/apps/dashboard/src/server/features/commands/routes.ts +++ b/apps/dashboard/src/server/features/commands/routes.ts @@ -1,4 +1,5 @@ import type { FastifyInstance } from "fastify"; +import safeRegex from "safe-regex"; import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; import { getCustomCommands, @@ -10,6 +11,23 @@ import { import { MAX_COMMANDS_PER_GUILD } from "@fluxcore/systems/customCommands/constants"; import { TRIGGER_TYPES } from "@fluxcore/systems/customCommands/constants"; +const MAX_REGEX_LENGTH = 200; + +function validateRegexPattern(pattern: string): string | null { + if (pattern.length > MAX_REGEX_LENGTH) { + return `Regex pattern too long (max ${MAX_REGEX_LENGTH} chars)`; + } + try { + new RegExp(pattern, "i"); + } catch { + return "Invalid regex pattern"; + } + if (!safeRegex(pattern)) { + return "Unsafe regex pattern (catastrophic backtracking risk)"; + } + return null; +} + function parseIntParam(value: string): number | null { const n = parseInt(value, 10); return Number.isFinite(n) && n > 0 ? n : null; @@ -32,6 +50,14 @@ export function registerCustomCommandRoutes(app: FastifyInstance): void { "/api/guilds/:guildId/custom-commands", { preHandler: [requireAuth, requireGuildAdmin, requirePermission("commands.list.manage")], + config: { + rateLimit: { + max: 10, + timeWindow: "1 minute", + keyGenerator: (req) => + (req as { session?: { userId?: string } }).session?.userId ?? req.ip, + }, + }, schema: { body: { type: "object", @@ -109,10 +135,9 @@ export function registerCustomCommandRoutes(app: FastifyInstance): void { // Validate regex if trigger type is regex if (body.triggerType === "regex") { - try { - new RegExp(body.name, "i"); - } catch { - reply.code(400).send({ error: "Invalid regex pattern" }); + const err = validateRegexPattern(body.name); + if (err) { + reply.code(400).send({ error: err }); return; } } @@ -218,10 +243,9 @@ export function registerCustomCommandRoutes(app: FastifyInstance): void { // Validate regex if trigger type is being changed to regex if (body.triggerType === "regex" && body.name) { - try { - new RegExp(body.name, "i"); - } catch { - reply.code(400).send({ error: "Invalid regex pattern" }); + const err = validateRegexPattern(body.name); + if (err) { + reply.code(400).send({ error: err }); return; } } diff --git a/apps/dashboard/src/server/features/giveaways/routes.ts b/apps/dashboard/src/server/features/giveaways/routes.ts index 7ed966c..ddb5831 100644 --- a/apps/dashboard/src/server/features/giveaways/routes.ts +++ b/apps/dashboard/src/server/features/giveaways/routes.ts @@ -50,6 +50,14 @@ export function registerGiveawayRoutes(app: FastifyInstance): void { "/api/guilds/:guildId/giveaways", { preHandler: [requireAuth, requireGuildAdmin, requirePermission("giveaways.list.manage")], + config: { + rateLimit: { + max: 10, + timeWindow: "1 minute", + keyGenerator: (req) => + (req as { session?: { userId?: string } }).session?.userId ?? req.ip, + }, + }, schema: { body: { type: "object", diff --git a/apps/dashboard/src/server/features/music/routes.ts b/apps/dashboard/src/server/features/music/routes.ts index c353156..799c249 100644 --- a/apps/dashboard/src/server/features/music/routes.ts +++ b/apps/dashboard/src/server/features/music/routes.ts @@ -105,6 +105,14 @@ export function registerMusicRoutes(app: FastifyInstance): void { "/api/guilds/:guildId/music/library", { preHandler: [requireAuth, requireGuildAdmin, requirePermission("music.library.manage")], + config: { + rateLimit: { + max: 10, + timeWindow: "1 minute", + keyGenerator: (req) => + (req as { session?: { userId?: string } }).session?.userId ?? req.ip, + }, + }, schema: { body: { type: "object", diff --git a/apps/dashboard/src/server/features/permissions/routes.ts b/apps/dashboard/src/server/features/permissions/routes.ts index 0ac2d25..4df98b8 100644 --- a/apps/dashboard/src/server/features/permissions/routes.ts +++ b/apps/dashboard/src/server/features/permissions/routes.ts @@ -3,7 +3,6 @@ import { getPrisma } from "@fluxcore/database"; import { PERMISSION_REGISTRY, ALL_PERMISSION_KEYS, - matchPermission, resolveEffectivePermissions, } from "@fluxcore/types"; import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js"; @@ -113,6 +112,12 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void { return; } + // Block self-grant: a user must never grant or modify their own permission set + if (session.userId === userId) { + reply.code(403).send({ error: "Cannot modify your own permissions" }); + return; + } + // Validate keys for (const perm of permissions) { if (!isValidPermKey(perm)) { @@ -121,15 +126,18 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void { } } - // Escalation check + // Strict escalation gate: non-owners must literally hold every key they grant. + // Wildcards (`*`, `module.*`, `module.feature.*`) may only be granted by the + // guild owner — match-by-wildcard is not enough. if (!request.resolvedPermissions?.isOwner) { - const userPerms = request.resolvedPermissions!.permissions; + const literalCallerPerms = request.resolvedPermissions!.permissions; for (const perm of permissions) { - if (!matchPermission(userPerms, perm)) { - reply.code(403).send({ - error: "Cannot grant permissions you don't have", - permission: perm, - }); + if (perm === "*" || perm.includes("*")) { + reply.code(403).send({ error: "Insufficient privileges to grant this permission" }); + return; + } + if (!literalCallerPerms.has(perm)) { + reply.code(403).send({ error: "Insufficient privileges to grant this permission" }); return; } } @@ -298,13 +306,39 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void { const skip = (page - 1) * limit; const where: Record = { guildId }; - if (query.userId) where.userId = query.userId; - if (query.action) where.action = { contains: query.action }; + const isOwner = request.resolvedPermissions?.isOwner === true; + if (isOwner) { + if (query.userId) where.userId = query.userId; + } else { + // Non-owners may only view their own audit entries + where.userId = request.session!.userId; + } + if (query.action) { + if (!ALLOWED_AUDIT_ACTIONS.has(query.action)) { + reply.code(400).send({ error: "Unknown action filter" }); + return; + } + where.action = query.action; + } if (query.targetType) where.targetType = query.targetType; if (query.from || query.to) { const createdAt: Record = {}; - if (query.from) createdAt.gte = new Date(query.from); - if (query.to) createdAt.lte = new Date(query.to); + if (query.from) { + const d = new Date(query.from); + if (!Number.isFinite(d.getTime())) { + reply.code(400).send({ error: "Invalid 'from' date" }); + return; + } + createdAt.gte = d; + } + if (query.to) { + const d = new Date(query.to); + if (!Number.isFinite(d.getTime())) { + reply.code(400).send({ error: "Invalid 'to' date" }); + return; + } + createdAt.lte = d; + } where.createdAt = createdAt; } @@ -339,6 +373,17 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void { // ─── Helpers ─── +const ALLOWED_AUDIT_ACTIONS = new Set([ + "dashboard.permissions.update", + "dashboard.permissions.clear", + "dashboard.settings.update", + "dashboard.role.create", + "dashboard.role.update", + "dashboard.role.delete", + "dashboard.role.assign", + "dashboard.role.unassign", +]); + function isValidPermKey(key: string): boolean { if (ALL_PERMISSION_KEYS.includes(key)) return true; if (key === "*") return true; diff --git a/apps/dashboard/src/server/features/scheduled/routes.ts b/apps/dashboard/src/server/features/scheduled/routes.ts index 2c07de6..17c33f9 100644 --- a/apps/dashboard/src/server/features/scheduled/routes.ts +++ b/apps/dashboard/src/server/features/scheduled/routes.ts @@ -220,7 +220,17 @@ export function registerScheduledMessageRoutes(app: FastifyInstance): void { // GET preview next run time for a cron expression app.get( "/api/guilds/:guildId/scheduled-messages/preview-cron", - { preHandler: [requireAuth, requireGuildAdmin, requirePermission("scheduled.messages.view")] }, + { + preHandler: [requireAuth, requireGuildAdmin, requirePermission("scheduled.messages.view")], + config: { + rateLimit: { + max: 5, + timeWindow: "10 seconds", + keyGenerator: (req) => + (req as { session?: { userId?: string } }).session?.userId ?? req.ip, + }, + }, + }, async (request, reply) => { const query = request.query as { cronExpr?: string; timezone?: string }; if (!query.cronExpr) { @@ -235,18 +245,34 @@ export function registerScheduledMessageRoutes(app: FastifyInstance): void { } const timezone = query.timezone ?? "UTC"; - const nextRun = getNextCronRun(query.cronExpr, timezone); - - // Calculate the next 5 run times - const nextRuns: string[] = [nextRun.toISOString()]; - let lastRun = nextRun; - for (let i = 0; i < 4; i++) { - const next = getNextCronRun(query.cronExpr, timezone, lastRun); - nextRuns.push(next.toISOString()); - lastRun = next; - } + const budgetMs = 250; + const start = Date.now(); - reply.send({ nextRuns }); + try { + const nextRun = getNextCronRun(query.cronExpr, timezone); + if (Date.now() - start > budgetMs) { + reply + .code(400) + .send({ error: "Cron expression too slow to evaluate (budget exceeded)" }); + return; + } + const nextRuns: string[] = [nextRun.toISOString()]; + let lastRun = nextRun; + for (let i = 0; i < 4; i++) { + if (Date.now() - start > budgetMs) { + reply + .code(400) + .send({ error: "Cron expression too slow to evaluate (budget exceeded)" }); + return; + } + const next = getNextCronRun(query.cronExpr, timezone, lastRun); + nextRuns.push(next.toISOString()); + lastRun = next; + } + reply.send({ nextRuns }); + } catch { + reply.code(400).send({ error: "Failed to evaluate cron expression" }); + } }, ); } diff --git a/apps/dashboard/src/server/features/welcome/routes.ts b/apps/dashboard/src/server/features/welcome/routes.ts index 57fe966..666dd4a 100644 --- a/apps/dashboard/src/server/features/welcome/routes.ts +++ b/apps/dashboard/src/server/features/welcome/routes.ts @@ -183,6 +183,7 @@ export function registerWelcomeRoutes(app: FastifyInstance): void { reply .header("Content-Type", "image/png") .header("Cache-Control", "no-cache") + .header("X-Content-Type-Options", "nosniff") .send(imageBuffer); }, ); @@ -214,7 +215,18 @@ export function registerWelcomeRoutes(app: FastifyInstance): void { return; } + // Strict base64 validation (RFC 4648, optional padding) + const base64Regex = /^[A-Za-z0-9+/]+={0,2}$/; + if (data.length === 0 || data.length % 4 !== 0 || !base64Regex.test(data)) { + reply.code(400).send({ error: "Invalid base64 payload" }); + return; + } + const buffer = Buffer.from(data, "base64"); + if (buffer.length === 0) { + reply.code(400).send({ error: "Empty payload" }); + return; + } if (buffer.length > MAX_BACKGROUND_SIZE) { reply.code(400).send({ @@ -223,6 +235,15 @@ export function registerWelcomeRoutes(app: FastifyInstance): void { return; } + // Magic byte sniffing — must match contentType + const detected = detectImageType(buffer); + if (!detected || detected !== contentType) { + reply.code(400).send({ + error: "File content does not match the declared image type", + }); + return; + } + const ext = contentType.split("/")[1] === "jpeg" ? "jpg" : contentType.split("/")[1]; const key = `backgrounds/${guildId}/${randomUUID()}.${ext}`; @@ -296,3 +317,26 @@ export function registerWelcomeRoutes(app: FastifyInstance): void { }, ); } + +function detectImageType(buffer: Buffer): string | null { + if (buffer.length < 12) return null; + // PNG: 89 50 4E 47 0D 0A 1A 0A + if ( + buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 && + buffer[4] === 0x0d && buffer[5] === 0x0a && buffer[6] === 0x1a && buffer[7] === 0x0a + ) { + return "image/png"; + } + // JPEG: FF D8 FF + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return "image/jpeg"; + } + // WebP: RIFF .... WEBP + if ( + buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && + buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50 + ) { + return "image/webp"; + } + return null; +} diff --git a/apps/dashboard/tests/server/features/actions/updateRuleErrors.test.ts b/apps/dashboard/tests/server/features/actions/updateRuleErrors.test.ts new file mode 100644 index 0000000..541c79c --- /dev/null +++ b/apps/dashboard/tests/server/features/actions/updateRuleErrors.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", dashboardSessionSecret: "s", logLevel: "info" }, +})); + +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: vi.fn().mockResolvedValue({ + userId: "user-1", + username: "u", + guilds: [{ id: "guild-1", name: "T", permissions: BigInt(0x20).toString() }], + }), + touchSession: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + channelExistsInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: vi.fn().mockResolvedValue("owner-1"), +})); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: vi + .fn() + .mockResolvedValue({ permissions: new Set(["*"]), isOwner: true }), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); + +const { mockUpdateRule } = vi.hoisted(() => ({ mockUpdateRule: vi.fn() })); +vi.mock("@fluxcore/systems/actions/persistence", () => ({ + notifyCacheInvalidation: vi.fn().mockResolvedValue(undefined), + listRules: vi.fn().mockResolvedValue([]), + getRule: vi.fn(), + createRule: vi.fn(), + updateRule: (...args: unknown[]) => mockUpdateRule(...args), + deleteRule: vi.fn(), + bulkUpdateRules: vi.fn(), + bulkDeleteRules: vi.fn(), + getRuleAnalytics: vi.fn(), + getActionLogs: vi.fn(), + getGuildSettings: vi.fn(), + upsertGuildSettings: vi.fn(), + getRulesByGuild: vi.fn().mockResolvedValue([]), + countRules: vi.fn().mockResolvedValue(0), + getRecentLogs: vi.fn().mockResolvedValue([]), + getAnalytics: vi.fn().mockResolvedValue({}), + getLastFiredByGuild: vi.fn().mockResolvedValue(new Map()), +})); + +vi.mock("@fluxcore/systems/actions/config", () => ({ + getGuildSettingsOrDefault: vi + .fn() + .mockReturnValue({ maxRules: 25, globalEnabled: true, logChannelId: null }), + setGuildSettings: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@fluxcore/systems/actions/constants", () => ({ + EVENT_TYPES: { memberJoin: { label: "Member Join" } }, + ACTION_TYPES: { sendMessage: { label: "Send Message" } }, + MAX_ACTIONS_PER_RULE: 5, + ACTION_TYPE_FIELDS: {}, + EVENT_TYPE_VARIABLES: {}, + TEMPLATE_VARIABLES: [], +})); + +const { mockLogger } = vi.hoisted(() => ({ + mockLogger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); +vi.mock("@fluxcore/utils", () => ({ logger: mockLogger })); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import { registerActionRoutes } from "../../../../src/server/features/actions/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + registerActionRoutes(app); + await app.ready(); + return app; +} + +describe("PUT /actions/rules/:ruleId — error handling", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildApp(); + }); + + it("returns 404 when Prisma throws P2025", async () => { + const err = Object.assign(new Error("Record not found"), { code: "P2025" }); + mockUpdateRule.mockRejectedValue(err); + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/actions/rules/123", + cookies: { session: app.signCookie("valid") }, + payload: { name: "x" }, + }); + expect(res.statusCode).toBe(404); + }); + + it("returns 500 and logs when an unexpected error occurs", async () => { + mockUpdateRule.mockRejectedValue(new Error("DB connection refused")); + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/actions/rules/123", + cookies: { session: app.signCookie("valid") }, + payload: { name: "x" }, + }); + expect(res.statusCode).toBe(500); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); diff --git a/apps/dashboard/tests/server/features/commands/regexValidation.test.ts b/apps/dashboard/tests/server/features/commands/regexValidation.test.ts new file mode 100644 index 0000000..758518b --- /dev/null +++ b/apps/dashboard/tests/server/features/commands/regexValidation.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", dashboardSessionSecret: "s", logLevel: "info" }, +})); + +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: vi.fn().mockResolvedValue({ + userId: "user-1", + username: "u", + guilds: [{ id: "guild-1", name: "T", permissions: BigInt(0x20).toString() }], + }), + touchSession: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: vi.fn().mockResolvedValue("owner-1"), +})); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: vi.fn().mockResolvedValue({ permissions: new Set(["*"]), isOwner: true }), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@fluxcore/systems/customCommands/persistence", () => ({ + getCustomCommands: vi.fn().mockResolvedValue([]), + getCustomCommandCount: vi.fn().mockResolvedValue(0), + createCustomCommand: vi.fn().mockResolvedValue({ id: 1 }), + updateCustomCommand: vi.fn().mockResolvedValue({ id: 1 }), + deleteCustomCommand: vi.fn(), +})); +vi.mock("@fluxcore/systems/customCommands/constants", () => ({ + MAX_COMMANDS_PER_GUILD: 100, + TRIGGER_TYPES: ["exact", "startsWith", "contains", "regex"], +})); +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import { registerCustomCommandRoutes } from "../../../../src/server/features/commands/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + registerCustomCommandRoutes(app); + await app.ready(); + return app; +} + +describe("POST /custom-commands — regex safety", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildApp(); + }); + + it("rejects catastrophic backtracking pattern (a+)+$", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/custom-commands", + cookies: { session: app.signCookie("valid") }, + payload: { name: "(a+)+$", triggerType: "regex" }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/unsafe|regex/i); + }); + + it("rejects nested quantifier pattern (a*)*", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/custom-commands", + cookies: { session: app.signCookie("valid") }, + payload: { name: "(a*)*", triggerType: "regex" }, + }); + expect(res.statusCode).toBe(400); + }); + + it("accepts a simple safe regex", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/custom-commands", + cookies: { session: app.signCookie("valid") }, + payload: { name: "^hello", triggerType: "regex" }, + }); + expect(res.statusCode).toBe(201); + }); +}); diff --git a/apps/dashboard/tests/server/features/music/musicRateLimit.test.ts b/apps/dashboard/tests/server/features/music/musicRateLimit.test.ts new file mode 100644 index 0000000..d1e1bf9 --- /dev/null +++ b/apps/dashboard/tests/server/features/music/musicRateLimit.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { + token: "t", + clientId: "c", + dashboardSessionSecret: "s", + logLevel: "info", + }, +})); + +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: vi.fn().mockResolvedValue({ + userId: "user-1", + username: "user", + guilds: [{ id: "guild-1", name: "T", permissions: BigInt(0x20).toString() }], + }), + touchSession: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: vi.fn().mockResolvedValue("owner-1"), +})); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: vi.fn().mockResolvedValue({ permissions: new Set(["*"]), isOwner: true }), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@fluxcore/systems/music/library", () => ({ + getAlbums: vi.fn().mockResolvedValue([]), + getAlbumById: vi.fn(), + addAlbum: vi.fn().mockResolvedValue({ id: 1, name: "x" }), + removeAlbum: vi.fn(), + addTrack: vi.fn(), + removeTrack: vi.fn(), + getAlbumTracks: vi.fn(), + getAlbumCount: vi.fn().mockResolvedValue(0), + getTrackCount: vi.fn().mockResolvedValue(0), + getTrackById: vi.fn(), +})); +vi.mock("@fluxcore/systems/music/config", () => ({ + fetchMusicSettings: vi.fn(), + upsertMusicSettings: vi.fn(), +})); +vi.mock("@fluxcore/systems/actions/persistence", () => ({ + notifyCacheInvalidation: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import fastifyRateLimit from "@fastify/rate-limit"; +import { registerMusicRoutes } from "../../../../src/server/features/music/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + await app.register(fastifyRateLimit, { global: false }); + registerMusicRoutes(app); + await app.ready(); + return app; +} + +describe("POST /api/guilds/:guildId/music/library — rate limit", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildApp(); + }); + + it("returns 429 after exceeding 10 requests/minute", async () => { + const cookie = { session: app.signCookie("valid") }; + let lastStatus = 0; + for (let i = 0; i < 12; i++) { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/music/library", + cookies: cookie, + payload: { name: `album-${i}` }, + }); + lastStatus = res.statusCode; + } + expect(lastStatus).toBe(429); + }); +}); diff --git a/apps/dashboard/tests/server/features/permissions/auditLog.test.ts b/apps/dashboard/tests/server/features/permissions/auditLog.test.ts new file mode 100644 index 0000000..15034d5 --- /dev/null +++ b/apps/dashboard/tests/server/features/permissions/auditLog.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { + token: "test-token", + clientId: "test-client-id", + dashboardSessionSecret: "session-secret", + logLevel: "info", + }, +})); + +const MANAGE_GUILD = BigInt(0x20); +const callerSession = { + userId: "caller-1", + username: "caller", + guilds: [{ id: "guild-1", name: "Test", permissions: MANAGE_GUILD.toString() }], +}; + +const mockGetSession = vi.fn().mockResolvedValue(callerSession); +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: (...args: unknown[]) => mockGetSession(...args), + touchSession: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: vi.fn().mockResolvedValue("owner-1"), +})); + +const mockResolveUserPermissions = vi.fn(); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: (...args: unknown[]) => mockResolveUserPermissions(...args), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); + +const mockFindMany = vi.fn().mockResolvedValue([]); +const mockCount = vi.fn().mockResolvedValue(0); +vi.mock("@fluxcore/database", () => ({ + getPrisma: () => ({ + dashboardAuditLog: { findMany: mockFindMany, count: mockCount }, + dashboardUserPermission: { findMany: vi.fn() }, + }), +})); +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import { registerDashboardPermissionRoutes } from "../../../../src/server/features/permissions/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + registerDashboardPermissionRoutes(app); + await app.ready(); + return app; +} + +describe("GET /api/guilds/:guildId/dashboard-audit — userId filter", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + mockGetSession.mockResolvedValue(callerSession); + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["dashboard.audit.view"]), + isOwner: false, + }); + mockFindMany.mockResolvedValue([]); + mockCount.mockResolvedValue(0); + app = await buildApp(); + }); + + it("forces userId filter to caller for non-owner", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/dashboard-audit?userId=other-user", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(200); + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ guildId: "guild-1", userId: "caller-1" }), + }), + ); + }); + + it("allows owner to filter by any userId", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["*"]), + isOwner: true, + }); + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/dashboard-audit?userId=other-user", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(200); + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ userId: "other-user" }), + }), + ); + }); +}); + +describe("GET /api/guilds/:guildId/dashboard-audit — date validation", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + mockGetSession.mockResolvedValue(callerSession); + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["*"]), + isOwner: true, + }); + mockFindMany.mockResolvedValue([]); + mockCount.mockResolvedValue(0); + app = await buildApp(); + }); + + it("returns 400 when from is not a valid date", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/dashboard-audit?from=not-a-date", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/from/i); + }); + + it("returns 400 when to is not a valid date", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/dashboard-audit?to=garbage", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/to/i); + }); + + it("accepts valid ISO dates", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/dashboard-audit?from=2026-01-01T00:00:00Z&to=2026-04-01T00:00:00Z", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(200); + }); +}); + +describe("GET /dashboard-audit — action filter is exact-match", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + mockGetSession.mockResolvedValue(callerSession); + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["*"]), + isOwner: true, + }); + mockFindMany.mockResolvedValue([]); + mockCount.mockResolvedValue(0); + app = await buildApp(); + }); + + it("uses exact match (not contains) when action is allowlisted", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/dashboard-audit?action=dashboard.permissions.update", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(200); + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ action: "dashboard.permissions.update" }), + }), + ); + }); + + it("returns 400 when action is not in the allowlist", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/dashboard-audit?action=permissions", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/apps/dashboard/tests/server/features/permissions/userPermissions.test.ts b/apps/dashboard/tests/server/features/permissions/userPermissions.test.ts new file mode 100644 index 0000000..7c05f84 --- /dev/null +++ b/apps/dashboard/tests/server/features/permissions/userPermissions.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { + token: "test-token", + clientId: "test-client-id", + dashboardSessionSecret: "session-secret", + logLevel: "info", + }, +})); + +const MANAGE_GUILD = BigInt(0x20); +const callerSession = { + userId: "caller-1", + username: "caller", + guilds: [{ id: "guild-1", name: "Test", permissions: MANAGE_GUILD.toString() }], +}; + +const mockGetSession = vi.fn().mockResolvedValue(callerSession); +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: (...args: unknown[]) => mockGetSession(...args), + touchSession: vi.fn().mockResolvedValue(undefined), +})); + +const mockGetGuildOwnerId = vi.fn().mockResolvedValue("owner-1"); +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: (...args: unknown[]) => mockGetGuildOwnerId(...args), +})); + +const mockResolveUserPermissions = vi.fn(); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: (...args: unknown[]) => mockResolveUserPermissions(...args), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); + +const mockPrisma = { + dashboardUserPermission: { + findMany: vi.fn().mockResolvedValue([]), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + dashboardAuditLog: { create: vi.fn(), findMany: vi.fn(), count: vi.fn() }, + $transaction: vi.fn(async (fn: (tx: unknown) => Promise) => fn(mockPrisma)), +}; + +vi.mock("@fluxcore/database", () => ({ getPrisma: () => mockPrisma })); +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import { registerDashboardPermissionRoutes } from "../../../../src/server/features/permissions/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + registerDashboardPermissionRoutes(app); + await app.ready(); + return app; +} + +describe("PUT /api/guilds/:guildId/user-permissions/:userId — escalation guards", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + mockGetSession.mockResolvedValue(callerSession); + mockGetGuildOwnerId.mockResolvedValue("owner-1"); + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["dashboard.roles.manage"]), + isOwner: false, + }); + app = await buildApp(); + }); + + it("rejects self-grant with 403", async () => { + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/user-permissions/caller-1", + cookies: { session: app.signCookie("valid") }, + payload: { permissions: ["dashboard.roles.view"] }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toMatch(/own/i); + }); + + it("rejects non-owner attempting to grant a wildcard they do not literally hold", async () => { + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/user-permissions/target-1", + cookies: { session: app.signCookie("valid") }, + payload: { permissions: ["dashboard.*"] }, + }); + expect(res.statusCode).toBe(403); + }); + + it("rejects non-owner attempting to grant the global wildcard", async () => { + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/user-permissions/target-1", + cookies: { session: app.signCookie("valid") }, + payload: { permissions: ["*"] }, + }); + expect(res.statusCode).toBe(403); + }); + + it("rejects when caller does not literally hold the requested key", async () => { + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/user-permissions/target-1", + cookies: { session: app.signCookie("valid") }, + payload: { permissions: ["actions.rules.manage"] }, + }); + expect(res.statusCode).toBe(403); + }); + + it("allows owner to grant any permission, including self", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["*"]), + isOwner: true, + }); + mockGetSession.mockResolvedValue({ ...callerSession, userId: "owner-1" }); + mockGetGuildOwnerId.mockResolvedValue("owner-2"); + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/user-permissions/target-1", + cookies: { session: app.signCookie("valid") }, + payload: { permissions: ["actions.rules.manage"] }, + }); + expect(res.statusCode).toBe(200); + }); +}); + +describe("PUT /user-permissions — error response does not leak key", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + mockGetSession.mockResolvedValue(callerSession); + mockGetGuildOwnerId.mockResolvedValue("owner-1"); + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["dashboard.roles.manage"]), + isOwner: false, + }); + app = await buildApp(); + }); + + it("does not echo the failed permission key in the error body", async () => { + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/user-permissions/target-1", + cookies: { session: app.signCookie("valid") }, + payload: { permissions: ["actions.rules.manage"] }, + }); + expect(res.statusCode).toBe(403); + const body = res.json(); + expect(body).not.toHaveProperty("permission"); + expect(JSON.stringify(body)).not.toContain("actions.rules.manage"); + }); +}); diff --git a/apps/dashboard/tests/server/features/scheduled/cronPreview.test.ts b/apps/dashboard/tests/server/features/scheduled/cronPreview.test.ts new file mode 100644 index 0000000..20f810b --- /dev/null +++ b/apps/dashboard/tests/server/features/scheduled/cronPreview.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", dashboardSessionSecret: "s", logLevel: "info" }, +})); + +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: vi.fn().mockResolvedValue({ + userId: "user-1", + username: "u", + guilds: [{ id: "guild-1", name: "T", permissions: BigInt(0x20).toString() }], + }), + touchSession: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: vi.fn().mockResolvedValue("owner-1"), +})); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: vi + .fn() + .mockResolvedValue({ permissions: new Set(["*"]), isOwner: true }), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); + +let slowMode = false; +vi.mock("@fluxcore/systems/scheduled-messages/cron", () => ({ + validateCronExpression: () => null, + getNextCronRun: () => { + if (slowMode) { + const start = Date.now(); + while (Date.now() - start < 100) { + /* spin */ + } + } + return new Date(); + }, +})); +vi.mock("@fluxcore/systems/scheduled-messages/persistence", () => ({ + getScheduledMessages: vi.fn(), + getScheduledMessageById: vi.fn(), + createScheduledMessage: vi.fn(), + updateScheduledMessage: vi.fn(), + deleteScheduledMessage: vi.fn(), +})); +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import fastifyRateLimit from "@fastify/rate-limit"; +import { registerScheduledMessageRoutes } from "../../../../src/server/features/scheduled/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + await app.register(fastifyRateLimit, { global: false }); + registerScheduledMessageRoutes(app); + await app.ready(); + return app; +} + +describe("GET /scheduled-messages/preview-cron — DoS guards", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + slowMode = false; + app = await buildApp(); + }); + + it("returns 429 after exceeding 5 requests per 10 seconds", async () => { + const cookie = { session: app.signCookie("valid") }; + let lastStatus = 0; + for (let i = 0; i < 7; i++) { + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/scheduled-messages/preview-cron?cronExpr=*+*+*+*+*", + cookies: cookie, + }); + lastStatus = res.statusCode; + } + expect(lastStatus).toBe(429); + }); + + it("returns 400 when cron evaluation exceeds time budget", async () => { + slowMode = true; + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/scheduled-messages/preview-cron?cronExpr=*+*+*+*+*", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/slow|budget|timeout/i); + }); +}); diff --git a/apps/dashboard/tests/server/features/welcome/imagePreviewHeaders.test.ts b/apps/dashboard/tests/server/features/welcome/imagePreviewHeaders.test.ts new file mode 100644 index 0000000..f713b54 --- /dev/null +++ b/apps/dashboard/tests/server/features/welcome/imagePreviewHeaders.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", dashboardSessionSecret: "s", logLevel: "info" }, +})); + +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: vi.fn().mockResolvedValue({ + userId: "123456789012345678", + username: "u", + avatar: "abc123", + guilds: [{ id: "guild-1", name: "T", permissions: BigInt(0x20).toString() }], + }), + touchSession: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: vi.fn().mockResolvedValue("owner-1"), +})); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: vi.fn().mockResolvedValue({ permissions: new Set(["*"]), isOwner: true }), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@fluxcore/systems/welcome/image", async () => { + const actual = await vi.importActual>("@fluxcore/systems/welcome/image"); + return { + ...actual, + createStorageAdapter: () => ({ + upload: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), + welcomeImageSettingsSchema: { safeParse: () => ({ success: true, data: {} }) }, + DEFAULT_WELCOME_IMAGE_SETTINGS: {}, + DEFAULT_FAREWELL_IMAGE_SETTINGS: {}, + MAX_BACKGROUND_SIZE: 5 * 1024 * 1024, + ALLOWED_BACKGROUND_TYPES: ["image/png"], + PRESET_BACKGROUNDS: [], + generateWelcomeImage: vi.fn().mockResolvedValue(Buffer.from([0x89, 0x50, 0x4e, 0x47])), + getAllTemplates: () => [], + getAvailableFonts: () => [], + }; +}); +vi.mock("@fluxcore/systems/welcome/config", () => ({ + getWelcomeConfig: vi.fn(), + upsertWelcomeConfig: vi.fn(), +})); +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import { registerWelcomeRoutes } from "../../../../src/server/features/welcome/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + registerWelcomeRoutes(app); + await app.ready(); + return app; +} + +describe("POST /welcome/image/preview — security headers", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildApp(); + }); + + it("sets X-Content-Type-Options: nosniff", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/welcome/image/preview", + cookies: { session: app.signCookie("valid") }, + payload: { settings: {}, type: "welcome" }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers["x-content-type-options"]).toBe("nosniff"); + }); +}); diff --git a/apps/dashboard/tests/server/features/welcome/imageUpload.test.ts b/apps/dashboard/tests/server/features/welcome/imageUpload.test.ts new file mode 100644 index 0000000..c55b732 --- /dev/null +++ b/apps/dashboard/tests/server/features/welcome/imageUpload.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", dashboardSessionSecret: "s", logLevel: "info" }, +})); + +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: vi.fn().mockResolvedValue({ + userId: "user-1", + username: "u", + guilds: [{ id: "guild-1", name: "T", permissions: BigInt(0x20).toString() }], + }), + touchSession: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: vi.fn().mockResolvedValue("owner-1"), +})); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: vi.fn().mockResolvedValue({ permissions: new Set(["*"]), isOwner: true }), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); + +const { mockUpload } = vi.hoisted(() => ({ mockUpload: vi.fn().mockResolvedValue(undefined) })); +vi.mock("@fluxcore/systems/welcome/image", async () => { + const actual = await vi.importActual>("@fluxcore/systems/welcome/image"); + return { + ...actual, + createStorageAdapter: () => ({ + upload: mockUpload, + delete: vi.fn().mockResolvedValue(undefined), + }), + MAX_BACKGROUND_SIZE: 5 * 1024 * 1024, + ALLOWED_BACKGROUND_TYPES: ["image/png", "image/jpeg", "image/webp"], + PRESET_BACKGROUNDS: [], + DEFAULT_WELCOME_IMAGE_SETTINGS: {}, + DEFAULT_FAREWELL_IMAGE_SETTINGS: {}, + welcomeImageSettingsSchema: { safeParse: () => ({ success: true, data: {} }) }, + generateWelcomeImage: vi.fn(), + getAllTemplates: () => [], + getAvailableFonts: () => [], + }; +}); +vi.mock("@fluxcore/systems/welcome/config", () => ({ + getWelcomeConfig: vi.fn(), + upsertWelcomeConfig: vi.fn(), +})); +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import { registerWelcomeRoutes } from "../../../../src/server/features/welcome/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + registerWelcomeRoutes(app); + await app.ready(); + return app; +} + +const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const JPEG_HEADER = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); + +describe("POST /api/guilds/:guildId/welcome/image/background — magic byte validation", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildApp(); + }); + + it("rejects invalid base64 with 400", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/welcome/image/background", + cookies: { session: app.signCookie("valid") }, + payload: { data: "@@@not-base64@@@", contentType: "image/png" }, + }); + expect(res.statusCode).toBe(400); + }); + + it("rejects payload whose magic bytes do not match contentType", async () => { + const fakePng = Buffer.from("hello world").toString("base64"); + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/welcome/image/background", + cookies: { session: app.signCookie("valid") }, + payload: { data: fakePng, contentType: "image/png" }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/png|magic|content/i); + }); + + it("rejects mismatched contentType vs header (jpeg sent as png)", async () => { + const data = JPEG_HEADER.toString("base64"); + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/welcome/image/background", + cookies: { session: app.signCookie("valid") }, + payload: { data, contentType: "image/png" }, + }); + expect(res.statusCode).toBe(400); + }); + + it("accepts a valid PNG header", async () => { + const data = Buffer.concat([PNG_HEADER, Buffer.alloc(16)]).toString("base64"); + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/welcome/image/background", + cookies: { session: app.signCookie("valid") }, + payload: { data, contentType: "image/png" }, + }); + expect(res.statusCode).toBe(200); + expect(mockUpload).toHaveBeenCalled(); + }); +}); diff --git a/apps/dashboard/tests/server/features/welcome/welcome.test.ts b/apps/dashboard/tests/server/features/welcome/welcome.test.ts index 1598526..8bf7d3c 100644 --- a/apps/dashboard/tests/server/features/welcome/welcome.test.ts +++ b/apps/dashboard/tests/server/features/welcome/welcome.test.ts @@ -373,7 +373,13 @@ describe("welcome routes", () => { method: "POST", url: "/api/guilds/guild-1/welcome/image/background", cookies: { session: app.signCookie("valid") }, - payload: { data: Buffer.from("fake-png").toString("base64"), contentType: "image/png" }, + payload: { + data: Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(8), + ]).toString("base64"), + contentType: "image/png", + }, }); expect(res.statusCode).toBe(200); diff --git a/docs/superpowers/plans/2026-04-07-sec-api-01-permission-grant-escalation.md b/docs/superpowers/plans/2026-04-07-sec-api-01-permission-grant-escalation.md new file mode 100644 index 0000000..97765ce --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-api-01-permission-grant-escalation.md @@ -0,0 +1,270 @@ +# Permission Grant Escalation Flaw — Fix Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Severity:** CRITICAL +**Goal:** Prevent privilege escalation through user permission overrides by enforcing a strict tier check, blocking self-grant, and rejecting any attempt to grant a permission the caller does not strictly out-rank. +**Architecture:** Replace the current "if I can match it I can grant it" check in `PUT /api/guilds/:guildId/user-permissions/:userId` with three explicit gates: (1) reject when `session.userId === target userId`, (2) require non-owner callers to hold strictly more keys than the resulting set (i.e. they must be a strict superset that excludes any wildcard the caller does not literally hold), and (3) explicitly forbid granting `*` or any module-level wildcard unless the caller is the guild owner. +**Tech Stack:** Fastify 5, TypeScript, Prisma 7, Vitest + +--- + +## Vulnerability +`apps/dashboard/src/server/features/permissions/routes.ts:109-135` validates each requested permission against `matchPermission(userPerms, perm)`. Because `matchPermission` treats `dashboard.*` as matching `dashboard.roles.manage`, a non-owner who holds `dashboard.roles.manage` can grant *themselves* `dashboard.roles.manage` (self-grant is not blocked) and, more dangerously, a holder of any wildcard like `actions.*` can grant `actions.rules.execute` to a user who previously had nothing — there is no check that the new permission set is *less* than the caller's. Combined with the missing self-grant block, an attacker who compromises any non-owner admin account can chain grants until they reach `*`. + +## Files +- Modify: `apps/dashboard/src/server/features/permissions/routes.ts:103-167` +- Test: `apps/dashboard/tests/server/features/permissions/userPermissions.test.ts` (new file) + +## Tasks + +### Task 1: Block self-grant and wildcard escalation + +- [ ] **Step 1: Write the failing test** + +Create `apps/dashboard/tests/server/features/permissions/userPermissions.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { + token: "test-token", + clientId: "test-client-id", + dashboardSessionSecret: "session-secret", + logLevel: "info", + }, +})); + +const MANAGE_GUILD = BigInt(0x20); +const callerSession = { + userId: "caller-1", + username: "caller", + guilds: [{ id: "guild-1", name: "Test", permissions: MANAGE_GUILD.toString() }], +}; + +const mockGetSession = vi.fn().mockResolvedValue(callerSession); +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: (...args: unknown[]) => mockGetSession(...args), + touchSession: vi.fn().mockResolvedValue(undefined), +})); + +const mockGetGuildOwnerId = vi.fn().mockResolvedValue("owner-1"); +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: (...args: unknown[]) => mockGetGuildOwnerId(...args), +})); + +const mockResolveUserPermissions = vi.fn(); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: (...args: unknown[]) => mockResolveUserPermissions(...args), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); + +const mockPrisma = { + dashboardUserPermission: { + findMany: vi.fn().mockResolvedValue([]), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + dashboardAuditLog: { create: vi.fn(), findMany: vi.fn(), count: vi.fn() }, + $transaction: vi.fn(async (fn: (tx: unknown) => Promise) => fn(mockPrisma)), +}; + +vi.mock("@fluxcore/database", () => ({ getPrisma: () => mockPrisma })); +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import { registerDashboardPermissionRoutes } from "../../../../src/server/features/permissions/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + registerDashboardPermissionRoutes(app); + await app.ready(); + return app; +} + +describe("PUT /api/guilds/:guildId/user-permissions/:userId — escalation guards", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + mockGetSession.mockResolvedValue(callerSession); + mockGetGuildOwnerId.mockResolvedValue("owner-1"); + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["dashboard.roles.manage"]), + isOwner: false, + }); + app = await buildApp(); + }); + + it("rejects self-grant with 403", async () => { + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/user-permissions/caller-1", + cookies: { session: app.signCookie("valid") }, + payload: { permissions: ["dashboard.roles.view"] }, + }); + expect(res.statusCode).toBe(403); + expect(res.json().error).toMatch(/self/i); + }); + + it("rejects non-owner attempting to grant a wildcard they do not literally hold", async () => { + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/user-permissions/target-1", + cookies: { session: app.signCookie("valid") }, + payload: { permissions: ["dashboard.*"] }, + }); + expect(res.statusCode).toBe(403); + }); + + it("rejects non-owner attempting to grant the global wildcard", async () => { + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/user-permissions/target-1", + cookies: { session: app.signCookie("valid") }, + payload: { permissions: ["*"] }, + }); + expect(res.statusCode).toBe(403); + }); + + it("rejects when caller does not literally hold the requested key", async () => { + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/user-permissions/target-1", + cookies: { session: app.signCookie("valid") }, + payload: { permissions: ["actions.rules.manage"] }, + }); + expect(res.statusCode).toBe(403); + }); + + it("allows owner to grant any permission, including self", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["*"]), + isOwner: true, + }); + mockGetSession.mockResolvedValue({ ...callerSession, userId: "owner-1" }); + mockGetGuildOwnerId.mockResolvedValue("owner-1"); + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/user-permissions/target-1", + cookies: { session: app.signCookie("valid") }, + payload: { permissions: ["actions.rules.manage"] }, + }); + expect(res.statusCode).toBe(200); + }); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- userPermissions +``` + +Expected: tests "rejects self-grant", "rejects non-owner attempting to grant a wildcard", "rejects non-owner attempting to grant the global wildcard" all FAIL (current code returns 200 because `matchPermission` accepts wildcards and self-grant is not blocked). + +- [ ] **Step 3: Implement the fix** + +Edit `apps/dashboard/src/server/features/permissions/routes.ts` — replace the body of the `PUT /api/guilds/:guildId/user-permissions/:userId` handler starting at line 103, swapping the existing escalation block (lines 124-136) with strict checks: + +```typescript + async (request, reply) => { + const { guildId, userId } = request.params as { guildId: string; userId: string }; + const { permissions } = request.body as { permissions: string[] }; + const session = request.session!; + const prisma = getPrisma(); + + // Cannot modify owner permissions + const ownerId = await getGuildOwnerId(guildId); + if (ownerId === userId) { + reply.code(400).send({ error: "Cannot modify guild owner permissions" }); + return; + } + + // Block self-grant: a user must never grant or modify their own permission set + if (session.userId === userId) { + reply.code(403).send({ error: "Cannot modify your own permissions" }); + return; + } + + // Validate keys + for (const perm of permissions) { + if (!isValidPermKey(perm)) { + reply.code(400).send({ error: `Invalid permission key: ${perm}` }); + return; + } + } + + // Strict escalation gate: non-owners must literally hold every key they grant. + // Wildcards (`*`, `module.*`, `module.feature.*`) may only be granted by the + // guild owner — match-by-wildcard is not enough. + if (!request.resolvedPermissions?.isOwner) { + const literalCallerPerms = request.resolvedPermissions!.permissions; + for (const perm of permissions) { + if (perm === "*" || perm.includes("*")) { + reply.code(403).send({ error: "Insufficient privileges to grant this permission" }); + return; + } + if (!literalCallerPerms.has(perm)) { + reply.code(403).send({ error: "Insufficient privileges to grant this permission" }); + return; + } + } + } + + // Replace all user permissions in a transaction + await prisma.$transaction(async (tx) => { + await tx.dashboardUserPermission.deleteMany({ where: { guildId, userId } }); + if (permissions.length > 0) { + await tx.dashboardUserPermission.createMany({ + data: permissions.map((perm) => ({ + guildId, + userId, + permission: perm, + grantedBy: session.userId, + })), + }); + } + }); + + invalidatePermissionCache(guildId, userId); + + await createDashboardAuditLog({ + guildId, + userId: session.userId, + username: session.username, + action: "dashboard.permissions.update", + targetType: "user", + targetId: userId, + details: { permissions }, + }); + + reply.send({ success: true, permissions }); + }, +``` + +Note: also remove the `import { matchPermission } from "@fluxcore/types"` if it becomes unused. + +- [ ] **Step 4: Run test to verify pass** + +```bash +pnpm --filter @fluxcore/dashboard test -- userPermissions +pnpm typecheck +``` + +All five tests should now pass. + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/features/permissions/routes.ts apps/dashboard/tests/server/features/permissions/userPermissions.test.ts +git commit -m "fix(permissions): block self-grant and wildcard escalation in user-permissions PUT" +``` diff --git a/docs/superpowers/plans/2026-04-07-sec-api-02-audit-log-userid-enumeration.md b/docs/superpowers/plans/2026-04-07-sec-api-02-audit-log-userid-enumeration.md new file mode 100644 index 0000000..90c83c9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-api-02-audit-log-userid-enumeration.md @@ -0,0 +1,177 @@ +# Audit Log userId Enumeration — Fix Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Severity:** CRITICAL +**Goal:** Restrict the audit-log `userId` filter so non-owners can only query their own actions, preventing user-id enumeration and disclosure of other admins' activity. +**Architecture:** In `GET /api/guilds/:guildId/dashboard-audit`, branch on `request.resolvedPermissions.isOwner`. Owners may pass any `userId`. Non-owners get the filter forced to their own session userId regardless of what they passed (or receive 403 if they tried a different value). +**Tech Stack:** Fastify 5, TypeScript, Prisma 7, Vitest + +--- + +## Vulnerability +`apps/dashboard/src/server/features/permissions/routes.ts:301` accepts `query.userId` and applies it to the Prisma `where` clause unconditionally. Anyone with `dashboard.audit.view` can iterate user IDs and observe what every other admin did, including failed permission attempts and target IDs — useful for both reconnaissance and harassment. The audit log is meant to be visible to admins, but per-user filtering by arbitrary IDs is enumeration. + +## Files +- Modify: `apps/dashboard/src/server/features/permissions/routes.ts:280-336` +- Test: `apps/dashboard/tests/server/features/permissions/auditLog.test.ts` (new file) + +## Tasks + +### Task 1: Restrict userId filter to own user unless owner + +- [ ] **Step 1: Write the failing test** + +Create `apps/dashboard/tests/server/features/permissions/auditLog.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { + token: "test-token", + clientId: "test-client-id", + dashboardSessionSecret: "session-secret", + logLevel: "info", + }, +})); + +const MANAGE_GUILD = BigInt(0x20); +const callerSession = { + userId: "caller-1", + username: "caller", + guilds: [{ id: "guild-1", name: "Test", permissions: MANAGE_GUILD.toString() }], +}; + +const mockGetSession = vi.fn().mockResolvedValue(callerSession); +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: (...args: unknown[]) => mockGetSession(...args), + touchSession: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: vi.fn().mockResolvedValue("owner-1"), +})); + +const mockResolveUserPermissions = vi.fn(); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: (...args: unknown[]) => mockResolveUserPermissions(...args), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); + +const mockFindMany = vi.fn().mockResolvedValue([]); +const mockCount = vi.fn().mockResolvedValue(0); +vi.mock("@fluxcore/database", () => ({ + getPrisma: () => ({ + dashboardAuditLog: { findMany: mockFindMany, count: mockCount }, + dashboardUserPermission: { findMany: vi.fn() }, + }), +})); +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import { registerDashboardPermissionRoutes } from "../../../../src/server/features/permissions/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + registerDashboardPermissionRoutes(app); + await app.ready(); + return app; +} + +describe("GET /api/guilds/:guildId/dashboard-audit — userId filter", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + mockGetSession.mockResolvedValue(callerSession); + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["dashboard.audit.view"]), + isOwner: false, + }); + mockFindMany.mockResolvedValue([]); + mockCount.mockResolvedValue(0); + app = await buildApp(); + }); + + it("forces userId filter to caller for non-owner", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/dashboard-audit?userId=other-user", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(200); + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ guildId: "guild-1", userId: "caller-1" }), + }), + ); + }); + + it("allows owner to filter by any userId", async () => { + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["*"]), + isOwner: true, + }); + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/dashboard-audit?userId=other-user", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(200); + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ userId: "other-user" }), + }), + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- auditLog +``` + +Expected: "forces userId filter to caller for non-owner" FAILS — current code forwards `other-user` directly. + +- [ ] **Step 3: Implement the fix** + +Edit `apps/dashboard/src/server/features/permissions/routes.ts` lines 300-302 — replace the `where` construction so that non-owners are forced to their own userId: + +```typescript + const where: Record = { guildId }; + const isOwner = request.resolvedPermissions?.isOwner === true; + if (isOwner) { + if (query.userId) where.userId = query.userId; + } else { + // Non-owners may only view their own audit entries + where.userId = request.session!.userId; + } + if (query.action) where.action = { contains: query.action }; + if (query.targetType) where.targetType = query.targetType; +``` + +- [ ] **Step 4: Run test to verify pass** + +```bash +pnpm --filter @fluxcore/dashboard test -- auditLog +pnpm typecheck +``` + +Both tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/features/permissions/routes.ts apps/dashboard/tests/server/features/permissions/auditLog.test.ts +git commit -m "fix(permissions): restrict audit log userId filter to caller for non-owners" +``` diff --git a/docs/superpowers/plans/2026-04-07-sec-api-03-audit-log-date-validation.md b/docs/superpowers/plans/2026-04-07-sec-api-03-audit-log-date-validation.md new file mode 100644 index 0000000..0019b29 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-api-03-audit-log-date-validation.md @@ -0,0 +1,121 @@ +# Audit Log Date Validation — Fix Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Severity:** HIGH +**Goal:** Reject malformed `from`/`to` query parameters with HTTP 400 instead of silently passing `Invalid Date` to Prisma. +**Architecture:** Parse each date with `new Date(...)`, then check `Number.isFinite(d.getTime())`. If parsing fails, return 400 with a clear error before constructing the `where` clause. +**Tech Stack:** Fastify 5, TypeScript, Prisma 7, Vitest + +--- + +## Vulnerability +`apps/dashboard/src/server/features/permissions/routes.ts:304-308` does `new Date(query.from)` without validation. Invalid input (e.g. `from=lol`) yields `Invalid Date`, which Prisma may either error on or silently produce empty results — making the audit log filter unreliable and easy to abuse to confuse incident responders. + +## Files +- Modify: `apps/dashboard/src/server/features/permissions/routes.ts:304-308` +- Test: `apps/dashboard/tests/server/features/permissions/auditLog.test.ts` (extend existing or add a new file `auditLogDates.test.ts`) + +## Tasks + +### Task 1: Validate from/to and 400 on bad input + +- [ ] **Step 1: Write the failing test** + +Append to `apps/dashboard/tests/server/features/permissions/auditLog.test.ts` (created in finding 02 — if not yet present, use the same scaffolding/mocks): + +```typescript +describe("GET /api/guilds/:guildId/dashboard-audit — date validation", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + mockGetSession.mockResolvedValue(callerSession); + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["*"]), + isOwner: true, + }); + mockFindMany.mockResolvedValue([]); + mockCount.mockResolvedValue(0); + app = await buildApp(); + }); + + it("returns 400 when from is not a valid date", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/dashboard-audit?from=not-a-date", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/from/i); + }); + + it("returns 400 when to is not a valid date", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/dashboard-audit?to=garbage", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/to/i); + }); + + it("accepts valid ISO dates", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/dashboard-audit?from=2026-01-01T00:00:00Z&to=2026-04-01T00:00:00Z", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(200); + }); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- auditLog +``` + +Expected: the two 400 cases FAIL (current handler accepts and produces an empty/200 response). + +- [ ] **Step 3: Implement the fix** + +Edit `apps/dashboard/src/server/features/permissions/routes.ts` lines 304-309 — replace the date block: + +```typescript + if (query.from || query.to) { + const createdAt: Record = {}; + if (query.from) { + const d = new Date(query.from); + if (!Number.isFinite(d.getTime())) { + reply.code(400).send({ error: "Invalid 'from' date" }); + return; + } + createdAt.gte = d; + } + if (query.to) { + const d = new Date(query.to); + if (!Number.isFinite(d.getTime())) { + reply.code(400).send({ error: "Invalid 'to' date" }); + return; + } + createdAt.lte = d; + } + where.createdAt = createdAt; + } +``` + +- [ ] **Step 4: Run test to verify pass** + +```bash +pnpm --filter @fluxcore/dashboard test -- auditLog +pnpm typecheck +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/features/permissions/routes.ts apps/dashboard/tests/server/features/permissions/auditLog.test.ts +git commit -m "fix(permissions): validate from/to dates in audit log query" +``` diff --git a/docs/superpowers/plans/2026-04-07-sec-api-04-resource-creation-rate-limits.md b/docs/superpowers/plans/2026-04-07-sec-api-04-resource-creation-rate-limits.md new file mode 100644 index 0000000..86d3797 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-api-04-resource-creation-rate-limits.md @@ -0,0 +1,169 @@ +# Resource Creation Rate Limiting — Fix Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Severity:** HIGH +**Goal:** Cap how often a single session can hit expensive POST endpoints (album creation, custom command creation, giveaway creation) to prevent resource exhaustion and spam. +**Architecture:** `@fastify/rate-limit` is already registered globally. We add per-route configuration via `config.rateLimit` on the create endpoints, keyed by `request.session?.userId` (falling back to IP) so a single user cannot DOS via burst creation. Limits chosen: 10 creations / minute per user. +**Tech Stack:** Fastify 5, TypeScript, Prisma 7, Vitest + +--- + +## Vulnerability +The global rate limit (100/min/IP) is too coarse for write endpoints that allocate DB rows. `apps/dashboard/src/server/features/music/routes.ts:104`, `features/commands/routes.ts:31`, and `features/giveaways/routes.ts:49` accept POST without per-route throttling. A single attacker behind a NAT can saturate the global bucket for innocent users by spamming `POST /music/library`, fill the per-guild caps, and trigger expensive cache reloads on every request. + +## Files +- Modify: `apps/dashboard/src/server/features/music/routes.ts:103-136` +- Modify: `apps/dashboard/src/server/features/commands/routes.ts:31-141` +- Modify: `apps/dashboard/src/server/features/giveaways/routes.ts:49-107` +- Test: `apps/dashboard/tests/server/features/music/musicRateLimit.test.ts` (new file) + +## Tasks + +### Task 1: Add per-route rate limits to create endpoints + +- [ ] **Step 1: Write the failing test** + +Create `apps/dashboard/tests/server/features/music/musicRateLimit.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { + token: "t", + clientId: "c", + dashboardSessionSecret: "s", + logLevel: "info", + }, +})); + +const session = { + userId: "user-1", + username: "user", + guilds: [{ id: "guild-1", name: "T", permissions: BigInt(0x20).toString() }], +}; + +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: vi.fn().mockResolvedValue(session), + touchSession: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: vi.fn().mockResolvedValue("owner-1"), +})); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: vi.fn().mockResolvedValue({ permissions: new Set(["*"]), isOwner: true }), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@fluxcore/systems/music/library", () => ({ + getAlbums: vi.fn().mockResolvedValue([]), + getAlbumById: vi.fn(), + addAlbum: vi.fn().mockResolvedValue({ id: 1, name: "x" }), + removeAlbum: vi.fn(), + addTrack: vi.fn(), + removeTrack: vi.fn(), + getAlbumTracks: vi.fn(), + getAlbumCount: vi.fn().mockResolvedValue(0), + getTrackCount: vi.fn().mockResolvedValue(0), + getTrackById: vi.fn(), +})); +vi.mock("@fluxcore/systems/music/config", () => ({ + fetchMusicSettings: vi.fn(), + upsertMusicSettings: vi.fn(), +})); +vi.mock("@fluxcore/systems/actions/persistence", () => ({ + notifyCacheInvalidation: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import fastifyRateLimit from "@fastify/rate-limit"; +import { registerMusicRoutes } from "../../../../src/server/features/music/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + await app.register(fastifyRateLimit, { global: false }); + registerMusicRoutes(app); + await app.ready(); + return app; +} + +describe("POST /api/guilds/:guildId/music/library — rate limit", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildApp(); + }); + + it("returns 429 after exceeding 10 requests/minute", async () => { + const cookie = { session: app.signCookie("valid") }; + let lastStatus = 0; + for (let i = 0; i < 12; i++) { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/music/library", + cookies: cookie, + payload: { name: `album-${i}` }, + }); + lastStatus = res.statusCode; + } + expect(lastStatus).toBe(429); + }); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- musicRateLimit +``` + +Expected: the loop completes with status 201 (or 400) on the 12th call — never 429 — so the test FAILS. + +- [ ] **Step 3: Implement the fix** + +Edit `apps/dashboard/src/server/features/music/routes.ts` — at the POST `/api/guilds/:guildId/music/library` route (line 104), add `config.rateLimit`: + +```typescript + app.post( + "/api/guilds/:guildId/music/library", + { + preHandler: [requireAuth, requireGuildAdmin, requirePermission("music.library.manage")], + config: { + rateLimit: { + max: 10, + timeWindow: "1 minute", + keyGenerator: (req) => req.session?.userId ?? req.ip, + }, + }, + schema: { +``` + +Edit `apps/dashboard/src/server/features/commands/routes.ts` — at the POST `/api/guilds/:guildId/custom-commands` route (line 31), add the same `config.rateLimit` block right after `preHandler`. + +Edit `apps/dashboard/src/server/features/giveaways/routes.ts` — at the POST `/api/guilds/:guildId/giveaways` route (line 49), add the same `config.rateLimit` block. + +If `req.session` is not typed on `FastifyRequest` in this file, cast: `keyGenerator: (req) => (req as { session?: { userId?: string } }).session?.userId ?? req.ip`. + +- [ ] **Step 4: Run test to verify pass** + +```bash +pnpm --filter @fluxcore/dashboard test -- musicRateLimit +pnpm typecheck +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/features/music/routes.ts apps/dashboard/src/server/features/commands/routes.ts apps/dashboard/src/server/features/giveaways/routes.ts apps/dashboard/tests/server/features/music/musicRateLimit.test.ts +git commit -m "fix(api): add per-user rate limits to resource creation endpoints" +``` diff --git a/docs/superpowers/plans/2026-04-07-sec-api-05-welcome-image-magic-byte-validation.md b/docs/superpowers/plans/2026-04-07-sec-api-05-welcome-image-magic-byte-validation.md new file mode 100644 index 0000000..ef30f14 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-api-05-welcome-image-magic-byte-validation.md @@ -0,0 +1,254 @@ +# Welcome Image base64 / Magic-Byte Validation — Fix Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Severity:** HIGH +**Goal:** Reject welcome background uploads whose base64 payload, decoded bytes, or content-type/magic-byte combination is malformed or mismatched. +**Architecture:** Add a strict base64 character regex, decode with `Buffer.from(data, "base64")`, then sniff the first bytes to confirm a real PNG (`89 50 4E 47`) / JPEG (`FF D8 FF`) / WebP (`52 49 46 46 .. 57 45 42 50`) header. The detected format must match the claimed `contentType`. Reject otherwise. +**Tech Stack:** Fastify 5, TypeScript, Prisma 7, Vitest + +--- + +## Vulnerability +`apps/dashboard/src/server/features/welcome/routes.ts:217-224` blindly trusts the supplied `data` and `contentType`. `Buffer.from(, "base64")` produces a buffer of length 0+ from any string, so attackers can store arbitrary binary blobs (e.g. HTML, SVG with scripts, executables) under filenames like `xyz.png`. Combined with the lenient MIME check, a stored XSS or content-type-confusion attack against the storage backend / CDN is possible. + +## Files +- Modify: `apps/dashboard/src/server/features/welcome/routes.ts:206-233` +- Test: `apps/dashboard/tests/server/features/welcome/imageUpload.test.ts` (new file) + +## Tasks + +### Task 1: Validate base64, decode, then verify magic bytes match contentType + +- [ ] **Step 1: Write the failing test** + +Create `apps/dashboard/tests/server/features/welcome/imageUpload.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", dashboardSessionSecret: "s", logLevel: "info" }, +})); + +const session = { + userId: "user-1", + username: "u", + guilds: [{ id: "guild-1", name: "T", permissions: BigInt(0x20).toString() }], +}; + +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: vi.fn().mockResolvedValue(session), + touchSession: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: vi.fn().mockResolvedValue("owner-1"), +})); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: vi.fn().mockResolvedValue({ permissions: new Set(["*"]), isOwner: true }), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); + +const mockUpload = vi.fn().mockResolvedValue(undefined); +vi.mock("@fluxcore/systems/welcome/image", async () => { + const actual = await vi.importActual>("@fluxcore/systems/welcome/image"); + return { + ...actual, + createStorageAdapter: () => ({ + upload: mockUpload, + delete: vi.fn().mockResolvedValue(undefined), + }), + MAX_BACKGROUND_SIZE: 5 * 1024 * 1024, + ALLOWED_BACKGROUND_TYPES: ["image/png", "image/jpeg", "image/webp"], + PRESET_BACKGROUNDS: [], + DEFAULT_WELCOME_IMAGE_SETTINGS: {}, + DEFAULT_FAREWELL_IMAGE_SETTINGS: {}, + welcomeImageSettingsSchema: { safeParse: () => ({ success: true, data: {} }) }, + generateWelcomeImage: vi.fn(), + getAllTemplates: () => [], + getAvailableFonts: () => [], + }; +}); +vi.mock("@fluxcore/systems/welcome/config", () => ({ + getWelcomeConfig: vi.fn(), + upsertWelcomeConfig: vi.fn(), +})); +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import { registerWelcomeRoutes } from "../../../../src/server/features/welcome/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + registerWelcomeRoutes(app); + await app.ready(); + return app; +} + +const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const JPEG_HEADER = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); + +describe("POST /api/guilds/:guildId/welcome/image/background — magic byte validation", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildApp(); + }); + + it("rejects invalid base64 with 400", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/welcome/image/background", + cookies: { session: app.signCookie("valid") }, + payload: { data: "@@@not-base64@@@", contentType: "image/png" }, + }); + expect(res.statusCode).toBe(400); + }); + + it("rejects payload whose magic bytes do not match contentType", async () => { + const fakePng = Buffer.from("hello world").toString("base64"); + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/welcome/image/background", + cookies: { session: app.signCookie("valid") }, + payload: { data: fakePng, contentType: "image/png" }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/png|magic|content/i); + }); + + it("rejects mismatched contentType vs header (jpeg sent as png)", async () => { + const data = JPEG_HEADER.toString("base64"); + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/welcome/image/background", + cookies: { session: app.signCookie("valid") }, + payload: { data, contentType: "image/png" }, + }); + expect(res.statusCode).toBe(400); + }); + + it("accepts a valid PNG header", async () => { + const data = Buffer.concat([PNG_HEADER, Buffer.alloc(16)]).toString("base64"); + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/welcome/image/background", + cookies: { session: app.signCookie("valid") }, + payload: { data, contentType: "image/png" }, + }); + expect(res.statusCode).toBe(200); + expect(mockUpload).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- imageUpload +``` + +Expected: the three rejection tests FAIL (current handler returns 200 for any base64-shaped string). + +- [ ] **Step 3: Implement the fix** + +Edit `apps/dashboard/src/server/features/welcome/routes.ts` — replace the body of the POST `/welcome/image/background` handler (lines 206-233): + +```typescript + async (request, reply) => { + const { guildId } = request.params as { guildId: string }; + const { data, contentType } = request.body as { data: string; contentType: string }; + + if (!ALLOWED_BACKGROUND_TYPES.includes(contentType)) { + reply.code(400).send({ + error: `Invalid file type. Allowed: ${ALLOWED_BACKGROUND_TYPES.join(", ")}`, + }); + return; + } + + // Strict base64 validation (RFC 4648, optional padding) + const base64Regex = /^[A-Za-z0-9+/]+={0,2}$/; + if (data.length === 0 || data.length % 4 !== 0 || !base64Regex.test(data)) { + reply.code(400).send({ error: "Invalid base64 payload" }); + return; + } + + const buffer = Buffer.from(data, "base64"); + if (buffer.length === 0) { + reply.code(400).send({ error: "Empty payload" }); + return; + } + + if (buffer.length > MAX_BACKGROUND_SIZE) { + reply.code(400).send({ + error: `File too large. Maximum size: ${MAX_BACKGROUND_SIZE / 1024 / 1024} MB`, + }); + return; + } + + // Magic byte sniffing — must match contentType + const detected = detectImageType(buffer); + if (!detected || detected !== contentType) { + reply.code(400).send({ + error: "File content does not match the declared image type", + }); + return; + } + + const ext = contentType.split("/")[1] === "jpeg" ? "jpg" : contentType.split("/")[1]; + const key = `backgrounds/${guildId}/${randomUUID()}.${ext}`; + + await storage.upload(key, buffer, contentType); + + reply.send({ key }); + }, +``` + +Add this helper at the bottom of the file (outside `registerWelcomeRoutes`): + +```typescript +function detectImageType(buffer: Buffer): string | null { + if (buffer.length < 12) return null; + // PNG: 89 50 4E 47 0D 0A 1A 0A + if ( + buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 && + buffer[4] === 0x0d && buffer[5] === 0x0a && buffer[6] === 0x1a && buffer[7] === 0x0a + ) { + return "image/png"; + } + // JPEG: FF D8 FF + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return "image/jpeg"; + } + // WebP: RIFF .... WEBP + if ( + buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && + buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50 + ) { + return "image/webp"; + } + return null; +} +``` + +- [ ] **Step 4: Run test to verify pass** + +```bash +pnpm --filter @fluxcore/dashboard test -- imageUpload +pnpm typecheck +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/features/welcome/routes.ts apps/dashboard/tests/server/features/welcome/imageUpload.test.ts +git commit -m "fix(welcome): validate base64 and magic bytes for background uploads" +``` diff --git a/docs/superpowers/plans/2026-04-07-sec-api-06-cron-preview-dos.md b/docs/superpowers/plans/2026-04-07-sec-api-06-cron-preview-dos.md new file mode 100644 index 0000000..e8552c9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-api-06-cron-preview-dos.md @@ -0,0 +1,208 @@ +# Cron Preview DoS — Fix Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Severity:** HIGH +**Goal:** Prevent denial-of-service against the cron-preview endpoint by adding a tight per-user rate limit and capping wall-clock time spent computing the next 5 runs. +**Architecture:** Add `config.rateLimit` (5 requests / 10s per session) to the GET `/scheduled-messages/preview-cron` route, and wrap the 5-iteration `getNextCronRun` loop with a 250 ms wall-clock budget. If the budget is exceeded, return 400 ("cron expression too slow to evaluate") instead of looping forever. +**Tech Stack:** Fastify 5, TypeScript, Prisma 7, Vitest + +--- + +## Vulnerability +`apps/dashboard/src/server/features/scheduled/routes.ts:222-250` calls `getNextCronRun` five times per request with no rate limit. A pathological-but-valid cron expression (very narrow constraint, e.g. `0 0 29 2 1` style) can take seconds per evaluation. Combined with the lack of per-route throttling, an attacker can pin a Node worker on the dashboard process. + +## Files +- Modify: `apps/dashboard/src/server/features/scheduled/routes.ts:220-251` +- Test: `apps/dashboard/tests/server/features/scheduled/cronPreview.test.ts` (new file) + +## Tasks + +### Task 1: Rate-limit and time-bound cron preview + +- [ ] **Step 1: Write the failing test** + +Create `apps/dashboard/tests/server/features/scheduled/cronPreview.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", dashboardSessionSecret: "s", logLevel: "info" }, +})); + +const session = { + userId: "user-1", + username: "u", + guilds: [{ id: "guild-1", name: "T", permissions: BigInt(0x20).toString() }], +}; + +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: vi.fn().mockResolvedValue(session), + touchSession: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: vi.fn().mockResolvedValue("owner-1"), +})); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: vi.fn().mockResolvedValue({ permissions: new Set(["*"]), isOwner: true }), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); + +let slowMode = false; +vi.mock("@fluxcore/systems/scheduledMessages/cron", () => ({ + validateCronExpression: () => null, + getNextCronRun: () => { + if (slowMode) { + const start = Date.now(); + while (Date.now() - start < 100) { /* spin */ } + } + return new Date(); + }, +})); +vi.mock("@fluxcore/systems/scheduledMessages/persistence", () => ({ + getScheduledMessages: vi.fn(), + getScheduledMessageById: vi.fn(), + createScheduledMessage: vi.fn(), + updateScheduledMessage: vi.fn(), + deleteScheduledMessage: vi.fn(), +})); +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import fastifyRateLimit from "@fastify/rate-limit"; +import { registerScheduledMessageRoutes } from "../../../../src/server/features/scheduled/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + await app.register(fastifyRateLimit, { global: false }); + registerScheduledMessageRoutes(app); + await app.ready(); + return app; +} + +describe("GET /scheduled-messages/preview-cron — DoS guards", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + slowMode = false; + app = await buildApp(); + }); + + it("returns 429 after exceeding 5 requests per 10 seconds", async () => { + const cookie = { session: app.signCookie("valid") }; + let lastStatus = 0; + for (let i = 0; i < 7; i++) { + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/scheduled-messages/preview-cron?cronExpr=*+*+*+*+*", + cookies: cookie, + }); + lastStatus = res.statusCode; + } + expect(lastStatus).toBe(429); + }); + + it("returns 400 when cron evaluation exceeds time budget", async () => { + slowMode = true; + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/scheduled-messages/preview-cron?cronExpr=*+*+*+*+*", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/slow|budget|timeout/i); + }); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- cronPreview +``` + +Expected: both tests FAIL — current code never returns 429 or a time-budget 400. + +- [ ] **Step 3: Implement the fix** + +Edit `apps/dashboard/src/server/features/scheduled/routes.ts` — replace the GET `/preview-cron` route definition (lines 220-251): + +```typescript + // GET preview next run time for a cron expression + app.get( + "/api/guilds/:guildId/scheduled-messages/preview-cron", + { + preHandler: [requireAuth, requireGuildAdmin, requirePermission("scheduled.messages.view")], + config: { + rateLimit: { + max: 5, + timeWindow: "10 seconds", + keyGenerator: (req) => + (req as { session?: { userId?: string } }).session?.userId ?? req.ip, + }, + }, + }, + async (request, reply) => { + const query = request.query as { cronExpr?: string; timezone?: string }; + if (!query.cronExpr) { + reply.code(400).send({ error: "cronExpr query parameter is required" }); + return; + } + + const cronError = validateCronExpression(query.cronExpr); + if (cronError) { + reply.code(400).send({ error: `Invalid cron expression: ${cronError}` }); + return; + } + + const timezone = query.timezone ?? "UTC"; + const budgetMs = 250; + const start = Date.now(); + + try { + const nextRun = getNextCronRun(query.cronExpr, timezone); + if (Date.now() - start > budgetMs) { + reply.code(400).send({ error: "Cron expression too slow to evaluate (budget exceeded)" }); + return; + } + const nextRuns: string[] = [nextRun.toISOString()]; + let lastRun = nextRun; + for (let i = 0; i < 4; i++) { + if (Date.now() - start > budgetMs) { + reply.code(400).send({ error: "Cron expression too slow to evaluate (budget exceeded)" }); + return; + } + const next = getNextCronRun(query.cronExpr, timezone, lastRun); + nextRuns.push(next.toISOString()); + lastRun = next; + } + reply.send({ nextRuns }); + } catch (err) { + reply.code(400).send({ error: "Failed to evaluate cron expression" }); + } + }, + ); +``` + +- [ ] **Step 4: Run test to verify pass** + +```bash +pnpm --filter @fluxcore/dashboard test -- cronPreview +pnpm typecheck +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/features/scheduled/routes.ts apps/dashboard/tests/server/features/scheduled/cronPreview.test.ts +git commit -m "fix(scheduled): rate-limit and time-bound cron preview to prevent DoS" +``` diff --git a/docs/superpowers/plans/2026-04-07-sec-api-07-custom-command-redos.md b/docs/superpowers/plans/2026-04-07-sec-api-07-custom-command-redos.md new file mode 100644 index 0000000..f0e99ed --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-api-07-custom-command-redos.md @@ -0,0 +1,200 @@ +# Custom Command Regex ReDoS — Fix Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Severity:** MEDIUM +**Goal:** Reject regex-trigger custom commands whose pattern is exponential / catastrophic-backtracking-prone before they are persisted, so the bot's hot path cannot be DOSed. +**Architecture:** Use the `safe-regex` package (already common in Node ecosystems; small footprint) inside both POST and PUT handlers to validate user-supplied patterns. If the pattern is unsafe, reject with 400. We also enforce a max length of 200 chars and reject patterns containing nested unbounded repetition. +**Tech Stack:** Fastify 5, TypeScript, Prisma 7, Vitest + +--- + +## Vulnerability +`apps/dashboard/src/server/features/commands/routes.ts:111-118` and `:220-227` compile user input via `new RegExp(body.name, "i")`. They only check that the pattern *parses* — not that it can be evaluated in bounded time. A malicious admin (or compromised account) can save `(a+)+$` as a regex trigger; every subsequent message in the guild will then run the bot's regex matcher into catastrophic backtracking, freezing the message handler. + +## Files +- Modify: `apps/dashboard/src/server/features/commands/routes.ts:110-118, 219-227` +- Modify: `apps/dashboard/package.json` (add `safe-regex` dep, inside Docker) +- Test: `apps/dashboard/tests/server/features/commands/regexValidation.test.ts` (new file) + +## Tasks + +### Task 1: Reject pathological regex patterns + +- [ ] **Step 1: Install safe-regex (inside Docker)** + +```bash +docker compose -f docker-compose.yml run --rm dashboard pnpm add safe-regex +docker compose -f docker-compose.yml run --rm dashboard pnpm add -D @types/safe-regex +``` + +- [ ] **Step 2: Write the failing test** + +Create `apps/dashboard/tests/server/features/commands/regexValidation.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", dashboardSessionSecret: "s", logLevel: "info" }, +})); + +const session = { + userId: "user-1", + username: "u", + guilds: [{ id: "guild-1", name: "T", permissions: BigInt(0x20).toString() }], +}; +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: vi.fn().mockResolvedValue(session), + touchSession: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: vi.fn().mockResolvedValue("owner-1"), +})); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: vi.fn().mockResolvedValue({ permissions: new Set(["*"]), isOwner: true }), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@fluxcore/systems/customCommands/persistence", () => ({ + getCustomCommands: vi.fn().mockResolvedValue([]), + getCustomCommandCount: vi.fn().mockResolvedValue(0), + createCustomCommand: vi.fn().mockResolvedValue({ id: 1 }), + updateCustomCommand: vi.fn().mockResolvedValue({ id: 1 }), + deleteCustomCommand: vi.fn(), +})); +vi.mock("@fluxcore/systems/customCommands/constants", () => ({ + MAX_COMMANDS_PER_GUILD: 100, + TRIGGER_TYPES: ["exact", "startsWith", "contains", "regex"], +})); +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import { registerCustomCommandRoutes } from "../../../../src/server/features/commands/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + registerCustomCommandRoutes(app); + await app.ready(); + return app; +} + +describe("POST /custom-commands — regex safety", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildApp(); + }); + + it("rejects catastrophic backtracking pattern (a+)+$", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/custom-commands", + cookies: { session: app.signCookie("valid") }, + payload: { name: "(a+)+$", triggerType: "regex" }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toMatch(/unsafe|regex/i); + }); + + it("rejects nested quantifier pattern (a*)*", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/custom-commands", + cookies: { session: app.signCookie("valid") }, + payload: { name: "(a*)*", triggerType: "regex" }, + }); + expect(res.statusCode).toBe(400); + }); + + it("accepts a simple safe regex", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/custom-commands", + cookies: { session: app.signCookie("valid") }, + payload: { name: "^hello", triggerType: "regex" }, + }); + expect(res.statusCode).toBe(201); + }); +}); +``` + +- [ ] **Step 3: Run test to verify fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- regexValidation +``` + +Expected: the two unsafe-pattern tests FAIL — current handler accepts them. + +- [ ] **Step 4: Implement the fix** + +Edit `apps/dashboard/src/server/features/commands/routes.ts` — at the top of the file add: + +```typescript +import safeRegex from "safe-regex"; + +const MAX_REGEX_LENGTH = 200; + +function validateRegexPattern(pattern: string): string | null { + if (pattern.length > MAX_REGEX_LENGTH) { + return `Regex pattern too long (max ${MAX_REGEX_LENGTH} chars)`; + } + try { + new RegExp(pattern, "i"); + } catch { + return "Invalid regex pattern"; + } + if (!safeRegex(pattern)) { + return "Unsafe regex pattern (catastrophic backtracking risk)"; + } + return null; +} +``` + +Then replace lines 110-118 (POST handler) with: + +```typescript + // Validate regex if trigger type is regex + if (body.triggerType === "regex") { + const err = validateRegexPattern(body.name); + if (err) { + reply.code(400).send({ error: err }); + return; + } + } +``` + +And replace lines 219-227 (PUT handler) with: + +```typescript + // Validate regex if trigger type is being changed to regex + if (body.triggerType === "regex" && body.name) { + const err = validateRegexPattern(body.name); + if (err) { + reply.code(400).send({ error: err }); + return; + } + } +``` + +- [ ] **Step 5: Run test to verify pass** + +```bash +pnpm --filter @fluxcore/dashboard test -- regexValidation +pnpm typecheck +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/dashboard/src/server/features/commands/routes.ts apps/dashboard/tests/server/features/commands/regexValidation.test.ts apps/dashboard/package.json pnpm-lock.yaml +git commit -m "fix(commands): reject unsafe regex patterns to prevent ReDoS" +``` diff --git a/docs/superpowers/plans/2026-04-07-sec-api-08-permission-error-leak.md b/docs/superpowers/plans/2026-04-07-sec-api-08-permission-error-leak.md new file mode 100644 index 0000000..2e4d42d --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-api-08-permission-error-leak.md @@ -0,0 +1,87 @@ +# Permission Error Echoes Failed Key — Fix Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Severity:** MEDIUM +**Goal:** Stop leaking which specific permission key tripped the escalation gate; respond with a generic message so attackers cannot binary-search the registry to map a victim's permissions. +**Architecture:** Drop the `permission` field from the 403 response body in the `PUT user-permissions` handler. The fix in finding 01 already replaces the response message; this plan locks in the no-leak guarantee with an explicit test so a future regression cannot reintroduce the field. +**Tech Stack:** Fastify 5, TypeScript, Prisma 7, Vitest + +--- + +## Vulnerability +`apps/dashboard/src/server/features/permissions/routes.ts:115-135` (specifically the rejection at lines 129-133) returns `{ error, permission: perm }`. An attacker can probe `PUT /user-permissions/` with each registry key and learn exactly which permissions another role holds (since the failure tells them precisely which key failed). This is information disclosure that aids privilege escalation. + +## Files +- Modify: `apps/dashboard/src/server/features/permissions/routes.ts:128-134` (already touched by finding 01; this finding requires the response shape to remain `{ error: }`) +- Test: `apps/dashboard/tests/server/features/permissions/userPermissions.test.ts` (extend) + +## Tasks + +### Task 1: Lock down the 403 response shape + +- [ ] **Step 1: Write the failing test** + +Append to `apps/dashboard/tests/server/features/permissions/userPermissions.test.ts`: + +```typescript +describe("PUT /user-permissions — error response does not leak key", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + mockGetSession.mockResolvedValue(callerSession); + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["dashboard.roles.manage"]), + isOwner: false, + }); + app = await buildApp(); + }); + + it("does not echo the failed permission key in the error body", async () => { + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/user-permissions/target-1", + cookies: { session: app.signCookie("valid") }, + payload: { permissions: ["actions.rules.manage"] }, + }); + expect(res.statusCode).toBe(403); + const body = res.json(); + expect(body).not.toHaveProperty("permission"); + expect(JSON.stringify(body)).not.toContain("actions.rules.manage"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- userPermissions +``` + +Expected: the test FAILS because the original code returns `{ error, permission: "actions.rules.manage" }`. (If finding 01 has already been applied, this test will pass — that's fine, it then serves as a regression guard.) + +- [ ] **Step 3: Implement the fix** + +Confirm/keep the fix from finding 01: in `apps/dashboard/src/server/features/permissions/routes.ts`, the 403 response inside the escalation loop must be: + +```typescript + reply.code(403).send({ error: "Insufficient privileges to grant this permission" }); + return; +``` + +No `permission:` field, no `key:` field, no echo of `perm`. + +- [ ] **Step 4: Run test to verify pass** + +```bash +pnpm --filter @fluxcore/dashboard test -- userPermissions +pnpm typecheck +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/features/permissions/routes.ts apps/dashboard/tests/server/features/permissions/userPermissions.test.ts +git commit -m "fix(permissions): generic 403 error to avoid leaking failed permission key" +``` diff --git a/docs/superpowers/plans/2026-04-07-sec-api-09-audit-action-exact-match.md b/docs/superpowers/plans/2026-04-07-sec-api-09-audit-action-exact-match.md new file mode 100644 index 0000000..1222f14 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-api-09-audit-action-exact-match.md @@ -0,0 +1,122 @@ +# Audit Action Filter Exact-Match — Fix Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Severity:** MEDIUM +**Goal:** Replace the substring `contains` filter on the audit log `action` field with an exact match against an allowlist, so attackers cannot enumerate or partially match arbitrary action strings. +**Architecture:** Build an `ALLOWED_AUDIT_ACTIONS` constant covering every action string emitted by the dashboard (`dashboard.permissions.update`, `dashboard.permissions.clear`, `dashboard.settings.update`, `dashboard.role.create`, `dashboard.role.update`, `dashboard.role.delete`, `dashboard.role.assign`, `dashboard.role.unassign`). When `query.action` is supplied, validate membership in the allowlist and use `where.action = query.action` instead of `{ contains }`. +**Tech Stack:** Fastify 5, TypeScript, Prisma 7, Vitest + +--- + +## Vulnerability +`apps/dashboard/src/server/features/permissions/routes.ts:302` does `where.action = { contains: query.action }`. Substring matching exposes information (an attacker can search for partial strings to discover undocumented action types) and allows confusion in the UI. Exact-match against an allowlist is both more correct and prevents partial-match enumeration. + +## Files +- Modify: `apps/dashboard/src/server/features/permissions/routes.ts:280-336` +- Test: `apps/dashboard/tests/server/features/permissions/auditLog.test.ts` (extend) + +## Tasks + +### Task 1: Replace `contains` with exact-match allowlist + +- [ ] **Step 1: Write the failing test** + +Append to `apps/dashboard/tests/server/features/permissions/auditLog.test.ts`: + +```typescript +describe("GET /dashboard-audit — action filter is exact-match", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + mockResolveUserPermissions.mockResolvedValue({ + permissions: new Set(["*"]), + isOwner: true, + }); + mockFindMany.mockResolvedValue([]); + mockCount.mockResolvedValue(0); + app = await buildApp(); + }); + + it("uses exact match (not contains) when action is allowlisted", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/dashboard-audit?action=dashboard.permissions.update", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(200); + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ action: "dashboard.permissions.update" }), + }), + ); + }); + + it("returns 400 when action is not in the allowlist", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/guilds/guild-1/dashboard-audit?action=permissions", + cookies: { session: app.signCookie("valid") }, + }); + expect(res.statusCode).toBe(400); + }); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- auditLog +``` + +Expected: both tests FAIL — current code uses `{ contains: "permissions" }` and never returns 400. + +- [ ] **Step 3: Implement the fix** + +Edit `apps/dashboard/src/server/features/permissions/routes.ts`. At the bottom of the file, add the allowlist: + +```typescript +const ALLOWED_AUDIT_ACTIONS = new Set([ + "dashboard.permissions.update", + "dashboard.permissions.clear", + "dashboard.settings.update", + "dashboard.role.create", + "dashboard.role.update", + "dashboard.role.delete", + "dashboard.role.assign", + "dashboard.role.unassign", +]); +``` + +Then in the GET handler (around line 302) replace: + +```typescript + if (query.action) where.action = { contains: query.action }; +``` + +with: + +```typescript + if (query.action) { + if (!ALLOWED_AUDIT_ACTIONS.has(query.action)) { + reply.code(400).send({ error: "Unknown action filter" }); + return; + } + where.action = query.action; + } +``` + +- [ ] **Step 4: Run test to verify pass** + +```bash +pnpm --filter @fluxcore/dashboard test -- auditLog +pnpm typecheck +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/features/permissions/routes.ts apps/dashboard/tests/server/features/permissions/auditLog.test.ts +git commit -m "fix(permissions): enforce exact-match allowlist on audit action filter" +``` diff --git a/docs/superpowers/plans/2026-04-07-sec-api-10-image-preview-nosniff.md b/docs/superpowers/plans/2026-04-07-sec-api-10-image-preview-nosniff.md new file mode 100644 index 0000000..0ec537d --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-api-10-image-preview-nosniff.md @@ -0,0 +1,147 @@ +# Image Preview X-Content-Type-Options nosniff — Fix Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Severity:** LOW +**Goal:** Ensure browsers do not MIME-sniff the welcome image preview response, eliminating the residual XSS-via-content-sniffing risk if a future bug allowed non-PNG bytes to be served from the preview endpoint. +**Architecture:** Add the `X-Content-Type-Options: nosniff` header to the reply chain in the POST `/welcome/image/preview` route. This is a one-line defense-in-depth fix. +**Tech Stack:** Fastify 5, TypeScript, Prisma 7, Vitest + +--- + +## Vulnerability +`apps/dashboard/src/server/features/welcome/routes.ts:183-186` sets `Content-Type: image/png` and `Cache-Control: no-cache` but omits `X-Content-Type-Options: nosniff`. If a future bug ever caused `imageBuffer` to contain HTML or SVG, browsers might sniff and execute it. Adding `nosniff` is a cheap defense-in-depth control. + +## Files +- Modify: `apps/dashboard/src/server/features/welcome/routes.ts:183-186` +- Test: `apps/dashboard/tests/server/features/welcome/imagePreviewHeaders.test.ts` (new file) + +## Tasks + +### Task 1: Add nosniff header to image preview + +- [ ] **Step 1: Write the failing test** + +Create `apps/dashboard/tests/server/features/welcome/imagePreviewHeaders.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", dashboardSessionSecret: "s", logLevel: "info" }, +})); + +const session = { + userId: "user-1", + username: "u", + avatar: null, + guilds: [{ id: "guild-1", name: "T", permissions: BigInt(0x20).toString() }], +}; + +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: vi.fn().mockResolvedValue(session), + touchSession: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: vi.fn().mockResolvedValue("owner-1"), +})); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: vi.fn().mockResolvedValue({ permissions: new Set(["*"]), isOwner: true }), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@fluxcore/systems/welcome/image", async () => { + const actual = await vi.importActual>("@fluxcore/systems/welcome/image"); + return { + ...actual, + createStorageAdapter: () => ({ + upload: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }), + welcomeImageSettingsSchema: { safeParse: () => ({ success: true, data: {} }) }, + DEFAULT_WELCOME_IMAGE_SETTINGS: {}, + DEFAULT_FAREWELL_IMAGE_SETTINGS: {}, + MAX_BACKGROUND_SIZE: 5 * 1024 * 1024, + ALLOWED_BACKGROUND_TYPES: ["image/png"], + PRESET_BACKGROUNDS: [], + generateWelcomeImage: vi.fn().mockResolvedValue(Buffer.from([0x89, 0x50, 0x4e, 0x47])), + getAllTemplates: () => [], + getAvailableFonts: () => [], + }; +}); +vi.mock("@fluxcore/systems/welcome/config", () => ({ + getWelcomeConfig: vi.fn(), + upsertWelcomeConfig: vi.fn(), +})); +vi.mock("@fluxcore/utils", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import { registerWelcomeRoutes } from "../../../../src/server/features/welcome/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + registerWelcomeRoutes(app); + await app.ready(); + return app; +} + +describe("POST /welcome/image/preview — security headers", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildApp(); + }); + + it("sets X-Content-Type-Options: nosniff", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/guilds/guild-1/welcome/image/preview", + cookies: { session: app.signCookie("valid") }, + payload: { settings: {}, type: "welcome" }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers["x-content-type-options"]).toBe("nosniff"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- imagePreviewHeaders +``` + +Expected: header is `undefined`, test FAILS. + +- [ ] **Step 3: Implement the fix** + +Edit `apps/dashboard/src/server/features/welcome/routes.ts` lines 183-186 — add the nosniff header: + +```typescript + reply + .header("Content-Type", "image/png") + .header("Cache-Control", "no-cache") + .header("X-Content-Type-Options", "nosniff") + .send(imageBuffer); +``` + +- [ ] **Step 4: Run test to verify pass** + +```bash +pnpm --filter @fluxcore/dashboard test -- imagePreviewHeaders +pnpm typecheck +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/features/welcome/routes.ts apps/dashboard/tests/server/features/welcome/imagePreviewHeaders.test.ts +git commit -m "fix(welcome): add X-Content-Type-Options nosniff to image preview" +``` diff --git a/docs/superpowers/plans/2026-04-07-sec-api-11-actions-update-error-mask.md b/docs/superpowers/plans/2026-04-07-sec-api-11-actions-update-error-mask.md new file mode 100644 index 0000000..d5a1cbe --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-sec-api-11-actions-update-error-mask.md @@ -0,0 +1,183 @@ +# Actions Update Generic Catch Masks Real Errors — Fix Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Severity:** LOW +**Goal:** Distinguish "rule not found" from real backend errors in `PUT /actions/rules/:ruleId` so genuine 500s are surfaced and logged instead of silently masked as 404s. +**Architecture:** Catch the error, inspect for Prisma's `P2025` (record not found) error code — if matched, return 404. For any other error, log via the project logger and return 500. This restores observability without changing the happy path. +**Tech Stack:** Fastify 5, TypeScript, Prisma 7, Vitest + +--- + +## Vulnerability +`apps/dashboard/src/server/features/actions/routes.ts:231` does `try { ... } catch { reply.code(404).send({ error: "Rule not found" }); }`. Any failure — DB connection drop, validation error inside `updateRule`, JSON parse fault — is reported as a 404. Operators lose visibility into real failures, and an attacker can trigger DB issues without ever seeing a 5xx response. + +## Files +- Modify: `apps/dashboard/src/server/features/actions/routes.ts:210-234` +- Test: `apps/dashboard/tests/server/features/actions/updateRuleErrors.test.ts` (new file) + +## Tasks + +### Task 1: Catch P2025 specifically; log other errors as 500 + +- [ ] **Step 1: Write the failing test** + +Create `apps/dashboard/tests/server/features/actions/updateRuleErrors.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@fluxcore/config", () => ({ + config: { token: "t", clientId: "c", dashboardSessionSecret: "s", logLevel: "info" }, +})); + +const session = { + userId: "user-1", + username: "u", + guilds: [{ id: "guild-1", name: "T", permissions: BigInt(0x20).toString() }], +}; +vi.mock("../../../../src/server/shared/session.js", () => ({ + getSession: vi.fn().mockResolvedValue(session), + touchSession: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("../../../../src/server/shared/discordApi.js", () => ({ + isBotInGuild: vi.fn().mockResolvedValue(true), + getGuildOwnerId: vi.fn().mockResolvedValue("owner-1"), +})); +vi.mock("../../../../src/server/shared/permissions.js", () => ({ + resolveUserPermissions: vi.fn().mockResolvedValue({ permissions: new Set(["*"]), isOwner: true }), + hasPermission: vi.fn().mockReturnValue(true), + invalidatePermissionCache: vi.fn(), + createDashboardAuditLog: vi.fn().mockResolvedValue(undefined), +})); + +const mockUpdateRule = vi.fn(); +vi.mock("@fluxcore/systems/actions/persistence", () => ({ + notifyCacheInvalidation: vi.fn().mockResolvedValue(undefined), + listRules: vi.fn().mockResolvedValue([]), + getRule: vi.fn(), + createRule: vi.fn(), + updateRule: (...args: unknown[]) => mockUpdateRule(...args), + deleteRule: vi.fn(), + bulkUpdateRules: vi.fn(), + getRuleAnalytics: vi.fn(), + getActionLogs: vi.fn(), + getGuildSettings: vi.fn(), + upsertGuildSettings: vi.fn(), +})); + +const mockLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; +vi.mock("@fluxcore/utils", () => ({ logger: mockLogger })); + +import Fastify from "fastify"; +import fastifyCookie from "@fastify/cookie"; +import { registerActionRoutes } from "../../../../src/server/features/actions/routes.js"; + +async function buildApp() { + const app = Fastify(); + await app.register(fastifyCookie, { secret: "test-secret" }); + registerActionRoutes(app); + await app.ready(); + return app; +} + +describe("PUT /actions/rules/:ruleId — error handling", () => { + let app: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildApp(); + }); + + it("returns 404 when Prisma throws P2025", async () => { + const err = Object.assign(new Error("Record not found"), { code: "P2025" }); + mockUpdateRule.mockRejectedValue(err); + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/actions/rules/123", + cookies: { session: app.signCookie("valid") }, + payload: { name: "x" }, + }); + expect(res.statusCode).toBe(404); + }); + + it("returns 500 and logs when an unexpected error occurs", async () => { + mockUpdateRule.mockRejectedValue(new Error("DB connection refused")); + const res = await app.inject({ + method: "PUT", + url: "/api/guilds/guild-1/actions/rules/123", + cookies: { session: app.signCookie("valid") }, + payload: { name: "x" }, + }); + expect(res.statusCode).toBe(500); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify fail** + +```bash +pnpm --filter @fluxcore/dashboard test -- updateRuleErrors +``` + +Expected: the "returns 500 and logs" test FAILS — current handler returns 404 for every error. + +- [ ] **Step 3: Implement the fix** + +Edit `apps/dashboard/src/server/features/actions/routes.ts` — replace the `try { ... } catch { ... }` block at lines 210-233: + +```typescript + try { + const updated = await updateRule(Number(ruleId), guildId, { + ...(body.name !== undefined && { name: body.name }), + ...(body.eventType !== undefined && { + eventType: body.eventType as ActionEventType, + }), + ...(body.actions !== undefined && { + actions: body.actions.map((a) => ({ + ...a, + type: a.type as ActionType, + })), + }), + ...(body.steps && body.entryStepId + ? { steps: body.steps as unknown as RuleStep[], entryStepId: body.entryStepId } + : {}), + ...(body.conditions !== undefined && { conditions: body.conditions }), + ...(body.priority !== undefined && { priority: body.priority }), + ...(body.enabled !== undefined && { enabled: body.enabled }), + }); + await notifyCacheInvalidation(guildId); + reply.send(updated); + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "P2025") { + reply.code(404).send({ error: "Rule not found" }); + return; + } + logger.error({ err, guildId, ruleId }, "Failed to update action rule"); + reply.code(500).send({ error: "Failed to update rule" }); + } +``` + +Make sure `logger` is imported at the top of the file: + +```typescript +import { logger } from "@fluxcore/utils"; +``` + +(If it's not already imported, add it; if already present, no change needed.) + +- [ ] **Step 4: Run test to verify pass** + +```bash +pnpm --filter @fluxcore/dashboard test -- updateRuleErrors +pnpm typecheck +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/dashboard/src/server/features/actions/routes.ts apps/dashboard/tests/server/features/actions/updateRuleErrors.test.ts +git commit -m "fix(actions): distinguish P2025 not-found from 500 errors in rule update" +```