Безопасный питон. Осваиваем приемы защищенного кодинга на Python

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0
Сегодня мы поговорим о том, что должно волновать каждого крутого программиста, — о безопасном коде. Ты думаешь, это скучно и сложно? Ничуть! Я поделюсь с тобой своим опытом и покажу, как научиться писать на Python код, за который потом не придется краснеть.

Ограничь область видимости переменных и функций​

Область видимости переменной — это контекст, в котором переменная определена и доступна. Если переменная доступна во всем коде, она называется глобальной. Если переменная доступна только внутри функции или метода, она называется локальной.

Смотри, что происходит, если мы используем глобальную переменную:
Python: Скопировать в буфер обмена
Код:
secret = "my super secret data"
def print_secret():
    # Используем глобальную переменную
    print(secret)
print_secret()
Это может быть опасно, потому что глобальные переменные доступны во всем коде и их можно легко изменить. А что, если это важная переменная, которую не следует менять? Злоумышленник может воспользоваться этим и нанести вред.

Поэтому лучше использовать локальные переменные:
Python: Скопировать в буфер обмена
Код:
def print_secret():
        # Объявляем локальную переменную
    secret = "my super secret data"
    print(secret)
print_secret()
Теперь переменная secret доступна только внутри функции print_secret(). Такой подход не только сделает код более безопасным, но и облегчит его чтение, а также упростит отладку и поддержку.

Разделяй код на модули​

Вместо огромного исходника, содержащего описание всех объектов и функций, ты можешь сделать несколько модулей, каждый из которых выполняет свою задачу. Это более выгодный подход, поскольку модули потом будет легко использовать в других проектах.

Но как модульность помогает обезопасить код? Дело в том, что чем меньше кусочки, тем легче в них будет искать ошибки и тем меньше шанс случайно что‑то сломать, когда вносишь изменения. Хорошо организованный код легко менять, и если он разбит на изолированные части, то изменения в одной не затронут другие.

Речь здесь не только о разделении большого проекта на пакеты, которые можно будет импортировать, но и о дроблении кода на функции и объекты.

Вот пример плохого кода:
Python: Скопировать в буфер обмена
Код:
def do_something():
    # Делаем много разных вещей здесь
    # ...
    # О, и тут мы делаем что-то еще
    # ...
    # И еще что-то здесь
    # ...
В этом коде все свалено в одну функцию, которая делает множество разных вещей. Это плохо, потому что, если ты найдешь уязвимость в одной из этих вещей, изменения могут повлиять на другие части огромной функции. Чем она больше, тем сложнее будет предсказать результат правок.

А теперь посмотрим на хороший пример:
Python: Скопировать в буфер обмена
Код:
def do_something_1():
    # Делаем что-то здесь
    # ...
def do_something_2():
    # Делаем что-то здесь
    # ...
def do_something_3():
    # Делаем что-то здесь
    # ...
Мы разбили большую функцию на несколько маленьких, каждая из которых делает что‑то свое. Это гораздо безопаснее, потому что, если мы найдем уязвимость в одной из этих функций, мы сможем ее исправить, не затрагивая остальные. Заодно это делает наш код более читаемым и легким для поддержки, потому что теперь мы знаем, что каждая функция делает только одну вещь.

Еще один хороший способ изолировать код и повторно использовать его — это классы и объекты Python. Классы позволяют нам группировать связанные функции и данные вместе, делая код более управляемым и безопасным.

Вот пример хорошего кода с использованием классов:
Python: Скопировать в буфер обмена
Код:
class MyAwesomeClass:
    def __init__(self, some_data):
        self.some_data = some_data
    def do_something_1(self):
        # Делаем что-то с some_data здесь
        # ...
    def do_something_2(self):
        # Делаем что-то еще с some_data здесь
        # ...
В этом примере мы создаем класс MyAwesomeClass, который содержит два метода: do_something_1 и do_something_2. Каждый из этих методов работает с данными, которые мы передаем при создании объекта класса. Это позволяет нам контролировать, как эти данные используются и обрабатываются. Безопасность сразу возрастет!

Главный вывод здесь: чем проще и понятнее код и чем легче его поддерживать, тем он безопаснее.

Защитись от инъекций кода​

