Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down
14 changes: 12 additions & 2 deletions apps/dashboard/src/server/features/actions/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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" });
}
},
);
Expand Down
40 changes: 32 additions & 8 deletions apps/dashboard/src/server/features/commands/routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FastifyInstance } from "fastify";
import safeRegex from "safe-regex";
import { requireAuth, requireGuildAdmin, requirePermission } from "../../shared/middleware.js";
import {
getCustomCommands,
Expand All @@ -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;
Expand All @@ -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",
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
}
}
Expand Down
8 changes: 8 additions & 0 deletions apps/dashboard/src/server/features/giveaways/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions apps/dashboard/src/server/features/music/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
69 changes: 57 additions & 12 deletions apps/dashboard/src/server/features/permissions/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)) {
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -298,13 +306,39 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void {
const skip = (page - 1) * limit;

const where: Record<string, unknown> = { 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<string, Date> = {};
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;
}

Expand Down Expand Up @@ -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;
Expand Down
50 changes: 38 additions & 12 deletions apps/dashboard/src/server/features/scheduled/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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" });
}
},
);
}
44 changes: 44 additions & 0 deletions apps/dashboard/src/server/features/welcome/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
);
Expand Down Expand Up @@ -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({
Expand All @@ -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}`;

Expand Down Expand Up @@ -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;
}
Loading