CORS, rate limit a audit#
Tři ochrany Api vrstvy, které spolu nesouvisí a každá řeší něco jiného. K tomu to, co
je veřejné, i když by nemělo být. Přepínače: config/Shared/security.neon.
| Ochrana | Před čím chrání | Kde je konfigurace |
|---|---|---|
| CORS | před tím, aby cizí web volal API z prohlížeče přihlášeného uživatele | config/Shared/cors.neon |
| Rate limit | před hádáním hesel, mailbombingem a plošným vytěžením endpointu | config/Shared/ratelimit.neon |
| Audit log | před tím, aby po incidentu nebylo z čeho zjistit, co se dělo | tabulka cms_system_auth_log |
Hlavní vypínače#
security:
cors: true # CORS hlavičky + preflight 204
rateLimit: true # brute-force na /auth/login a /auth/refresh
auditLog: true # zápis do cms_system_auth_log
Každý přepínač vypíná jen svou ochranu; detailní konfigurace zůstává ve svém
souboru. JWT guard tu není — řídí ho jwt.guardMode (log = jen loguje,
enforce = 401), aktuálně enforce.
Ne každý limit jde přes hlavní vypínač — a je to schválně
showPhone a sendNewPassword se security.rateLimit neřídí. Jsou to
poslední brzdy před zásahem do cizího účtu, a vypínat je jedním přepínačem spolu
s brute-force ochranou loginu by znamenalo vypnout dvě velmi různě závažné věci
naráz. Kdo hledá „proč mě to omezuje, když mám rate limit vypnutý“, hledá tady.
CORS#
CORS je odpověď serveru na otázku prohlížeče „smí tenhle cizí web přečíst, co mu
vrátíš?". Aplikuje ho CorsTrait::applyCors() v Api presenteru.
| Krok | Chování |
|---|---|
požadavek bez hlavičky Origin a ne OPTIONS |
same-origin → CORS se vůbec neřeší |
| povolený origin | Access-Control-Allow-Origin + Vary: Origin (+ Allow-Credentials) |
preflight (OPTIONS) |
vrátí 204 a ukončí požadavek, ať je origin povolený nebo ne |
| nepovolený origin | hlavičky se nepošlou, prohlížeč odpověď zahodí sám |
applyCors() musí běžet PŘED kontrolou JWT
Preflight OPTIONS neposílá Authorization. Kdyby ho JWT guard viděl první,
vrátil by 401 — a prohlížeč by skutečný požadavek vůbec neodeslal. Projeví se to
jako „z JavaScriptu to nejde, z curlu ano“.
Hvězdička v allowedOrigins je na produkci k ničemu, ne jen nebezpečná
Při allowCredentials: true prohlížeč Access-Control-Allow-Origin: *
ignoruje. Kód to ošetřuje (hvězdička se použije jen bez credentials), ale
důsledek je, že hvězdička v dev konfiguraci vypadá jako fungující nastavení, které
na produkci s přihlašováním přestane platit. Vypisujte konkrétní originy.
CORS chrání prohlížeč, ne server
Požadavek z curlu, ze serveru nebo z mobilní aplikace CORS neomezuje vůbec. Není to autorizace a nesmí se za ni brát — tu dělá JWT guard a RBAC.
Rate limit#
Počítá se počet zásahů na klíč v posuvném okně. Nakonfigurované limity
(config/Shared/ratelimit.neon):
| Co | Klíč | Limit | Okno |
|---|---|---|---|
/auth/login |
(uživatel, IP), jméno hashované |
5 | 5 min |
/auth/refresh |
IP | 30 | 5 min |
| odmaskování telefonu | IP | 20 | 1 hodina |
| zapomenuté heslo — IP | IP | 10 | 1 hodina |
| zapomenuté heslo — účet | hash uživatelského jména | 3 | 1 hodina |
Zapomenuté heslo je jediné se dvěma dimenzemi a musí projít obě: limit per IP nezastaví botnet mířící na jeden účet, limit per účet nezastaví jednu IP střílející po tisících jmen.
Uživatelské jméno se do klíče dává hashované
Dvakrát proto: je to osobní údaj (často e-mail) a nemá co ležet v tabulce rate
limitu, a hash zároveň sjednotí Franta a franta na jeden klíč — jinak by se
limit obešel velikostí písmen.
Rate limit je fail-open
Výpadek Redisu nebo databáze znamená allowed = true. Je to záměr — přihlášení
nesmí přestat fungovat kvůli počítadlu — ale znamená to, že výpadek úložiště
sundá brzdu a nikde to nevyskočí jako chyba.
U loginu se počítají jen NEúspěšné pokusy
Kdyby se počítaly i úspěšné, uživatel s pěti přihlášeními za pět minut by si sám zavřel okno. Důsledek: limit hlídá hádání hesel, ne zátěž.
Limit podle IP zasáhne celou firmu naráz
Uživatelé za jednou veřejnou adresou sdílejí kvótu. U integrací klíčujte podle identity, ne podle adresy.
Postup: přidávám rate limit na novou akci#
- Rozhodni dimenzi. Co je ta věc, kterou chráníš — účet (klíčuj podle uživatele), zdroj (podle IP), nebo obojí (pak musí projít obě).
- Přidej limit a okno do
config/Shared/ratelimit.neona prožeň je konstruktoremRateLimitConfig(services:na konci souboru). Ke každé hodnotě napiš komentářem proč zrovna tolik — číslo bez odůvodnění nikdo později nezvýší ani nesníží. - V presenteru zavolej
hit()(počítá a rozhoduje) nebocheck()(jen se ptá). Osobní údaj v klíči hashuj. - Rozhodni, jestli limit poslouchá hlavní vypínač. Brzda před zásahem do cizího účtu ho poslouchat nemá.
- Odpověď při zaškrcení nesmí prozradit víc než odpověď při průchodu.
- Fakt zaškrcení zaloguj do
authlogu — tam patří, protože v odpovědi být nesmí.
Odlišná odpověď při zaškrcení je oracle na existenci účtu
U „zapomenutého hesla“ vrací kód konstantní úspěch i při zaškrcení — přesně
proto, aby útočník nepoznal, na kolikátém pokusu je, a hlavně aby u limitu per účet
nerozeznal existující účet od neexistujícího. Shodu obou tvarů hlídá test
SendNewPasswordRateLimitTest. Kdo přidá „byl překročen limit“ do odpovědi, tuhle
ochranu zruší, aniž by cokoli spadlo.
Audit log#
Tabulka cms_system_auth_log, zapisuje AuthLogManager::log(). Typy událostí:
| Skupina | Události |
|---|---|
| přihlášení | login_success, login_fail, login_external_success, logout |
| tokeny | refresh_success, refresh_fail, refresh_reuse_detected, jwt_failure |
| přístup | api_key_failure, scope_denied, rate_limited |
Do meta jde volný JSON kontext. Retenci řeší cron
/cron/system/auth-log/purge-old — výchozí 90 dní, přepíše se ?days=N.
Do meta NIKDY nepatří syrový token ani heslo
Jen hash nebo prvních pár znaků. Audit log je to poslední místo, kam chcete uložit použitelný přihlašovací údaj — přežije tam měsíce a čte ho víc lidí než produkční databázi.
Zápis do logu je fail-open
Chyba databáze se zaloguje přes Tracy a pokračuje se — audit nesmí zablokovat přihlášení. Znamená to ale, že chybějící řádek v auditu nedokazuje, že se událost nestala.
Bez cronu tabulka roste donekonečna
Každý neúspěšný pokus o přihlášení je řádek. U webu, na který míří boti, to jsou tisíce řádků denně — a nikdo si toho nevšimne, dokud nezačne být pomalá administrace.
Auditní log se nikdy nepřepisuje
Ani při úklidu testovacích dat. Mazání podle stáří přes cron ano, ruční úprava obsahu ne — přepsaný log je padělek a nemá cenu ho vést.
Postup: vyšetřuji podezřelou aktivitu#
- Začni u
cms_system_auth_logfiltrem na IP nebouser_id. - Poměř
login_failklogin_successve stejném okně — hádání hesel vypadá jako dlouhá série selhání z jedné adresy. refresh_reuse_detectedber vážně. Znovupoužitý refresh token znamená, že ho má někdo další.rate_limitedříká, že brzda zabrala — ale ne, jestli byla dost přísná.- Do
log/se sype i Tracy log (auth) — tam jsou věci, které se do odpovědi dostat nesměly: zaškrcené resety hesel, selhání zápisu auditu. - Až nakonec sahej na konfiguraci limitů. Nejdřív musíš vědět, co se dělo.
CSRF u signálů#
Každý handle*() má same-origin guard automaticky
Nette (AccessPolicy::applyInternalRules(), verze 3.3) přidá každé signálové
metodě Requires(sameOrigin: true), pokud není označená #[CrossOrigin] nebo
Requires(sameOrigin: false). Cizí origin skončí jako detekovaný CSRF.
Same-origin se pozná z hlavičky Sec-Fetch-Site, kterou klient mimo prohlížeč neposílá
Bez ní Nette padá na záložní kontrolu přes striktní cookie. Klient z curlu ani server-to-server volání proto signálem neprojde — což je správně jako CSRF ochrana, ale je to zároveň důvod, proč se signál nedá použít jako API endpoint.
Vypnout guard přes #[CrossOrigin] znamená napsat si autorizaci sám
Signál bez same-origin kontroly jde vyvolat z libovolné cizí stránky v prohlížeči přihlášeného uživatele. Platí to i pro akce, které vypadají neškodně — „jen lajk" nebo „jen odběr“ je pořád zápis jménem někoho jiného.
Co je veřejné, i když by nemělo být#
Rewrite pravidla jsou v .htaccess na dvou úrovních: kořenový soubor přesměruje
všechno na /www/, kromě whitelistované složky docs/. A www/.htaccess posílá
existující soubory rovnou Apachi jako statiku.
| Cesta | Stav |
|---|---|
docs/ |
servíruje se staticky, mimo aplikaci a mimo její autorizaci |
docs/sql/ |
zakázáno vlastním .htaccess (Require all denied) |
www/private/ |
zakázáno vlastním .htaccess — PDF dokladů a QR kódy |
www/uploads/, www/modules/** |
veřejné, stažitelné bez přihlášení |
Existující soubor Apache doservíruje BEZ jediné kontroly
RewriteCond %{REQUEST_FILENAME} !-f platí jen pro neexistující soubory —
na existující .pdf se přepisovací blok vůbec nepoužije. Přesně tak byly faktury
v www/private/pdf/… stažitelné komukoli, kdo uhodl cestu: aplikace o tom
požadavku vůbec nevěděla, takže se ani nemělo kde zalogovat. Opraveno souborem
www/private/.htaccess; stahování jde výhradně přes PHP proxy, která soubor pošle
readfile()em až po ověření vlastnictví.
docs/ je veřejná — nic s daty tam negenerujte
Byl tam kompletní SQL sešit se schématem databáze a vracel HTTP 200. Výstupy
s daty patří do var/.
.htaccess je obranná vrstva, ne řešení
Funguje jen tam, kde Apache pro tu složku povoluje AllowOverride. Spolehlivé je
nepouštět citlivou cestu už ve vhostu serveru.
Ladicí režim#
Debug se zapíná explicitně, produkční výchozí stav je vypnuto. Rozhoduje se
v App\Bootstrap::resolveDebugMode() v tomhle pořadí:
- proměnná prostředí
NETTE_DEBUG—1,true,on,yeszapnou; cokoli jiného včetně0afalsevypne, - existence souboru
config/debug-mode.flag(v repu není, na dev serveru se vytvořítouchem), - jinak vypnuto.
Zapnutý Tracy vypisuje konfiguraci včetně tajemství
Klíče k platebním branám a přístupy ke službám skončí ve zdroji stránky nebo na bluescreenu. Kdo si zapne debug na produkci „na chvilku“, vystaví je každému, kdo v té chvíli způsobí chybu.
Kam sáhnout#
| Chci | Kde |
|---|---|
| zapnout/vypnout ochranu | config/Shared/security.neon |
| povolené originy | config/Shared/cors.neon |
| limity a okna | config/Shared/ratelimit.neon |
| aplikaci CORS hlaviček | app/Core/Traits/Api/Presenters/CorsTrait.php |
| backendy rate limitu | app/Core/Security/RateLimit/ |
| zápis auditu | app/UI/Api/System/Models/Managers/AuthLogManager.php |
| úklid auditu a limitů | app/UI/Cron/System/Presenters/{AuthLog,RateLimit}Presenter.php |
| rozhodování o debug módu | app/Bootstrap.php → resolveDebugMode() |
Navazující kapitoly: Autentizace a autorizace · RBAC v API · Naplánované úlohy · Checklist nasazení