fix(security): cross-event authz, SSE ticket flow, account hardening, audit logs

Follow-up to the comprehensive code review. Five batches:

1. Cross-event authorization: host_delete_upload, unban_user, and
   host_delete_comment now scope by auth.event_id. Adds
   Upload::find_by_id_and_event / soft_delete_in_event and a
   Comment::soft_delete_in_event variant that joins through upload.

2. Token exposure: SSE auth no longer puts the JWT in the URL.
   New /api/v1/stream/ticket endpoint mints a short-lived single-use
   ticket bound to the session; the EventSource passes ?ticket=...
   instead. Refuse to start in APP_ENV=production with the dev JWT
   sentinel; warn loudly otherwise.

3. Account hardening: per-IP+name rate limit on /recover (mitigates
   targeted lockout DoS), per-IP rate limit on /admin/login, random
   32-char admin recovery PIN (replaces "0000"), structured tracing
   events for wrong PIN, lockout, failed admin login, ban/unban/role
   change/pin-reset/host-delete.

4. DoS / correctness: comment listing paginated (LIMIT 50 + ?before=
   cursor), hashtag extraction whitelisted to ASCII alnum+underscore
   (≤40 chars) with unit tests, display_name / caption / comment body
   length validated in chars rather than bytes.

5. Cleanup: session-touch failures now logged, DATABASE_MAX_CONNECTIONS
   env var (default 10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-17 21:00:51 +02:00
parent 2340f21637
commit d228676a56
17 changed files with 507 additions and 79 deletions

View File

@@ -40,15 +40,35 @@ impl Comment {
.await
}
pub async fn list_for_upload(pool: &PgPool, upload_id: Uuid) -> Result<Vec<CommentDto>, sqlx::Error> {
/// Paginated comment listing — returns up to `limit` rows in chronological
/// order (oldest first). If `before` is set, only comments older than that
/// timestamp are returned, enabling backward cursor pagination ("load
/// earlier"). Without the LIMIT a hot post with thousands of comments could
/// OOM the server on a single GET.
pub async fn list_for_upload(
pool: &PgPool,
upload_id: Uuid,
before: Option<DateTime<Utc>>,
limit: i64,
) -> Result<Vec<CommentDto>, sqlx::Error> {
// Two-step: pick the newest `limit` rows older than `before`, then flip
// them back into ascending order so the caller can render top-to-bottom.
sqlx::query_as::<_, CommentDto>(
"SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name, c.body, c.created_at
FROM comment c
JOIN \"user\" u ON u.id = c.user_id
WHERE c.upload_id = $1 AND c.deleted_at IS NULL
ORDER BY c.created_at ASC",
"SELECT * FROM (
SELECT c.id, c.upload_id, c.user_id, u.display_name AS uploader_name,
c.body, c.created_at
FROM comment c
JOIN \"user\" u ON u.id = c.user_id
WHERE c.upload_id = $1 AND c.deleted_at IS NULL
AND ($2::timestamptz IS NULL OR c.created_at < $2)
ORDER BY c.created_at DESC
LIMIT $3
) page
ORDER BY created_at ASC",
)
.bind(upload_id)
.bind(before)
.bind(limit)
.fetch_all(pool)
.await
}
@@ -69,4 +89,25 @@ impl Comment {
.await?;
Ok(())
}
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if the
/// comment doesn't exist or belongs to a different event.
pub async fn soft_delete_in_event(
pool: &PgPool,
id: Uuid,
event_id: Uuid,
) -> Result<bool, sqlx::Error> {
let result = sqlx::query(
"UPDATE comment
SET deleted_at = NOW()
WHERE id = $1
AND deleted_at IS NULL
AND upload_id IN (SELECT id FROM upload WHERE event_id = $2)",
)
.bind(id)
.bind(event_id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
}

View File

@@ -67,11 +67,46 @@ impl Hashtag {
}
}
/// Extract #hashtags from text (caption or body).
/// Extract `#hashtags` from text (caption or body). Tags are restricted to
/// ASCII letters, digits, and underscore — emoji / punctuation / accented
/// characters are rejected. This is deliberately strict: hashtags are an index
/// (used for filtering and SQL JOINs), and tags like `#🎉` or `#!?` accumulate
/// noise without helping anyone find content.
pub fn extract_hashtags(text: &str) -> Vec<String> {
const MAX_TAG_LEN: usize = 40;
text.split_whitespace()
.filter(|w| w.starts_with('#') && w.len() > 1)
.map(|w| w.trim_start_matches('#').to_lowercase())
.filter(|t| !t.is_empty())
.filter_map(|w| w.strip_prefix('#'))
.map(|t| {
t.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect::<String>()
.to_lowercase()
})
.filter(|t| !t.is_empty() && t.chars().count() <= MAX_TAG_LEN)
.collect()
}
#[cfg(test)]
mod tests {
use super::extract_hashtags;
#[test]
fn ascii_word_chars_extracted() {
assert_eq!(
extract_hashtags("hello #wedding #Day_2 #cake!"),
vec!["wedding", "day_2", "cake"]
);
}
#[test]
fn emojis_and_punctuation_excluded() {
// The 🎉 tag drops out entirely (no ASCII chars after #), the next tag
// stops at the !? and yields only "fun".
assert_eq!(extract_hashtags("#🎉 #!? #fun!"), vec!["fun"]);
}
#[test]
fn empty_or_bare_hash_skipped() {
assert_eq!(extract_hashtags("# #"), Vec::<String>::new());
}
}

View File

@@ -69,6 +69,23 @@ impl Upload {
.await
}
/// Event-scoped lookup used by host endpoints so a host of event A cannot
/// reach uploads belonging to event B.
pub async fn find_by_id_and_event(
pool: &PgPool,
id: Uuid,
event_id: Uuid,
) -> Result<Option<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>(
"SELECT * FROM upload
WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL",
)
.bind(id)
.bind(event_id)
.fetch_optional(pool)
.await
}
pub async fn set_preview_path(
pool: &PgPool,
id: Uuid,
@@ -128,6 +145,43 @@ impl Upload {
Ok(())
}
/// Event-scoped variant of [`Self::soft_delete`]. Returns `false` if no row
/// matched (already deleted, wrong event, or unknown id) so host handlers
/// can return a clean 404 instead of silently no-op'ing.
pub async fn soft_delete_in_event(
pool: &PgPool,
id: Uuid,
event_id: Uuid,
) -> Result<bool, sqlx::Error> {
let mut tx = pool.begin().await?;
let row: Option<(Uuid, i64)> = sqlx::query_as(
"UPDATE upload
SET deleted_at = NOW()
WHERE id = $1 AND event_id = $2 AND deleted_at IS NULL
RETURNING user_id, original_size_bytes",
)
.bind(id)
.bind(event_id)
.fetch_optional(&mut *tx)
.await?;
let deleted = if let Some((user_id, bytes)) = row {
sqlx::query(
"UPDATE \"user\"
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
WHERE id = $1",
)
.bind(user_id)
.bind(bytes)
.execute(&mut *tx)
.await?;
true
} else {
false
};
tx.commit().await?;
Ok(deleted)
}
pub async fn update_caption(
pool: &PgPool,
id: Uuid,