Automatické zabezpečení

Školení, která pořádám

Článek vyšel na serveru Zdroják.

Úroveň zabezpečení aplikace bych rozdělil do tří úrovní:

  1. Aplikace zabezpečená není, neošetřuje uživatelské vstupy ani své výstupy.
  2. Aplikace se o zabezpečení snaží, ale takovým způsobem, že na něj lze zapomenout.
  3. Aplikace se o zabezpečení stará sama, prakticky se nedá udělat chyba.

Jak se tyto úrovně projevují v jednotlivých oblastech?

XSS

Druhou úroveň představuje ruční ošetřování pomocí htmlspecialchars. Třetí úroveň zdánlivě reprezentuje automatické ošetřování v šablonách, např. v Nette Latte. Proč píšu zdánlivě? Problém je v tom, že ošetření se dá obvykle snadno zakázat, např. v Latte pomocí {!$var}. Viděl jsem šablony plné vykřičníků i na místech, kde být neměly. Autor to vysvětlil tak, že psaní {$var} někde způsobovalo problémy, které po přidání vykřičníku zmizely, tak je začal psát všude.

Je to samozřejmě hloupá chyba, ale přesto – jak jí zamezit? Řešením je informaci o ošetření dat uložit přímo do proměnné a v šablonách {!$var} úplně zakázat. V Nette jde data označit jako ošetřená takto:

<?php
$safeHtml = $texy->process($contentTexy);
$content = Html::el()->setHtml($safeHtml);
// v šabloně pak můžeme použít {$content}
?>

Ideální by bylo, když by už samotná metoda process() vracela instanci Html.

Samozřejmě lze namítnout, že chybu lze stále udělat nevhodným použitím Html. Nicméně už alespoň šablony jsou neprůstřelné a jejich autor (který může být míň zkušený) nemusí přemýšlet, která data ošetřit má a která ne.

SQL Injection

Druhou úroveň představuje ruční ošetřování pomocí mysql_real_escape_string nebo obdobné funkce. Třetí úroveň zdánlivě reprezentuje vázání proměnných, např. v PDO. Proč píšu zdánlivě? Problém je v tom, že napsat $pdo->prepare("... WHERE id = $_GET[id]")->execute() je funkční a ještě jednodušší než $pdo->prepare("... WHERE id = ?")->execute($_GET["id"]). V některých případech to je dokonce jediné možné řešení, alternativu k $pdo->prepare("... ORDER BY $_GET[order]") vázání proměnných nenabízí.

Jedno „dokonalé“ řešení jsem před časem nabídl, i když je poněkud nepraktické. NotORM šlo praktičtější cestou, ale výsledek z pohledu SQL Injection je jen o málo lepší než u PDO. Chybu pořád lze udělat, i když bezpečná verze je ve většině případů alespoň jednodušší: where("id", $_GET["id"]) je jednodušší než where("id = $_GET[id]").

Správné řešení by mohlo vypadat nějak takhle: where("id", "=", $_GET["id"]) – první parametr by byl ošetřen jako identifikátor, druhý by byl z whitelistu a třetí jako hodnota. I třídění podle uživatelem zvoleného sloupce by bylo jednoduché: order($_GET["order"]). Alternativou je identifikátor předat přímo v názvu funkce, např. jako whereIdEquals($_GET["id"]), tam je ale použití obecného sloupce krkolomné ({"orderBy$_GET[order]"}()).

Problém nastává u složitějších výrazů, např. s operátorem OR, což je jeden z důvodů, proč jsem toto řešení v NotORM nepoužil.

Neautorizovaný přístup k datům

Další běžná chyba, kde je řešení druhé úrovně jednoduché: stačí místo WHERE id = ? použít WHERE id = ? AND user_id = ? – při získávání dat, zobrazování jejich detailu, jejich aktualizaci a mazání. Problém je, že na tohle se dá zapomenout velmi snadno.

Řešení třetí úrovně se v tomto případě hledá hůř. Místo jednoho ID platného pro všechny můžeme použít dvojici user_id, number. Když má uživatel třeba články a články mají komentáře, tak u komentáře bude primární klíč user_id, article_number, number. Některé dotazy to dokonce může zjednodušit – např. pro vypsání všech komentářů daného uživatele se obejdeme bez použití tabulky článků.

Pokud žádné ID uživatele uložené nemáme, dá se místo snadno uhodnutelného číselného ID použít dlouhé náhodné GUID.

Označování dat