Что такое эти самые инъекции? Представь, что злой пользователь вводит в твое приложение не данные, которые от него запросили, а исполняемый код, который приложение по какой‑то причине возьмет и выполнит. Причем зачастую это не код на Python, а запросы к базе данных на SQL или команды операционной системы. Звучит страшновато? Давай посмотрим, почему такое иногда случается.

Плохой пример​

Взгляни на этот кусок кода. Что здесь не так?
Python: Скопировать в буфер обмена
Код:
def get_user(name):
    query = "SELECT * FROM users WHERE name = '" + name + "'"
    return execute_query(query)

Ты просто берешь имя пользователя и сразу втыкаешь его в запрос SQL. А что, если пользователь введет что‑то вроде 'John'; DROP TABLE users;--? Поздравляю, ты только что потерял всех своих пользователей!

Вот как выглядит безопасная версия этого кода:
Python: Скопировать в буфер обмена
Код:
def get_user(name):
    query = "SELECT * FROM users WHERE name = ?"
    return execute_query(query, (name,))

Здесь мы используем параметризованный запрос, то есть передаем имя пользователя отдельно, и наша база данных гарантированно его экранирует. Это значит, что, даже если пользователь попытается ввести SQL-код, тот будет воспринят просто как строка и ничего плохого не произойдет.

Но это только начало. Всегда помни: ты должен доверять пользовательским данным настолько, насколько доверяешь незнакомцу, вдруг предлагающему тебе сладкую конфету.

Используй безопасные методы сериализации и десериализации​

Что за страшные слова — «сериализация» и «десериализация»? Не вызывают ли они дереализацию? Не пугайся! Сериализация — это по сути просто превращение всяких структур вроде списков и словарей в строку, которую легко хранить на диске или передавать по сети. Десериализация — обратный процесс, то есть превращение последовательности символов в структуру.

Здесь кроется целый класс уязвимостей. Если превращать строки в структуры неаккуратно, то злоумышленник, манипулируя данными, сможет перехватить управление твоей программой.

Пример опасного кода:
Python: Скопировать в буфер обмена
Код:
import pickle
# Никогда так не делай!
def unsafe_deserialization(serialized_data):
    return pickle.loads(serialized_data)

В этом примере я использовал модуль pickle для десериализации данных. Это удобно, но pickle не обеспечивает безопасность. Если злоумышленник подменит сериализованные данные, он сможет выполнить произвольный код на твоем компьютере.

Хороший пример:
Python: Скопировать в буфер обмена
Код:
import json
# Гораздо лучше!
def safe_deserialization(serialized_data):
    return json.loads(serialized_data)

Здесь я использую для десериализации модуль json. Он не позволяет выполнить произвольный код, так что он безопаснее. Всегда помни о рисках и выбирай безопасные методы!
1724300983823.png

Используй принцип наименьших привилегий​

Этот принцип гласит: дай программе только те привилегии, которые ей действительно нужны для выполнения ее задачи.

Это очень важно для безопасности, потому что, если злоумышленник взломает твою программу, он получит те же привилегии, что и программа. Если ее привилегии ограниченны, сузится и круг возможных действий злоумышленника.

Посмотрим на пример. Представь, что у тебя есть функция, которая должна записывать данные в файл:
Python: Скопировать в буфер обмена
Код:
def write_to_file(file_path, data):
    with open(file_path, 'w') as f:
        f.write(data)

Этой функции не нужны никакие привилегии, кроме возможности записи в конкретный файл. Но если ты запустишь эту функцию с привилегиями администратора, злоумышленник, взломавший эту функцию, сможет делать в системе абсолютно все.

Всегда давай своим функциям и программам только те привилегии, которые им действительно нужны, и ничего больше.

Избегай уязвимостей, связанных с аутентификацией и авторизацией​

Безопасная авторизация пользователей — это огромная тема, в которой есть масса подводных камней. Впрочем, некоторых из них избежать очень легко.

Безопасное хранение паролей​

Начнем с того, что абсолютно недопустимо. Никогда (никогда!) не храни пароли в открытом виде. Например, вот так:
Python: Скопировать в буфер обмена
Код:
users = {
    "alice": "password123",
    "bob": "qwerty321"
}
Если эти данные утекут (а вероятность этого всегда есть), то все пароли твоих пользователей станут известны.

