phpFashion

Na navigaci | Klávesové zkratky

Latte 3: největší skok v dějinách Nette

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 🙂


Nejsi ve vleku cargo kultů?

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.


Zapisovat nullable types s otazníkem nebo bez?

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.


Readonly proměnné v PHP 8.1 vás zaskočí

PHP 8.1 přichází se zajímavou novinkou: readonly členské proměnné:

Začneme rovnou příkladem použití:

class Test
{
	public readonly string $prop;

	public function setProp(string $prop): void
	{
		$this->prop = $prop; // legal initialization
	}
}

$obj = new Test;
$obj->setProp('abc');
echo $obj->prop; // legal read
$obj->prop = 'foo'; // throws exception: Cannot modify readonly property Test::$prop

Tedy jednou inicializovaná proměnná už nemůže být přepsána jinou hodnotou.

Scope

Překvapivě ale přiřazení do $obj->prop vyhodí výjimku i v případě, že proměnná inicializovaná není:

$obj = new Test;
$obj->prop = 'foo';
// throws exception too: Cannot initialize readonly property Test::$prop from global scope

Dokonce i tohle vyhodí výjimku:

class Child extends Test
{
	public function setProp(): void
	{
		$this->prop = 'hello';
		// throws exception: Cannot initialize readonly property Test::$prop from scope Child
	}
}

$obj = new Child;
$obj->setProp();

Do readonly proměnné prostě nelze zapsat odjinud než ze třídy, která ji definovala. Zvláštní.

Neměnnost

To, že nelze měnit obsah readonly proměnných, ještě neznamená, že data tam zapsané jsou neměnné. Pokud do takové proměnné zapíšeme objekt, můžeme nadále měnit jeho vnitřní proměnné. Objekt se nestane immutable.

To stejné platí pro pole. Byť tam je chování trošku odlišné. Změna prvků v poli se považuje za změnu celého pole a tedy jako taková je v readonly proměnné nepřípustná. Ale pokud pole obsahuje prvek, který je referencí, změna jeho obsahu se za změnu celého pole nepovažuje a tedy k ní může v readonly prvku docházet. Což je nicméně standardní chování PHP odjakživa.

Jinými slovy tohle lze:

class Test
{
	public readonly array $prop;

	public function setProp(): void
	{
		$item = 'foo';
		$this->prop = [1, &$item, 2];
		var_dump($this->prop); // [1, 'foo', 2]
		$item = 'bar'; // legal
		var_dump($this->prop); // [1, 'bar', 2]
	}
}

Ale tohle nelze:

class Test
{
	public readonly array $prop;

	public function setProp(): void
	{
		$this->prop = ['a', 'b'];
		$this->prop[1] = 'c'; // throws exception!
	}
}

Typ

Protože readonly proměnné využívají stavu ‚uninitialized‘, který existuje u proměnných s definovaným typem, je možné readonly uvádět jen společně s datovým typem.


Který framework má nejlepší dokumentaci?

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.

před 3 lety v rubrice PHP


Jak probíhá shutdown v PHP a volání destruktorů?

Ukončení požadavku v PHP se skládá z těchto kroků prováděných v uvedeném pořadí:

  1. Volání všech funkcí registrovaných pomocí register_shutdown_function()
  2. Volání všech metod __destruct()
  3. Vyprázdnění všech output bufferů
  4. Ukončení všech rozšíření PHP (např. sessions)
  5. 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:

  1. PHP se nejprve pokusí zrušit objekty v globální tabulce symbolů.
  2. Poté volá destruktory všech zbývajících objektů.
  3. 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


Jak napsat error handler v 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);
});

Objevena první zranitelnost v Nette, aktualizujte!

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!!!


Co jsou SameSite cookie a proč je potřebujeme?

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ží.

  1. 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.
  2. 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.
  3. 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.

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.


Jak zprovoznit https na localhost?

Abych si přiblížil vývoj na lokálním počítači tomu ostrému, zprovoznil jsem si lokální https. Jak na to?

Nejsnazší cesta

V komentářích padla zmínka o aplikaci mkcert, která následující postup udělá za vás. Ale pokud máte rádi ruční práci, čtěte dál :)

Vygenerujeme SSL certifikát

K tomu použijeme program openssl, který určitě na počítači najdete, třeba jako součást Gitu na C:\Program Files\Git\usr\bin\openssl.exe nebo jinde. Následující příkaz vygeneruje klíč do souboru rootCA.key. Bude to po vás chtít vymyslet nějaké heslo, které si uložte.

openssl genrsa -des3 -out rootCA.key 2048

Další příkaz z klíče vygeneruje kořenový SSL certifikát a uloží ho do souboru rootCA.pem. Jeho platnost bude 2000 dní (tedy pět let), ale číslo klidně změňte:

openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 2000 -out rootCA.pem

Uvěříme certifikátu

