Подписывайтесь:

Блог AST-SoftPro

Безопасность веб-сайтов: типовые дыры в коде и способы их закрыть

05.09.2026 20 мин чтения
Безопасность веб-сайтов: типовые дыры в коде и способы их закрыть

Введение

Каждый второй сайт, который сегодня появляется в интернете, собран наспех: шаблон, пара 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 за секунды. Но автоматика не заменяет мышление: логические дыры — «пользователь может отменить чужой заказ» — не видны ни одному сканеру, их находят только люди с вопросом «а что тут вообще разрешено?».

Чек-лист перед релизом

Короткая версия того, что должно быть закрыто у каждого сайта:

  1. Все запросы к БД параметризованы; конкатенации строк в SQL нет.

  2. Вывод шаблонов экранирован; |safe/dangerouslySetInnerHTML — только после ревью.

  3. На каждом приватном эндпоинте есть проверка роли и владельца данных (deny-by-default).

  4. Пароли — Argon2id/bcrypt; MFA для админов; rate limiting на входе.

  5. HTTPS везде + HSTS; куки HttpOnly, Secure, SameSite.

  6. Debug-режим выключен, стектрейсы клиенту не видны, ошибки логируются серверно.

  7. Секретов нет в коде и истории git; .env наружу не отдаётся.

  8. Зависимости пинятся и сканируются (audit/Dependabot).

  9. Заголовки безопасности настроены (CSP как минимум базовый).

  10. Есть логирование входов/подозрительных операций и хотя бы минимальный алертинг.

Заключение

Безопасность сайта — это не отдельная функция, которую «допилим потом», а сумма десятка маленьких решений: параметр вместо конкатенации, проверка владельца вместо доверия к ID из URL, Argon2 вместо MD5, выключенный debug перед релизом. Каждая из этих мер стоит минуты работы программиста, а их отсутствие в сумме складывается в среднюю стоимость утечки с шестью нулями.

Тренды только подтверждают серьёзность: эксплуатация уязвимостей впервые обогнала украденные пароли как главный путь взлома (Verizon DBIR 2026), а OWASP в редакцию 2025 добавила отдельную категорию про некорректную обработку ошибок — значит, даже зрелая индустрия продолжает наступать на грабли.

Быстрые инструменты генерации кода сняли проблему «умеем ли мы написать», но поставили другую: Сгенерированный без проверки код часто выглядит работающим и при этом остаётся уязвимым — конкатенация SQL, отсутствующая проверка прав, секрет в комментарии. Поэтому для проектов, где живут чужие персональные данные или деньги, экспертиза безопасности остаётся не роскошью, а ценой входа на рынок: аудит архитектуры и кода до релиза обходится кратно дешевле, чем ликвидация утечки после.

Источники