Раньше я думал, что написание собственного слоя аутентификации — это предмет гордости. Если ты понимаешь, как работают JWT, и умеешь хешировать пароли, то что в этом сложного? Я настроил бэкенд на Node.js, выдавал токены и считал дело сделанным. Код работал. Тесты проходили. Но потом я начал читать о том, как на самом деле происходят реальные атаки, и у меня буквально ушла почва из-под ног. Каждая глава об инъекциях, переборе данных и утечках по сторонним каналам заставляла меня возвращаться к редактору с тяжелым чувством. В моей системе аутентификации были не просто пробелы; в ней были четыре широко распахнутые двери, которые я установил сам. Вот что я обнаружил и что именно я изменил.
SQL-инъекция через конкатенацию строк
Первая ошибка была старым как мир трюком. Я брал пользовательский ввод и вставлял его напрямую в SQL-строки. В моем маршруте логина я извлекал email из тела запроса и конкатенировал его в запрос вида SELECT * FROM users WHERE email = '${email}'. Это казалось безобидным, потому что я контролировал фронтенд. Это опасное заблуждение. Атакующему не нужен ваш фронтенд. Один специально сформированный POST-запрос мог превратить эту проверку входа в утечку данных или полное удаление базы данных. Отправьте полезную нагрузку вроде ' OR '1'='1 или, что еще хуже, составной запрос (stacked query), который удаляет таблицы, и если строка будет выполнена «как есть», ваши данные исчезнут. Я не оставил базе данных никакого способа отличить мой код от данных атакующего.
Решением была не дополнительная валидация ввода или ручное экранирование строк. Настоящим решением стали параметризованные запросы. Я перешел на node-postgres и начал использовать плейсхолдеры вроде $1. Запрос превращается в шаблон: SELECT * FROM users WHERE email = $1. Драйвер отправляет SQL-запрос и значения через разные каналы. База данных воспринимает ввод строго как данные, независимо от того, какие символы он содержит. Это одно изменение закрывает весь класс атак типа «инъекция». Это проще читать, легче поддерживать, и это избавляет от необходимости быть мастером регулярных выражений каждый раз, когда вы пишете условие WHERE.
Перебор email-адресов через сообщения об ошибках
Моя вторая ошибка выглядела как хороший UX. Когда пользователь вводил неверный email, я возвращал User not found. Когда email был верным, но пароль не подходил, я возвращал Incorrect password. Это казалось полезным. Но это также стало инструментом разведки для атакующих. Скрипты перебора могут бомбардировать ваш эндпоинт логина тысячами email-адресов. Если тело ответа или статус-код меняются в зависимости от того, существует ли аккаунт, скрипт может составить проверенный список ваших пользователей. Этот список становится основой для credential stuffing, целевого фишинга и дальнейших попыток брутфорса.
Мне пришлось признать, что удобство пользователя иногда должно уступать место безопасности. Я изменил все пути неудачного входа так, чтобы они возвращали одну и ту же строку: Invalid credentials. Никаких подсказок. Никакой разветвленной логики в ответе об ошибке. Независимо от того, отсутствует ли email, неверный ли пароль или учетная запись заблокирована, текст остается идентичным. Это также относится к процессам регистрации и сброса пароля: не раскрывайте, есть ли такой адрес в вашей системе. Одно универсальное сообщение устраняет утечку информации, на которую полагаются атакующие.
Атаки по времени при сравнении паролей
Третья ошибка была невидимой. Я