Teď oznámíme operačnímu systému, aby certifikát považoval za důvěryhodný. Postup pro Mac jsem našel třeba tady, pro Windows je pěkně obrázek za obrázkem sepsaný zde a pro české Windows jsem vám jej přeložil:

  • spustit mmc
  • v menu Soubor > Přidat nebo odebrat moduly snap-in
  • doubleclick na Certifikáty v levém okně
  • zvolte Účet počítače a Další
  • ponechte Místní počítač a Dokončit
  • zavřete okno tlačítkem OK
  • v levém okně zvolte Certifikáty, v pravém Důvěryhodné kořenové certifikační autority
  • pravé tlačítko a v kontextovém menu Všechny úkoly > Importovat
  • objeví se Průvodce importem certifikátu, dejte Další
  • vyberte soubor rootCA.pem a odklikejte průvodce na konec

Ufff, jsme v půlce. Pokračujeme v Konzoli:

  • opět v menu Soubor > Přidat nebo odebrat moduly snap-in
  • doubleclick na Editor objektů zásad skupiny
  • stiskněte Dokončit a zavřete okno tlačítkem OK
  • v levém okně otevřete Místní počítač – zásady > Konfigurace počítače > Nastavení systému Windows > Nastavení zabezpečení > Zásady veřejných klíčů
  • doubleclick na Nastavení ověření cesty certifikátů
  • zatrhněte Definovat tato nastavení, Povolit ověřování certifikátů … a Povolit uživatelům důvěřovat certifikátum …
  • potvrďte tlačítkem OK

Hotovo, zavřete Konzoli.

Všichni už certifikátu věří, akorát Firefox chce ještě popostrčit:

  • ve Firefoxu otevřete stránku about:config
  • odklikejte všechna varování
  • vyhledejte položku security.enterprise_roots.enabled
  • doubleklikem změňte hodnotu na true
  • zavřete stránku

Vyrobíme certifikát pro webový server

Klikačku máme za sebou, teď vyrobíme certifikáty pro server. Vytvoříme soubor server.csr.cnf s tímto obsahem:

[req]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn

[dn]
C = CZ
ST = Random
L = Random
O = Random
OU = Random
emailAddress = example@example.com
CN = localhost

A dále soubor v3.ext, kde bude uveden seznam všech domén, které na localhostu provozujete:

authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names

[alt_names]
DNS.1 = localhost
DNS.2 = texy.l
DNS.3 = *.texy.l
DNS.4 = nette.l
DNS.5 = *.nette.l
DNS.6 = navlnachekg.l

Jak vidíte, lokální verze webů provozuji na doménách, které končí na .l, u vás to třeba bude jinak. Ty jednotlivé klíče DNS.1 apod. je potřeba fakt jako idiot postupně očíslovat.

Následujícím krokem vygenerujeme soubory server.key aserver.csr:

openssl req -new -sha256 -nodes -out server.csr -newkey rsa:2048 -keyout server.key -config server.csr.cnf

A konečně vygenerujeme SSL certifikát pro server do server.crt (teď to bude po vás chtít zadat heslo, které jste si vymysleli na začátku):

openssl x509 -req -in server.csr -CA rootCA.pem -CAkey rootCA.key -CAcreateserial -out server.crt -days 500 -sha256 -extfile v3.ext

Všechny dosud vytvořené soubory včetně hesla si někam uložte. Ať se totiž změní domény, které provozujete lokálně, jen upravíte v3.ext a posledním příkazem znovu vygenerujete certifikát pro server.

Konfigurace webového serveru

Zbývá povolit https na serveru. Tj. nakonfigurovat server tak, aby naslouchal na portu 443, na kterém běží https, a používal vygenerovaný certifikát.

Používám Apache, do jehož konfiguračního soubor httpd.conf jsem přidal následující řádky s cestou k souborům server.key aserver.crt:

Listen 443
SSLCertificateFile "C:\Apache24\ssl\server.crt"
SSLCertificateKeyFile "C:\Apache24\ssl\server.key"

Konfiguraci virtuál hostů možná řešíte úplně jinak než já, těžko říct, ale v mém případě zprovoznění obnášelo ke každému virtuálu jako je

<VirtualHost *>
    ServerName localhost
    DocumentRoot "W:/"
</VirtualHost>

doplnit ještě druhý:

<VirtualHost *:443>
    ServerName localhost
    DocumentRoot "W:/"
    SSLEngine on
</VirtualHost>

V nginx by mělo stačit do konfiguračního souboru nginx.conf doplnit pro každý server něco takového:

server {
	listen 443 ssl;
	ssl_certificate     path/to/server.crt;
	ssl_certificate_key   path/to/server.key;
}

A to je vše. Docela mazec, co? Otevřete v prohlížeči https://localhost a jestli se stránka zobrazí, je to důvod k oslavě.


phpFashion © 2004, 2024 David Grudl | o blogu

Ukázky zdrojových kódů smíte používat s uvedením autora a URL tohoto webu bez dalších omezení.