Přeskočit na obsah

Rate-limit / pokus-counter jako mrtvý kód (dotaz filtruje podle hádané hodnoty)

import { Aside } from ‘@astrojs/starlight/components’;

OTP/SMS verifikační endpoint má vizuálně kompletní brute-force ochranu — limit attempts >= 3 i strop „X kódů za hodinu”. Přesto je kód proti hádání 6místného kódu prakticky neomezený: každá špatná hádka vrátí „Invalid code” bez jakéhokoli započítaného pokusu.

Verifikační dotaz filtroval řádek rovnou podle zadaného kódu:

const { data: verification } = await db
.from('phone_verifications')
.select('*')
.eq('phone', phone)
.eq('code', code) // ← filtr podle HÁDANÉ hodnoty
.is('verified_at', null)
.order('created_at', { ascending: false })
.limit(1).single();
if (!verification) return { error: 'Invalid code.' }; // špatná hádka končí TADY
// ...
if (verification.attempts >= 3) return { error: 'Too many attempts.' }; // nedosažitelné
await db.from('phone_verifications')
.update({ attempts: verification.attempts + 1 }).eq('id', verification.id);

Když je hádka špatná, .eq('code', code) nenajde žádný řádek → funkce se vrátí dřív, než se k inkrementu attempts vůbec dostane. Counter roste jen u SPRÁVNÉ hádky, kde už na ničem nezáleží. Limit je mrtvý kód a útočník může zkoušet kódy bez omezení (do vypršení 10min platnosti).

Vyber aktivní záznam podle identity (telefon), inkrementuj pokus VŽDY, a kód porovnej až v aplikační logice:

const { data: verification } = await db
.from('phone_verifications')
.select('*')
.eq('phone', phone) // ← podle identity, NE podle kódu
.is('verified_at', null)
.order('created_at', { ascending: false })
.limit(1).single();
if (!verification) return { error: 'Invalid code.' };
if (new Date(verification.expires_at) < new Date()) return { error: 'Expired.' };
if (verification.attempts >= MAX_ATTEMPTS) return { error: 'Too many attempts.' };
// každá hádka (správná i špatná) se počítá
await db.from('phone_verifications')
.update({ attempts: verification.attempts + 1 }).eq('id', verification.id);
if (String(verification.code) !== String(code)) return { error: 'Invalid code.' };
// ... mark verified

V kombinaci se „send” limitem (např. 5 kódů/hod) je celkový počet hádek omezen na ~25/hod proti prostoru 1 000 000 kódů — brute force neproveditelný.

Jak se tomu vyvarovat v jiných systémech

Sekce “Jak se tomu vyvarovat v jiných systémech”
  • Detection: najdi verify/OTP/token dotazy, kde se ve WHERE/.eq() objevuje právě ta hodnota, kterou útočník hádá (code, token, otp, pin). Pak zkontroluj, zda inkrement counteru leží ZA early-returnem při „nenalezeno”.
  • Anti-pattern: „najdi řádek podle (identity + tajemství) → když nic, vrať chybu → jinak započítej pokus”. Pokus se započítá jen při úspěchu.
  • Lepší přístup: oddělit lookup podle identity od porovnání tajemství. Counter/limit patří k identitě nebo k aktivnímu challenge řádku, ne k řádku vybranému tajemstvím. Porovnání tajemství dělej konstantně-časově v aplikaci.

Sister bugs / související

Sekce “Sister bugs / související”

Stejný vzor hrozí u: reset-password tokenů (.eq('token', t)), e-mail verifikace, 2FA TOTP záloh, „magic link” kódů. Vždy, kdy je counter na stejném řádku, který se hledá tajemstvím.

Přidal aiarchitekt.cz · 11. 6. 2026 2:00
Provozuje aiarchitekt.cz