Do PHP byla kdysi navržena podpora pro značkování řetězců, která nakonec vznikla jako extenze Taint. Za její zásadní nevýhodu považuji především to, že jako nebezpečná označuje jen data, která pochází bezprostředně od uživatele. Takže když vezmu $_POST parametr, ošetřím ho pro uložení do databáze, uložím a načtu, tak je najednou bezpečný i třeba pro výstup do HTML nebo pro nové uložení do databáze. Já bych byl radikálnější a jako nebezpečná data označil všechno, to by ale samozřejmě zcela zlikvidovalo zpětnou kompatibilitu. Přesně tohle dovoluje udělat implementace v HipHop for PHP.

Ošetřování dat

Za ideální považuji aplikaci, kde vývojář vůbec nemusí vědět, na jaké speciální znaky si musí dát pozor a jaké má k jejich ošetření použít funkce. Třeba dotaz prepare("WHERE name = 'admin'") je z tohoto pohledu špatně, protože musím vědět, že pro ohraničení řetězců se používají apostrofy. Dotaz prepare("WHERE name = ?", "admin") je lepší, pořád si ale musím dávat pozor na správné ošetření sloupce. U where("name", "=", "admin") nemusím myslet na nic.

Závěr

Nespokojte se s druhou úrovní zabezpečení a nespoléhejte se na to, že na ošetření určitě nezapomenete. Snažte se aplikaci navrhnout tak, aby se na nic zapomenout nedalo. Za cenu o něco složitějšího jádra bude veškerý kód, který ho používá, obvykle taky mnohem jednodušší.

Ono se to ostatně netýká jen bezpečnosti, ale třeba i výkonnosti. V NotORM je docela těžké napsat kód tak, aby se dotazy nepokládaly optimálně nebo téměř optimálně (při správně vytvořených indexech v databázi). Naproti tomu třeba framework Laravel jde opačnou cestou – základ je pomalý a když chcete zrychlit, musíte napsat kód navíc.

Přijďte si o tomto tématu popovídat na školení Bezpečnost PHP aplikací (13.6.2018 - 14.6.2018, Praha).

Jakub Vrána, Seznámení s oblastí, 28.1.2013, diskuse: 33 (nové: 0)

Diskuse

Peter L.:

Nie som si istý či sa šablóny týmto riešením stanú neprestrielnými. Vložením html elementu do premennej sa však znemožní použiť ju v inom kontexte (<script>, <style>, atribút, ...). Myslím, že to je dôvod prečo takéto chovanie nie je v Nette štandardne. Latte sa chváli kontextovým escapeovaním a toto riešenie ho úplne zruší.

v6ak:

Nezruší. Třeva <a href="{$link}">web</a> může též způsobit XSS, pokud $link poskytne útočník.

javascript://%0d%0aalert(%22XSS%22)

To "://%0d%0a" tu je, aby to prošlo špatně napsanými regulárními výrazy, "//" je komentář a "%0d%0a" jsou znaky CR a LF, tedy nový řádek.

ikona Jakub Vrána OpenID:

Možnost použít proměnnou v jiném kontextu se nezruší. Pokud se např. proměnnou ošetřenou pro HTML pokusím použít v JS, tak se jen ještě ošetří pro JS. Např.:

<script>
el.innerHTML = {$content};
</script>

Jarda:

"Autor to vysvětlil tak, že psaní {$var} někde způsobovalo problémy, které po přidání vykřičníku zmizely, tak je začal psát všude."

Já myslím, že tohle je demence programátora a takovýhle lidé by programovat neměli.

Fafin:

Neměli, ale programují a podle stávajícího trendu zoufalého nedostatku kohokoliv kdo umí napsat {} i budou.

Jinak při použití Nette\Databaze lze problém autorizace dostat do jednoho místa - konfigurace. Při injekci tabulky stačí zavolat (zjednodušeně, nevím, jestli to syntaxe už podporuje, řešil sem to dříve přes továrnu)
- setTable(@database::table('table')::where('id_user', @user::id))

Potom uvnitř objektu, do kterého jsem injectoval tabulku už pracuju jenom s řádky náležící uživateli. Nevím, jak je to ve stávající implementaci, ale co si pamatuju, tak se jednou zapsané podmínky už nešlo zbavit - takže by to mohlo být blbuvzdorné.

ikona Jakub Vrána OpenID:

Je to lepší, ale já bych měl tuhle kontrolu radši ještě o patro výš.

Fafin:

Výš či níž?
Dá se to řešit na databázové úrovni (níž?) přes view (platí pro MySQL).

Vytvoří se funkce, která vrací session proměnnou @user_id:
create function user_id() returns integer return @user_id;
pak view:
create view ... where id_user = user_id();

