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
32 changes: 26 additions & 6 deletions apps/bot/src/events/guildMemberAdd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { sendLogEmbed } from "@fluxcore/systems/logging/sender";
import { formatMemberJoin } from "@fluxcore/systems/logging/formatter";
import { getWelcomeConfig } from "@fluxcore/systems/welcome/config";
import { buildWelcomeEmbed } from "@fluxcore/systems/welcome/builder";
import { generateWelcomeImage, createStorageAdapter } from "@fluxcore/systems/welcome/image";
import { AttachmentBuilder } from "discord.js";
import { generateWelcomeImage, createStorageAdapter, sanitizeDisplayName } from "@fluxcore/systems/welcome/image";
import { AttachmentBuilder, DiscordAPIError } from "discord.js";
import { getAntiRaidConfig } from "@fluxcore/systems/antiraid/config";
import { recordJoin } from "@fluxcore/systems/antiraid/tracker";
import { executeRaidAction, lockdownGuild } from "@fluxcore/systems/antiraid/actions";
Expand Down Expand Up @@ -133,7 +133,24 @@ const event: Event<"guildMemberAdd"> = {
});
if (rolesToAdd.length > 0) {
await member.roles.add(rolesToAdd, "Auto-role on join").catch((err) => {
logger.error(`Failed to assign auto-roles in guild ${member.guild.id}`, err instanceof Error ? err : new Error(String(err)));
if (err instanceof DiscordAPIError) {
if (err.code === 50013) {
logger.warn(
`auto-role: missing permissions in guild ${member.guild.id} (role likely moved above bot between cache check and API call)`,
);
return;
}
if (err.code === 10011) {
logger.warn(
`auto-role: unknown role in guild ${member.guild.id} (role was deleted between cache check and API call)`,
);
return;
}
}
logger.error(
`Failed to assign auto-roles in guild ${member.guild.id}`,
err instanceof Error ? err : new Error(String(err)),
);
});
}
}
Expand All @@ -149,15 +166,18 @@ const event: Event<"guildMemberAdd"> = {
if (welcomeConfig.welcomeImageEnabled) {
try {
const storage = createStorageAdapter();
const safeUsername = sanitizeDisplayName(member.user.username, 32);
const safeDisplayName = sanitizeDisplayName(member.displayName, 80);
const safeGuildName = sanitizeDisplayName(member.guild.name, 80);
const imageBuffer = await generateWelcomeImage({
settings: welcomeConfig.welcomeImageConfig,
member: {
username: member.user.username,
displayName: member.displayName,
username: safeUsername,
displayName: safeDisplayName,
avatarUrl: member.user.displayAvatarURL({ extension: "png", size: 256 }),
},
guild: {
name: member.guild.name,
name: safeGuildName,
iconUrl: member.guild.iconURL({ size: 256 }) ?? undefined,
memberCount: member.guild.memberCount,
},
Expand Down
18 changes: 15 additions & 3 deletions apps/bot/src/events/messageCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,28 @@ async function handleLevelUp(
.replace("{level}", String(newLevel))
.replace("{username}", message.author.displayName);

// Lock allowedMentions so a moderator-configured template cannot ping
// @everyone, @here, or arbitrary roles. Only the leveling user
// themself is allowed to be mentioned.
const allowedMentions = { parse: [], users: [message.author.id] } as const;

try {
if (settings.announceChannel === "dm") {
await message.author.send(text);
await message.author.send({ content: text, allowedMentions: { parse: [] } });
} else if (settings.announceChannel) {
const channel = message.guild?.channels.cache.get(settings.announceChannel);
if (channel?.isTextBased()) {
await (channel as TextChannel).send(text);
await (channel as TextChannel).send({ content: text, allowedMentions });
}
} else {
await (message.channel as { send: (text: string) => Promise<unknown> }).send(text);
await (
message.channel as {
send: (payload: {
content: string;
allowedMentions: typeof allowedMentions;
}) => Promise<unknown>;
}
).send({ content: text, allowedMentions });
}
} catch (error) {
logger.debug(
Expand Down
52 changes: 47 additions & 5 deletions apps/bot/src/features/automation/system/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,27 @@ type ActionExecutor = (
config: ActionConfig,
) => Promise<void>;

/**
* Validates that a moderator-supplied emoji is either a single Unicode
* emoji cluster or a Discord custom-emoji literal `<:name:id>` /
* `<a:name:id>`. Anything else is rejected to avoid hitting the Discord
* API with garbage values.
*/
const CUSTOM_EMOJI_REGEX = /^<a?:[A-Za-z0-9_]{2,32}:\d{17,20}>$/;
// Matches strings whose code points all belong to the Unicode "Emoji"
// property (single emoji or ZWJ sequence). Length cap of 64 covers
// flag/family ZWJ sequences while preventing pathological inputs.
const UNICODE_EMOJI_REGEX =
/^(?:\p{Extended_Pictographic}|\p{Emoji_Component})(?:\u200D(?:\p{Extended_Pictographic}|\p{Emoji_Component}))*$/u;

function isValidEmoji(value: string): boolean {
if (typeof value !== "string" || value.length === 0 || value.length > 64) {
return false;
}
if (CUSTOM_EMOJI_REGEX.test(value)) return true;
return UNICODE_EMOJI_REGEX.test(value);
}

const executors = new Map<ActionType, ActionExecutor>();

executors.set("sendMessage", async (client, ctx, config) => {
Expand Down Expand Up @@ -168,18 +189,33 @@ executors.set("sendWebhook", async (_client, ctx, config) => {
body = body.slice(0, MAX_TEMPLATE_LENGTH);
}

const BLOCKED_HEADERS = new Set([
"host", "cookie", "set-cookie", "transfer-encoding",
"connection", "proxy-authorization", "te", "trailer",
"upgrade",
// Strict allowlist: only headers that are safe for the bot to forward on
// behalf of a guild admin. Denylists are unsafe — any new sensitive
// header (Authorization, X-Api-Key, X-Forwarded-*, Cookie, etc.) would
// silently leak. Add to this set only after a security review.
const ALLOWED_HEADERS = new Set([
"accept",
"accept-language",
"cache-control",
"user-agent",
"x-idempotency-key",
"x-request-id",
]);
const ALLOWED_PREFIX = "x-fluxcore-";

const userHeaders = config.webhook.headers ?? {};
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
for (const [key, value] of Object.entries(userHeaders)) {
if (!BLOCKED_HEADERS.has(key.toLowerCase())) {
const lower = key.toLowerCase();
if (lower === "content-type") continue; // we set this ourselves
if (ALLOWED_HEADERS.has(lower) || lower.startsWith(ALLOWED_PREFIX)) {
headers[key] = value;
} else {
logger.warn(
`sendWebhook: dropped non-allowlisted header "${key}" for guild ${ctx.guildId ?? "unknown"}`,
);
}
}

Expand Down Expand Up @@ -235,6 +271,12 @@ executors.set("createThread", async (client, ctx, config) => {

executors.set("addReaction", async (client, ctx, config) => {
if (!config.emoji || !ctx.extra?.["message.id"] || !ctx.channelId) return;
if (!isValidEmoji(config.emoji)) {
logger.warn(
`addReaction skipped: invalid emoji "${config.emoji}" for guild ${ctx.guildId ?? "unknown"}`,
);
return;
}
const channel = await client.channels.fetch(ctx.channelId);
if (!channel?.isTextBased() || !("messages" in channel)) return;
try {
Expand Down
15 changes: 15 additions & 0 deletions apps/bot/src/features/general/commands/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ACTION_TYPES,
MAX_ACTIONS_PER_RULE,
CONDITION_TYPES,
isValidRuleName,
type ConditionType,
} from "@fluxcore/systems/actions/constants";
import {
Expand Down Expand Up @@ -472,6 +473,20 @@ async function handleCreate(
guildId: string,
) {
const name = interaction.options.getString("name", true);

if (!isValidRuleName(name)) {
await interaction.reply({
embeds: [
errorEmbed(
"Invalid Name",
"Rule names must be 1-50 characters using only letters, digits, spaces, underscores, or hyphens.",
),
],
ephemeral: true,
});
return;
}

const eventType = interaction.options.getString("event", true) as ActionEventType;
const actionType = interaction.options.getString("action-type", true) as ActionType;
const channel = interaction.options.getChannel("channel");
Expand Down
4 changes: 4 additions & 0 deletions apps/bot/src/features/moderation/commands/warn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
errorEmbed,
warnEmbed,
checkPermissions,
checkBotPermissions,
isAboveTarget,
logger,
} from "@fluxcore/utils";
Expand Down Expand Up @@ -41,6 +42,9 @@ const command: Command = {
if (
!(await checkPermissions(interaction, [
PermissionFlagsBits.ManageMessages,
])) ||
!(await checkBotPermissions(interaction, [
PermissionFlagsBits.ManageMessages,
]))
) {
return;
Expand Down
128 changes: 128 additions & 0 deletions apps/bot/tests/events/autorole-error-handling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@fluxcore/config", () => ({
config: { token: "t", clientId: "c", guildId: undefined, logLevel: "info" },
}));

const warn = vi.fn();
const error = vi.fn();
vi.mock("@fluxcore/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fluxcore/utils")>();
return {
...actual,
logger: { warn, error, info: vi.fn(), debug: vi.fn() },
};
});

vi.mock("@fluxcore/systems/logging/config", () => ({
getLogConfig: vi.fn().mockResolvedValue(null),
}));
vi.mock("@fluxcore/systems/logging/persistence", () => ({ createLogEntry: vi.fn() }));
vi.mock("@fluxcore/systems/logging/sender", () => ({ sendLogEmbed: vi.fn() }));
vi.mock("@fluxcore/systems/logging/formatter", () => ({ formatMemberJoin: vi.fn() }));

vi.mock("@fluxcore/systems/antiraid/config", () => ({
getAntiRaidConfig: vi.fn().mockResolvedValue({ enabled: false }),
}));
vi.mock("@fluxcore/systems/antiraid/tracker", () => ({ recordJoin: vi.fn() }));
vi.mock("@fluxcore/systems/antiraid/actions", () => ({
executeRaidAction: vi.fn(),
lockdownGuild: vi.fn(),
}));
vi.mock("@fluxcore/systems/antiraid/persistence", () => ({ createRaidEvent: vi.fn() }));

vi.mock("@fluxcore/systems/welcome/config", () => ({
getWelcomeConfig: vi.fn().mockResolvedValue({
autoRoleIds: ["role-1"],
welcomeEnabled: false,
welcomeChannelId: null,
welcomeMessage: "",
welcomeImageEnabled: false,
welcomeImageConfig: { sendMode: "with" },
dmEnabled: false,
dmMessage: "",
}),
}));

vi.mock("@fluxcore/systems/welcome/builder", () => ({
buildWelcomeEmbed: vi.fn(),
}));
vi.mock("@fluxcore/systems/welcome/image", () => ({
generateWelcomeImage: vi.fn(),
createStorageAdapter: vi.fn(),
sanitizeDisplayName: (s: string) => s,
}));

import { DiscordAPIError } from "discord.js";

const event = (await import("../../src/events/guildMemberAdd.js")).default;

function mkMember(rolesAdd: ReturnType<typeof vi.fn>) {
return {
id: "m1",
user: {
bot: false,
username: "alice",
createdTimestamp: Date.now() - 86_400_000 * 100,
displayAvatarURL: () => "",
tag: "",
},
displayName: "Alice",
guild: {
id: "g1",
name: "G",
memberCount: 5,
iconURL: () => null,
members: { me: { roles: { highest: { position: 100 } } } },
roles: {
cache: new Map([["role-1", { position: 5, id: "role-1" }]]),
},
channels: { cache: { get: () => null } },
},
roles: { cache: new Map(), add: rolesAdd },
send: vi.fn(),
} as never;
}

function makeApiError(code: number, message: string): DiscordAPIError {
const err = new DiscordAPIError(
{ message, code } as never,
code,
403,
"PUT",
"url",
{ files: [] } as never,
);
return err;
}

describe("auto-role failure handling", () => {
beforeEach(() => {
warn.mockClear();
error.mockClear();
});

it("logs WARN with 'missing permissions' on Discord code 50013", async () => {
const rolesAdd = vi.fn().mockRejectedValue(makeApiError(50013, "Missing Permissions"));
await event.execute(mkMember(rolesAdd));
expect(warn).toHaveBeenCalledWith(
expect.stringMatching(/auto-role.*missing permissions/i),
);
expect(error).not.toHaveBeenCalled();
});

it("logs WARN with 'unknown role' on Discord code 10011", async () => {
const rolesAdd = vi.fn().mockRejectedValue(makeApiError(10011, "Unknown Role"));
await event.execute(mkMember(rolesAdd));
expect(warn).toHaveBeenCalledWith(
expect.stringMatching(/auto-role.*unknown role/i),
);
expect(error).not.toHaveBeenCalled();
});

it("logs ERROR for unexpected failure (e.g. 5xx)", async () => {
const rolesAdd = vi.fn().mockRejectedValue(new Error("ECONNRESET"));
await event.execute(mkMember(rolesAdd));
expect(error).toHaveBeenCalled();
});
});
Loading