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ý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 zápisku se podíváme na ukázkový kód, který se zdá naprosto vpořádku, ale přesto obsahuje naprosto zásadní chybu. Myšlenku na tento zápisek mi vnukl článek na serveru Zdroják.cz.

Č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 jej 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 potencioná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.

Napsat komentář