dsec-discord-bot/SECURITY.md
Samridh Limbu 60d38eada5
SEC-19 + COL-BOT-01: harden Discord verification + /unlink (#8)
* SEC-19: harden Discord verification (identical failures, attempt limits, id uniqueness)

Collapse both verification failure paths into one shared verification_failed_embed()
constructor so "not found" and "name mismatch" are byte-identical and can no longer be
used as an oracle for whether a student id holds an Active membership.

Remove the stale in-memory student_cache entirely: it was consulted before the only
query carrying membership_status = "Active", had no TTL and was never evicted, making it
a stale-membership bypass in a long-lived container. fetch_student now runs on every
verification attempt before any role grant.

Add a per-Discord-user failed-attempt counter in AppState
(Mutex<HashMap<UserId, (u32, Instant)>>). After 5 failures in 15 minutes an attempt is
refused with the generic embed and no database round trip. Each failure is logged to the
logs channel with the Discord user id and a timestamp only -- never the submitted name
or student id.

Add an application-level student-id uniqueness check in add_dsec_discord_table: if a
different discord_id already holds the student id, refuse with the generic embed and log
the conflict to the logs channel with both discord ids (not the student id).

The UNIQUE constraint DDL and duplicate sweep on live Supabase are an owner step (see
SECURITY.md); the emailed-OTP possession proof is noted as a follow-up TODO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

* COL-BOT-01: add /unlink to undo hijacked verifications + manual runbook

Add a moderator-gated /unlink <user> slash command (required_permissions =
"MANAGE_ROLES", same gate as embed()). It deletes the dsec_discord_members row FIRST,
then removes the verified role -- that order avoids leaving a member un-roled but still
linked, the state that permanently breaks /member_info. The delete uses .returning(...)
so PostgREST returns the row body (the COR-03 empty-204 gotcha) and so we can tell
whether a link actually existed. It replies ephemerally with what it did and reports
clearly if the role-removal half fails so a human can finish it, then writes a log_embed
entry to the logs channel naming the acting moderator. Registered in main.rs.

Add SECURITY.md: the moderator runbook for undoing a link (via /unlink and by hand),
who holds the Supabase credentials, the fact that deleting the row does NOT revoke the
Discord role (why /unlink does both), and the owner-only SEC-19 UNIQUE-constraint step.

A member-facing /unverify is deliberately not added. The /member_info leave/trim/remove
policy call is an owner decision and is intentionally not implemented here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

* 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

* COL-BOT-01: fix /unlink auth, deferral and delete-failure audit (review)

Address adversarial-review findings on /unlink:

- Self-service-in-DM / cross-guild destructive (CRITICAL): the MANAGE_ROLES gate is not
  trusted for auth -- poise 0.6 treats a DM invoker as Permissions::all(), so it passed
  in a DM and let /unlink @self delete the row before any guild check. The command is now
  guild_only and, as the FIRST thing before any DB op, asserts ctx.guild_id() is the DSEC
  guild specifically (which also stops a moderator of another guild the bot is in from
  deleting DSEC rows). The gate is kept for command visibility only.

- Expired-interaction mutate-then-skip-audit (MED): defer_ephemeral is sent before any
  DB/HTTP work; the moderator reply is best-effort so a failed reply cannot skip the audit
  log, which now always runs.

- Delete failure skipped audit + could leak (MED): the row delete no longer uses `?`. A
  delete error is handled in-command with a sanitized ephemeral (no raw error) and a
  distinct failure audit line; the raw error is redacted (redact_digits) before it reaches
  stderr.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

* SEC-19: close cross-guild verify exploit + 4 remaining review defects

MERGE-BLOCKER — verify flow was cross-guild exploitable: /verify was globally
registered and the button/modal handler accepted ANY guild, so a foreign guild's
copy of the button could reach live Supabase (insert a link row over real PII, then
fail the role grant) and hand an outsider a valid/invalid oracle while blocking the
real student. Fix: /verify is now guild_only, and handle_verify asserts the
interaction's guild == the configured DSEC guild as its FIRST action, before any
query — anything else is bounced with no DB round trip.

#3 fail-closed: after a 23505 the owner re-query now grants ONLY when the resolved
owner is the caller. A different owner is a conflict; an unresolved owner (winning
row vanished, or a violation from another constraint) is a new RefusedUnresolved
outcome — an infrastructure refusal, audited and never a silent grant.

#4 no submitted id in any log, in any format: normalise_student_id now reduces the
id to digits only, so no punctuated form (123-456-789) can survive into a query URL;
and redact_digits now masks separator-joined digit tokens (>=7 digits) as one unit,
not just contiguous runs. Two layers.

#6 a refused link always counts: record_failure moved INTO link_and_grant, before the
fallible Discord edit, so a lost/failed reply can no longer make a refusal count zero.

#7a verify defer failure now ABORTS before any DB/role mutation, so an expired modal
can never mutate state with no acknowledged interaction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

* COL-BOT-01: /unlink edits its deferred reply + disambiguates delete failures

#7b — one coherent reply: /unlink now takes ApplicationContext, defers via
defer_response(true), and EDITs that deferred ephemeral (create/edit at the serenity
layer). Previously ctx.send after a defer posted a followup in this poise version,
leaving the "thinking…" placeholder dangling. Defer failure now aborts before any DB
op (mirrors the verify fix).

new #A — no false "nothing changed": a DELETE whose HTTP call errors may still have
committed server-side. On a delete error /unlink now reads the row back: row present
-> genuinely failed, nothing changed; row gone -> the delete took effect, proceed to
role removal; read-back also fails -> report status UNKNOWN and do NOT touch the role,
telling the moderator to verify by hand. It never asserts "no change" on a bare error.

new #B — runbook SQL corrected: the bot stores student ids as digits only (strips the
leading s and punctuation), so the find-link query now searches '123456789', not
's123456789', with a note on the stored format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

* SEC-19: serialize /unlink against verify + floor supabase logging config-independently

1. Cross-command race: /unlink deleted the row and removed the role without the
   per-user lock that verification holds across its attempt, so the two could
   interleave into role-with-no-row (or the inverse) and /unlink could audit a removal
   the race had undone. Fix: /unlink now acquires the SAME per-user async lock
   (user_attempt_lock / verify_locks), keyed on the TARGET user's id, held across the
   delete + role removal — so it serializes against that user's own verification.
   user_attempt_lock is now pub(crate); it is a tokio Mutex (safe across awaits) and
   the std map guard is still released before the await, so no deadlock and no std
   lock held across an await.

2. "No submitted id in logs" made config-independent: supabase-lib-rs logs the full
   SELECT URL (student_id=eq.<id>) via tracing::debug! on target `supabase` (its [lib]
   name), which bypasses our redact_digits if an operator sets RUST_LOG=debug. The
   EnvFilter default now appends `supabase=info` via add_directive, which replaces any
   same-target directive from RUST_LOG — so supabase debug lines never emit even under
   RUST_LOG=debug or RUST_LOG=supabase=debug, while our own modules keep their level.

Left to the owner (documented deploy gate, non-blocking): the live UNIQUE(student_id)
constraint + dup sweep on Supabase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 18:26:32 +10:00

3.8 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. The bot stores the id as digits only (it strips the leading s and any punctuation before saving), so search the bare digits — '123456789', not 's123456789':

select * from dsec_discord_members where student_id = '123456789';

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.