Так как же делать правильно? Нужно использовать хеширование паролей. Хеширование — это процесс, при котором из пароля генерируется уникальная строка фиксированной длины. При этом уникальность хеша означает, что даже незначительное изменение в исходном пароле полностью изменит его хеш.

В Python для хеширования можно использовать модуль hashlib. Посмотрим, как это работает, на примере:
Python: Скопировать в буфер обмена
Код:
import hashlib
password = "password123"
hashed_password = hashlib.sha256(password.encode()).hexdigest()
print(hashed_password)

Теперь, даже если база данных утечет, хакеры увидят только хеши паролей, а не сами пароли.

Соль для паролей​

Простое хеширование тоже неидеально. Хакеры могут использовать так называемые радужные таблицы для угадывания паролей. Усложнит им задачу «соль» — случайная строка, которую мы добавляем к паролю перед хешированием. Так каждый пароль будет иметь уникальный хеш, даже если два пользователя зададут один и тот же пароль.

Python: Скопировать в буфер обмена
Код:
import hashlib
import os
password = "password123"
salt = os.urandom(16)  # Сгенерируем соль
salted_password = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 100000)
print(salted_password)

В этом примере мы использовали функцию pbkdf2_hmac из модуля hashlib, которая позволяет применять соль к паролю. Соль мы генерируем с помощью функции os.urandom, а затем используем ее вместе с паролем и количеством итераций для создания хешированного пароля.

Таким образом, даже если два пользователя выберут одинаковые пароли, их хеш будет отличаться из‑за разной соли. Так угадать пароль гораздо сложнее.

Однако теперь тебе нужно хранить и соль. Как правило, соль и хеш хранятся вместе, например:
Python: Скопировать в буфер обмена
Код:
import hashlib
import os
password = "password123"
# Генерируем соль
salt = os.urandom(16)
salted_password = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 100000)
# Сохраняем соль вместе с хешем
stored_password = salt + salted_password

Теперь у тебя есть основные инструменты для безопасного хранения паролей. Перейдем к проверке входных данных.

Тщательно проверяй все входные данные​

Проверка входных данных — это один из краеугольных камней безопасного программирования. Да, всегда сначала кажется, что это лишняя работа. Но верь мне, в долгосрочной перспективе это может спасти тебя от многих проблем.

Когда твоя программа получает данные из какого‑то внешнего источника, важно убедиться, что они соответствуют ожидаемому формату и не содержат вредоносного кода. Ведь не все пользователи хорошие. Некоторые могут пытаться взломать твою систему. Поэтому нам нужно быть внимательными, особенно если ты используешь старый метод форматирования с помощью str.format().

Уязвимый пример со str.format() (пример взят с security.stackexchange.com):
Python: Скопировать в буфер обмена
Код:
from http.server import HTTPServer, BaseHTTPRequestHandler
secret = 'abc123'
class Handler(BaseHTTPRequestHandler):
    name = 'xakep'
    msg = 'welcome to {site.name}'
    def do_GET(self):
        res = ('<title>' + self.path + '</title>\n' + self.msg).format(site=self)
        self.send_response(200)
        self.send_header('content-type', 'text/html')
        self.end_headers()
        self.wfile.write(res.encode())
HTTPServer(('localhost', 8888), Handler).serve_forever()

Этот код запускает простой веб‑сервер, который при обработке GET-запроса возвращает HTML, вставляя имя сайта в сообщение приветствия. Название сайта берется из атрибута name обработчика Handler.

Ключевая уязвимость этого кода в том, что он использует значение self.path (то есть часть URL, предоставленного пользователем) в качестве части строки формата в строке res. Это позволяет злоумышленнику управлять строкой формата, что может привести к нежелательному поведению.

Эксплуатация будет выглядеть так:
Bash: Скопировать в буфер обмена
Код:
$ python3 example.py

$ curl 'http://localhost:8888/test'

welcome to xakep

Но атакующий может обратиться и к глобальным переменным:
Bash: Скопировать в буфер обмена
Код:
$ curl -g 'http://localhost:8888/XXX{site.do_GET.__globals__[secret]}'
<title>/XXXabc123</title>
welcome to xakep

