main independently shipped 021_hashtag_counts_respect_bans and 022_client_upload_idempotency. sqlx::migrate! embeds ./migrations and refuses two files per version, so these three had to move before the branch could merge at all. Contents are untouched — only the version prefixes change. Renumbering (rather than renumbering main's) is the safe direction: main is already deployed, so its 021 and 022 are applied in production and their versions are now immutable.
49 lines
2.8 KiB
SQL
49 lines
2.8 KiB
SQL
-- Two independent auth defects that share a migration because they share a table.
|
|
|
|
-- 1. RESERVED NAMES — free any guest squatting on a name the admin path used to depend on.
|
|
--
|
|
-- Migration 007 made display_name unique per event case-insensitively, and `join` had no
|
|
-- reserved-name guard. So any guest could join as "admin"/"Admin"/"ADMIN" before the operator's
|
|
-- first admin login; admin_login then looked its user up BY NAME, missed (wrong role), fell
|
|
-- through to creating "Admin", violated that unique index, and returned a 500 — permanently,
|
|
-- with no in-app recovery. Moderation, config and gallery release all gone, fixed only by SQL.
|
|
--
|
|
-- The real fix is in code (look the admin up by role, never by name — see auth/handlers.rs).
|
|
-- This clears the state an already-deployed database may be carrying.
|
|
--
|
|
-- RENAMED, NEVER DELETED: the guest keeps their uploads, their PIN and their session. Only
|
|
-- non-admin rows are touched — a real admin row named "Admin" is the expected state.
|
|
UPDATE "user" u
|
|
SET display_name = u.display_name || ' (' || left(u.id::text, 8) || ')'
|
|
WHERE u.role <> 'admin'
|
|
AND lower(u.display_name) IN ('admin', 'administrator', 'host', 'eventsnap');
|
|
|
|
-- 2. PIN LOCKOUT DECAY.
|
|
--
|
|
-- failed_pin_attempts only ever cleared on a successful recovery or after a lockout expired, so
|
|
-- honest typos accumulated across days: a guest who fat-fingered their PIN twice last night
|
|
-- arrives today already two-thirds of the way to being locked out. With the threshold now
|
|
-- raised (see below) a decay window is what keeps that raise safe rather than merely lenient.
|
|
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS last_failed_pin_at TIMESTAMPTZ;
|
|
|
|
-- Rate-limit knobs introduced with this release.
|
|
--
|
|
-- recover_name_rate_per_15min (4, was a hardcoded 5): the per-(IP, name) ceiling. It MUST stay
|
|
-- below the account-lock threshold, which is the whole defect — at 5-per-IP against a 3-strike
|
|
-- lock, three requests from one IP locked any guest whose name is visible on the feed, every 15
|
|
-- minutes, forever. The lock threshold moves to 12 in code, so locking a victim now needs at
|
|
-- least three distinct sources while an honest guest never comes close.
|
|
--
|
|
-- pin_reset_ip_rate_per_min (30): /recover/request was the one unauthenticated endpoint with no
|
|
-- per-IP ceiling at all — /join got one in 017 and /recover in 019, and this third one was
|
|
-- simply missed. Its per-name key is attacker-chosen, so cycling names minted a fresh bucket
|
|
-- every time and the per-IP cost was unbounded.
|
|
--
|
|
-- upload_edit_rate_per_min (30): PATCH /upload/{id} had no rate limit of any kind.
|
|
INSERT INTO config (key, value) VALUES
|
|
('recover_name_rate_per_15min', '4'),
|
|
('pin_reset_ip_rate_per_min', '30'),
|
|
('upload_edit_rate_per_min', '30'),
|
|
('upload_edit_rate_enabled', 'true')
|
|
ON CONFLICT (key) DO NOTHING;
|