dsec-discord-bot/SECURITY.md
Clupai8o0 2c8c1818ec SEC-19: fix 7 review defects in the verification hardening
Address adversarial-review findings on the verify path:

- Already-linked caller bypass (HIGH): add_dsec_discord_table no longer early-returns
  Linked on any existing row. It now checks the SUBMITTED id's owner first (idempotent
  only when that owner is the caller) and refuses a caller already linked to a DIFFERENT
  student id -- closing the path where a stale COR-03 partial-insert row let an account
  claim someone else's id.

- Check-then-insert race (HIGH): the insert now catches a UNIQUE(student_id) violation
  (SQLSTATE 23505), re-queries the owner and converts it to the generic refusal + audit.
  SECURITY.md now marks the live UNIQUE constraint (after the dup sweep) as a merge/deploy
  gate, not optional.

- PII leak in error prints (HIGH): raw supabase/reqwest errors (which embed
  student_id=eq.<id> and a 23505 Key detail) are never printed on the verify path. New
  redact_digits() masks 7+ digit runs; the interaction id is used as an opaque
  correlation ref. student_id_owner now selects only discord_id.

- Concurrent-attempt rate-limit bypass (HIGH): a per-UserId tokio Mutex (AppState.
  verify_locks) serializes a whole verification attempt so concurrent modal submits
  cannot each slip under the 5-in-15min limit. Requires tokio "sync" feature.

- Ownership conflict never counted (MED): a refused link now records exactly one failed
  attempt (success/infra-error record none), so a stolen-but-claimed credential can no
  longer loop the query set forever.

- Expired-interaction mutate-then-skip (MED): defer_ephemeral is now sent the instant the
  modal arrives, before any DB/logs work; every later reply edits the deferred response.

- Log oracle (MED): the failed-attempt audit line is one fixed generic string, so
  "not found" vs "name mismatch" are indistinguishable in the logs channel too.

Also: on_error no longer forwards a handled FrameworkError::Command to poise's builtin
(whose Command arm does a non-ephemeral ctx.say(raw_error), leaking DB error text); mutex
locks recover from poisoning instead of panicking; and the attempt/lock maps evict
stale/idle entries so they cannot grow unbounded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
2026-08-30 17:42:54 +10:00

3.6 KiB

Security runbook

Operational notes for moderators and maintainers. This file is for the people who run the bot, not for contributors setting up a dev environment (that is README.md).

The bot links a Discord account to a student id in the dsec_discord_members table when someone verifies. Because verification only checks a name + student id pair — both semi-public — a person can verify as someone else. When that happens, undo it.

Run /unlink @member in the server. It requires the Manage Roles permission and:

  1. deletes the member's dsec_discord_members row, then
  2. removes the verified role from them,
  3. replies to you privately (ephemeral) with what it did, and
  4. writes a log entry to the logs channel naming you as the moderator who ran it.

It does the delete first and the role removal second on purpose: the reverse order can leave someone un-roled but still linked, which permanently breaks /member_info for them. If the role removal half fails, the reply and the log both say so — finish it by hand (next section) and the row is already gone.

You need the Supabase project credentials for this. Who holds them: the club committee — ask in the committee Discord. (Historically the bot maintainer; TODO: name the current holder here.) Do not paste real credentials into a chat or a ticket.

Find the link row(s) for a student id:

select * from dsec_discord_members where student_id = 's123456789';

Delete a specific link by hand:

delete from dsec_discord_members where discord_id = '<discord user id>';

Deleting the row does NOT revoke the Discord role. Removing the row and stripping the verified role are two separate actions — that is exactly why /unlink does both. After a manual delete, also remove the verified role from the member in Discord (Server Settings → Members, or right-click the member → Roles), or they keep their access with no link.

Owner-only database step (SEC-19) — MERGE / DEPLOY GATE

This is a deploy gate, not optional. dsec_discord_members.student_id MUST have a UNIQUE constraint so one student id cannot be claimed by two Discord accounts. Run the duplicate sweep and add the constraint (below) as part of shipping this change.

The application also refuses the second claimant and catches a concurrent-insert unique violation (SQLSTATE 23505), converting it to a generic refusal — but the check-then- insert has an inherent race, so the database constraint is what actually guarantees uniqueness. Until the constraint exists, two accounts verifying the same unused id at the exact same moment can both succeed. The constraint is not applied by any migration in this repo — it touches live data and must be run by a maintainer.

Sweep for existing duplicates first; the DDL fails if any exist:

select student_id, count(*) from dsec_discord_members
group by student_id having count(*) > 1;

Resolve any duplicates by hand (decide which Discord account keeps each link), then:

alter table dsec_discord_members add constraint dsec_discord_members_student_id_key unique (student_id);

There is no staging Supabase project — do the sweep and the ALTER with a second maintainer watching. Expect it to start rejecting inserts that used to succeed.

The real fix for the weak identity check is a possession proof — email a one-time code to the roster address (dsec-app already owns OTP machinery; the bot would call dsec-api). That is feature-sized work tracked under SEC-19, not covered here.