Здесь {site.do_GET.globals[secret]} используется для чтения глобальной переменной secret, значение которой abc123. Когда сервер обрабатывает этот запрос, он вставляет значение abc123 в заголовок страницы.

Это происходит из‑за того, что self.path контролируется пользователем, и ты можешь это использовать для изменения форматированной строки.

Что делать? Использовать f-strings! Мало того что они безопасны, так еще и работают быстрее, да и код выглядит куда опрятнее.

Для примера выше безопасно будет использовать форматирование так:
Python: Скопировать в буфер обмена
res = f"<title>{self.path}\n{self.msg}"

Иногда необходимо фильтровать пользовательский ввод на наличие определенных символов. Например, нам нужно получить имя пользователя и в нем не должно быть ничего, кроме букв:
Python: Скопировать в буфер обмена
Код:
def say_hello(name):
    if not isinstance(name, str) or not name.isalpha():
        raise ValueError("Имя должно быть строкой и содержать только буквы")
    print(f"Привет, {name}!")
try:
    user_input = input("Введите ваше имя: ")
    say_hello(user_input)
except ValueError as e:
    print(f"Ошибка: {e}")

В этом примере мы проверяем, является ли введенное имя строкой и содержит ли оно только буквы. Если данные не соответствуют этим требованиям, вызывается исключение. В результате мы избегаем потенциально опасных ситуаций, связанных с некорректным вводом.

Проверка входных данных — это здорово, но не забывай и про санитизацию пользовательского ввода. При разработке веб‑приложений это важнейшая вещь, так как позволяет избежать самых разнообразных атак, таких как SQL-инъекции и cross-site scripting (XSS).

В Python для этого есть функции escape и библиотека Bleach.

Функцию escape предоставляет модуль html из стандартной библиотеки Python. Она преобразует специальные символы (например, <, >, & и кавычки) в их HTML-эквиваленты. Это позволяет безопасно отображать пользовательский ввод на веб‑страницах без риска выполнения вредоносного кода.

Пример использования escape:
Python: Скопировать в буфер обмена
Код:
from html import escape
user_input = "<script>malicious_code();</script>"
safe_input = escape(user_input)
print(safe_input)

Результат:
Код: Скопировать в буфер обмена
<script>malicious_code();</script>

Bleach — это сторонняя библиотека, которая предоставляет более широкий набор инструментов для санитизации и очистки HTML и текста. Bleach может убирать из HTML нежелательные или потенциально вредоносные теги и атрибуты.

Пример использования Bleach:
Python: Скопировать в буфер обмена
Код:
import bleach
user_input = "<script>malicious_code();</script>"
safe_input = bleach.clean(user_input)
print(safe_input)  # Результат: <script>malicious_code();</script>

По умолчанию bleach.clean() удаляет все HTML-теги. Если хочешь разрешить определенные безопасные теги, можешь передать их в параметр tags:
Python: Скопировать в буфер обмена
safe_input = bleach.clean(user_input, tags=['b', 'i', 'u'])
В этом примере только теги <b>, <i> и <u> будут разрешены, а все остальные — удалены.

Не забивай на управление сессиями​

Разрабатываешь веб‑приложение? Тогда давай поговорим о сессиях. Сессия — это способ сохранить данные между запросами пользователя. Когда пользователь входит в систему, мы создаем сессию, которая продолжается до тех пор, пока пользователь не выйдет из системы или сессия не истечет по тайм‑ауту.

Управление сессиями — это серьезный вопрос, и здесь мы можем столкнуться с несколькими уязвимостями, включая угон сессии и перехват сессионных куки. Поэтому правильное управление сессиями — это критически важно.

Давай пройдемся по нескольким основным принципам.

Используй безопасные куки​

Механизм cookies часто используется для хранения сессионных идентификаторов. В этом случае ты должен не забыть выставить своим куки флаги Secure и HttpOnly. Secure означает, что куки будут передаваться только через HTTPS, а HttpOnly запрещает доступ к куки через JavaScript, что может помочь предотвратить перехват через межсайтовый скриптинг (XSS).
Python: Скопировать в буфер обмена
Код:
from flask import session, Flask
app = Flask(__name__)
app.config.update(
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE='Lax',
)

Восстанавливай идентификатор сессии​

