fix(security): enforce trusted sender auth for discord moderation

This commit is contained in:
Peter Steinberger
2026-02-19 15:18:00 +01:00
parent baa335f258
commit 775816035e
15 changed files with 498 additions and 22 deletions

View File

@@ -0,0 +1,128 @@
import type { RequestClient } from "@buape/carbon";
import { PermissionFlagsBits, Routes } from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import {
fetchMemberGuildPermissionsDiscord,
hasGuildPermissionDiscord,
} from "./send.permissions.js";
const mockRest = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock("./client.js", () => ({
resolveDiscordRest: () => mockRest as unknown as RequestClient,
}));
describe("discord guild permission authorization", () => {
describe("fetchMemberGuildPermissionsDiscord", () => {
it("returns null when user is not a guild member", async () => {
mockRest.get.mockRejectedValueOnce(new Error("404 Member not found"));
const result = await fetchMemberGuildPermissionsDiscord("guild-1", "user-1");
expect(result).toBeNull();
});
it("includes @everyone and member roles in computed permissions", async () => {
mockRest.get.mockImplementation(async (route: string) => {
if (route === Routes.guild("guild-1")) {
return {
id: "guild-1",
roles: [
{ id: "guild-1", permissions: PermissionFlagsBits.ViewChannel.toString() },
{ id: "role-mod", permissions: PermissionFlagsBits.KickMembers.toString() },
],
};
}
if (route === Routes.guildMember("guild-1", "user-1")) {
return {
id: "user-1",
roles: ["role-mod"],
};
}
throw new Error(`Unexpected route: ${route}`);
});
const result = await fetchMemberGuildPermissionsDiscord("guild-1", "user-1");
expect(result).not.toBeNull();
expect((result! & PermissionFlagsBits.ViewChannel) === PermissionFlagsBits.ViewChannel).toBe(
true,
);
expect((result! & PermissionFlagsBits.KickMembers) === PermissionFlagsBits.KickMembers).toBe(
true,
);
});
});
describe("hasGuildPermissionDiscord", () => {
it("returns true when user has required permission", async () => {
mockRest.get.mockImplementation(async (route: string) => {
if (route === Routes.guild("guild-1")) {
return {
id: "guild-1",
roles: [
{ id: "guild-1", permissions: "0" },
{ id: "role-mod", permissions: PermissionFlagsBits.KickMembers.toString() },
],
};
}
if (route === Routes.guildMember("guild-1", "user-1")) {
return { id: "user-1", roles: ["role-mod"] };
}
throw new Error(`Unexpected route: ${route}`);
});
const result = await hasGuildPermissionDiscord("guild-1", "user-1", [
PermissionFlagsBits.KickMembers,
]);
expect(result).toBe(true);
});
it("returns true when user has ADMINISTRATOR", async () => {
mockRest.get.mockImplementation(async (route: string) => {
if (route === Routes.guild("guild-1")) {
return {
id: "guild-1",
roles: [
{ id: "guild-1", permissions: "0" },
{
id: "role-admin",
permissions: PermissionFlagsBits.Administrator.toString(),
},
],
};
}
if (route === Routes.guildMember("guild-1", "user-1")) {
return { id: "user-1", roles: ["role-admin"] };
}
throw new Error(`Unexpected route: ${route}`);
});
const result = await hasGuildPermissionDiscord("guild-1", "user-1", [
PermissionFlagsBits.KickMembers,
]);
expect(result).toBe(true);
});
it("returns false when user lacks all required permissions", async () => {
mockRest.get.mockImplementation(async (route: string) => {
if (route === Routes.guild("guild-1")) {
return {
id: "guild-1",
roles: [{ id: "guild-1", permissions: PermissionFlagsBits.ViewChannel.toString() }],
};
}
if (route === Routes.guildMember("guild-1", "user-1")) {
return { id: "user-1", roles: [] };
}
throw new Error(`Unexpected route: ${route}`);
});
const result = await hasGuildPermissionDiscord("guild-1", "user-1", [
PermissionFlagsBits.BanMembers,
PermissionFlagsBits.KickMembers,
]);
expect(result).toBe(false);
});
});
});

View File

@@ -1,8 +1,8 @@
import type { RequestClient } from "@buape/carbon";
import type { APIChannel, APIGuild, APIGuildMember, APIRole } from "discord-api-types/v10";
import { ChannelType, PermissionFlagsBits, Routes } from "discord-api-types/v10";
import { resolveDiscordRest } from "./client.js";
import type { DiscordPermissionsSummary, DiscordReactOpts } from "./send.types.js";
import { resolveDiscordRest } from "./client.js";
const PERMISSION_ENTRIES = Object.entries(PermissionFlagsBits).filter(
([, value]) => typeof value === "bigint",
@@ -34,6 +34,10 @@ function hasAdministrator(bitfield: bigint) {
return (bitfield & ADMINISTRATOR_BIT) === ADMINISTRATOR_BIT;
}
function hasPermissionBit(bitfield: bigint, permission: bigint) {
return (bitfield & permission) === permission;
}
export function isThreadChannelType(channelType?: number) {
return (
channelType === ChannelType.GuildNewsThread ||
@@ -50,6 +54,58 @@ async function fetchBotUserId(rest: RequestClient) {
return me.id;
}
/**
* Fetch guild-level permissions for a user. This does not include channel-specific overwrites.
*/
export async function fetchMemberGuildPermissionsDiscord(
guildId: string,
userId: string,
opts: DiscordReactOpts = {},
): Promise<bigint | null> {
const rest = resolveDiscordRest(opts);
try {
const [guild, member] = await Promise.all([
rest.get(Routes.guild(guildId)) as Promise<APIGuild>,
rest.get(Routes.guildMember(guildId, userId)) as Promise<APIGuildMember>,
]);
const rolesById = new Map<string, APIRole>((guild.roles ?? []).map((role) => [role.id, role]));
const everyoneRole = rolesById.get(guildId);
let permissions = 0n;
if (everyoneRole?.permissions) {
permissions = addPermissionBits(permissions, everyoneRole.permissions);
}
for (const roleId of member.roles ?? []) {
const role = rolesById.get(roleId);
if (role?.permissions) {
permissions = addPermissionBits(permissions, role.permissions);
}
}
return permissions;
} catch {
// Not a guild member, guild not found, or API failure.
return null;
}
}
/**
* Returns true when the user has ADMINISTRATOR or any required permission bit.
*/
export async function hasGuildPermissionDiscord(
guildId: string,
userId: string,
requiredPermissions: bigint[],
opts: DiscordReactOpts = {},
): Promise<boolean> {
const permissions = await fetchMemberGuildPermissionsDiscord(guildId, userId, opts);
if (permissions === null) {
return false;
}
if (hasAdministrator(permissions)) {
return true;
}
return requiredPermissions.some((permission) => hasPermissionBit(permissions, permission));
}
export async function fetchChannelPermissionsDiscord(
channelId: string,
opts: DiscordReactOpts = {},

View File

@@ -46,6 +46,10 @@ export {
export { sendDiscordComponentMessage } from "./send.components.js";
export {
fetchChannelPermissionsDiscord,
fetchMemberGuildPermissionsDiscord,
hasGuildPermissionDiscord,
} from "./send.permissions.js";
export {
fetchReactionsDiscord,
reactMessageDiscord,
removeOwnReactionsDiscord,