dsec-discord-bot/src/events/interaction_create.rs
Clupai8o0 dcf0866fef 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
2026-08-30 18:08:27 +10:00

778 lines
31 KiB
Rust

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::Mutex as AsyncMutex;
use crate::{
Data, Error,
commands::{
mods_only::log_embed,
verification::{StudentRow, VerificationModal},
},
redact_digits,
};
use ::serenity::{
all::{
ComponentInteraction, Context, CreateEmbed, CreateInteractionResponse,
CreateInteractionResponseMessage, EditInteractionResponse, GuildId, ModalInteraction,
RoleId, UserId, collector::ModalInteractionCollector,
},
model::guild::Member,
};
use poise::Modal as _;
use poise::serenity_prelude as serenity;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Debug)]
pub struct DiscordMemberRow {
pub student_id: String,
pub discord_id: String,
}
/// Wrap an embed into an ephemeral interaction response.
fn ephemeral_embed(embed: CreateEmbed) -> CreateInteractionResponse {
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.add_embed(embed)
.ephemeral(true),
)
}
/// The single verification-failure embed (SEC-19).
///
/// Every failure path — student id not on the roster, name mismatch, rate-limit
/// refusal, and a student id already claimed by a different Discord account —
/// renders this exact embed. Because there is one constructor the responses cannot
/// drift apart, so the flow can no longer be used as an oracle for whether a given
/// student id holds an Active DSEC membership.
fn verification_failed_embed() -> CreateEmbed {
CreateEmbed::new().title("Verification failed").description(
"We couldn't verify that name and student ID. Check both and try again — it can take up to a week after signing up for your membership to appear.",
)
}
/// After this many failed attempts inside `ATTEMPT_WINDOW`, a user is refused with
/// no database round trip. Kept deliberately generous — a real member fixing a typo
/// in a hyphenated name must not be locked out — and the counter only moves on a
/// genuine failure (SEC-19).
const MAX_FAILURES: u32 = 5;
const ATTEMPT_WINDOW: Duration = Duration::from_secs(15 * 60);
/// Per-Discord-user failed-verification counter: `(failures, window_start)`.
type AttemptMap = Mutex<HashMap<UserId, (u32, Instant)>>;
/// Whether `user_id` has already failed `MAX_FAILURES` times within the current
/// window. A window that has fully elapsed is treated as no failures.
///
/// Recovers a poisoned lock rather than panicking: a poisoned `verify_attempts`
/// mutex must not turn every future verification into a panic (the map holds only
/// counters, never invariant-critical state).
fn is_rate_limited(attempts: &AttemptMap, user_id: UserId) -> bool {
let attempts = attempts.lock().unwrap_or_else(|p| p.into_inner());
matches!(
attempts.get(&user_id),
Some((count, window_start))
if *count >= MAX_FAILURES && window_start.elapsed() < ATTEMPT_WINDOW
)
}
/// Record one failed verification attempt for `user_id`. Fully-elapsed windows are
/// evicted first, which both resets a returning user's window and bounds the map so
/// it cannot grow without limit.
fn record_failure(attempts: &AttemptMap, user_id: UserId) {
let mut attempts = attempts.lock().unwrap_or_else(|p| p.into_inner());
attempts.retain(|_, (_, window_start)| window_start.elapsed() < ATTEMPT_WINDOW);
match attempts.get_mut(&user_id) {
// Any surviving entry is within the window (retain kept it), so increment.
Some(entry) => entry.0 += 1,
None => {
attempts.insert(user_id, (1, Instant::now()));
}
}
}
/// Get (or create) the per-user attempt lock. The std mutex guarding the map is
/// released before the caller awaits the returned tokio lock, so no std guard is
/// ever held across an await. Entries no attempt is using any more (only the map
/// still references them) are pruned so the map cannot grow without bound.
fn user_attempt_lock(data: &Data, user_id: UserId) -> Arc<AsyncMutex<()>> {
let mut locks = data
.state
.verify_locks
.lock()
.unwrap_or_else(|p| p.into_inner());
locks.retain(|_, lock| Arc::strong_count(lock) > 1);
Arc::clone(
locks
.entry(user_id)
.or_insert_with(|| Arc::new(AsyncMutex::new(()))),
)
}
/// Edit the deferred ephemeral response for a modal submission (SEC-19 #7: we
/// `defer_ephemeral` the moment the modal arrives, so every later reply is an edit).
async fn edit_reply(
ctx: &Context,
modal_submit: &ModalInteraction,
embed: CreateEmbed,
) -> Result<(), Error> {
modal_submit
.edit_response(ctx, EditInteractionResponse::new().embed(embed))
.await?;
Ok(())
}
/// Whether a supabase error is a Postgres unique-constraint violation (SQLSTATE
/// 23505). Used only as a boolean signal — the error string embeds the student id
/// (`Key (student_id)=(…)`) and must never be logged (SEC-19 #4).
fn is_unique_violation(err: &supabase::Error) -> bool {
err.to_string().contains("23505")
}
/// Show the verification modal and wait for (and parse) the submission.
///
/// Returns `None` when the user let the modal time out or the submission
/// could not be parsed (an error response is sent in the latter case).
async fn collect_verification_modal(
ctx: &Context,
component_interaction: &ComponentInteraction,
) -> Result<Option<(ModalInteraction, VerificationModal)>, Error> {
let modal_custom_id = component_interaction.id.to_string();
let modal = VerificationModal::create(None, modal_custom_id.clone());
component_interaction.create_response(ctx, modal).await?;
let modal_submit = ModalInteractionCollector::new(&ctx.shard)
.filter(move |d| d.data.custom_id == modal_custom_id)
.timeout(Duration::from_secs(120))
.await;
let Some(modal_submit) = modal_submit else {
return Ok(None);
};
match VerificationModal::parse(modal_submit.data.clone()) {
Ok(data) => Ok(Some((modal_submit, data))),
Err(_) => {
modal_submit
.create_response(
ctx,
ephemeral_embed(
CreateEmbed::new()
.title("Error")
.description("Failed to parse modal data. Please try again."),
),
)
.await?;
Ok(None)
}
}
}
/// Outcome of trying to link a Discord account to a student id.
enum LinkOutcome {
/// The row now exists for this Discord id (freshly inserted, or already present
/// with exactly this student id — an idempotent re-verify).
Linked,
/// The submitted student id is already held by a *different* Discord account;
/// nothing was written. Carries that other Discord id for the conflict audit.
RefusedIdClaimed { existing_discord_id: String },
/// This caller's Discord account is already linked to a *different* student id, so
/// it may not claim another. The stale-row / hijack path (SEC-19 #2).
RefusedCallerLinked,
/// A `UNIQUE(student_id)` violation fired but the owner could not be resolved
/// afterwards (the winning row vanished, or the violation came from a different
/// constraint). This is an infrastructure refusal — never a success (SEC-19 #3).
RefusedUnresolved,
}
/// Add to dsec_discord_members table.
///
/// The ownership of the *submitted* id is always checked before anything is written
/// (SEC-19 #2): an idempotent re-verify is allowed only when this caller already owns
/// exactly that id. A caller already linked to a *different* id is refused, closing
/// the path where a stale COR-03 partial-insert row let an account claim someone
/// else's id. A concurrent-insert `UNIQUE` violation (23505) is caught and converted
/// to the same refusal (SEC-19 #3) — the constraint itself is an owner/deploy step.
async fn add_dsec_discord_table(
data: &Data,
student_id: &str,
member_id: &String,
) -> Result<LinkOutcome, Error> {
// 1. Who owns the SUBMITTED id right now? Always check before granting anything.
if let Some(existing_discord_id) = student_id_owner(data, student_id).await? {
if &existing_discord_id == member_id {
return Ok(LinkOutcome::Linked); // idempotent: caller already holds this id
}
return Ok(LinkOutcome::RefusedIdClaimed {
existing_discord_id,
});
}
// 2. Submitted id is unowned. If this caller already holds a DIFFERENT id, refuse:
// a linked account trying to claim a new student id is the hijack / stale-row path.
if member_recorded(data, member_id).await? {
return Ok(LinkOutcome::RefusedCallerLinked);
}
// 3. Insert. `.returning(...)` makes supabase-lib-rs send `Prefer:
// return=representation`; without it PostgREST answers a POST with an empty
// `return=minimal` body that fails to deserialise, aborting before the role
// grant even though the row was written (COR-03).
let new_member = serde_json::json!({
"student_id": student_id,
"discord_id": member_id,
});
let insert: supabase::Result<Vec<DiscordMemberRow>> = data
.state
.supabase
.database()
.insert("dsec_discord_members")
.values(new_member)?
.returning("student_id,discord_id")
.execute()
.await;
match insert {
Ok(_) => Ok(LinkOutcome::Linked),
// A UNIQUE(student_id) violation means another account inserted the same id
// between our check and our insert. Re-query the owner and refuse — never
// surface the raw 23505 body, which echoes the student id (SEC-19 #3, #4).
// This must FAIL CLOSED: only a re-query that proves the winning row is ours
// grants the role. A different owner is a conflict; an owner that cannot be
// resolved (row already gone, or a violation from a different constraint) is an
// infrastructure refusal — never a silent success (SEC-19 #3).
Err(err) if is_unique_violation(&err) => match student_id_owner(data, student_id).await? {
Some(owner) if &owner == member_id => Ok(LinkOutcome::Linked),
Some(owner) => Ok(LinkOutcome::RefusedIdClaimed {
existing_discord_id: owner,
}),
None => Ok(LinkOutcome::RefusedUnresolved),
},
Err(err) => Err(err.into()),
}
}
/// The Discord id currently linked to `student_id`, if any. Selects only `discord_id`
/// (minimum columns — SEC-19 #4).
async fn student_id_owner(data: &Data, student_id: &str) -> Result<Option<String>, Error> {
#[derive(Deserialize)]
struct OwnerRow {
discord_id: String,
}
let rows: Vec<OwnerRow> = data
.state
.supabase
.database()
.from("dsec_discord_members")
.select("discord_id")
.eq("student_id", student_id)
.execute()
.await?;
Ok(rows.into_iter().next().map(|row| row.discord_id))
}
/// A name-matched attempt: try to link the account and grant the role.
///
/// On any refusal (ownership conflict, caller already linked to a different id, or an
/// unresolved unique-violation) this records exactly one failed attempt **before** the
/// fallible Discord reply, so a refusal always counts even if the edit is lost (SEC-19
/// #6); a successful grant counts none. The user sees the generic embed on every
/// refusal, and all replies edit the deferred ephemeral (SEC-19 #7).
async fn link_and_grant(
ctx: &Context,
data: &Data,
modal_submit: &ModalInteraction,
discord_member: &Member,
student_id: &str,
verified_role_id: RoleId,
) -> Result<(), Error> {
let user_id = discord_member.user.id;
let member_id = user_id.to_string();
match add_dsec_discord_table(data, student_id, &member_id).await? {
LinkOutcome::Linked => {
discord_member.add_role(ctx, verified_role_id).await?;
let embed = CreateEmbed::new().title("Verified ✅").description(format!(
"You have been assigned the <@&{}> role!",
verified_role_id
));
edit_reply(ctx, modal_submit, embed).await?;
}
LinkOutcome::RefusedIdClaimed {
existing_discord_id,
} => {
record_failure(&data.state.verify_attempts, user_id);
log_link_conflict(ctx, data, &member_id, &existing_discord_id).await;
edit_reply(ctx, modal_submit, verification_failed_embed()).await?;
}
LinkOutcome::RefusedCallerLinked => {
record_failure(&data.state.verify_attempts, user_id);
log_caller_already_linked(ctx, data, &member_id).await;
edit_reply(ctx, modal_submit, verification_failed_embed()).await?;
}
LinkOutcome::RefusedUnresolved => {
record_failure(&data.state.verify_attempts, user_id);
log_unresolved_conflict(ctx, data, &member_id).await;
edit_reply(ctx, modal_submit, verification_failed_embed()).await?;
}
}
Ok(())
}
/// Lower-case, trim, and collapse runs of internal whitespace to one space.
/// Used for every name comparison so a stray space or a double space in the
/// DUSA roster never rejects a real member.
fn normalise_name(raw: &str) -> String {
raw.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
/// Reduce a submitted student id to digits only, so "s123456789", "S123-456-789" and
/// "123 456 789" all look up as "123456789". Keeping *only* digits (rather than just
/// stripping spaces and a leading "s") is deliberate: the normalised value is what
/// goes into the PostgREST query, so this guarantees no separator-punctuated form of
/// a submitted id can survive into an error URL and thence a log line (SEC-19 #4).
fn normalise_student_id(raw: &str) -> String {
raw.chars().filter(|c| c.is_ascii_digit()).collect()
}
/// Whether the submitted name matches the roster name closely enough to be the
/// same person.
///
/// The first and last name tokens must BOTH match, and any tokens the student
/// typed in between must appear in the roster name in order — so an omitted
/// middle name is fine, but a single token, an arbitrary subset, a reordered
/// name, or a wrong surname is not. This is deliberately strict: verification is
/// already weak identity evidence (a name plus a student id), and a looser rule
/// would let a student id plus one common name token ("John", "Doe") claim the
/// verified role for someone else.
fn name_matches(roster: &str, submitted: &str) -> bool {
let roster = normalise_name(roster);
let submitted = normalise_name(submitted);
let roster_words: Vec<&str> = roster.split_whitespace().collect();
let submitted_words: Vec<&str> = submitted.split_whitespace().collect();
// A single token (or empty) is far too weak to identify a person, and a roster
// row without a distinct first and last name cannot be matched safely.
if submitted_words.len() < 2 || roster_words.len() < 2 {
return false;
}
// The first and last name must both match.
if submitted_words.first() != roster_words.first()
|| submitted_words.last() != roster_words.last()
{
return false;
}
// Every token the student typed must appear in the roster name in order.
let mut idx = 0usize;
for &word in &submitted_words {
match roster_words[idx..].iter().position(|&w| w == word) {
Some(offset) => idx += offset + 1,
None => return false,
}
}
true
}
/// Look up a student by id in the database.
async fn fetch_student(data: &Data, student_id: &str) -> Result<Option<StudentRow>, Error> {
let student_data: Vec<StudentRow> = data
.state
.supabase
.database()
.from("active_members")
.select("full_name, student_id")
.eq("student_id", student_id)
.eq("membership_status", "Active")
.execute()
.await?;
Ok(student_data.into_iter().next())
}
async fn member_recorded(data: &Data, user_id: &str) -> Result<bool, Error> {
let rows: Vec<serde_json::Value> = data
.state
.supabase
.database()
.from("dsec_discord_members")
.select("discord_id")
.eq("discord_id", user_id)
.execute()
.await?;
Ok(!rows.is_empty())
}
/// Post an audit line to the logs channel: a fixed title, the Discord user id and a
/// timestamp only — NEVER the submitted name or student id. The logs channel is read
/// by humans and persists forever, so nothing a member typed into the modal may go
/// here (SEC-19). `title` and `body` are always fixed literals.
async fn log_verify_audit(ctx: &Context, data: &Data, title: &str, body: String) {
let _ = log_embed(
ctx,
data.state.logs_channel_id,
Some(title.to_string()),
None,
Some(body),
None,
None,
None,
None,
Some(true),
)
.await;
}
/// Log a failed verification attempt. The description is one fixed generic string:
/// "student id not found" and "name mismatch" must be indistinguishable in the log
/// too, so it cannot become a mod-visible membership oracle (SEC-19 #8).
async fn log_verification_failure(ctx: &Context, data: &Data, user_id: UserId) {
log_verify_audit(
ctx,
data,
"Verification failed",
format!("User <@{user_id}> (id `{user_id}`) — a verification attempt failed."),
)
.await;
}
/// Log that a user was refused because they are already at the attempt limit.
async fn log_rate_limited(ctx: &Context, data: &Data, user_id: UserId) {
log_verify_audit(
ctx,
data,
"Verification rate-limited",
format!("User <@{user_id}> (id `{user_id}`) — too many attempts; refused without a database query."),
)
.await;
}
/// Log that a caller already linked to a *different* student id tried to claim a new
/// one. Carries only the caller's Discord id — never a student id (SEC-19 #2).
async fn log_caller_already_linked(ctx: &Context, data: &Data, member_id: &str) {
log_verify_audit(
ctx,
data,
"Verification refused: account already linked",
format!(
"<@{member_id}> (id `{member_id}`) is already linked to a different student id and tried to claim another; refused."
),
)
.await;
}
/// Log a unique-violation whose owner could not be resolved: the insert hit a
/// `UNIQUE` constraint but a follow-up owner lookup found no row, so the attempt was
/// refused rather than granted. Worth a mod's eye — it can indicate a race or a
/// constraint firing for a reason we did not expect (SEC-19 #3).
async fn log_unresolved_conflict(ctx: &Context, data: &Data, member_id: &str) {
log_verify_audit(
ctx,
data,
"Verification refused: unresolved unique violation",
format!(
"<@{member_id}> (id `{member_id}`) hit a unique-constraint violation whose owner could not be resolved; refused (not granted). Investigate if this recurs."
),
)
.await;
}
/// Log a student-id link conflict to the logs channel with BOTH Discord ids and no
/// student id (SEC-19): someone tried to verify with a student id already linked to
/// a different Discord account.
async fn log_link_conflict(
ctx: &Context,
data: &Data,
attempting_discord_id: &str,
existing_discord_id: &str,
) {
log_verify_audit(
ctx,
data,
"Verification refused: student id already linked",
format!(
"<@{attempting_discord_id}> (id `{attempting_discord_id}`) tried to verify with a student id already linked to <@{existing_discord_id}> (id `{existing_discord_id}`)."
),
)
.await;
}
// TODO(SEC-19 follow-up): name + student id is not proof of ownership — both are
// semi-public, so anyone who knows a classmate's name and id can verify as them.
// The real fix is a possession proof: email a one-time code to the address on the
// roster and require it back. dsec-app already owns OTP machinery, so the cheap
// version is this bot calling dsec-api rather than growing its own email sender.
// That is feature-sized work, tracked separately, not a patch to this handler.
//
// Owner-only DB step (NOT done here, needs a maintainer on live Supabase): sweep
// for duplicates, then add `UNIQUE` on dsec_discord_members.student_id. See
// SECURITY.md. The uniqueness check below is the application-level safety net until
// that constraint exists.
//
/// Handle a click on the "verify" button: collect the modal, then verify the
/// submitted student id/name against the database on every attempt.
async fn handle_verify(
ctx: &Context,
component_interaction: &ComponentInteraction,
data: &Data,
) -> Result<(), Error> {
// AuthZ (SEC-19 merge-blocker): this handler queries live Supabase, inserts a link
// row over real PII, and grants the DSEC role — so it must run in the configured
// DSEC guild specifically, not merely "some guild". The bot may be in other guilds;
// a foreign `/verify` button must never reach the database. Bounce anything else
// before any query. (The button carries no state, so this is the only gate.)
let guild_id = data.state.guild_id;
if component_interaction.guild_id != Some(guild_id) {
component_interaction
.create_response(
ctx,
ephemeral_embed(
CreateEmbed::new()
.title("Unable to perform action")
.description("Verification can only be performed in the DSEC server."),
),
)
.await?;
return Ok(());
}
let verified_role_id = data.state.verified_role_id;
// Fast, no-network "already verified" check using the member data that is
// already attached to the button interaction. Anything slower than this
// (a DB query, a member fetch) must NOT run before the modal is shown, or
// Discord's ~3s acknowledgement window elapses and the click fails.
if let Some(member) = &component_interaction.member
&& member.roles.contains(&verified_role_id)
{
component_interaction
.create_response(
ctx,
ephemeral_embed(CreateEmbed::new().title("Already Verified ✅").description(
format!("You already have the <@&{}> role!", verified_role_id),
)),
)
.await?;
return Ok(());
}
// Respond to the click with the modal immediately.
let Some((modal_submit, modal_data)) =
collect_verification_modal(ctx, component_interaction).await?
else {
return Ok(());
};
let user_id = component_interaction.user.id;
// Acknowledge the modal submission within Discord's ~3s window BEFORE any database
// or logs-channel work, so a slow query can never leave a dead "This interaction
// failed" and a mutation can never happen with no ack (SEC-19 #7). Every later
// reply edits this deferred ephemeral response. If the defer itself fails the
// interaction is already dead — ABORT before any query so we never mutate state
// (insert a link row, grant a role) against an un-acknowledged interaction.
if let Err(err) = modal_submit.defer_ephemeral(ctx).await {
eprintln!(
"[verify] defer failed for interaction {}: {}",
modal_submit.id,
redact_digits(&err.to_string())
);
return Ok(());
}
// Serialize all verification work for THIS user, held across the whole attempt, so
// concurrent modal submits cannot each slip under the attempt limit or the
// uniqueness checks (SEC-19 #5). Acquired AFTER defer so waiting on it never eats
// the ack window; different users never contend.
let attempt_lock = user_attempt_lock(data, user_id);
let _attempt_guard = attempt_lock.lock().await;
// Any failure inside still edits the deferred response rather than leaving the user
// stuck — poise's on_error cannot reach this modal submission (COR-03).
let verify_result: Result<(), Error> = async {
// Cap failed attempts before ANY database work (SEC-19 #3): a user already over
// the limit gets the generic embed and no query runs.
if is_rate_limited(&data.state.verify_attempts, user_id) {
log_rate_limited(ctx, data, user_id).await;
edit_reply(ctx, &modal_submit, verification_failed_embed()).await?;
return Ok(());
}
let student_id = normalise_student_id(&modal_data.student_id);
// `fetch_student` is the only query carrying `membership_status = "Active"`,
// and it runs on every attempt before any role grant (SEC-19): no cache
// shortcut can admit a member whose membership has since lapsed.
let Some(student) = fetch_student(data, &student_id).await? else {
record_failure(&data.state.verify_attempts, user_id);
log_verification_failure(ctx, data, user_id).await;
edit_reply(ctx, &modal_submit, verification_failed_embed()).await?;
return Ok(());
};
if name_matches(&student.full_name, &modal_data.name) {
// Fetch the guild member only once we know we may grant the role.
// `link_and_grant` records its own failure (before its fallible reply) on a
// refused link, so a refusal always counts even if the edit is lost (#6).
let discord_member = GuildId::member(guild_id, ctx, user_id).await?;
link_and_grant(
ctx,
data,
&modal_submit,
&discord_member,
&student_id,
verified_role_id,
)
.await?;
} else {
record_failure(&data.state.verify_attempts, user_id);
log_verification_failure(ctx, data, user_id).await;
edit_reply(ctx, &modal_submit, verification_failed_embed()).await?;
}
Ok(())
}
.await;
if let Err(err) = verify_result {
// Never print the raw error on the verify path: supabase/reqwest errors embed
// the PostgREST URL (…student_id=eq.<id>) and a 23505 body echoes the id, both
// PII. Redact digit runs; the interaction id is the correlation ref (SEC-19 #4).
eprintln!(
"[verify] interaction {} failed: {}",
modal_submit.id,
redact_digits(&err.to_string())
);
let _ = edit_reply(
ctx,
&modal_submit,
CreateEmbed::new()
.title("Something went wrong")
.description(format!(
"A maintainer has been notified. Please try again in a minute. (ref: {})",
modal_submit.id
)),
)
.await;
}
Ok(())
}
pub async fn on_interaction_create(
ctx: &Context,
interaction: &serenity::Interaction,
data: &Data,
) -> Result<(), Error> {
let Some(component_interaction) = interaction.as_message_component() else {
return Ok(());
};
if component_interaction.data.custom_id == "verify" {
handle_verify(ctx, component_interaction, data).await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trims_and_collapses_names() {
assert!(name_matches("John Doe", " john doe "));
assert!(name_matches("John Doe", "JOHN DOE"));
}
#[test]
fn verifies_full_name_and_omitted_middle() {
assert!(name_matches("John Michael Doe", "John Michael Doe"));
assert!(name_matches("John Michael Doe", "John Doe"));
assert!(name_matches("John Michael Doe", "john michael doe"));
}
#[test]
fn rejects_a_single_token() {
// A student id plus one common name token must never verify.
assert!(!name_matches("John Michael Doe", "John"));
assert!(!name_matches("John Michael Doe", "Doe"));
assert!(!name_matches("John Michael Doe", "Michael"));
assert!(!name_matches("John Doe", "John"));
}
#[test]
fn rejects_wrong_surname_or_first_name() {
assert!(!name_matches("John Michael Doe", "John Smith"));
assert!(!name_matches("John Michael Doe", "Jane Doe"));
assert!(!name_matches("John Doe", "Jack Doe"));
}
#[test]
fn rejects_a_different_person() {
assert!(!name_matches("John Michael Doe", "Jane Doe"));
assert!(!name_matches("John Doe", "Doe John"));
assert!(!name_matches("John Doe", ""));
}
#[test]
fn normalises_student_ids() {
assert_eq!(normalise_student_id("s123456789 "), "123456789");
assert_eq!(normalise_student_id("S123456789"), "123456789");
assert_eq!(normalise_student_id(" 123 456 789 "), "123456789");
// Punctuated forms are reduced to digits only, so no separator survives into a
// query URL / log line (SEC-19 #4).
assert_eq!(normalise_student_id("s123-456-789"), "123456789");
assert_eq!(normalise_student_id("123.456.789"), "123456789");
}
#[test]
fn rate_limits_after_max_failures() {
let attempts: AttemptMap = Mutex::new(HashMap::new());
let user = UserId::new(1);
// A fresh user is never limited.
assert!(!is_rate_limited(&attempts, user));
// The first MAX_FAILURES attempts are allowed through (they still hit the DB).
for _ in 0..MAX_FAILURES {
assert!(!is_rate_limited(&attempts, user));
record_failure(&attempts, user);
}
// The next attempt (the 6th, with MAX_FAILURES == 5) is refused with no query.
assert!(is_rate_limited(&attempts, user));
// A different user is unaffected.
assert!(!is_rate_limited(&attempts, UserId::new(2)));
}
#[test]
fn redacts_ids_but_keeps_short_numbers() {
// Student id (9 digits) and Discord snowflake (19) are masked.
assert_eq!(
redact_digits("student_id=eq.123456789 for <@1234567890123456789>"),
"student_id=eq.<redacted> for <@<redacted>>"
);
// A 23505 key detail is masked.
assert_eq!(
redact_digits("Key (student_id)=(220123456) already exists"),
"Key (student_id)=(<redacted>) already exists"
);
// Punctuated ids are masked as one unit, not left half-visible (SEC-19 #4).
assert_eq!(
redact_digits("student_id=eq.123-456-789&x=1"),
"student_id=eq.<redacted>&x=1"
);
assert_eq!(redact_digits("id 123.456.789 seen"), "id <redacted> seen");
// Short runs (< 7 digits, e.g. the SQLSTATE code or v1.2.3) are preserved.
assert_eq!(redact_digits("code 23505"), "code 23505");
assert_eq!(redact_digits("version v1.2.3"), "version v1.2.3");
assert_eq!(redact_digits("no digits here"), "no digits here");
}
}