Принципы безопасного проектирования
Учимся базовым принципам, которые делают систему устойчивой по своей конструкции, а не за счёт удачно расставленных заплаток.
Безопасное проектирование — встраивание защиты в саму архитектуру через набор проверенных принципов, чтобы безопасность была свойством конструкции, а не дополнением post factum.
Большая часть классических принципов сформулирована ещё в 1975 году Зальцером и Шрёдером и с тех пор только подтверждалась практикой. Их ценность в том, что они не зависят от языка, фреймворка и моды: следуя им, вы сокращаете число ошибок ещё на чертеже. Ниже — ключевые принципы из этого набора, каждый с пояснением «почему» и тем, как он выглядит в коде.
Зачем это знать защитнику
Категория «Insecure Design» — отдельный пункт OWASP Top 10 именно потому, что никакая фильтрация ввода не спасёт систему, спроектированную небезопасно по сути. Защитник, владеющий принципами, на ревью архитектуры быстро называет, какой из них нарушен: «здесь дефолт открыт», «здесь сервису выданы лишние права», «здесь при сбое всё распахивается». Это переводит разговор о безопасности из плоскости вкусов в плоскость проверяемых критериев.
Защита по умолчанию (secure defaults)
Система в исходном состоянии должна быть безопасной: доступ закрыт, пока явно не открыт; функции с риском выключены; дефолтные пароли отсутствуют. Принцип fail-safe defaults гласит: решение о доступе принимается на основе явного разрешения, а не отсутствия запрета. То есть «запрещено всё, кроме явно разрешённого», а не наоборот.
# УЯЗВИМО: доступ открыт по умолчанию, закрываем «чёрным списком».
def can_access(user, resource):
if user in BLOCKED_USERS: # забыли кого-то внести — он внутри
return False
return True # дефолт — РАЗРЕШЕНО
# БЕЗОПАСНО: дефолт — запрет, доступ по явному «белому списку».
def can_access(user, resource):
if user.has_permission(resource.required_permission):
return True
return False # дефолт — ЗАПРЕЩЕНО
Минимум привилегий (least privilege)
Каждый компонент, процесс и пользователь получает ровно те права, что нужны для задачи, и не больше. Если веб-сервису достаточно читать таблицу товаров — он не должен ходить в БД под суперпользователем с правом DROP TABLE. Тогда даже успешная атака через этот сервис ограничена его скромными правами: ущерб от компрометации сжимается до минимума.
# УЯЗВИМО: приложение подключается к БД под админом.
DB_USER = "postgres" # может всё, включая удаление таблиц
# БЕЗОПАСНО: отдельная роль только с нужными правами.
DB_USER = "shop_readonly" # SELECT по нужным таблицам, и только
Эшелонированная защита (defense in depth)
Не полагайтесь на единственный рубеж. Если есть только один барьер и он пал — пала вся система. Несколько независимых слоёв (валидация на входе, проверка прав в бизнес-логике, ограничения на уровне БД, мониторинг) означают, что пробой одного не открывает дорогу сразу к цели. Это та же идея, что в дереве атак: усложняем каждый путь, а не один.
Fail securely (безопасный отказ)
При ошибке или исключении система должна переходить в безопасное состояние — закрытое, а не открытое. Классическая ошибка: код проверки прав падает с исключением, а обработчик в этом случае пропускает запрос. Правильно — наоборот: не смогли убедиться, что доступ разрешён, значит, отказываем.
# УЯЗВИМО: при сбое проверки доступ РАЗРЕШАЕТСЯ (fail open).
def is_allowed(user, res):
try:
return auth_service.check(user, res)
except Exception:
return True # сбой — и мы всех впускаем
# БЕЗОПАСНО: при сбое доступ ЗАПРЕЩАЕТСЯ (fail closed).
def is_allowed(user, res):
try:
return auth_service.check(user, res)
except Exception:
log.warning("Сбой проверки прав — отказываем")
return False # сбой — отказ
Экономия механизма (economy of mechanism)
Чем проще защитный механизм, тем меньше в нём места для ошибок и тем легче его проверить. Сложную, «умную» систему авторизации с десятком исключений трудно отревьюить и легко обойти. Принцип призывает делать защиту настолько простой, насколько возможно: меньше кода — меньше дыр. Сюда же относится least common mechanism — поменьше общих ресурсов между компонентами, чтобы они меньше влияли друг на друга, и complete mediation — проверять права при каждом доступе, а не кешировать «однажды проверили — больше не надо».
Как это работает под капотом
Принципы дополняют друг друга. Минимум привилегий ограничивает ущерб, если барьер пробит; эшелонированная защита уменьшает шанс, что его вообще пробьют; fail securely гарантирует, что случайный сбой не превратится в открытую дверь; secure defaults делают безопасным даже состояние «о настройке забыли»; экономия механизма снижает число ошибок в самой защите. Ещё один важный принцип — не полагаться на секретность устройства (no security through obscurity): прятать алгоритм бесполезно, стойкость должна держаться на секрете ключа и правильной конструкции, а не на том, что код «никто не видел».
Как защититься
Применяйте принципы как чек-лист на ревью дизайна: дефолт — запрет (fail-safe defaults); каждому компоненту — минимум прав; несколько независимых слоёв защиты; при сбое — отказ, а не пропуск; механизмы — максимально простые и проверяемые; проверка прав при каждом доступе; стойкость не на секретности кода, а на ключах и конструкции. Эти правила универсальны и проверяются ещё на доске — до того, как написана первая строка.
Итоги
- Безопасность встраивается в архитектуру принципами, а не добавляется заплатками потом.
- Secure defaults: запрещено всё, кроме явно разрешённого; никаких дефолтных паролей и открытых функций.
- Least privilege: минимум прав каждому — это ограничивает ущерб при компрометации.
- Defense in depth: несколько независимых рубежей, чтобы пробой одного не открывал путь к цели.
- Fail securely: при ошибке система закрывается (fail closed), а не открывается.
- Economy of mechanism: чем проще защита, тем меньше в ней дыр и тем легче её проверить.