Kde se asi stala chyba: obsluha výjimek v Pythonu

Python, stejně jako většina moderních programovacích jazyků, obsluhuje chyby vzniklé za běhu pomocí výjimek. Pro každého programátora v Pythonu je blok try … except základem všeho bytí. V tomto článku se podíváme na ukázkový kód, který se zdá naprosto v pořádku, ale přesto obsahuje naprosto zásadní chybu.
Nálepky:
Článek původně vyšel na autorově blogu.
Python, stejně jako většina moderních programovacích jazyků, obsluhuje chyby vzniklé za běhu pomocí výjimek. Pro každého programátora v Pythonu je blok try ... except
základem všeho bytí. Výjimky se používají nejen pro obsluhu běhových chyb a chyb vzniklých v operačním systému, ale velice úzce souvisí i s dynamickým typováním samotného Pythonu (viz příklad zde). V tomto článku se podíváme na ukázkový kód, který se zdá naprosto v pořádku, ale přesto obsahuje naprosto zásadní chybu. Myšlenku na tento zápisek mi vnukl článek web.py – autentizace a autorizace.
Článek samotný se věnuje autentizaci a autorizaci uživatelů ve frameworku web.py. Autor Petr Horáček v něm popisuje svůj vlastní modul, který tuto problematiku řeší. Nicméně ukázkový kód zveřejněný ve článku obsahuje chybu v obsluze výjimek vzniklých při přihlašování uživatele (viz můj komentář). Tato chyba byla natolik inspirativní, že jsem se rozhodl ji dále rozebrat v tomto blogu. Tato chyba se týká Pythonu 2.x, v Pyhonu 3 je zápis, který na chybu vede, považován za syntaktickou chybu.
Chyba není na první pohled vůbec zřejmá, kód funguje, vše je syntakticky správně, ale přece jen něco není v pořádku. Pro ilustraci v tomto zápisku nejprve modul, který definuje uživatelské výjimky jako třídy (soubor nazvěme mod_exc.py
):
class UserNotFound(Exception):
pass
class WrongPassword(Exception):
pass
Nyní již samotný kód, za funkcí POST()
si představte obsluhu HTTP POST požadavku, kterému jsou předány parametry user
a passwd
obsahující jméno a heslo uživatele k přihlášení, tyto údaje jsou ověřovány ve funkci login()
oproti slovníku PWD_DICT
. Funkce login() proběhne, pokud je vše v pořádku, jinak vyhodí výjimku UserNotFound
nebo WrongPassword
:
import mod_exc
import sys
PWD_DICT = {'honzas': 'hPsWd'}
def login(user, passwd):
try:
if PWD_DICT[user] != passwd:
print 'wrong password'
raise mod_exc.WrongPassword
except KeyError:
print 'user not found'
raise mod_exc.UserNotFound
def POST(**args):
usr = args.get('user')
passwd = args.get('passwd')
try:
login(usr, passwd)
print 'access granted'
except mod_exc.UserNotFound, mod_exc.WrongPassword:
print 'access denied'
print
POST(user='honzas', passwd='hPsWd')
POST(user='test', passwd='hPsWd')
POST(user='honzas', passwd='foobar')
POST(user='test', passwd='hPsWd')
POST(user='honzas', passwd='foobar')
Výstup je dle očekávání:
access granted
user not found
access denied
wrong password
access denied
user not found
access denied
wrong password
access denied
Nicméně kód obsahuje jednu naprosto zásadní (a opovažuji se říci v tomto kontextu i potenciálně bezpečnostní) chybu. Prvním indikátorem je již to, že pokud se pokusíme upravit posloupnost volání funkce POST()
následujícím způsobem, získáme traceback namísto očekávaného výstupu.
Upravené volání:
POST(user='honzas', passwd='hPsWd')
POST(user='honzas', passwd='foobar')
POST(user='test', passwd='hPsWd')
A výstup:
access granted
wrong password
Traceback (most recent call last):
File "exceptions.py", line 28, in
POST(user='honzas', passwd='foobar')
File "exceptions.py", line 20, in POST
login(usr, passwd)
File "exceptions.py", line 10, in login
raise mod_exc.WrongPassword
mod_exc.WrongPassword
Stopujeme brouka…
Přidáme si ladící výpisy do funkce POST()
, abychom měli ponětí o tom, co se děje:
def POST(**args):
usr = args.get('user')
passwd = args.get('passwd')
try:
login(usr, passwd)
print 'access granted'
except mod_exc.UserNotFound, mod_exc.WrongPassword:
print 'access denied'
type, value, traceback = sys.exc_info()
if type == mod_exc.UserNotFound:
print '... user not found'
else:
print '... wrong password'
print
POST(user='honzas', passwd='hPsWd')
POST(user='test', passwd='hPsWd')
POST(user='honzas', passwd='foobar')
POST(user='test', passwd='hPsWd')
POST(user='honzas', passwd='foobar')
Výstup pak dokáže docela překvapit:
access granted
user not found
access denied
... user not found
wrong password
access denied
... user not found
user not found
access denied
... user not found
wrong password
access denied
... user not found
Ve všech případech se ukazuje, že je odchycena výjimka UserNotFound
, a to i pokud je uživatelské jméno správně a je předáno chybné heslo!
Kde se asi stala chyba
Obecně lze říci, že chyba vznikla již při živelném návrhu Pythonu 2.x. Při snaze o očištění světa, na jejímž konci je Python 3.x, bylo chybě efektivně zabráněno již při syntaktické kontrole kódu. Chybu totiž způsobují chybějící závorky ve větvi except (v kódu se očekává odchycení výjimek dvou tříd):
except mod_exc.UserNotFound, mod_exc.WrongPassword:
Takovýto zápis je však ekvivalentní odchycení výjimky třídy UserNotFound
pod jménem mod_exc.WrongPassword
:
except mod_exc.UserNotFound as mod_exc.WrongPassword:
Namísto odchycení výjimek buď jedné nebo druhé třídy se vždy odchytí pouze výjimky třídy UserNotFound
a uloží se do modulu mod_exc
pod jméno WrongPassword
! A následně, pokud se vyvolá výjimka mod_exc.WrongPassword
, tak ve skutečnosti dojde opět k vyhození UserNotFound
.
Oprava
Oprava kódu je v tomto případě velice jednoduchá, z programu s opravenou funkcí POST() již získáme správné výstupy:
access granted
user not found
access denied
... user not found
wrong password
access denied
... wrong password
user not found
access denied
... user not found
wrong password
access denied
... wrong password
Závěr
Přestože výše popsaný problém ovlivňuje pouze Python 2.x, je chyba, vzhledem k jeho rozšíření a stále aktivní podpoře, velice zásadního typu a dala by se přirovnat k přepsání kódu programu vhodně navrženou posloupností vstupů, což obecně může vést k bezpečnostím chybám.
Povšimněte si, že chyba je způsobena především vyvezením definice výjimek do samostatného modulu, importem tohoto modulu a odkazování výjimek pomocí tečkové notace. Jinými slovy odkazuji se na jména uvnitř jiného modulu namísto na jména získaná z globálního namespace (ta bez jejich definice jako global
nelze měnit). To umožní trvalé přepsání jednoho jména jiným objektem a modifikaci chování následně vykonaného kódu.
Nakonec kopnutí do Python 2.x: je jedině dobře, že zmíněný zápis již není podporován v Python 3.x; jedná se o historickou zátěž způsobenou chaotickým vývojem Pythonu v časech pradávných.
Jak jsem jiz uvedl v komentari pod predchozim clankem, nasledujici zapis je chybuvzdorny:
Ta proměnná je tam vcelku zbytečná.
A ještě je lepší dát výjímkám společnou base třídu a pak odchytávat jenom tu.
Promenna tam neni zbytecna. Hrozi, ze clovek zapmene na zavorky (zejmena v Py2.x) a jsme zpatky u puvodniho problemu.
na zapomínání jsou nejlepší unit testy ;)
Unit testy v tomto případě nejsou samospásné – pokud se v unit testu podaří trefit pořadí jako v prvním výstupu, pak je vše v pořádku a testem nic neodhalíme.
Zdravím z Plzně do Plzně ;-) a díky za upozornění na tuto zvláštnost.
Věřím, že tato finta může způsobit krásné, leč bezesné noci.
MK