Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Bezpečnost / CORS, rate limit a audit

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#

  1. 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ě).
  2. Přidej limit a okno do config/Shared/ratelimit.neon a prožeň je konstruktorem RateLimitConfig (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íží.
  3. V presenteru zavolej hit() (počítá a rozhoduje) nebo check() (jen se ptá). Osobní údaj v klíči hashuj.
  4. Rozhodni, jestli limit poslouchá hlavní vypínač. Brzda před zásahem do cizího účtu ho poslouchat nemá.
  5. Odpověď při zaškrcení nesmí prozradit víc než odpověď při průchodu.
  6. Fakt zaškrcení zaloguj do auth logu — 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#

  1. Začni u cms_system_auth_log filtrem na IP nebo user_id.
  2. Poměř login_fail k login_success ve stejném okně — hádání hesel vypadá jako dlouhá série selhání z jedné adresy.
  3. refresh_reuse_detected ber vážně. Znovupoužitý refresh token znamená, že ho má někdo další.
  4. rate_limited říká, že brzda zabrala — ale ne, jestli byla dost přísná.
  5. 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.
  6. 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í:

  1. proměnná prostředí NETTE_DEBUG1, true, on, yes zapnou; cokoli jiného včetně 0 a false vypne,
  2. existence souboru config/debug-mode.flag (v repu není, na dev serveru se vytvoří touchem),
  3. 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.phpresolveDebugMode()

Navazující kapitoly: Autentizace a autorizace · RBAC v API · Naplánované úlohy · Checklist nasazení