From e8e114e4ada41262243c2734374aa9a6b0cc911b Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Tue, 19 May 2026 19:55:01 +0000 Subject: [PATCH] security: gate ticket buttons + tag-delete confirm on staff role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleButton routed claim_ticket, close_ticket, confirm_close / confirm_close_with_email / confirm_close_no_email, cancel_close, escalate_ticket, escalate_to_tier2, escalate_to_tier3, deescalate_ticket, and confirm_delete_tag::* straight to their handlers without any staff check. Any non-staff member with View Channel on a ticket — the ticket creator themselves, or anyone /add'd to it — could click those buttons and mutate ticket state (claim, escalate, close, delete saved-response tags). The slash-command dispatcher in handlers/commands/index.js already calls requireStaffRole before invoking any handler; the button dispatcher needed the same gate. Now: - confirm_delete_tag:: → requireStaffRole, then proceed. - TICKET_BUTTON_HANDLERS dispatch → requireStaffRole, then proceed. - FREE_BUTTON_HANDLERS (open_ticket* panel buttons, cancel_delete_tag) remain ungated — those are public-facing by design. requireStaffRole replies ephemerally ("This command is only available to the support team (<@&role>)") and returns true when the caller should bail, matching the slash-command behavior. --- handlers/buttons.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/handlers/buttons.js b/handlers/buttons.js index 8979e80..a597cee 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -34,6 +34,7 @@ const { addMemberToStaffThread, createStaffThread } = require('../services/staff const { pinMessage } = require('../services/pinMessage'); const { logError, logTicketEvent } = require('../services/debugLog'); const { findTicketForChannel, runDeferred } = require('./sharedHelpers'); +const { requireStaffRole } = require('./commands/helpers'); const Ticket = mongoose.model('Ticket'); const Transcript = mongoose.model('Transcript'); @@ -732,16 +733,28 @@ async function handleButton(interaction) { const { customId } = interaction; // Tag-delete confirm has a dynamic id (`confirm_delete_tag::`). + // Mutates the Tag collection — staff only. if (customId.startsWith('confirm_delete_tag::')) { + if (await requireStaffRole(interaction)) return; return handleTagDeleteConfirm(interaction); } + // FREE_BUTTON_HANDLERS are public-facing: open_ticket* (panel buttons, + // anyone can open a ticket) and cancel_delete_tag (no-op cancel). const freeHandler = FREE_BUTTON_HANDLERS[customId]; if (freeHandler) return freeHandler(interaction); const ticketHandler = TICKET_BUTTON_HANDLERS[customId]; if (!ticketHandler) return; + // Every TICKET_BUTTON_HANDLERS entry mutates ticket state + // (claim/close/confirm_close*/cancel_close/escalate*/deescalate). The slash + // command dispatcher in handlers/commands/index.js gates these via + // requireStaffRole; the button dispatcher must do the same — non-staff + // members with view access to the ticket channel (creator, /add'd users) + // could otherwise click Claim, Escalate, Close, etc. + if (await requireStaffRole(interaction)) return; + const ticket = await findTicketForChannel( interaction, 'This channel is not linked to a ticket, or the ticket could not be found.'