Určitě už jste někdy narazili debatu „tabulátory vs. mezery“ pro
odsazování. Polemika probíhá od nepaměti a oba tábory vyzdvihují své
argumenty:
Tabulátory:
- odsazování je jejich účel
- menší soubory, protože odsazení zabírá jeden znak
- můžete si nastavit vlastní šířku odsazení (? k tomu se
vrátíme)
Mezery:
- kód bude vypadat všude stejně a konzistence je klíčová
- vyhnete se možným problémům v prostředích citlivých na
bílé znaky
Co když jde ale o víc než o osobní preference? ChaseMoskal nedávno
zveřejnil na Redditu velmi podnětný příspěvek s názvem Nikdo
nezmínil skutečný důvod, proč používat tabulátory místo mezer,
který vám otevře oči.
Stěžejní důvod, proč
používat tabulátory
Chase ve svém příspěvku popisuje zkušenost se zaváděním mezer na
svém pracovišti a negativní dopady, které to mělo na spolupracovníky se
zrakovým postižením.
Jeden z nich byl zvyklý používat šířku tabulátoru 1, aby se vyhnul
velkým odsazením při použití obřího písma. Druhý používá šířku
tabulátoru 8, protože mu nejlépe vyhovuje na ultraširokém monitoru. Pro oba
však představuje kód s mezerami vážný problém, musí je převádět na
tabulátory před čtením a zase zpátky na mezery před komitováním.
Pro nevidomé programátory, kteří používají braillské displeje,
představuje každá mezera jednu braillskou buňkou. Pokud je tedy výchozí
odsazení 4 mezery, odsazení 3. úrovně plýtvá 12 cennými braillskými
buňkami ještě před začátkem kódu. Na 40buňkovém displeji, který se
u notebooků používá nejčastěji, je to více než čtvrtina dostupných
buněk, které jsou promrhány bez jakékoliv informace.
Nám se může přizpůsobení šířky odsazení zdát jako zbytečnost,
jsou ale mezi námi programátoři, pro které je naprosto nezbytné. A to
prostě nemůžeme ignorovat.
Tím, že budeme v našich projektech používat tabulátory, dáváme jim
možnost tohoto přizpůsobení.
Nejdříve
přístupnost, pak osobní preference
Jistě, nejde přesvědčit každého, aby se přiklonil na jednu či druhou
stranu, jde-li o preference. Každý má své. A měli bychom být rádi za
možnost volby.
Zároveň však musíme dbát na to, abychom zohledňovali všechny. Abychom
respektovali odlišnosti a používali přístupné prostředky. Jakým je
například znak tabulátor.
Myslím, že Chase to vystihl dokonale, když ve svém příspěvku uvedl,
že „…neexistuje žádný protiargument, který by se jen blížil k tomu
převážit potřeby přístupnosti našich spolupracovníků“.
Accessible first
Stejně jako při navrhování webů se vžila metodika „mobile first“,
kdy se snažíme zajistit, aby každý, bez ohledu na zařízení, měl
s vaším produktem skvělou user experience – měli bychom usilovat o
„accessible first“ prostředí tím, že zajistíme, aby každý měl
stejnou možnost pracovat s kódem, ať už v zaměstnání nebo na opensource
projektu.
Pokud se tabulátory stanou výchozí volbou pro odsazování, odstraníme
jednu bariéru. Spolupráce pak bude příjemná pro každého, bez ohledu na
jeho schopnosti. Pokud budou mít všichni stejné možnosti, můžeme
maximálně využít společný potenciál ❤️
Článek vychází z Default
to tabs instead of spaces for an ‚accessible first‘ environment.
Podobně přesvědčivý post jsem si přečetl v roce 2008 a ještě ten den
změnil ve všech svých projektech mezery na tabulátory. Zůstala po tom stopa
v Gitu, ale samotný článek už zmizel v propadlišti dějin.
Knihovna Texy od verze 3.1.6 přidává podporu pro Latte
3 v podobě značky {texy}
. Co umí a jak ji nasadit?
Značka {texy}
představuje snadný způsob, jak v Latte
šablonách psát přímo v syntaxi Texy:
{texy}
You Already Know the Syntax
----------
No kidding, you know Latte syntax already. **It is the same as PHP syntax.**
{/texy}
Stačí do Latte nainstalovat rozšíření a předat mu objekt Texy
nakonfigurovaný podle potřeby:
$texy = new Texy\Texy;
$latte = new Latte\Engine;
$latte->addExtension(new Texy\Bridges\Latte\TexyExtension($texy));
Pokud je mezi značkami {texy}...{/texy}
statický text, tak se
přeloží pomocí Texy už během kompilace šablony a výsledek do ní
uloží. Pokud je obsah dynamický (tj. jsou uvnitř Latte značky),
zpracování pomocí Texy se provádí pokaždé při vykreslování
šablony.
Pokud je žádoucí Latte značky uvnitř vypnout, dá se to
udělat takto:
{texy syntax: off} ... {/texy}
Do rozšíření lze kromě objektu Texy předat také vlastní funkci a tak
umožnit předávat ze šablony parametry. Kupříkladu chceme mít možnost
předávat parametry locale
a heading
:
$processor = function (string $text, int $heading = 1, string $locale = 'cs'): string {
$texy = new Texy\Texy;
$texy->headingModule->top = $heading;
$texy->typographyModule->locale = $locale;
return $texy->process($text);
};
$latte = new Latte\Engine;
$latte->addExtension(new Texy\Bridges\Latte\TexyExtension($processor));
Parametry v šabloně předáme takto:
{texy locale: en, heading: 3}
...
{/texy}
Pokud chcete pomocí Texy formátovat text uložený v proměnné, můžete
použít filtr:
{$description|texy}
Prosím o fanfáry, na scénu přichází Latte
3. S kompletně přepsaným kompilátorem. Nová verze představuje
největší vývojový skok, jaký kdy v Nette nastal.
Proč vlastně Latte
Latte má překvapivou historii.
Původně totiž nebylo myšleno vážně. Mělo dokonce demonstrovat, že
žádný šablonovací systém není v PHP potřeba. Bylo pevně spjato
s presentery v Nette, kde však nebylo defaultně zapnuté a programátor jej
musel aktivovat přes tehdejší ošklivý název CurlyBracketsFilter.
Zvrat přišel až s nápadem, že šablonovací systém by mohl HTML
stránce rozumět. Vysvětlím. Pro ostatní šablonovací systémy je text
v okolí značek jen šumem bez jakéhokoliv významu. Je jedno, jestli jde
o HTML stránku, CSS styl nebo třeba text v Markdownu, šablonovací engine
vidí jen shluk bajtů. Latte naopak dokument chápe. Což přináší spoustu
zásadních výhod. Od komfortu v podobě vychytávek jako jsou třeba n:attributy, až po
ultimátní bezpečnost.
Latte tak ví, jakou použít escapovací funkci (což
většina programátorů neví, ale díky Latte to nevadí a nevytvoří
bezpečnostní díru Cross-site
scripting). Zabrání vypsání řetězce, který by v určitém místě byl nebezpečný. Dokonce
dokáže předejít dezinterpretaci mustache
závorek frontendovým frameworkem. A bezpečnostní experti nebudou mít
co žrát :)
Nečekal bych, že tímto nápadem přeběhne Latte ostatní systémy
o 10 let, protože dodneška vím pouze o dvou, co takto fungují. Krom Latte
je to ještě Soy od Google. Latte a Soy jsou jediné opravdu bezpečné
šablonovací systémy pro web. (Byť teda Soy ze zmíněných vychytávek má
pouze to escapování.)
Druhou klíčovou vlastností Latte je, že pro výrazy uvnitř značek
(někdy se říká maker) používá jazyk PHP. Tedy syntaxi programátorovi
důvěrně známou. Vývojář se tak nemusí učit nový jazyk. Nemusí
zkoumat, jak se to či ono v Latte píše. Prostě to napíše tak jak umí.
Naopak třeba populární šablonovací systém Twig používá syntaxi Pythonu,
kde se i zcela základní konstrukce píší odlišně. Například
foreach ($people as $person)
se v Pythonu (a tedy i Twigu) píše
jako for person in people
, což zcela zbytečně nutí mozek
přepínat mezi dvěma opačnými konvencemi.
Latte tedy má oproti konkurenci natolik podstatnou přidanou hodnotu, že
má smysl investovat úsilí do jeho údržby a vývoje.
Současný kompilátor
Latte a jeho syntax vznikla před 14 lety (rok 2008), současný kompilátor
o tři roky později. Uměl už tehdy vše podstatné, co se dodnes používá,
tedy i bloky, dědičnost, snippety atd.
Kompilátor fungoval jako jednoprůchodový, což znamená, že parsoval
šablonu a rovnou ji přetvářel do PHP kódu, který sestavil do výsledného
souboru. Jazyk PHP používaný ve značkách (tj. v makrech) se tokenizoval a
poté procházel několika procesy, které tokeny upravovaly. Jeden proces
doplňoval řetězcové uvozovky kolem identifikátorů, jiný přidával
syntaktické vychytávky, které PHP tehdy neznalo (například zápis polí
pomocí []
místo array()
, nullsafe operátory
?->
) nebo které nezná doposud (zkrácený ternární
operátor, filtry ($var|upper|truncate)
, atd).
Tyto procesy ale nijak nekontrolovaly PHP syntax nebo používané
konstrukce. Což se výrazně změnilo až před dvěma lety (rok 2020)
s příchodem sandbox režimu.
Sandbox hledá v tokenech možné volání funkcí a metod a upravuje je, což
není vůbec jednoduché. Přičemž případné selhání je vlastně
bezpečností chybou.
Nový kompilátor
Za jedenáct let vývoje Latte se našly situace, kdy jednoprůchodový
kompilátor nestačil (třeba při inkludování bloku, který ještě nebyl definován). Všechny
issue šlo sice vyřešit, ale ideální by bylo přejít na dvoukrokovou
kompilaci, tedy nejprve šablonu naparsovat do mezipodoby, do AST stromu, a pak
teprve z něj vygenerovat kód třídy.
Taktéž s postupným vylepšováním PHPlike jazyka používaného
ve značkách přestávala dostačovat reprezentace v tokenech a ideální by
bylo i jej naparsovat do AST stromu. Naprogramovat sandbox nad AST stromem je
výrazně snadnější a dá se garantovat, že bude skutečně
neprůstřelný.
Trvalo mi pět let se do přepsání kompilátoru pustit, protože jsem
věděl, že to bude extrémně náročné. Už samotná tokenizace
šablony představuje výzvu, neboť musí běžet paralelně s parsováním.
Parser totiž musí mít možnost ovlivňovat tokenizaci, když například
narazí na atribut n:syntax=off.
Podporu pro paralelní běh dvou kódů přináší až Fibers v PHP 8.1,
nicméně Latte je zatím nevyužívá, aby mohlo fungovat na PHP 8.0. Místo
toho používá obdobné coroutines (v dokumentaci PHP o nich nic nenajdete,
tak alespoň odkaz na Generator
RFC). Pod kapotou Latte se tedy odehrávají kouzla.
Nicméně jako ještě mnohem náročnější úkol mi připadalo napsat
lexer a parser pro tak komplexní jazyk, jako je dialekt PHP používaný ve
značkách. V podstatě to znamenalo vytvořit něco jako nikic/PHP-Parser pro
Latte. A zároveň i nutnost formalizovat gramatiku tohoto jazyka.
Dnes můžu říct, že se mi povedlo všechno dokončit. Latte má
kompilátor, jaký jsem si dlouhá léta přál. A z toho původního nezbyl
ani jediný řádek kódu 🙂
Před mnoha lety jsem si uvědomil, že když v PHP ve funkci používám
proměnnou obsahující předdefinovanou tabulku dat, tak při každém volání
funkce musí být pole znovu „vytvořené“, což je překvapivě dost
pomalé. Příklad:
function isSpecialName(string $name): bool
{
$specialNames = ['foo' => 1, 'bar' => 1, 'baz' => 1, ...];
return isset($specialNames[$name]);
}
A přišel jsem na jednoduchý trik, který znovuvytváření zabránil.
Stačilo proměnnou definovat jako statickou:
function isSpecialName(string $name): bool
{
static $specialNames = ['foo' => 1, 'bar' => 1, 'baz' => 1, ...];
return isset($specialNames[$name]);
}
Zrychlení, pokud pole bylo trošku větší, se pohybovalo v několika
řádech (jako třeba klidně 500×).
Takže od té doby jsem u konstantních polí vždy používal
static
. Je možné, že tento zvyk někdo následoval, a třeba ani
netušil, jaký má skutečný důvod. Ale to nevím.
Před pár týdny jsem psal třídu, která nesla v několika properties
velké tabulky předdefinovaných dat. Uvědomil jsem si, že to bude zpomalovat
vytváření instancí, tedy že operátor new
bude pokaždé
„vytvářet“ pole, což jak víme je pomalé. Tudíž musím properties
změnit na statické, nebo možná ještě lépe použít konstanty.
A tehdy jsem si položil otázku: Hele a nejsi jen ve vleku cargo kultu? Opravdu pořád
platí, že bez static je to pomalé?
Těžko říct, PHP prošlo revolučním vývojem a staré pravdy nemusí
být platné. Připravil jsem proto testovací vzorek a udělal pár měření.
Samozřejmě jsem si potvrdil, že v PHP 5 použití static uvnitř funkce
nebo u properties přineslo zrychlení o několik řádů. Ale pozor, v PHP
7.0 už šlo jen o jeden řád. Výborně, projev optimalizací v novém
jádře, ale stále je rozdíl podstatný. Nicméně u dalších verzí PHP
rozdíl dál klesal a až postupně téměř vymizel.
Dokonce jsem zjistil, že použití static uvnitř funkce v PHP 7.1 a
7.2 běh zpomalovalo. Zhruba 1,5–2×, tedy z pohledu řádů, o kterých se
tu celou dobu bavíme, zcela zanedbatelně, ale byl to zajímavý paradox. Od
PHP 7.3 rozdíl zmizel zcela.
Zvyklosti jsou dobrá věc, ale je nutné jejich smysl stále validovat.
Zbytečný static v těle funkcí už používat nebudu. Nicméně u oné
třídy, která držela velké tabulky předdefinovaných dat v properties,
jsem si řekl, že je programátorsky správné konstanty použít. Za chvíli
jsem měl refaktoring hotový, ale už jak vznikal jsem naříkal nad tím, jak
se kód stává ošklivým. Místo $this->ruleToNonTerminal
nebo
$this->actionLength
se v kódu objevovalo řvoucí
$this::RULE_TO_NON_TERMINAL
a $this::ACTION_LENGTH
a
vypadalo to fakt hnusně. Zatuchlý závan ze sedmdesátých let.
Až jsem zaváhal, jestli vůbec chci koukat na tak hnusný kód, a jestli
raději nezůstanu u proměnných, případně statických proměnných.
A tehdy mi to došlo: Hele nejsi jen ve vleku cargo kultu?
No jasně že jsem. Proč by měla konstanta řvát? Proč by měla na sebe
upozorňovat v kódu, být vyčnívajícím elementem v toku programu? Fakt,
že struktura slouží jen ke čtení, není důvod PRO ZASEKNUTÝ CAPSLOCK,
AGRESIVNÍ TÓN A HORŠÍ ČITELNOST.
TRADICE VELKÝCH PÍSMEN POCHÁZÍ Z JAZYKA C, KDE SE TAKTO OZNAČOVALY
MAKROKONSTANTY PREPROCESORU. BYLO UŽITEČNÉ NEPŘEHLÉDNUTELNĚ ODLIŠIT KÓD
PRO PARSER OD KÓDU PRO PREPROCESOR. V PHP SE ŽÁDNÉ PREPROCESORY NIKDY
NEPOUŽÍVALY, TAKŽE NENÍ ANI DŮVOD psát konstanty velkými písmeny.
Ještě ten večer jsem je všude zrušil. A stále nemohl pochopil, proč
mě to nenapadlo už před dvaceti lety. Čím větší blbost, tím tužší
má kořínek.
Vždycky mi vadila jakákoliv nadbytečnost nebo duplicita v kódu. Už jsem
o tom psal před mnoha
lety. Při pohledu na tento kód prostě trpím:
interface ContainerAwareInterface
{
/**
* Sets the container.
*/
public function setContainer(ContainerInterface $container = null);
}
Obsahovou zbytečnost komentáře u metody ponechme stranou.
A protentokrát i projev nepochopení dependency injection, pokud knihovna
potřebuje disponovat takovým rozhraním. O tom, že použití slova
Interface
v názvu rozhraní je pro změnu projevem nepochopení
objektového programování, chystám samostatný článek. Koneckonců jsem si
tím sám prošel.
Ale proč proboha uvádět viditelnost public
? Vždyť je to pleonasmus. Kdyby to nebylo
public, tak to pak není rozhraní, ne? No a ještě někoho napadlo z toho
udělat „standard“ ?♂️
Uff, omlouvám se za dlouhý úvod, to, kam celou dobu směřuju, je zda
psát volitelné nullable typy s otazníkem nebo bez. Tj:
// bez
function setContainer(ContainerInterface $container = null);
// s
function setContainer(?ContainerInterface $container = null);
Osobně jsem se vždycky klonil k první možnosti, protože informace daná
otazníkem je redundantní (ano, oba zápisy znamenají z pohledu jazyka
totéž). Zároveň se tak zapisoval veškerý kód do příchodu PHP 7.1, tedy
verze, která otazník přidala, a musel by být dobrý důvod jej
najednou měnit.
S příchodem PHP 8.0 jsem názor změnil a vysvětlím proč. Otazník
totiž není volitelný v případě properties. Na tomhle PHP zařve:
class Foo
{
private Bar $foo = null;
}
// Fatal error: Default value for property of type Bar may not be null.
// Use the nullable type ?Bar to allow null default value
A dále od PHP 8.0 lze používat promoted
properties, což umožňuje psát takovýto kód:
class Foo
{
public function __construct(
private ?Bar $foo = null,
string $name = null,
) {
// ...
}
}
Zde je vidět nekonzistence. Pokud je v kódu použito ?Bar
(což je nutnost), mělo by o řádek níže následovat ?string
.
A pokud v některých případech budu psát otazník, měl bych ho psát
ve všech.
Zůstává otázka, zda není lepší používat místo otazníku přímo
union typ string|null
. Pokud bych třeba chtěl zapsat
Stringable|string|null
, verze s otazníkem možná
vůbec není.
Aktualizace: vypadá to, že PHP 8.4 bude zápis s otazníkem přímo
vyžadovat.
Zajímalo mě, který PHP framework má nejlepší dokumentaci.
A jak si v žebříčku stojí Nette. Jenže jak to zjistit?
Všichni víme, že nejhorší je žádná dokumentace. Pak následuje
nedostatečná dokumentace. Opakem je obsáhlá dokumentace. Tedy zdá se, že
důležitým vodítkem je samotný objem dokumentace. Pochopitelně obrovskou
roli hraje i její srozumitelnost a aktuálnost, dojem dělá čtivost a
bezchybnost. Tyto faktory se velmi těžko měří. Nicméně sám vím, kolik
částí dokumentace Nette jsem mnohokrát přepsal, aby byly jasnější, kolik
oprav jsem mergoval, a předpokládám, že se tak děje u každého letitého
frameworku. Že tedy postupně všechny dokumentace konvergují k podobné
vysoké kvalitě. Tudíž si jako vodítko dovolím brát čistě objem dat,
byť jde o zjednodušení.
Pochopitelně se objem dokumentace musí dát do poměru s velikostí té
které knihovny. Některé jsou i řádově větší než jiné a pak by měly
mít i řádově větší dokumentaci. Pro jednoduchost budu velikost knihovny
stanovovat podle objemu PHP kódu. S normalizovaným bílým místem, bez
komentářů.
Vytvořil jsem graf poměru anglické dokumentace ku kódu u známých
frameworků CakePHP (4.2), CodeIgniter (3.1), Laravel (8.62), Nette (3.1),
Symfony (5.4), YII (2.0) a Zend Framework (2.x, již nevyvíjený):
Jak z grafu vidíte, obsáhlost dokumentace vůči kódu je u všech
frameworků víceméně podobná.
Vyčnívá CodeIgniter. Smekám před CakePHP a YII, které se snaží
udržovat dokumentaci v celé řadě dalších jazyků. Obsáhlost dokumentace
Nette je nad průměrem. Zároveň Nette je jediný framework, který má 1:1
překlad i v naší mateřštině.
Smyslem grafu NENÍ ukázat, že ten či onen framework má o tolik procent
obsáhlejší dokumentaci než jiný. Na to je metrika příliš primitivní.
Smyslem je naopak ukázat, že obsáhlost dokumentace u jednotlivých
frameworků z velké míry srovnatelná. Vytvořil jsem jej hlavně pro sebe,
abych získal představu, jak je na tom dokumentace Nette ve srovnání
s konkurencí.
Původně vyšlo v srpnu 2019, údaje jsou aktualizované pro
říjen 2021.
Ukončení požadavku v PHP se skládá z těchto kroků prováděných
v uvedeném pořadí:
- Volání všech funkcí registrovaných pomocí
register_shutdown_function()
- Volání všech metod
__destruct()
- Vyprázdnění všech output bufferů
- Ukončení všech rozšíření PHP (např. sessions)
- Vypnutí výstupní vrstvy (odeslání HTTP hlaviček, vyčištění output
handlerů atd.)
Zaměříme se podrobněji na krok č. 2, tedy volání destruktorů.
Samozřejmě už v prvním kroku, tedy při volání registrovaných shutdown
funkcí, může dojít k destrukci objektů, např. pokud některá z funkcí
držela poslední referenci na nějaký objekt nebo pokud byla samotná shutdown
funkce objektem.
Volání destruktorů probíhá takto:
- PHP se nejprve pokusí zrušit objekty v globální tabulce symbolů.
- Poté volá destruktory všech zbývajících objektů.
- Pokud je provádění zastaveno např. kvůli
exit()
,
zbývající destruktory se nevolají.
ad 1) PHP projde globální tabulku symbolů pozpátku, tj. začne od
proměnné, která byla vytvořena jako poslední, a postupuje k proměnné,
která byla vytvořena jako první. Při procházení zruší všechny objekty
s refcount=1. Tato iterace se provádí, dokud takové objekty existují.
V podstatě se tedy dělá to, že a) odstraní všechny nepoužívané
objekty v globální tabulce symbolů b) pokud se objeví nové nepoužívané
objekty, odstraní je také c) a tak dále. Tento způsob destrukce se
používá proto, aby objekty mohly být závislé na jiných objektech
v destruktoru. Obvykle to funguje dobře, pokud objekty v globálním oboru
nemají komplikované (např. kruhové) vzájemné vazby.
Destrukce globální tabulky symbolů se výrazně liší od destrukce
ostatních tabulek symbolů, viz dále. Pro globální tabulku symbolů tedy PHP
používá chytřejší algoritmus, který se snaží respektovat závislosti
objektů.
ad 2) Ostatní objekty se prochází v pořadí podle jejich vytvoření a
zavolá se jejich destruktor. Ano, PHP pouze zavolá __destruct
,
ale ve skutečnosti objekt nezruší (a dokonce ani nezmění jeho refcount).
Pokud se tedy na objekt budou odkazovat jiné objekty, bude stále k dispozici
(i když destruktor již byl zavolán). V jistém smyslu budou používat
jakýsi „napůl zničený“ objekt.
ad 3) V případě, že je provádění zastaveno během volání
destruktorů např. kvůli exit()
, zbývající destruktory se
nevolají. Místo toho PHP označí objekty za již destruované. Důležitý
důsledek je, že volání destruktorů není jisté. Případy, kdy se tak
stane, jsou spíše vzácné, ale stát se to může.
Zdroj https://stackoverflow.com/…ucted-in-php
Pokud píšete vlastní error handler pro PHP, je
bezpodmínečně nutné dodržet několik pravidel. Jinak může nabourat
chování dalších knihoven a aplikací, které nečekají v error
handleru zradu.
Parametry
Signatura handleru vypadá takto:
function errorHandler(
int $severity,
string $message,
string $file,
int $line,
array $context = null // pouze v PHP < 8
): ?bool {
...
}
Parametr $severity
obsahuje úroveň chyby
(E_NOTICE
, E_WARNING
, …). Pomocí handleru nelze
zachytávat fatální chyby, jako třeba E_ERROR
, takže těchto
hodnot nikdy nebude parametr nabývat. Naštěstí fatální chyby v podstatě
z PHP zmizely a byly nahrazeny za výjimky.
Parametr $message
je chybová hláška. Pokud je zapnutá
direktiva html_errors,
jsou speciální znaky jako <
apod. zapsány jako HTML entity,
takže do podoby plain textu je musíte dekódovat.
Ovšem pozor, některé znaky jako entity zapsány nejsou, což je bug.
Samotné zobrazování chyb v čistém PHP je tak náchylné na XSS.
Parametry $file
a $line
představují název
souboru a řádek, kde k chybě došlo. Pokud chyba nastala uvnitř
eval()
, bude $file
doplněný o tuto informaci.
A nakonec parametr $context
obsahuje pole lokálních
proměnných, což představuje pro debugování užitečnou informaci, ale od
PHP 8 je zrušený. Pokud má handler fungovat v PHP 8, parametr vynechte nebo
mu dejte výchozí hodnotu.
Návratová hodnota
Návratová hodnota handleru může být null
nebo
false
. Pokud handler vrátí null
, nestane se nic.
Pokud vrátí false
, zavolá se ještě standardní PHP handler.
Ten podle konfigurace PHP může chybu vypsat, zalogovat atd. Co je důležité,
tak že také naplní interní informaci o poslední chybě, kterou
zpřístupňuje funkce error_get_last().
Potlačené chyby
V PHP lze potlačit zobrazování chyb buď pomocí shut-up operátoru
@
nebo pomocí error_reporting()
:
// potlač chyby úrovně E_USER_DEPRECATED
error_reporting(~E_USER_DEPRECATED);
// potlač všechny chyby při volání fopen()
$file = @fopen($name, 'r');
I při potlačení chyb dojde k volání handleru. Proto je nejprve
nutné ověřit, zda chyba je potlačená, a pokud ano, tak musíme
vlastní handler ukončit:
if (!($severity & error_reporting())) {
return false;
}
Ale pozor, musíme je v tomto případě ukončit pomocí
return false
, aby se spustil ještě standardní error handler. Ten
nic nevypíše ani nezaloguje (protože chyba je potlačená), ale zajistí, že
chybu půjde zjistit pomocí error_get_last()
.
Ostatní chyby
Pokud náš handler chybu zpracuje (například vypíše vlastní hlášku
atd.), už není potřeba volat standardní handler. Sice pak nebude možné
chybu zjistit pomocí error_get_last()
, ale to v praxi nevadí,
protože tato funkce se používá především v kombinaci s shut-up
operátorem.
Pokud handler naopak chybu z jakéhokoliv důvodu nezpracuje, měl by
vrátit false
, aby ji nezatajil.
Ukázkový příklad
Takto by vypadal kód vlastního error handleru, který transformuje chyby na
výjimky ErrorException:
set_error_handler(function (int $severity, string $message, string $file, int $line) {
if (!(error_reporting() & $severity)) {
return false;
}
throw new \ErrorException($message, 0, $severity, $file, $line);
});
Hurá, Nette už má první záznam v CVE! To znamená, že
v něm byla objevena první vážná zranitelnost. Co se vlastně stalo?
Na konci prázdnin mi napsal vývojář Cyku
Hong z malebného Taiwanu, že našel v Nette zranitelnost a
v následujícím e-mailu vysvětlil princip možného zneužití. Ověřil
jsem, že jde o uskutečnitelný útok. Dovoluje útočníkovi za určitých
okolností na některých webech pomocí speciálně sestaveného URL spustit
kód, tedy jde o zranitelnost Remote code execution (RCE). Cyku, díky!
Musím říct, že to bylo v 13leté historii frameworku Nette vlastně
poprvé, co někdo našel takto závažnou zranitelnost. Dříve byly
několikrát reportovány drobné záležitosti, např. letos v březnu Jan
Gocník odhalil možnou zranitelnost v případě, že by programátor
deserializoval a vypsal query proměnnou
echo unserialize($_GET['a'])
, což je samo o sobě principiálně
velmi nebezpečné, nicméně jeho nález jsem samozřejmě opravil. Také jsem
dostal řadu hlášení, které nebyly opodstatněné, například že
uploadovaný obrázek vyhovující testu isImage()
může v sobě
obsahovat PHP kód. Což samozřejmě může, například v metadatech, ale
není to bezpečnostní problém Nette.
Ale zpět k chybě, o které je tento článek. Bezprostředně po
nahlášení jsem ji opravil a vydal nové verze balíčků
nette/application
a nette/nette
.
Nejstarší zasaženou verzí bylo Nette 2.0, které už sice není 6 let
udržované, ale protože Nette má bezpečnost jako jednu z priorit, vydal
jsem nové verze také u všech nepodporovaných verzí. Což je ve světě
opensource frameworků ojedinělý krok. Díky tomu mohou uživatelé snadno
a bez prodlení aktualizovat nejen projekty udržované a běžící na
současných verzích, ale i projekty s technologickým dluhem. Vlastně
se teď dá říci, že každá řada Nette je nejen Long-Term Support
Release (tedy podporovaná alespoň dva roky, viz tabulka), ale z pohledu
bezpečnostních fixů i Forever-Term Supported 🙂
Druhým krokem bylo o chybě informovat. Samotné zveřejnění chyby na
blogu by i bez podrobného popisu zneužití představovalo vodítko pro
darebáky, kteří by se o chybě dozvěděli a mohli se pokusit ji zneužít.
Proto mi připadalo fér nejprve informovat všechny podporovatele Nette, poté i další
uživatele na které mám kontakt a teprve s určitým časovým odstupem
publikovat oznámení veřejně na blogu,
GitHubu
a katalogu
CVE. Prostě dát partnerům určitý čas zaktualizovat všechny weby
dříve, než by se objevil první útočník. Původně jsem zamýšlel dát
odstup týden, ale pak jsem na základě diskusí pochopil, že to je doslova
šibeniční termín a vhodnější je dát alespoň 2–4 týdny.
Jak už jsem zmiňoval, šlo o mou první zkušenost s takovou situací,
ale chtěl jsem ji zvládnout příkladně. Abych se nedopustil žádného
přešlapu, napsat jsem Michalu
Špačkovi, kterého považuji za nejlepšího odborníka v této oblasti,
a všechno s ním konzultoval. Michal mi schválil postup, dal řadu
užitečných rad, připomínkoval emaily atd. Michale, moc děkuji!
Ačkoliv žádný z mnou provozovaných webů nebyl tímto způsobem
zranitelný, prohledal jsem jejich access logy za posledních 8 let (co díra
existuje) a zjistil, že tento typ útoku na ně historicky nikdo nezkusil.
Soudím, že na zranitelnost nikdo dříve nepřišel. Útočníci totiž
obvykle zkouší testovat také přímo web nette.org.
Nechci zveřejňovat přesný postup zneužití chyby a doufám, že to ani
nikdo jiný neudělá. Alespoň ne v dohledné době, protože by tím
způsobil ostatním nepříjemnosti a zpronevěřil se duchu open source.
Aktualizujte prosím co nejdříve na nejnovější
setinkové verze:
- nette/application 3.0.6 (případně 3.0.2.1, 3.1.0-RC2 nebo dev)
- nette/application 2.4.16
- nette/application 2.3.14
- nette/application 2.2.10
- nette/nette 2.1.13
- nette/nette 2.0.19
Nejrychlejší oprava
Michal připravil Linuxový
skript a já obdobu
v PHP, který automaticky aplikuje patch přímo do zdrojových kódů
Nette na disku. Hodí se v případě, že udržujete velké množství
projektů, které nemáte čas korektně aktualizovat pomocí Composeru.
Informaci o chybě rozeslal emailem svým klientům VSHosting, WEDOS nebo
HostingBB, zároveň některé hostingy přímo blogují problematickou URL,
případně rovnou aplikovaly výše uvedený fix. Díky!!!
SameSite cookies poskytují mechanismus, jak rozpoznat, co
vedlo k načtení stránky. Jestli to bylo prokliknutí odkazu na jiném webu,
odeslání formuláře, načtení uvnitř iframe, pomocí JavaScriptu atd.
Rozlišit, jak byla stránka načtena, je totiž naprosto zásadní kvůli
bezpečnosti. Závažná zranitelnost Cross-Site
Request Forgery (CSRF) je tu s námi už dlouhých dvacet let a teprve
SameSite cookie nabízí systémovou cestu, jak ji řešit.
Útok CSRF spočívá v tom, že útočník naláká oběť na stránku,
která nenápadně v prohlížeči oběti vykoná požadavek na webovou
aplikaci, na které je oběť přihlášena, a ta se domnívá, že požadavek
vykonala oběť o své vůli. A tak pod identitou oběti provede nějaký
úkon, aniž by ta o tom věděla. Může jít o změnu nebo smazání dat,
odeslání zprávy atd. Aby aplikace útoku zabránila, musí rozlišit, jestli
požadavek vznikl povolenou cestou, např. odesláním formuláře v ní
samotné, nebo nějak jinak. SameSite cookie tohle umí.
Jak to funguje? Řekněme, že mám web běžící na nějaké doméně a
vytvořím na něm tři různé cookies s atributy SameSite=Lax
,
SameSite=Strict
a SameSite=None
. Název ani hodnota
nehrají roli. Prohlížeč si je uloží.
- Když libovolnou URL na mém webu otevřu přímým zadáním do adresního
řádku nebo kliknutím na záložku, prohlížeč všechny tři cookie
odešle.
- Když se na libovolnou URL na mém webu dostanu jakkoliv ze stránky
z téhož webu, prohlížeč všechny tři cookie odešle.
- Když se na libovolnou URL na mém webu dostanu ze stránky z jiného
webu, prohlížeč pošle jen cookie s atributem
None
a
v určitých případech i Lax
, viz tabulka:
Kód na jiném webu |
|
Odeslané cookie |
Link |
<a href="…"> |
None + Lax |
Form GET |
<form method="GET" action="…"> |
None + Lax |
Form POST |
<form method="POST" action="…"> |
None |
iframe |
<iframe src="…"> |
None |
AJAX |
$.get('…'), fetch('…') |
None |
Image |
<img src="…"> |
None |
Prefetch |
<link rel="prefetch" href="…"> |
None |
… |
|
None |
SameSite cookies dokáží rozlišit jen několik málo případů, ale jde
právě o ty podstatné pro ochranu před CSRF.
Pokud mám třeba na webu v administraci formulář nebo nějaký odkaz pro
smazání položky a ten byl odeslán/odkliknut, tak nepřítomnost cookie
vytvořené s atributem Strict
znamená, že se tak nestalo na
mém webu, ale že požadavek přišel odjinud, tedy že jde o CSRF útok.
Cookie pro odhalení CSRF útoku vytvářejte jako tzv. session cookie bez
atributu Expires
, platnost je pak v podstatě nekonečná.
Doména vs site
„Na mém webu“ není to stejné jako „na mé doméně“, nejde
o doménu, ale o web site (proto i název SameSite). Site sice často
odpovídá doméně, ale třeba u služby github.io
odpovídá
subdoméně. Požadavek z doc.nette.org
na
files.nette.org
je same-site, zatímco požadavek z
nette.github.io
na tracy.github.io
je už cross-site.
Tady je to hezky
vysvětlené.
<iframe>
Z předchozích řádků již vyplynulo, že pokud je stránka z mého webu
načtená uvnitř <iframe>
na jiném webu, nepošle jí
prohlížeč Strict
ani Lax
cookies. Je tu ale ještě
jedna důležitá věc: pokud takto načtená stránka vytvoří
Strict
nebo Lax
cookie, prohlížeč je
ignoruje.
Tím vzniká možnost se bránit proti podvodnému získávání cookie
neboli Cookie
Stuffing, kde dosud systémová obrana taky chyběla. Trik spočívá
v tom, že podvodník inkasuje provizi za affiliate marketing, ačkoliv
uživatele na web obchodníka nepřivedl. Místo odkazu s affiliate ID, na
který by musel uživatel kliknout, vloží do stránky neviditelný
<iframe>
se stejným odkazem a značkuje tak všechny
návštěvníky.
Cookie bez atributu SameSite
Sušenky bez atributu SameSite se vždy posílaly při jakémkoliv same-site
i cross-site požadavku. Stejně jako SameSite=None
. Jenže
v blízké budoucnosti začnou prohlížeče považovat příznak
SameSite=Lax
za výchozí, takže sušenky bez atributu budou
považovány za Lax
. Což je docela nebývale velký BC break
v chování prohlížečů. Pokud chcete, aby se cookie i nadále chovala
stejně a přenášela se při jakémkoliv cross-site požadavku, je potřeba
jí nastavit SameSite=None
. (Pokud nevyvíjíte embedované widgety
apod., moc často to nechcete.) Bohužel pro loňské prohlížeče je hodnota
None
nečekaná. Safari 12 ji chápe jako Strict
,
takže na starších iOS a macOS vzniká ošemetný problém.
A ještě pozor: None
funguje jen když je nastaven
s atributem Secure
.
Co udělat při útoku?
Utéct! Základní pravidlo sebeobrany, jak v reálném životě, tak na
webu. Obrovskou chybou spousty frameworků je, že při detekci CSRF útoku
zobrazí formulář znovu a napíší něco jako „Token CSRF je neplatný.
Zkuste prosím formulář znovu odeslat“. Tím, že jej uživatel odešle
znovu, je útok dokonán. Taková ochrana postrádá smysl, když vlastně
uživatele vyzvete, aby ji obešel.
Ještě nedávno dělal Chrome v případě cross-site požadavku to, že po
refreshi stránku zobrazil znovu, ale tentokrát cookie s atributem
Strict
poslal. Takže refresh vyřadil ochranu před CSRF
založenou na SameSite cookie. Dnes už to naštěstí nedělá, ale je možné,
že to dělají jiné nebo starší prohlížeče. Uživatel také může
stránku „refreshnout“ kliknutím na adresní řádek + enter, což se bere
jako přímé zadání URL (bod 1) a všechny cookie se odešlou.
Takže při detekci CSRF je nejlepší přesměrovat s HTTP kódem
302 jinam, třeba na homepage. Zbavíte se tak nebezpečných POST dat a ani
problematická URL se neuloží do historie.
Nekompatibility
SameSite dlouho nefungovalo ani zdaleka tak, jak by mělo. Především
kvůli chybám v prohlížečích a nedostatkům ve specifikaci, která třeba
vůbec neřešila přesměrování nebo refresh. Samesite cookie se
nepřenášely třeba při uložení nebo tisku stránky, naopak se přenášely
po refreshi, když zrovna neměly atd. Naštěstí dnes už je situace lepší.
Mám za to, že z vážných nedostatků přetrvává v aktuálních verzích
prohlížečů jen ten výše zmíněný u Safari.
Doplnění: kromě SameSite lze velmi čerstvě rozlišit původ
požadavku i hlavičkou Origin,
což je nástupce hlavičky Referer více respektující soukromí uživatelů a
pravopis.