Po připojení k databázi nastavíme proměnnou a kdykoliv se někdo z aplikace zeptá na obsah view, dostane jen své řádky.
Boční efekt - pokud není proměnná nastavena, je implicitně null.

ikona Jakub Vrána OpenID:

To je super trik! Díky, to jsem neznal. V pohledu se nedají použít uživatelské proměnné, ale že to jde obejít přes funkci, jsem netušil.

NoxArt:

Zajímavý nápad. Není mi úplně jasné konkrétní použití, jestli má být univerzální - pokud se to musí zadávat všude, tak je to stejně náchylné na zapomenutí; pokud ne - jak odlišit případy, kdy v tabulce není user_id nebo se user_id nemusí shodovat s $user->id, např. získání dat profilu jiného uživatele. Mít nějaké explicitní povolení výjimky (jako !$var)? Díky za odpověď

Fafin:

Přesně jak píšeš, není to univerzální a je to náchylné k zapomenutí. Výhodou je umístění veškerého tohoto kódu "omezování na uživatele" na jednom místě - v konfiguraci. Je podle mě jdnodužší projít si celou konfiguraci a zkontrolovat, kde se všude předává @database::.. (i kdyby měla být hodně dlouhá), než procházet všechny zdrojáky, a kontrolovat, kde se pracuje s databází.

Pokud získávám data z profilu jiného uživatele, asi by se měla dělat kontrola oprávnění k tomuto úkonu jinak, a to už asi nebude takto triviální.

ikona Jakub Vrána OpenID:

Jde o to, že šablony často ani programátor nedělá a dostane je za úkol HTML kodér, který o správném ošetřování nemá potuchy, protože {$var} prostě funguje správně. Tedy až do okamžiku, kdy musí použít {!$texy}, z čehož je zmaten.

dp:

Zase nemit možnost vypnout escapovaní je taky špatně. Ta možnost tu existovat musi (reklamy treba) a tím padem to bude vždy zaležet na programatorovi.

Filip Procházka:

Nesouhlasím, například z Latte bych to klidně vyhodil, kdybych se nebál o zpětnou kompatibilitu. Přesně od toho tu máme třídu Nette\Utils\Html, které můžeme vnutit HTML kód (vypneš tím escapování).

v6ak:

... což je ale jedna z možností, jak vypnout escapování. Jestli se u toho použije "!", nebo \Nette\Utils\Html je už technický detail.

Filip Procházka:

Máš pravdu, ale jde mi čistě o to, že kodér pak nemá jak vypnout escapování, pokud může upravovat jen šablony - což je vždy lepší :)

ikona David Grudl OpenID:

Říkal jsem si, že bych {!$var} nahradil za {$var|noescape}.

Michal:

Myslím, že na to už je pozdě, už je to celkem zažité. Nic bych na tom neměnil.

Filip Procházka:

Dobrý nápad, vykřičník vypadá strašně nevinně :)

Michal:

Už zase vidím ten důvod proč mi mnozí někdy říkají, že nechtějí použít Nette. Zase se změní něco co už je zažité.

Filip Procházka:

A mění se snad něco? https://github.com/nette/nette/commits/master

ikona David Grudl OpenID:

Mnozí někdy milují změny!

v6ak:

No pokud může v šabloně vytvořit instanci Html. Nevím, jak je to v Nette, ale v Play stačí, když místo @foo napíše @Html(foo).

Napadá mě každou šablonu před commitem staticky analyzovat na takovéto konstrukce, pokud máme kodéra za blbce. Každý podezřelý commit mu pak musí schválit někdo další.

Podobně by se dalo řešit href="", src="", style="" a další.

ikona David Grudl OpenID:

V nových verzích Nette se místo {!$var} preferuje {$var|noescape} a používání vykřičníku zcela zmizelo i z dokumentace a příkladů. Díky Jakubovi za nakopnutí!

ikona Jakub Vrána OpenID:

Bravo! Teď ještě zcela zrušit |noescape a umožnit pouze předávání Html::el(). Data je potřeba označit jako bezpečná na místě, kde vznikají, nikoliv kde se používají.

ikona David Grudl OpenID:

Data často „vznikají“ právě v šabloně, do které si předám třeba objekt NotORM, nad kterým iteruji. A nevidím výhodu psát {Nette\Utils\Html::el()->setText($product->description)} oproti {$product->description|noescape}

ikona Jakub Vrána OpenID:

Podle mě by tato logika měla být mimo šablony. U $product->description asi formou nějaké anotace, i když nevím, jak by to bylo technicky realizovatelné. Html::el() by se ze šablony pokud možno taky už nemělo dát zavolat.

Cílem je mít šablony neprůstřelné. Můžu kamkoliv napsat cokoliv a nehrozí, že mi vznikne XSS nebo double escaping.

