Jak jsme zlepšili zabezpečení Slevomatu.
Chceš zlepšit zabezpečení webu a nevíš kde začít a kdy skončit? Ukážu ti, co jsme udělali na Slevomatu, co všechno jsme museli vyřešit, čemu jsme se divili a co plánujeme. Třeba tě to trochu taky nakopne.
Jak jsme zlepšili
zabezpečení Slevomat.cz
Michal Špaček
@spazef0rze
www.michalspacek.cz
Tyto slajdy obsahují poznámky nejen pro ty, kteří na přednášce nebyli.
$this context→
Slevomat má ~1M zákazníků, v databázi ~1.8M uživatelských účtů a web
slevomat.cz denně navštíví přes 200k návštěvníků. Slevomat v roce 2013 dosáhl
miliardového obratu, má vlastní značku zboží i vlastní sklad. Ve Slevomatu pracuje
150 lidí, vývojové oddělení čítá 10 hlav, mezi nimi např. @kukulich, @vasekpurchart
nebo @patrikvotocek. Nechceš se k nim přidat? Slevomat používá PHP 5.5, Nette
2.1, Elasticsearch a MySQL resp. Percona Server, který obslouží ~2.5M dotazů za
hodinu. Čísla kreditních karet nejsou uložena v databázi Slevomatu, pamatuje si je
platební brána. Uživatelé mohou na svých účtech mít kredity, kterými mohou platit,
ale nedají se převádět na jiného uživatele. Aktualizace: všechny informace na tomto
slajdu jsou z roku 2014 a mohou se od aktuálního stavu lišit.
Dříve bylo možné se přihlásit z jakékoliv stránky – po kliknutí na tlačítko vpravo
nahoře se rovnou objevil přihlašovací formulář a to i přesto, že stránka přišla po
nezabezpečeném HTTP. Formulář se sice odesílal šifrovaně pomocí HTTPS, ale
mohl být po cestě do prohlížeče změněn tak, aby se odesílal někam úplně jinam.
Přihlašování jsme tedy přesunuli na samostatnou stránku, která se stahuje po
HTTPS, obsah formuláře tedy do prohlížeče dorazí v původním stavu.
Web slevomat.cz zatím neběží celý na HTTPS (update: od července 2014 již ano),
takže ačkoliv se případný mizera nedostane k přihlašovacím údajům změnou
formuláře po cestě, může se odposlechem dostat k session id cookie, protože ta se
přenáší i po nezabezpečeném HTTP. Až bude celý web přístupný pouze po HTTPS,
cookie označíme jako Secure a tím zajistíme, že se bude přenášet jen po
šifrovaném HTTPS a nepůjde odposlechnout. Direktivy session.cookie_httponly
a session.use_only_cookies pro obranu před útoky na session máme
samozřejmě zapnuté už docela dlouho.
session.cookie_secure
Při relativně velké zátěži webových serverů Slevomatu nechceme přechod na
zabezpečené HTTPS udělat najednou, ale raději postupně přesunujeme jednotlivé
části webu. Velmi brzy bude celý web přístupný jen a pouze přes HTTPS. A pak
zas můžeme vrátit přihlašovací formulář do Lightboxu na každou stránku.
HTTP Strict Transport Security
Strict-Transport-Security:
max-age=31536000; includeSubDomains
Až bude celý web přístupný pouze pomocí HTTPS, budeme posílat hlavičku
Strict-Transport-Security, která zajístí to, že se prohlížeč ani nebude pokoušet
server kontaktovat po HTTP a rovnou bude po stanovenou dobu všechny odkazy
sám převádět na HTTPS. Zabráníme tím man-in-the-middle útokům, které převádějí
šifrované HTTPS spojení na obyčejné HTTP. Hlavičku podporuje většina moderních
browserů, IE ji bude podporovat od verze 12.
Přenos hesel a session ids tedy bude zabezpečen docela dobře. K uživatelským
údajům se ale dá dostat i jinak, než odposlechem. Třeba tak, že jednoho krásného
dne najdete na serveru 12 GB záloh cizích databází, protože je tam někdo kdysi
nahrál a nesmazal. Opravdu se to stává a je lepší počítat s tím, že i vaše databáze
se může někde objevit.
$dibi
→select('jmeno')
where(→ "id = $_GET[id]");
Ale nemusí to být jenom zálohy na nepatřičných místech. Firemní údaje a hesla
uživatelů se z databáze dají vysosat třeba úspěšným provedením útoku SQL
Injection. Spousta uživatelů používá jedno heslo na více místech a když z
jednoho takového místa to heslo unikne, tak … raději nedomýšlet. A ano, SQL
Injection lze provést i v Nette, Dibi a PDO, pokud se tyto nástroje použijí trochu
nešikovně. Žádný nástroj automagicky nevyřeší zabezpečení webu.
8b8f05049b2114ad3d33
0db391e78549670d6b4b
Slevomat dříve heslo před uložením zahashoval pomocí algoritmu SHA-1, aby
nebylo tak jednoduché ho získat, pokud by se k databázi nějakým způsobem dostal
někdo nepovolaný. Heslo nebylo saltované, takže nebylo chráněno proti tzv.
narozeninovým útokům, ani proti použití předpočítaných tabulek. Jenže ty se už
dnes stejně moc nepoužívají, hesla se crackují hrubou silou, slovníkovými nebo
hybridními útoky. Každý obyčejný laptop umí spočítat desítky miliónů SHA-1
hashů za vteřinu a moderní grafické karty jsou stokrát rychlejší. Cracknout takové
běžné heslo hashované SHA-1 je tedy otázkou maximálně pár minut a ani salt by
crackování nezpomalil. Zkuste si to sami pomocí nástroje oclHashcat.
cHJvZDE=;GwEuCSSgBCL
x0BHHAAb8IA==;kvmDV/
DEJ3H+how7bErLa+/xap
V2282MmISXqVo8xREfnB
KcNmAixIaDDnYkODCu9K
W3+6sbM04W7Tm3w1S+NQ==
Nyní Slevomat ukládá hesla tak, že heslo zahashuje relativně pomalým algoritmem
bcrypt, aktuálně s cost parametrem 10. Výsledný hash se navíc zašifruje pomocí
AES-256-CBC a poté se uloží do databáze. Je to klacek pod nohy,
pravděpodobnost, že se mizera dostane k databázi a zároveň ke konfiguračnímu
souboru aplikace, ve kterém je uložen šifrovací klíč, je mnohem menší, než že se
dostane jenom k databázi. A pokud získá databázi i klíč, tak po rozšifrování má před
sebou pořád ty bcrypt hashe. Výše je mé aktuální heslo tak, jak je uloženo v
produkční databázi Slevomatu. Všimněte si id klíče, inicializačního vektoru
a šifrovaného hashe, vše odděleno středníkem a zakódováno do Base64.
password_hash(…, PASSWORD_DEFAULT)
ircmaxell/password_compat
Pro pouhé hashování hesel algoritmem bcrypt použijte funkci password_hash()
a pro ověření password_verify(). Tyto funkce jsou dostupné až od PHP 5.5, ale
pro starší PHP (od PHP 5.3.7) existuje knihovna password_compat, která tyto
funkce implementuje v čistém PHP. Najdete ji na GitHubu nebo ji můžete
nainstalovat pomocí Composeru. Jednoduchý příklad použití najdete u mě na
GitHubu na https://github.com/spaze/encrypt-hash-password-php v souboru
example-hash.php.
mcrypt_encrypt()
MCRYPT_RIJNDAEL_128
Pro následné šifrování hashů hesel pomocí AES-256 v PHP použijte funkce
mcrypt_encrypt() a mcrypt_decrypt() z rozšíření mcrypt. Aktualizace: mcrypt
je depracated od PHP 7.1, použijte raději na konci odstavce zmiňovanou knihovnu
defuse/php-encryption. Bloková šifra AES je variantou šifry Rijndael s velikostí bloku
128 bitů, použijeme tedy konstantu MCRYPT_RIJNDAEL_128. Hodnota 256 v názvu
znamená velikost použitého šifrovacího klíče v bitech. Pro ukládání hashů je vhodný
režim CBC (Cipher-block chaining), ten při volání mcrypt_encrypt() zvolíme
pomocí MCRYPT_MODE_CBC. Inicializační vektor vytvoříme funkcí
mcrypt_create_iv() s parametrem MCRYPT_DEV_URANDOM. Jednoduchý příklad
najdete opět na GitHubu: https://github.com/spaze/encrypt-hash-password-php v
souboru example-encrypthash.php. Pro obecné šifrování dat raději použijte
knihovnu https://github.com/defuse/php-encryption, která přidává autentizaci
zašifrovaných dat pomocí metody Encrypt-then-MAC.
aes(bcrypt(sha1(…)))
aes(bcrypt(…))
Ve Slevomatu jsme chtěli zabezpečit i hesla uživatelů, kteří se dlouho nepřihlásili.
Vzali jsme tedy stávající SHA-1 hashe, zahashovali bcryptem a pak zašifrovali AES-
256. Taková hesla jsme si označili, abychom při ověřování věděli, že z hesla
zadaného uživatelem máme nejdříve udělat SHA-1 hash a až pak s ním pracovat
dále. Když se takový uživatel úspěšně přihlásí, tak zadané heslo v čitelné podobě
zahashujeme jen bcryptem s vynecháním SHA-1, hash zašifrujeme a výsledek
uložíme do databáze. To děláme i při nastavení hesla nového. Mezikrok s SHA-1 je
lepší, než vynucená změna hesla všech uživatelů, je ale zbytečný a pokud můžeme,
tak ho odstraníme.
bcrypt(token)
Pomocí funkce password_hash() hashujeme i tokeny pro semi-permanentní
přihlášení i pro přihlášení pomocí Facebooku. V čitelné podobě je v databázi
nemáme. Token z cookie pak ověřujeme stejně jako heslo při přihlašování, tedy
funkcí password_verify(). Hash tokenu v databázi nalezneme podle identifikátoru
uživatele z cookie. Aktualizace: tokeny jsou na rozdíl od běžných hesel náhodně
generované a dlouhé minimálně 20 bajtů, pro uložení by tedy stačilo použít funkci
hash('sha256', $token) a pro ověření funkci hash_equals().
Funkci Zapomenuté heslo máme vyřešenu tak, že uživateli posíláme odkaz, který
obsahuje token, jehož platnost je časově omezena – momentálně na 30 minut od
odeslání odkazu. Tento interval by neměl přesáhnout 60 minut. E-mail nově
obsahuje i odkaz pro zneplatnění tokenu. Token hashujeme bcryptem, v databázi
není uložen v čitelné podobě. Zákaznické podpoře jsme odebrali možnost
nastavovat hesla uživatelům v administraci Slevomatu, ale pro některé domény jsme
tuto možnost museli vrátit zpět. Centrum.cz totiž doručuje některé e-maily s
mnohahodinovým zpožděním a uživatel tak prošvihne interval, během kterého je
token platný a o pomoc pak žádá právě zákaznickou podporu.
Vaše heslo je: •••••
Hesla nově nikdy neposíláme e-mailem, ani při registraci. Veškeré změny
logujeme a víme kdo, kdy a z jaké IP adresy změnil heslo nebo e-mailovou adresu.
Uživatele po změně informujeme, pro jistotu. Brzy chceme změnit politiku
vytváření hesel, současné omezení na min. 5 znaků je dnes již nevyhovující. Nová
hesla by měla mít minimálně 8 znaků. Maximální povolená délka hesla je 4096
znaků, delší hesla příliš zatěžují server při hashování bcryptem. Aktualizace:
implementace bcryptu v PHP delší hesla automaticky ořezává na 72 znaků, toto
omezení je na Slevomatu nadbytečné.
Content Security Policy
Ve Slevomatu používáme šablonovací systém Latte, který je součástí Nette
Frameworku. Latte automagicky ošetřuje vypisované proměnné, ale i přesto se
může stát, že uděláte chybu, třeba při tvorbě vlastních maker, a váš web bude
zranitelný pomocí útoku Cross-Site Scripting (XSS). Ke snížení dopadu případného
úspěšného útoku bude Slevomat brzy posílat HTTP hlavičku Content-Security-
Policy, která vyjmenovává zdroje (whitelisting), odkud může prohlížeč do stránky
vkládat obrázky, kaskádové styly, JavaScript a další. Browser nebude spouštět ani
inline JavaScript, pokud se pošle i hodnota 'unsafe-inline', JavaScriptový kód
pak musí být v externích souborech. Na přesunu pilně pracujeme.
<script>
var foo = 123;
var color = 'purple';
</script>
Posílat hodnotu 'unsafe-inline' rozhodně chcete, zakázáním spouštění
JavaScriptu vloženého přímo do stránky zabráníte úspěšnému provedení útoku
Cross-Site Scripting i kdyby někdo nějak do stránky nějaký JavaScript vložil.
Občas se přímo do stránky úmyslně vkládá nějaká konfigurace, ovšem pokud je to
uděláno výše uvedeným způsobem, tak to přestane fungovat. S 'unsafe-inline'
momentálně nefunguje ani kód Google Tag Manageru, a to ani s využitím
experimentální podpory nonce-source z připravované specifikace CSP 1.1 v
Chrome 35. Slevomat používá Google Tag Manager a 'unsafe-inline' je pro nás
tedy momentálně nepoužitelné.
<script id="conf" type="text/json">
{
"foo" = "123",
"color" = "purple"
}
</script>
<script src="code.js"></script>
Konfiguraci ve stránce je možné zachovat, jen je potřeba změnit typ obsahu značky
SCRIPT na text/json. JSON není kód, nelze spustit a browser ho tedy při
'unsafe-inline' nemusí blokovat. V code.js se pak k hodnotám dostanete takto:
var c = document.getElementById('conf'); var d =
JSON.parse(c.textContent || c.innerHTML);.
$c = stream_context_create(array(
'ssl' => array(
'verify_peer' => true,
'verify_depth' => 9,
'cafile' => $caFile,
'disable_compression' => true,
)
));
Pokud potřebujeme komunikovat s nějakým API, používáme k tomu šifrovaný
protokol HTTPS. Pro bezpečnou komunikaci musíme také ověřovat, že opravdu
komunikujeme třeba s bankou a že to není nějaký mizera, který se za banku vydává.
Vypnutím TLS komprese se pak bráníme proti útoku CRIME. Takto bude defaultně
nastaveno i PHP 5.6+.
JDE TO!
www.michalspacek.cz
V zabezpečování Slevomatu a uživatelských dat budeme samozřejmě pokračovat
dále, ještě nás čeká spousta práce, ale když se chce, tak to jde. Snad jsem vás také
trochu inspiroval a pokud byste chtěli se zabezpečením webů a aplikací pomoci,
napište mi, rád pomohu. Kontakty jsou uvedeny na mém webu. Díky!