Каждый раз, когда пользователь входит в систему или выходит из нее, следует регенерировать идентификатор сессии. Это помогает предотвратить угон сессии.
Python: Скопировать в буфер обмена
Код:
from flask import session
@app.route('/login', methods=['POST'])
def login():
    # ...
    # Проверка учетных данных
    # ...
    # Регенерация ID сессии после успешного входа
    session.regenerate()
    return "Успешный вход в систему!"

Устанавливай тайм-аут сессии​

Бесконечные сессии — это плохо. Всегда устанавливай тайм‑аут для сессий.
Python: Скопировать в буфер обмена
Код:
from flask import Flask, session
from datetime import timedelta
app = Flask(__name__)
app.permanent_session_lifetime = timedelta(minutes=15)

Помни, сессии — это мощный инструмент, но их надо использовать осторожно и правильно, так что будь внимателен.

Будь аккуратен с eval() и exec()​

В Python есть встроенные функции eval() и exec(), которые выполняют переданный им в виде строки код на Python. Обе выполняют код, но с некоторыми отличиями.

Функция eval() ожидает строку, содержащую выражение Python, и возвращает значение выражения. Например, если ты передашь '1 + 2' функции eval(), она вернет 3.

Пример:
Python: Скопировать в буфер обмена
Код:
x = 1
print(eval('x + 1'))  # Результат: 2

Функция exec() выполняет несколько строк кода Python. В отличие от eval() она не возвращает значение, а выполняет любые операторы в строке. Например, ты можешь использовать exec() для определения новых функций или классов.

Пример:
Python: Скопировать в буфер обмена
exec('x = 1\ny = 2\nprint(x + y)') # Результат: 3
То есть основная разница между eval() и exec() в том, что eval() возвращает значение выражения и может обрабатывать только одно выражение, тогда как exec() выполняет блок кода без возврата значения.

Но вот в чем загвоздка. Эти функции могут выполнить код, выполнение которого ты не планировал. Разумеется, это открывает двери для хакеров. Если злоумышленник получит доступ к eval() или exec() либо передаваемым в них параметрам, он может запустить любой код Python со всеми последствиями.

Давай посмотрим на примеры хорошего и плохого кода с использованием eval().

Плохой пример:
Python: Скопировать в буфер обмена
Код:
import os
def bad_eval(input_string):
    return eval(input_string)
# Представь, что следующая строка пришла от пользователя
user_input = "os.system('rm -rf /')"
result = bad_eval(user_input)

В этом примере мы использовали eval() для выполнения строки, введенной пользователем. Если пользователь злонамерен, он может ввести строку, которая, к примеру, удалит все файлы на диске.

Хороший пример:
Python: Скопировать в буфер обмена
Код:
def good_eval(input_string):
    safe_list = ['+', '-', '*', '/', ' ', '4', '2']
    for i in input_string:
        if i not in safe_list:
            return "Error! Unsafe input."
    return eval(input_string)
# Даже если пользователь пытается ввести опасный код, ничего не случится
user_input = "4 / 2 * os.system('rm -rf /')"
result = good_eval(user_input)
print(result)
# Вывод: "Error! Unsafe input."
В этом примере мы ограничиваем, что может быть введено в eval(), и тем самым уменьшаем риск. Мы создаем список безопасных символов и проверяем ввод: в нем не должно быть ничего, кроме этих символов. Если введенный пользователем символ не в списке, мы возвращаем сообщение об ошибке и не выполняем eval().

Однако даже в этом случае использование eval() все еще не совсем безопасно, потому что нам пришлось учесть все возможные варианты ввода. Это не всегда достижимо, особенно когда ввод становится более сложным.

Лучшей практикой будет вовсе избегать eval(), если это возможно. Есть много других способов обработки ввода пользователя, которые не подвержены такому риску.

Например, если ты хочешь обрабатывать математические выражения, можно использовать безопасные библиотеки, такие как SymPy. В них есть свои функции парсинга и выполнения математических выражений.

Пример безопасного использования SymPy:
Python: Скопировать в буфер обмена
Код:
from sympy import sympify
def safe_eval(input_string):
    safe_list = ['+', '-', '*', '/', ' ', '4', '2']
    for i in input_string:
        if i not in safe_list:
            return "Error! Unsafe input."
    return sympify(input_string)
