Принципы безопасного проектирования

Учимся базовым принципам, которые делают систему устойчивой по своей конструкции, а не за счёт удачно расставленных заплаток.

Безопасное проектирование — встраивание защиты в саму архитектуру через набор проверенных принципов, чтобы безопасность была свойством конструкции, а не дополнением 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: чем проще защита, тем меньше в ней дыр и тем легче её проверить.
Проверьте себя
1. Что предписывает принцип «fail-safe defaults» (защита по умолчанию)?
AДоступ по умолчанию открыт, а закрывается чёрным списком
BРешение о доступе принимается на основе явного разрешения: запрещено всё, кроме того, что явно разрешено
CПри сбое система должна перезагружаться
DВсе пароли по умолчанию должны быть одинаковыми для удобства
2. Как принцип «fail securely» предписывает вести себя коду проверки прав при исключении?
AПропускать запрос (return True), чтобы не ломать пользователю работу
BОтказывать в доступе (return False, fail closed): если не удалось убедиться, что доступ разрешён, его не дают
CИгнорировать исключение и продолжать без проверки
DКешировать прошлый успешный результат и использовать его
3. В чём смысл принципа «минимум привилегий» (least privilege)?
AДавать всем максимум прав, чтобы ничего не сломалось
BВыдавать каждому компоненту и пользователю ровно те права, что нужны для задачи, и не больше — тогда ущерб от компрометации ограничен этими скромными правами
CЗапускать всё от имени root для простоты
DМинимизировать число пользователей в системе