Блог AST-SoftPro
Безопасность веб-сайтов: типовые дыры в коде и способы их закрыть
Введение
Каждый второй сайт, который сегодня появляется в интернете, собран наспех: шаблон, пара API, база данных — и «работает». Проблема в том, что работает ровно до первой атаки. По данным отчёта Verizon DBIR 2026 года, эксплуатация уязвимостей стала причиной 31% утечек данных и впервые обогнала украденные учётные записи как главный вектор начального доступа. Атаки на веб-приложения участвуют ещё в 26% успешных взломов — это второй по частоте сценарий после фишинга. Средняя стоимость одной утечки, по оценке IBM Cost of a Data Breach, держится около 4,44 млн долларов.
Хорошая новость: подавляющее большинство этих дыр — не изобретения хакеров-гениев, а хрестоматийные ошибки разработки. SQL-инъекции, сломанный контроль доступа, открытые служебные панели, пароли в открытом виде. Всё это описано в OWASP Top 10 — документе, который пересматривают примерно каждые три года; свежая редакция 2025 года добавила туда даже новую категорию — некорректную обработку нештатных ситуаций (Mishandling of Exceptional Conditions).
В этой статье разберем типовые проблемы безопасности веб-сайтов: как они выглядят в реальном коде, почему возникают и чем закрываются — с примерами на Python и JavaScript. Статья будет полезна и тем, кто пишет бэкенд сам, и тем, кто принимает чужой код перед запуском в продакшн.
OWASP Top 10:2025 — карта проблем
Прежде чем латать дыры, полезно понимать карту местности. OWASP Top 10 — это не учебник, а рейтинг: какие классы уязвимостей встречаются чаще всего и наносят больше всего ущерба. Актуальная редакция 2025 года выглядит так:
| Код | Категория | Что изменилось с 2021 года |
|---|---|---|
| A01 | Broken Access Control (сломанный контроль доступа) | Удерживает 1-е место; сюда теперь входит SSRF |
| A02 | Security Misconfiguration (ошибки конфигурации) | Подъём с 5-го места на 2-е |
| A03 | Software Supply Chain Failures (цепочка поставок ПО) | Расширенная версия «устаревших компонентов» |
| A04 | Cryptographic Failures (криптография) | С 2-го места на 4-е |
| A05 | Injection (инъекции) | С 3-го места на 5-е |
| A06 | Insecure Design (небезопасный дизайн) | С 4-го на 6-е |
| A07 | Authentication Failures (аутентификация) | Без существенных изменений |
| A08 | Software or Data Integrity Failures (целостность ПО и данных) | Без изменений |
| A09 | Security Logging & Alerting Failures (логирование и оповещение) | Уточнено название: теперь важен не только сбор логов, но и алертинг |
| A10 | Mishandling of Exceptional Conditions (нештатные ситуации) | Новая категория 2025 года |
Обратите внимание на два момента. Первый: контроль доступа — проблема №1 уже много лет подряд; в среднем у 3,73% протестированных приложений находили хотя бы одну из сорока связанных CWE этой категории. Второй: инъекции за пять лет сместились вниз — не потому что перестали быть опасными, а потому что фреймворки и ORM сделали их заметно реже. Об этом подробнее в разделе про SQLi.
⚠️ Ориентироваться только на Top 10 недостаточно. Для систематической проверки есть OWASP ASVS версии 5.0 (вышла в мае 2025 года) — стандарт верификации с сотнями конкретных требований и тремя уровнями строгости. Top 10 отвечает на вопрос «что болит», ASVS — «как проверить, что вылечили».
Теперь — по каждому классу проблем: как ошибка выглядит в коде и чем закрывается. Примеры на Python (Flask/SQLAlchemy) и общие для любого стека; логика переносится на PHP, Node.js, Java без изменений.
Инъекции: классика, которая до сих пор работает
Как выглядит уязвимость
SQL-инъекция — приём, придуманный в начале двухтысячных, но до сих пор стабильно приносящий взломщикам даром доступ к чужим базам. Механика простая: пользовательский ввод подставляется в текст запроса конкатенацией, и база не может отличить данные от команд.
# Плохо: пользовательский ввод прямо в SQL-строке
login = request.args.get("login")
user = db.execute(
"SELECT * FROM users WHERE login = '" + login + "'"
).fetchone()
Стоит злоумышленнику подать login = ' OR 1=1 --, и запрос превращается в «выдай всю таблицу». Вариант с '; DROP TABLE users; -- ещё веселее, если база позволит несколько операторов подряд. По сути та же логика работает в командных инъекциях (os.system("convert " + filename) с файлом вида a.jpg; rm -rf /) и в XSS — когда чужой код выполняется на стороне браузера пользователя.
Как закрывать: параметризованные запросы
Правильное лечение — не экранировать кавычки «на всякий случай», а передать данные отдельно от текста запроса. Тогда они физически не смогут стать частью синтаксиса SQL.
# Хорошо: параметры передаются отдельно от текста запроса
user = db.execute(
"SELECT * FROM users WHERE login = %s", (login,)
).fetchone()
В SQLAlchemy тот же принцип — через text() с именованными параметрами или ORM-фильтры. Аналогично закрывается XSS: шаблонизаторы Jinja2, React, Vue экранируют вывод по умолчанию; опасны только ручные вставки «сырого» HTML (|safe, dangerouslySetInnerHTML) — их стоит держать под запретом код-ревью.
💡 Если вы используете современную ORM и шаблонизатор — большая часть работы по защите от инъекций уже сделана за вас. Опасность начинается там, где кто-то «для скорости» пишет конкатенацию строк. Это то место, где вайб-код сгенерированный ИИ без проверки чаще всего и ломается: модель легко подставляет ввод прямо в строку запроса, потому что так короче.
Сломанный контроль доступа: проблема №1
Как выглядит уязвимость
Контроль доступа ломается двумя способами. Первый — «вертикальная» привилегия: обычный пользователь получает возможность выполнить действие администратора, потому что проверка просто забыта на одном эндпоинте. Второй — «горизонтальная»: пользователь №17 читает данные пользователя №18, потому что приложение поверило идентификатору из URL, а не сессии.
# Плохо: проверяем только факт входа, роль игнорируется
@app.route("/admin/users")
def admin_users():
if not session.get("user"):
abort(401) # пользователь — тоже "вошедший"
return render_all_users()
Или тоньше:
# Плохо: объект ищется по id из запроса без проверки владельца
order = Order.query.get(request.args["id"]) # любой id — заказ любого клиента
return jsonify(order.to_dict())
Как закрывать: проверка прав на каждом эндпоинте
Логика владения должна быть частью запроса к данным, а не отдельным слоем «проверил и выдал».
# Хорошо: фильтр по владельцу сразу в запросе
order = Order.query.filter_by(
id=request.args["id"], user_id=session["user_id"]
).first_or_404()
Для ролей — декораторы фреймворка (@login_required + @roles_required("admin") в Flask, guard'ы в NestJS, middleware в Express) и единая политика авторизации на уровне приложения. Принцип deny-by-default: если эндпоинт не помечен явно как публичный — доступ закрыт.
⚠️ Частая ошибка — думать, что «злой пользователь не найдёт ссылку». Сокрытие URL (security through obscurity) не является защитой: админские панели массово сканируются ботами, а ID документов в мессенджерах пересылаются и утекают. Проверка прав должна быть серверной — всегда.
Аутентификация: пароли, сессии, токены
Как выглядит уязвимость
Три типовые ошибки. Первая: пароли хранятся хешем без «соли» или, хуже того, криптографически слабым алгоритмом (MD5, SHA-1) — такие базы расшифровываются радужными таблицами за минуты. Вторая: сессии не инвалидируются после выхода и живут вечно. Третья: токены выдаются навсегда и без возможности отзыва.
# Плохо: MD5 без соли, сравнение по времени уязвимо к timing-атаке
user = User.query.filter_by(login=login).first()
if user and hashlib.md5(password.encode()).hexdigest() == user.hash:
session["user"] = user.id
Как закрывать: проверенные алгоритмы и короткие сессии
Для паролей — специализированные медленные функции bcrypt, scrypt или Argon2id. Они спроектированы так, чтобы перебор стоил дорого: каждая попытка требует памяти и процессорного времени.
# Хорошо: Argon2id из библиотеки argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
ph = PasswordHasher()
user.hash = ph.hash(password) # при регистрации
try:
ph.verify(user.hash, password) # при входе; сравнение безопасное по времени
except VerifyMismatchError:
abort(401)
Дальше — разумная политика сессий: срок жизни куки (SESSION_COOKIE_MAXAGE), инвалидация при выходе и при смене пароля, флаг HttpOnly (кука недоступна JavaScript'у), Secure (только HTTPS), SameSite=Lax/Strict (защита от CSRF). Для API — короткоживущие JWT с refresh-токенами и списком отзыва. И обязательно: rate limiting на вход (например, 5 неудач → пауза) и поддержка MFA для админских ролей.
💡 Собственный «криптографический» код писать не нужно никогда. Нужны библиотеки: argon2-cffi, bcrypt, django.contrib.auth, NextAuth — они уже учли то, на что у самоучек уходят годы ошибок. Именно поэтому в продакшн-проектах аутентификацию чаще берут из готовых решений, а не пишут с нуля.
Конфигурация: открытая дверь не заперта изнутри
Как выглядит уязвимость
OWASP поднимает конфигурационные ошибки на второе место не случайно: облачные бакеты с публичным доступом, дефолтные пароли админок (admin/admin), включённый debug-режим, раскрытые .env и .git в корне веб-сервера. Из debug-страницы Flask видно стектрейс, переменные окружения и иногда содержимое запросов — подарок разведчику.
# Плохо: debug в проде — стектрейс с секретами наружу
app.run(debug=True)
Как закрывать: минимум по умолчанию и проверка перед релизом
Debug — только из переменной окружения, причём по умолчанию выключен: debug = os.getenv("FLASK_DEBUG") == "1". Дальше — чек-лист: убрать баннеры серверов (ServerTokens Prod), закрыть служебные пути, не хранить секреты в коде и репозитории (использовать переменные окружения или секрет-менеджеры), регулярно обновлять зависимости.
Отдельная боль — цепочка поставок (A03:2025): чужие пакеты в package.json/requirements.txt. Атаки через подставные имена пакетов (typosquatting) и взломанные версии мейнтейнеров — уже реальность, а не паранойя. Лечится пинингом версий, lock-файлами, автоматическими сканерами зависимостей (npm audit, pip-audit, Dependabot) и зеркалированием реестров в корпоративных средах.
⚠️ Прогоните перед релизом простой тест: пробуйте открыть
/debug,/.env,/.git/config,/server-status, админку с дефолтным паролем. Если хоть что-то отвечает — у вас как минимум одна конфигурационная дыра.
Криптография и передача данных
Три частые ошибки: незащищённый HTTP вместо HTTPS, самоподписанные сертификаты «для экономии», шифрование алгоритмами, которые не стоит изобретать (AES в режиме ECB, собственный код на XOR). Лечение короткое: всегда зашифрованный HTTPS с валидным сертификатом от Let's Encrypt (бесплатно), HSTS-заголовок, TLS 1.2+, а для данных «в покое» — штатное шифрование средствами СУБД или диска без самодеятельности. Для паролей и токенов криптография не нужна вообще — нужны хеши (см. предыдущий раздел).
Нештатные ситуации: новая категория OWASP
OWASP в 2025 году не случайно вынес обработку ошибок в отдельную категорию (A10): через неё утекают стектрейсы, секретные значения и, что хуже всего, безопасность «падает открытой дверью» — fail-open. Два типовых сценария.
Обработчик исключений, который печатает print(e) в ответ клиенту, или «fail-open» логика (try: check_permission() except: allow()), — это дыра не меньше SQLi. Правило простое: любое исключение логируется на сервере полностью, а клиенту отдаётся общая ошибка без деталей. И никогда не превращайте сбой проверки безопасности в разрешение доступа.
# Плохо: подробности ошибки — прямо клиенту
except Exception as e:
return jsonify(error=str(e)), 500
# Хорошо: детали — в серверный лог, клиенту — общая формулировка
except Exception:
logger.exception("order failed") # стектрейс только у вас
abort(500) # пользователю — «внутренняя ошибка»
Инструменты и процессы: как не пропустить дыру руками
Одного чтения кода мало — глаз замыливается. Рабочий набор на каждый день:
-
SAST (анализ своего кода): Semgrep, Bandit для Python, ESLint с
eslint-plugin-security, SonarQube в CI. -
Скан уязвимостей зависимостей: аудит пакетов (
npm audit/pip-audit), Dependabot или Renovate для автообновлений. -
DAST (скан запущенного сайта): OWASP ZAP — бесплатно, подходит и для ручных проверок; коммерческие сканеры — для регулярного мониторинга.
-
Секреты в репозитории: gitleaks или trufflehog на pre-commit хуке.
-
Заголовки безопасности: Content-Security-Policy (главная защита от XSS), X-Content-Type-Options: nosniff, Referrer-Policy, SameSite-куки. Проверить одним взглядом — сканер securityheaders.com.
Отдельно про процесс: код-ревью с фокусом на безопасность (кто может вызвать этот эндпоинт? чьи данные он трогает?), тесты на негативных сценариях, и хотя бы раз в год — внешний пентест. Для критичных систем ориентируйтесь на ASVS 5.0: там требования разложены по уровням L1/L2/L3, и можно честно проверить себя чек-листом, а не «на глаз».
💡 Внедрение сканеров в CI окупается быстро: Semgrep намерен правила под конкретные фреймворки и находит конкатенацию SQL или
evalза секунды. Но автоматика не заменяет мышление: логические дыры — «пользователь может отменить чужой заказ» — не видны ни одному сканеру, их находят только люди с вопросом «а что тут вообще разрешено?».
Чек-лист перед релизом
Короткая версия того, что должно быть закрыто у каждого сайта:
-
Все запросы к БД параметризованы; конкатенации строк в SQL нет.
-
Вывод шаблонов экранирован;
|safe/dangerouslySetInnerHTML— только после ревью. -
На каждом приватном эндпоинте есть проверка роли и владельца данных (deny-by-default).
-
Пароли — Argon2id/bcrypt; MFA для админов; rate limiting на входе.
-
HTTPS везде + HSTS; куки
HttpOnly,Secure,SameSite. -
Debug-режим выключен, стектрейсы клиенту не видны, ошибки логируются серверно.
-
Секретов нет в коде и истории git;
.envнаружу не отдаётся. -
Зависимости пинятся и сканируются (audit/Dependabot).
-
Заголовки безопасности настроены (CSP как минимум базовый).
-
Есть логирование входов/подозрительных операций и хотя бы минимальный алертинг.
Заключение
Безопасность сайта — это не отдельная функция, которую «допилим потом», а сумма десятка маленьких решений: параметр вместо конкатенации, проверка владельца вместо доверия к ID из URL, Argon2 вместо MD5, выключенный debug перед релизом. Каждая из этих мер стоит минуты работы программиста, а их отсутствие в сумме складывается в среднюю стоимость утечки с шестью нулями.
Тренды только подтверждают серьёзность: эксплуатация уязвимостей впервые обогнала украденные пароли как главный путь взлома (Verizon DBIR 2026), а OWASP в редакцию 2025 добавила отдельную категорию про некорректную обработку ошибок — значит, даже зрелая индустрия продолжает наступать на грабли.
Быстрые инструменты генерации кода сняли проблему «умеем ли мы написать», но поставили другую: Сгенерированный без проверки код часто выглядит работающим и при этом остаётся уязвимым — конкатенация SQL, отсутствующая проверка прав, секрет в комментарии. Поэтому для проектов, где живут чужие персональные данные или деньги, экспертиза безопасности остаётся не роскошью, а ценой входа на рынок: аудит архитектуры и кода до релиза обходится кратно дешевле, чем ликвидация утечки после.