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#
- 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.
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#
- Manager implementuje
ContentCreationLimitedInterfacea použijeApp\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. - V
beforeSave()zavolejguardContentCreation($isInsert, '<scope>', $userId)(u anonymní cesty navícguardAnonymousInsert(…, $email)) a vrať jehoResult, není-linull. Počítá se jen insert. - Odpověď je
RATE_LIMIT_EXCEEDED(kód404) se zprávouToo many new records, try again later., na externí cestě s HTTP 200. - Vzory:
ResponseManager(Bazaar, Comcat),MessageManageraMessageReportManager(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#
- 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á. U limitu klíče rozliš podlemeta.window, jestli aplikaci vytěžuje jeden volající (user_minute,ip_minute), nebo celý provoz (client_*).- Série
api_key_failurez jedné adresy je zkoušení klíčů;meta.reason = disabledneborevokeds konkrétnímapi_client_idje aplikace, která po vypnutí dál volá. - 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.
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/#
- Hlavičky:
curl -sI https://vase-domena.cz/— v odpovědi musí býtx-content-type-options: nosniffareferrer-policy: strict-origin-when-cross-origin. - Pravidlo:
curl -sI -o /dev/null -w '%{http_code} %{redirect_url}\n' https://vase-domena.cz/www/inzerce— má vrátit301a adresu bez/www. - 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í:
- 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 |
| 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í