Já teď používám Closure Templates (neboli Soy) a ty se ve strict režimu chovají právě takhle – noescape je zakázané, ruční ošetřování taky, všechno je automaticky kontextově ošetřené. Dokonce to máme nastavené tak, že jako bezpečná může data posílaná do šablony označit jen jeden konkrétní package.

ikona v6ak OpenID:

Opravdu všechno? Co třeba <script>var a={$a}; foo.innerHTML = a; bar.href=a;</script>?

Nedávno jsem dostal určitou skepsi k context aware escapingu. Neříkám, že je to kompletně špatná myšlenka. Určitě CAS umí ledacos zjednodušit. Ale nemohou uhlídat všechno. Proto bych spíš řešil, jak je udělat průhlednými - aby člověku bylo jasné, co se stane. Z tohoto hlediska je mimochodem nedávné omezení pro <a href="{$url}"> kontroverzní - nevím, kde všude se toto omezení použije. Použije se třeba i u meta refresh?

ikona Jakub Vrána OpenID:

Např. použití innerHTML máme zakázané presubmit skriptem. Ale máš pravdu, že když se hodně snažíš, tak se do nohy pořád střelit můžeš. Jde o to hranici posunout tak, abys na něco nemohl zapomenout nevědomky.

I to .href by se dalo zakázat a přiřazení do něj by bylo možné jen skrz funkci, která by data náležitě ošetřila.

ikona v6ak OpenID:

Hmm, budu muset o tom trošku popřemýšlet...

To .innerHTML (a další) máte jako blacklist, nebo whitelist? Co se stane u ['innerHTML']? U dynamických jazyků může být celkem drsné řešit něco takového, ale např. TypeScript by to mohl vyřešit.

Moc se mi nezamlouvá přístup "blacklist", kdy s přidáním nové konstrukce do HTML může vzniknout neošetřený problém, aniž by si toho někdo všiml.

Na druhou stranu, obávám se, že i přes whitelist by mohlo menší nepozorností proniknout psaní do meta tagů, a byl by potom povolen meta refresh, což je potenciálně nebezpečná věc (javascript://%0d%0a...). Dá se dobře řešit i toto? (Jde mi o nějaký vhodný přístup, ne o vyřešení konkrétně meta refresh.)

ikona Jakub Vrána OpenID:

Je to blacklist. Jak jsem psal – když se snažíš, do nohy se pořád střelit můžeš. Jde o to zabránit opomenutím.

Není to jediný způsob kontroly, další je code review. Většina programátorů taky píše kód podle toho, jak vypadá okolní kód. Takže když tam vidí bezpečné konstrukce, tak je automaticky použijí, aniž by nad tím nějak přemýšleli.

ikona David Grudl OpenID:

Ale to přece není možné. Neexistuje nic jako "bezpečná data". Jednak je zcela legitimní požadavek zobrazit na stránce výpis zdrojového kódu a v takovém případě chci escapovat i HTML kód. Není tedy jen jedna jediná možnost, jak data vypsat. A šablona je právě to místo, které rozhoduje, jak se věc vypíše.

ikona Jakub Vrána OpenID:

Šablona by neměla rozhodovat o tom, jakého druhu data jsou. Většina dat používaných v šabloně je čistý text a když je v šabloně použiji v různých kontextech, tak by se měla náležitě ošetřit.

Pokud chci šabloně říct, že je něco fragment HTML kódu, tak je to potřeba udělat na místě, kde data vznikají, a ne všude tam, kde se používají. Jinak se na to dá v šabloně zapomenout a vznikne double escaping. Data si zkrátka s sebou musí nést informaci o tom, jakého druhu jsou.

Šablona vědomě vypisující fragment HTML kódu jako text je něco tak speciálního (napadá mě použití jen v debuggeru), že by o tomto použití opět neměla rozhodovat šablona, ale ten, kdo jí data posílá.

ikona v6ak OpenID:

Co třeba různá CMS?

Ano, existuje mnoho způsobů, jak to udělat jinak, ale s použitím něčeho jako HTML Purifier by toto mělo být bezpečné. Při požadavky WYSIWYG by to mělo být taky nejbezpečnější řešení.

Vložit komentář

Používejte diakritiku. Vstup se chápe jako čistý text, ale URL budou převedeny na odkazy a PHP kód uzavřený do <?php ?> bude zvýrazněn. Pokud máte dotaz, který nesouvisí s článkem, zkuste raději diskusi o PHP, zde se odpovědi pravděpodobně nedočkáte.

Jméno: URL:

avatar © 2005-2018 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.