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#

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
Limity klíče aplikace před aplikací (nebo jedním jejím volajícím), která vytěžuje celé Api api.clientKey.limits v config/Shared/api.neon, sloupce klienta
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
    reCaptcha: true  # reCAPTCHA ve frontových formulářích

reCaptcha: false dá formulářům místo reCAPTCHA prázdné pole (ReCaptchaToggleExtension) a šablony nenačtou skript Googlu — na localhostu nutné, Google ho nepodporuje a odeslání by se tiše zablokovalo. Podrobně Externí připojení → reCAPTCHA.

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. Totéž platí pro limity klíče aplikace — security.rateLimit je nevypne.

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 klíče a JWT

Preflight OPTIONS neposílá X-API-Key ani Authorization. Kdyby ho kontrola klíče nebo JWT guard viděly první, vrátily 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.

Ve sdíleném cors.neon je seznam originů PRÁZDNÝ

Localhost originy do sdílené konfigurace nepatří, jinak by je dostala i produkce. Každý stroj si své originy doplní v config/Shared/local.neon (vývoj: localhost porty, produkce: partneři a mobilní web); seznamy se při slučování konfigurace sčítají. Zapomenutý local.neon na novém stroji se projeví jako „z prohlížeče to nejde, z curlu ano".

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. X-API-Key je v allowedHeaders, aby ji prohlížeč v preflightu povolil.

Rate limit#

Počítá se počet zásahů na klíč v posuvném okně (limity klíče aplikace mají pevná okna, viz níž). Nakonfigurované limity (config/Shared/ratelimit.neon):

Co Klíč Limit Okno
/auth/login (uživatel, IP), jméno hashované 5 5 min
/auth/login — účet napříč IP id účtu 20 1 hodina
/auth/login — neúspěchy jednoho známého zařízení id zařízení + účet 10 1 hodina
/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
znovu poslat aktivaci — IP / účet IP / hash jména 10 / 3 1 hodina
heslo k editaci inzerátu (neúspěchy) — inzerát / IP id inzerátu / IP 5 / 20 15 min / 1 hodina
„Doporučit“ — IP / příjemce IP / normalizovaný e-mail 10 / 3 1 hodina
zakládání obsahu přihlášeným (contentCreate) — uživatel / IP druh obsahu + uživatel / IP 30 / 60 1 hodina
zakládání obsahu anonymem (anonymousInsert) — e-mail / IP druh obsahu + e-mail / IP 5 / 20 1 hodina
reakce na inzerát (advertResponse) — IP / IP + inzerát IP / IP a id inzerátu 10 / 3 1 hodina
odpověď ve vlákně zpráv (messageReply) hash id uživatele 30 10 min
potvrzení inzerátu klíčem z e-mailu (advertConfirm, neúspěchy) — záznam / IP id záznamu / IP 10 / 20 15 min / 1 hodina
reklamace z frontu (complaintSubmit) — uživatel / IP id uživatele / IP 3 / 10 1 hodina
ověření DIČ ve VIES (vatIdCheck) — tlačítko / automaticky uživatel (bez identity IP) / IP 5 / 20 24 h / 1 hodina
našeptávač obcí a kategorií (regionSuggest, categorySuggest) IP 60 1 min

Rezervace a komentáře zakázek modulu Company jsou v Company.

Proč má reakce na inzerát klíč IP + inzerát, ne jen inzerát

Limit per IP brání jednomu odesílateli obeslat stovky inzerátů, limit per IP a inzerát zaplavit jeden inzerát. Klíč jen podle inzerátu by dal útočníkovi vypínač: vyčerpal by kvótu a nikdo jiný by na inzerát neodpověděl.

VIES počítá jen skutečná online volání

Odpověď z evidence (platné DIČ se 30 dní bere z ní) kvótu nečerpá. Automatické ověření při odeslání objednávky nebo platby má vlastní kvótu podle IP a nečerpá kvótu tlačítka — odmítnutí by poctivé firmě naúčtovalo DPH navíc. Privilegovaná role je bez limitu, limit <= 0 kvótu vypne.