user_input = "4 / 2 * 2"
result = safe_eval(user_input)
print(result) # Выведет 4.0
В этом примере мы используем функцию sympify из библиотеки SymPy для выполнения математического выражения, введенного пользователем. Это безопаснее, чем использовать eval(), потому что SymPy не выполняет произвольный код Python, а только обрабатывает математические выражения.

Применение eval() может быть обоснованно — в основном когда тебе нужно динамически исполнить код на Python, который ты получаешь в виде строки. Но даже в этих случаях будь предельно осторожен и всегда валидируй ввод, чтобы избежать возможных уязвимостей.

Давай рассмотрим несколько примеров, когда вызывать eval() может быть полезно.

Наиболее очевидный случай — это создание собственного интерпретатора Python или REPL (read — eval — print loop). Тебе может потребоваться eval(), чтобы исполнять код, введенный пользователем.
Python: Скопировать в буфер обмена
Код:
while True:
    user_input = input(">>> ")
    try:
        print(eval(user_input))
    except Exception as e:
        print("Ошибка: ", e)
Иногда eval() используют для динамического импортирования модулей. Например, нужно загрузить какие‑то модули, перечисленные как строковые значения. Однако в таких случаях лучше использовать для тех же целей библиотеку importlib.

Всегда помни: eval() — это мощный инструмент, но с большой мощью идет большая ответственность. Применяй его с осторожностью и только тогда, когда других вариантов нет.

Используй виртуальное окружение Python​

Виртуальное окружение — это изолированная зона, в которой установлена определенная версия Python и библиотек. Этот механизм помогает оградить твой проект от изменений в системе. Заодно виртуальные окружения дают дополнительную безопасность.

Как это работает?​

Допустим, ты пишешь два проекта: Project_A и Project_B. Project_A требует Django версии 1.11, а Project_B — Django версии 2.2. Если установить обе версии Django глобально, ты столкнешься с конфликтом версий. Виртуальное окружение решает эту проблему, позволяя иметь две отдельные «копии» Python и библиотек для каждого проекта.

Допустим, тебе нужно создать виртуальное окружение для Project_A. Открой терминал и перейди в каталог Project_A, а затем введи
Bash: Скопировать в буфер обмена
python3 -m venv env
Это создаст виртуальное окружение с именем env. Теперь, чтобы активировать это окружение, используй следующую команду:
Bash: Скопировать в буфер обмена
source env/bin/activate
Просто и надежно! Так почему же это еще и более безопасно? Вот несколько причин:
  • Изоляция зависимостей. У каждого виртуального окружения — свой набор зависимостей, которые изолированы от системного Python. Это означает, что, даже если в каком‑то из системных пакетов Python есть уязвимость, это не затронет твое виртуальное окружение.
  • Контроль версий. Использование виртуальных окружений помогает контролировать версии используемых библиотек и пакетов. Ты можешь использовать конкретные версии пакетов, в безопасности которых не сомневаешься.
  • Уменьшение риска. Если ты случайно установишь вредоносный пакет, он будет ограничен виртуальным окружением и не сможет навредить системному Python или другим проектам.
  • Сохранение чистоты глобального пространства. Установка пакетов глобально может создать множество проблем, особенно при работе с разными версиями Python. Виртуальные окружения помогают избежать этого, сохраняя глобальное пространство чистым и организованным.
  • Легкость воспроизведения и развертывания. Когда развертываешь приложение на сервере или передаешь код другому разработчику, виртуальное окружение позволяет легко воспроизвести нужные условия, включая все зависимости.
Использовать виртуальные окружения не только удобно, это еще и важный аспект безопасного программирования на Python.

Выводы​

Следуя этим основным принципам, ты снизишь вероятность появления уязвимостей и сделаешь свой код более надежным. Всегда будь в курсе последних угроз и используй лучшие практики, чтобы свести риск к минимуму.

И помни, что безопасность — это не что‑то, что можно добавить в конце разработки. О ней нужно думать при написании каждой строчки кода. И хотя это может занять больше времени и усилий, в долгосрочной перспективе они обязательно окупятся.

ВЗЯТО: ТЫК
 
Сверху Снизу