From b8b5ac946a11a9fc0e34ae1f7050d5235e559608 Mon Sep 17 00:00:00 2001 From: Isaac Date: Fri, 6 Sep 2024 03:59:24 +0100 Subject: [PATCH] fix(security): transcript access control (closes #555) --- src/autocomplete/ticket.js | 30 +++++++++---------- src/commands/slash/tickets.js | 49 +++++++++++++++++++++++++++----- src/commands/slash/transcript.js | 20 +++++++++---- 3 files changed, 69 insertions(+), 30 deletions(-) diff --git a/src/autocomplete/ticket.js b/src/autocomplete/ticket.js index c18eec6..157bcb0 100644 --- a/src/autocomplete/ticket.js +++ b/src/autocomplete/ticket.js @@ -18,12 +18,13 @@ module.exports = class TicketCompleter extends Autocompleter { } async getOptions(value, { - guildId, + interaction, open, userId, }) { /** @type {import("client")} */ const client = this.client; + const guildId = interaction.guild.id; const cacheKey = [guildId, userId, open].join('/'); let tickets = await this.cache.get(cacheKey); @@ -34,27 +35,22 @@ module.exports = class TicketCompleter extends Autocompleter { where: { id: guildId }, }); tickets = await client.prisma.ticket.findMany({ - include: { - category: { - select: { - emoji: true, - name: true, - }, - }, - }, + include: { category: true }, where: { createdById: userId, guildId, open, }, }); - tickets = tickets.map(ticket => { - const date = new Date(ticket.createdAt).toLocaleString([locale, 'en-GB'], { dateStyle: 'short' }); - const topic = ticket.topic ? '- ' + decrypt(ticket.topic).replace(/\n/g, ' ').substring(0, 50) : ''; - const category = emoji.hasEmoji(ticket.category.emoji) ? emoji.get(ticket.category.emoji) + ' ' + ticket.category.name : ticket.category.name; - ticket._name = `${category} #${ticket.number} (${date}) ${topic}`; - return ticket; - }); + tickets = tickets + .filter(ticket => client.commands.commands.slash.get('transcript').shouldAllowAccess(interaction, ticket)) + .map(ticket => { + const date = new Date(ticket.createdAt).toLocaleString([locale, 'en-GB'], { dateStyle: 'short' }); + const topic = ticket.topic ? '- ' + decrypt(ticket.topic).replace(/\n/g, ' ').substring(0, 50) : ''; + const category = emoji.hasEmoji(ticket.category.emoji) ? emoji.get(ticket.category.emoji) + ' ' + ticket.category.name : ticket.category.name; + ticket._name = `${category} #${ticket.number} (${date}) ${topic}`; + return ticket; + }); this.cache.set(cacheKey, tickets, ms('1m')); } @@ -77,7 +73,7 @@ module.exports = class TicketCompleter extends Autocompleter { const userId = otherMember || interaction.user.id; await interaction.respond( await this.getOptions(value, { - guildId: interaction.guild.id, + interaction, open: ['add', 'close', 'force-close', 'remove'].includes(command.name), // false for `new`, `transcript` etc userId, }), diff --git a/src/commands/slash/tickets.js b/src/commands/slash/tickets.js index d6af912..625800f 100644 --- a/src/commands/slash/tickets.js +++ b/src/commands/slash/tickets.js @@ -1,5 +1,8 @@ const { SlashCommand } = require('@eartharoid/dbf'); -const { ApplicationCommandOptionType } = require('discord.js'); +const { + ApplicationCommandOptionType, + PermissionsBitField, +} = require('discord.js'); const { isStaff } = require('../../lib/users'); const ExtendedEmbedBuilder = require('../../lib/embed'); const Cryptr = require('cryptr'); @@ -60,12 +63,45 @@ module.exports = class TicketsSlashCommand extends SlashCommand { } const fields = []; + let base_filter; + + if (member.id === interaction.member.id) { + base_filter = { + createdById: member.id, + guildId: interaction.guild.id, + }; + } else { + const { categories } = await client.prisma.guild.findUnique({ + select: { + categories: { + select: { + id: true, + staffRoles: true, + }, + }, + }, + where: { id: interaction.guild.id }, + }); + const allow_category_ids = ( + ( + client.supers.includes(interaction.member.id) || + interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild) + ) + ? categories + : categories.filter(c => c.staffRoles.some(id => interaction.member.roles.cache.has(id))) + ) + .map(c => c.id); + base_filter = { + categoryId: { in: allow_category_ids }, + createdById: member.id, + guildId: interaction.guild.id, + }; + } const open = await client.prisma.ticket.findMany({ include: { category: true }, where: { - createdById: member.id, - guildId: interaction.guild.id, + ...base_filter, open: true, }, }); @@ -75,8 +111,7 @@ module.exports = class TicketsSlashCommand extends SlashCommand { orderBy: { createdAt: 'desc' }, take: 10, // max 10 rows where: { - createdById: member.id, - guildId: interaction.guild.id, + ...base_filter, open: false, }, }); @@ -84,8 +119,8 @@ module.exports = class TicketsSlashCommand extends SlashCommand { if (open.length >= 1) { fields.push({ name: getMessage('commands.slash.tickets.response.fields.open.name'), - value: open.map(ticket =>{ - const topic = ticket.topic ? `- \`${decrypt(ticket.topic).replace(/\n/g, ' ').slice(0, 30) }\`` : ''; + value: open.map(ticket => { + const topic = ticket.topic ? `- \`${decrypt(ticket.topic).replace(/\n/g, ' ').slice(0, 30)}\`` : ''; return `> <#${ticket.id}> ${topic}`; }).join('\n'), }); diff --git a/src/commands/slash/transcript.js b/src/commands/slash/transcript.js index d0b72e8..b17959d 100644 --- a/src/commands/slash/transcript.js +++ b/src/commands/slash/transcript.js @@ -1,12 +1,14 @@ const { SlashCommand } = require('@eartharoid/dbf'); -const { ApplicationCommandOptionType } = require('discord.js'); +const { + ApplicationCommandOptionType, + PermissionsBitField, +} = require('discord.js'); const fs = require('fs'); const { join } = require('path'); const Mustache = require('mustache'); const { AttachmentBuilder } = require('discord.js'); const Cryptr = require('cryptr'); const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY); -const { isStaff } = require('../../lib/users'); const ExtendedEmbedBuilder = require('../../lib/embed'); module.exports = class TranscriptSlashCommand extends SlashCommand { @@ -46,6 +48,15 @@ module.exports = class TranscriptSlashCommand extends SlashCommand { ); } + shouldAllowAccess(interaction, ticket) { + if (interaction.guild.id !== ticket.guildId) return false; + if (ticket.createdById === interaction.member.id) return true; + if (interaction.client.supers.includes(interaction.member.id)) return true; + if (interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) return true; + if (interaction.member.roles.cache.filter(role => ticket.category.staffRoles.includes(role.id)).size > 0) return true; + return false; + } + async fillTemplate(ticket) { /** @type {import("client")} */ const client = this.client; @@ -146,10 +157,7 @@ module.exports = class TranscriptSlashCommand extends SlashCommand { if (!ticket) throw new Error(`Ticket ${ticketId} does not exist`); - if ( - ticket.createdById !== interaction.member.id && - !(await isStaff(interaction.guild, interaction.member.id)) - ) { + if (!this.shouldAllowAccess(interaction, ticket)) { const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } }); const getMessage = client.i18n.getLocale(settings.locale); return await interaction.editReply({