Většina limitů má dvě dimenze 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. Změna hesla přihlášeným (update-password) čerpá neúspěchy z hodnot login na klíči účtu.

Limity klíče aplikace#

Každé vnější volání s klíčem aplikace projde vlastními limity klienta (ApiClientRateLimiter, volá ho ApiClientKeyTrait). Na rozdíl od tabulky výš jsou to pevná okna (INCR + EXPIRE v Redisu, všechna okna klienta v jednom pipeline); na hranici okna projde nejvýš dvojnásobek limitu.

Okno (meta.window) Identita Výchozí Kde
client_second celá aplikace 20 / 1 s checkClientKey()
client_minute celá aplikace 600 / 1 min checkClientKey()
user_minute userId z JWT 120 / 1 min checkClientCallerLimit()
ip_minute SHA-256 adresy (bez userId, i u #[PublicAction]) 120 / 1 min checkClientCallerLimit()

Výchozí hodnoty jsou v api.clientKey.limits, klient je přebije sloupci limit_per_second, limit_per_minute, user_limit_per_minute, ip_limit_per_minute (NULL = výchozí). Okno s limitem ≤ 0 se nepočítá. Bez Redisu jdou okna přes DbRateLimit — dva dotazy na okno, tedy šest navíc na každý požadavek s klíčem.

Překročení = HTTP 429, Retry-After (sekundy do konce okna) a {"success":false,"message":"Too many requests.","data":{"retryAfter":N},"code":404}. Do auditu jde jen první odmítnutí v okně (rate_limited, meta.reason = api_client_limit, meta.window, meta.limit) — zahlcení nemá zahltit i log. Výpadek Redisu je fail-open.

Dvě různá těla 429

Limit klíče vrací zprávu Too many requests., limit přihlášení a obnovy tokenu (AuthPresenter::rateLimitedResult()) Too many requests, retry in <N>s. Obojí nese data.retryAfter, kód RATE_LIMIT_EXCEEDED (404) a hlavičku Retry-After.

Přihlášení: známé a neznámé zařízení#

Limit účtu napříč IP by jinak dovolil komukoli, kdo zná jméno, zablokovat majiteli přihlášení heslem. Nad limitem proto AuthPresenter::internalActionLogin() rozlišuje, odkud pokus přichází:

Zdroj pokusu Nad limitem
známé zařízení: platná podepsaná cookie cms_login_device téhož účtu heslo se ověří; projde přes všechny tři brány (údaj+IP, účet+IP, účet napříč IP)
známá adresa: úspěšné přihlášení účtu z této IP v posledních knownAddressWindow heslo se ověří, ale jen přes bránu účtu napříč IP; dvojice (údaj nebo účet, IP) dál brzdí
ostatní stejné odmítnutí (429, Retry-After) jako dřív, bez ověření hesla

Cookie vydává LoginDeviceCookie (Front a Admin po úspěšném přihlášení heslem i přes cizí službu) a Api ji ověřuje podpisem; je bezstavová (v1.<deviceId>.<userId>.<expires>.<hmac>), klíč HMAC se odvozuje z %jwt.secret% s účelovým štítkem. Zařízení má vlastní počítadlo neúspěchů (ratelimit.loginDevice): nad ním se bere jako neznámé, aby ukradená cookie nedávala neomezené hádání. Úspěšné přihlášení počítadla nenuluje. Důvod propuštění nebo odmítnutí jde jen do auth logu (meta.reason, meta.throttle_bypass), odpověď klientovi je stejná.

Nastavení ratelimit.loginDevice.* Výchozí Význam
limit / window 10 / 3600 s neúspěchy jednoho zařízení, po nich se bere jako neznámé
cookieLifetime 15552000 s (180 dní) životnost cookie, každé přihlášení ji obnoví
knownAddressWindow 2592000 s (30 dní) jak dlouho platí „známá adresa"

Externí auth/login je vždy neznámé zařízení

actionLogin() cookii záměrně nepředává. Externímu klientovi může pomoci jen známá adresa. Přihlášení z Frontu a Adminu cookii předává interním voláním internalActionLogin($username, $password, $deviceToken).

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.

Kvóta zakládání obsahu#

Reakce, příspěvky, komentáře, nahlášení a kontaktní zprávy mají na frontu reCAPTCHA a vlastní limity — jenže tentýž INSERT jde i externím :Api:*:save s JWT, který má každý registrovaný. Brzda proto sedí v Api manageru, kudy projde každý insert: App\Core\Security\RateLimit\ContentCreationLimiter.

Metoda Komu platí Konfigurace
isAllowed($scope, $userId) přihlášenému uživateli (anonym projde) ratelimit.contentCreate
isAnonymousInsertAllowed($scope, $email) nepřihlášenému — dnes kontaktní formulář ratelimit.anonymousInsert
isActivationMailAllowed($scope, $email) komukoli — chrání příjemce aktivačních e-mailů (odběr novinek) ratelimit.sendActivationEmail

Klíč je vždy per druh obsahu ($scope, např. bazaar-response, discussion-message), takže kvóty se nesčítají. IP se bere z IRequest, nikdy z payloadu; v CLI je null a počítá se jen uživatel. Limit <= 0 = bez limitu.

Kdy brzda neplatí

Rozhoduje FieldAudienceContext, který plní jen guard: privilegovaný volající (správce) je vyňatý a nevyhodnocený kontext (nouzový režim apiRbac.internalMode: off, cron, CLI) také. V produkci jsou oba režimy enforce, takže na Api cestě brzda platí pro každého neprivilegovaného volajícího.

Postup: zapojit kvótu do manageru#

  1. Manager implementuje ContentCreationLimitedInterface a použije App\Core\Traits\Api\Models\Managers\ContentCreationLimitTrait. Limiter mu dosadí DI decorator (Shared/decorators.neon); bez něj (unit test stavějící manager ručně) brána nic neomezuje.
  2. V beforeSave() zavolej guardContentCreation($isInsert, '<scope>', $userId) (u anonymní cesty navíc guardAnonymousInsert(…, $email)) a vrať jeho Result, není-li null. Počítá se jen insert.
  3. Odpověď je RATE_LIMIT_EXCEEDED (kód 404) se zprávou Too many new records, try again later., na externí cestě s HTTP 200.
  4. Vzory: ResponseManager (Bazaar, Comcat), MessageManager a MessageReportManager (Discussion), CommentManager (Base), ContactMessageManager (Blog), AdvertReportGuardTrait.

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, rate_limited; scope_denied jen ve starších řádcích (rozsahy klíčů zanikly 6. 10. 2026)

Do meta jde volný JSON kontext. Sloupec api_client_id nese klienta, přes jehož klíč požadavek přišel — AuthLogManager ho doplní z ApiClientContext i tam, kde ho volající nepředá (přihlášení, obnova tokenu).

Událost Kdy meta
api_key_failure neznámý, vypnutý nebo zrušený klíč (chybějící ne — ten jen do auth.log jako API_CLIENT_KEY reason=missing) reason, presenter, action
rate_limited první odmítnutí v okně limitu klíče reason = api_client_limit, window, limit
rate_limited limit přihlášení nebo obnovy tokenu type (login, refresh), retry_after

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á. U limitu klíče rozliš podle meta.window, jestli aplikaci vytěžuje jeden volající (user_minute, ip_minute), nebo celý provoz (client_*).
  5. Série api_key_failure z jedné adresy je zkoušení klíčů; meta.reason = disabled nebo revoked s konkrétním api_client_id je aplikace, která po vypnutí dál volá.
  6. 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.
  7. 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.

Zápis při GET#

Některé stránky při zobrazení zapisují: otevřený detail reakce nastaví read, přehled notifikací shown, detail zprávy z kontaktního formuláře read. Zápis při GET lze vyvolat cizí stránkou (obrázek, přesměrování, předběžné načtení), proto platí:

Pravidlo Provedení
zápis při vykreslení jen z vlastního webu $this->getHttpRequest()->isSameSite() — platí, je-li přítomen striktní cookie _nss, který prohlížeč k cizímu požadavku nepřipojí
neúspěch zápisu se loguje, uživateli se neukazuje Debugger::log(…)
obsah se zobrazí i bez zápisu podstrčený odkaz stránku ukáže, jen nic nepřepne

Místa: Front ResponseDetailTrait (Bazaar, Comcat), Admin NotificationPresenter, ContactMessagePresenter, WarehousePresenter::actionMoveUp() a actionMoveDown().

Důsledek: odkaz z webové pošty reakci neoznačí

Požadavek z cizí stránky isSameSite() neprojde. Pro označení z e-mailu proto existuje odkaz s klíčem.

Nová stránka, která při GET něco zapíše, musí použít stejnou kontrolu

Správný postup je zápis udělat signálem s #[Requires(ajax: true)] nebo POSTem. Pokud musí zůstat při vykreslení, obal ho isSameSite() a zalogujte neúspěch. Holý GET zapisující bez kontroly spouštějí roboti.

Roboti a akční odkazy#

Zapisující signály (?do=… — oblíbené, košík, hodnocení, hlášení příspěvku) nesmějí spouštět roboti — holým GET by jinak plnili databázi oblíbenými, košíky, hodnoceními i hlášeními. Ochrana má tři vrstvy:

Vrstva Co dělá
#[Requires(ajax: true)] na zapisujících handlerech holý GET (robot, předběžné načtení) končí 403
CrawlerDetector v Front\BasePresenter::startup() signál od klienta, který se hlásí jako robot, končí 403 dřív než se cokoli načte
robots.txt Disallow pro ?do=, &do=, ?_fid=, /admin/, /api/, /cron/

Podrobně, včetně sitemap a postupů, v kapitole Roboti, robots.txt a sitemapy.

Hlavičky odpovědi a adresy s /www/#

Dvě pravidla ve www/.htaccess platí pro každou odpověď Apache.

Hlavička Hodnota Proč
X-Content-Type-Options nosniff prohlížeč nesmí hádat typ obsahu; hlavně u souborů, které nahrají uživatelé (www/uploads/)
Referrer-Policy strict-origin-when-cross-origin na cizí web odchází jen adresa webu, ne celá cesta

Nastavují se přes Header always set uvnitř <IfModule mod_headers.c>, takže fungují jen s modulem mod_headers. HSTS v souboru záměrně není: prohlížeč si ho pamatuje pro celý host, proto se nastavuje ve virtuálním hostu serveru.

Header always set PŘEPÍŠE hlavičku, kterou nastavila aplikace

Stránka, která si nastaví vlastní Referrer-Policy, ji neudrží: Apache hodnotu z .htaccess nastaví až nakonec a nahradí ji. Příklad: stránka označení reakce z e-mailu posílá Referrer-Policy: no-referrer (viz Odkaz s klíčem), ale odpověď nese strict-origin-when-cross-origin. Cizímu webu prohlížeč v takovém případě pošle jen adresu webu, ne cestu s klíčem, takže klíč neuniká; záměr „žádný referrer“ se ale neuplatní. Aby měla přednost hodnota stránky, bylo by potřeba Header always setifempty.

Druhé pravidlo řeší duplicitní adresy: web běží přes kořenový .htaccess (^(.*)$ → /www/$1), takže stejná stránka by odpovídala i pod /www/…. Doslovný prefix /www/ v požadavku se proto přesměruje (301) na adresu bez něj — kromě existujících souborů. Stylesheety, skripty a obrázky se na takových webech odkazují s prefixem /www, a každé přesměrování by stálo požadavek navíc. Rozlišuje se podle %{THE_REQUEST} (původní řádek požadavku), ne podle REQUEST_URI, který už nese vnitřní /www/; viz Jedna kanonická adresa.

Postup: ověřit hlavičky a pravidlo /www/#

  1. Hlavičky: curl -sI https://vase-domena.cz/ — v odpovědi musí být x-content-type-options: nosniff a referrer-policy: strict-origin-when-cross-origin.
  2. Pravidlo: curl -sI -o /dev/null -w '%{http_code} %{redirect_url}\n' https://vase-domena.cz/www/inzerce — má vrátit 301 a adresu bez /www.
  3. Existující soubor s prefixem se nepřesměrovává: curl -sI https://vase-domena.cz/www/themes/… (cesta k opravdovému souboru) vrátí 200.

Bez mod_headers hlavičky mlčky chybí

<IfModule> nic nehlásí, když modul není. Po nasazení na nový server hlavičky vždy ověř příkazem výš.

Hlavičky stažených souborů#

Soubor, který prošel kontrolou oprávnění (PDF faktury, příloha reklamace, log importu, export z gridu), nesmí zůstat v cache sdíleného proxy, CDN ani prohlížeče na společném počítači — autorizace by pak chránila jen první požadavek.

App\Core\Utils\Helpers\PrivateDownloadHeaders::apply($response) nastaví Cache-Control: no-store, private, Pragma: no-cache a Expires: 0. Je zavolaná na 9 místech (Admin Invoicer InvoicePresenter, FileSystemPresenter, CsvExportTrait, Admin Eshop ComplaintPresenter, CatalogExportDownloadTrait, ProductImportEditTrait, Front MyOrderListPresenter, MyComplaintListPresenter, OrderDonePresenter). PrivateDownloadHeadersTest hlídá kontrakt a strážní test projde app/ a najde stažení bez hlaviček.

Nové autorizované stahování = zavolat apply()

Bez toho strážní test spadne; chybějící hlavička se jinak neprojeví chybou, jen cizí osobou, která dostane soubor z cache.

Soubory, cesty a odchozí požadavky#

Malé politiky, které se mají používat všude, kde kód sahá na soubor nebo na cizí adresu podle vstupu zvenčí.

Politika Co hlídá Kde se vynucuje
App\Core\Security\Http\AssetPathPolicy legacy assetPath (obrázek kategorie, regionu, avatar) je lokální relativní cesta: žádné schéma, //host, zpětné lomítko, řídicí znaky ani ./.. Api SaveTrait::save() u každé entity s AssetPathTrait (všechny cesty zápisu; porušení = VALIDATION_FAILED), formuláře přes ColumnGate
App\Core\Security\Upload\ImageUploadPolicy obrázek ověří bez dekódování: typ podle obsahu (jpg, png, gif, webp), strop 40 Mpx a odhad paměti GD (4 B/px × 1,3 + 16 MB) proti volné části memory_limit; pak ho znovu uloží s aplikovanou EXIF orientací a bez metadat (GPS pryč) front dropzone, Api RasterImageProcessor (neprošlý soubor = odmítnut, ne 500), VariantRenderer, avatar
App\Core\Security\Http\NavigationUrlPolicy uložený odkaz ke kliknutí je http(s):// s hostem a bez user:pass@, mailto: nebo tel:; kde to dává smysl (menu, úvodní panel blogu), i relativní /cesta a #kotva — ne //host; nikdy řídicí znaky, mezery ani zpětné lomítko Api beforeSave() (profil uživatele, reference firmy, úvodní panel, článek, položka menu) a formulářové pravidlo NavigationUrlPolicy::rule()
App\Core\Security\Http\ReturnUrlPolicy návratová adresa platby (successUrl, cancelUrl, notifyUrl) jde do Location: jen relativní /cesta, nebo absolutní adresa se schématem https či schématem požadavku a se stejným hostem i portem PaymentCreateTrait — nevyhovující adresu nahradí serverový výchozí
App\Core\Security\Upload\AttachmentFilePolicy příloha (inzeráty Bazaar a Comcat, reklamace) má příponu z allowlistu (ALLOWED_EXTENSIONS) a obsah podle finfo není aktivní (HTML, SVG, XML, PHP, JS); ukládá se pod náhodným jménem s jedinou příponou Api rejectionReason() před zápisem na disk; dropzone dává jen okamžitou zpětnou vazbu
App\Core\Security\Http\PublicIpPolicy cíl odchozího požadavku je veřejná adresa — kromě privátních rozsahů i CGNAT 100.64/10, 198.18/15, NAT64, 6to4, Teredo a IPv4 schovaná v IPv6 import obrázků e-shopu (ImportImageDownloader) na každém skoku přesměrování
App\Core\Utils\Helpers\AssetPathResolver::toAbsoluteWithin() cesta z databáze na soubor, který se vydává přes proxy: odmítne .., zpětné lomítko a nulový bajt, soubor musí existovat a po realpath() (vyřešené symlinky) ležet uvnitř zadané složky PDF dokladů přes toPrivatePdfAbsolute() (kořen /www/private/pdf/) — Admin i Api faktury, rozvrhy, přechody objednávky
App\Core\Utils\Helpers\LogRedactor do provozního logu typ události, kód chyby a korelace, ne e-mail, DIČ, předmět zprávy ani token: fingerprint() = 12 znaků SHA-256 normalizované hodnoty, scrubMessage() vystříhá ze zprávy výjimky query hodnoty, user:pass@, Authorization, Bearer/Basic, páry secret/token/password/key = … a klíče sk_live_… zákazník objednávky, rezervace firmy, ověření DIČ (VIES), platební brány a expresní platby, volání cronu, zablokovaný e-mail (NullMailer)
App\Core\Utils\Helpers\SafeUnserialize unserialize() s allowed_classes => false; objekt kdekoli v datech = odmítnout celý obsah fronta e-mailů, přílohy, varianty assetů, oblíbené v session

Latte checkUrl není brána pro uložené odkazy

Latte v atributu href="{$x}" vyprázdní javascript: a data:, ale jen při vykreslení: neplatí mimo atributový kontext ani pro hodnotu odeslanou ven přes Api JSON a https://cizi-server/… pustí. Nové pole s adresou od uživatele proto ověř NavigationUrlPolicy (odkaz) nebo AssetPathPolicy (lokální soubor do <img src>) před uložením. U návratové adresy nestačí porovnat host z parse_url() — ten je null i pro javascript:alert(1) nebo https:evil.example a vše by prošlo jako „relativní“.

unserialize() bez allowed_classes neprojde testem

SafeUnserializeTest::testNoUnrestrictedUnserializeInApp() projde app/ a najde každé volání bez omezení tříd. Nová čtečka serializovaných dat jde přes SafeUnserialize::toArray().

fingerprint() je pseudonymizace, ne anonymizace

Bezsolný hash jen spáruje dva řádky logu o téže hodnotě; známou hodnotu lze porovnáním dohledat. Nepoužívej ho pro tajemství s malou entropií (PIN, krátký kód) a mimo provozní log ho neposílej. scrubMessage() je best effort — text výjimky proto patří jen do logu, nikdy do veřejné odpovědi.

Strop 40 Mpx potřebuje paměť

Odhad paměti se počítá proti skutečnému memory_limit. Se 128M projdou obrázky jen do zhruba 12–15 Mpx (větší se odmítnou hláškou); pro plný strop potřebuje PHP-FPM kolem 320M. Video se ověřuje přes ffprobe — bez něj se nahrát nedá. Viz Nasazení.

Nový odchozí požadavek na adresu od uživatele

Rozlož název na IP, ověř PublicIpPolicy::isPublic(), připni ji pro spojení a přesměrování nenásleduj automaticky — každý skok ověř znovu. Vzor je ImportImageDownloader.

Blacklist e-mailů, telefonů a IP#

Systém → Blacklist (cms_system_blacklists) blokuje e-mail, telefon nebo IP adresu. Kontrola je jedna a běží všude, kde web přijímá zápis od návštěvníka, ne jen při registraci.

Část Kde Úkol
Porovnání Api System\BlacklistManager::findBlacklistedTypes(?email, ?phone, ?ip) vrátí zablokované typy (BlacklistType); e-mail bez ohledu na velikost písmen, telefon jen číslice (00 = +), IP jako adresa nebo rozsah CIDR, IPv4 i IPv6 (IpRange). Skalární čtení, smí běžet uvnitř ukládací transakce
Brána uložení BlacklistManager::refusalFor(email, phone, ip) neúspěch s errorCode blacklisted a data.types (BlacklistRefusal); administrátor, cron a CLI se nekontrolují (podle FieldAudienceContext)
Zapojení BlacklistGuardTrait::blacklistRefusal() v beforeSave() nového záznamu komentáře, newsletter, inzeráty a profily firem, odpovědi a poptávky, hlídací psi, kontaktní formulář, příspěvky diskuze, reklamace, objednávky; rezervace v ReservationManager::book()
Účet UserManager::beforeSave() nový účet: e-mail, telefon a IP z meta.ip (posílá ji front, externí meta se zahazuje); úprava: jen změněný e-mail nebo telefon, takže účet s později zablokovaným údajem si uloží zbytek profilu
Front SaveTrait (repository) předá z neúspěchu doménový errorCode; formulář místo obecné chyby ukáže front.general.blacklisted registrace se ptá předem jednou otázkou (findBlacklistedTypes() → internalActionIsBlacklisted), chybu dá k poli e-mailu nebo telefonu, IP jako chybu formuláře; bez odpovědi Api neprojde (fail-closed)

IP se bere z entity (front ji u záznamu ukládá, např. insertIp), u registrace z meta.ip. Administrační formulář blacklistu u typu IP přijme jen platnou adresu nebo rozsah.

Nový formulář návštěvníka = zapoj bránu

Nová entita, kterou zakládá návštěvník, převezme BlacklistGuardTrait (?BlacklistManager $blacklistManager = null jako poslední parametr konstruktoru) a v beforeSave() při vložení zavolá blacklistRefusal($email, $phone, $ip). Bez toho blacklist pro tuhle cestu neplatí a nic to neohlásí.

Další ochrany a kde jsou#

Mechanismy mimo předchozí oddíly, na které se dá odkázat:

Oblast Mechanismus
správce souborů správce souborů při rekurzivním mazání (deleteDir()) a normalizaci názvů symlink odpojí / přeskočí a nenásleduje (is_link())
hlášení příspěvku hlášení příspěvku se založí jen pro ID, které patří komponentě; ID z adresy se jen porovná; hlášení je AJAX-only
nahlášené příspěvky, inzeráty a profily v administraci sdílená ReportModerationPresenterTrait se serverovou bránou signálů, reported dopočítává ReportedFlagSyncTrait v Api managerech hlášení
stahování soukromých souborů PrivateDownloadHeaders
zápis při GET isSameSite() při zápisu při GET
odkazy z notifikací zvonek i přehled notifikací nabídnou cíl jen s právem na něj
zvonek notifikací zvonek ukazuje a označuje jen notifikace příjemce a společné
modál termínu (Moje firma) SlotModal vyžaduje :Admin:Company:TimeSlot:edit u každého zápisu a kontroluje, že kalendář a slot patří firmě, ze které se modál otevřel (jinak 403), a že rezervace patří danému slotu
seznam originů CORS sdílený seznam originů je prázdný, localhost patří do local.neon
osobní údaje v migracích verzované migrace ani rollbacky nesmí obsahovat osobní údaje (jsou v gitu)
prefixy migrací strážní test prefixů migrací, viz Checklist nasazení
soukromé soubory v /www/private/ /www/private/* je v .gitignore kromě .htaccess

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_DEBUG — 1, 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
klíč aplikace a jeho limity config/Shared/api.neon (api.clientKey), app/Core/Traits/Api/Presenters/ApiClientKeyTrait.php, app/Core/Security/ApiClient/
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()
hlavičky stažených souborů app/Core/Utils/Helpers/PrivateDownloadHeaders.php
detekci robotů app/Core/Utils/Helpers/CrawlerDetector.php
kvótu zakládání obsahu app/Core/Security/RateLimit/ContentCreationLimiter.php, app/Core/Traits/Api/Models/Managers/ContentCreationLimitTrait.php
politiky souborů a adres app/Core/Security/Http/{AssetPathPolicy,NavigationUrlPolicy,ReturnUrlPolicy,PublicIpPolicy}.php, app/Core/Security/Upload/{ImageUploadPolicy,AttachmentFilePolicy}.php, app/Core/Utils/Helpers/SafeUnserialize.php

Navazující kapitoly: Autentizace a autorizace · RBAC v API · Podmíněné role · Roboti a sitemapy · Naplánované úlohy · Checklist nasazení

Nastavení cookies