security: gate ticket buttons + tag-delete confirm on staff role

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::<name>  → 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.
This commit is contained in:
2026-05-19 19:55:01 +00:00
parent 452f005aea
commit e8e114e4ad

View File

@@ -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::<name>`).
// 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.'