Как я добавил безопасный поиск в свой блог на чистом PHP (и не словил SQL-инъекцию)
Недавно решил, что на моём техническом блоге не хватает поиска. Казалось бы — дело пяти минут: вставил форму, написал пару строк SQL — и готово. Но через пару часов понял: половина решений в интернете — это дыры в безопасности размером с грузовик.
Разобрался, как сделать всё правильно — и делюсь кейсом.
Задача
Нужно добавить поиск по:
- заголовкам статей,
- анонсам (preview_text),
- полному тексту (content),
- и аналогично — по код-постам (title и intro).
При этом:
- не ломать пагинацию,
- не открывать дыру для SQL-инъекций,
- и не позволять XSS-атакам через вывод результата.
Шаг 1: Форма поиска
Самое простое — обычная GET-форма:
<form method="GET" action="/">
<input type="text" name="q" value="<?= htmlspecialchars($_GET['q'] ?? '') ?>" placeholder="Поиск по блогу...">
<button type="submit">🔍</button>
</form>
Обрати внимание: htmlspecialchars() при выводе значения — это первая линия защиты от XSS.
Шаг 2: Безопасный SQL-запрос
Многие пишут так:
// НИКОГДА НЕ ДЕЛАЙ ТАК!
$query = "SELECT * FROM articles WHERE title LIKE '%" . $_GET['q'] . "%'";
Это прямой путь к SQL-инъекции. Вместо этого — используем подготовленные выражения (Prepared Statements) через PDO:
$search_query = trim($_GET['q'] ?? '');
$is_search = !empty($search_query);
if ($is_search) {
$search_sql = " AND (a.title LIKE :search OR a.preview_text LIKE :search OR a.content LIKE :search)";
} else {
$search_sql = "";
}
$stmt = $pdo->prepare("SELECT a.id, a.title, a.preview_text, a.image, a.created_at, c.name as category
FROM articles a
LEFT JOIN categories c ON a.category_id = c.id
WHERE 1=1 $search_sql
ORDER BY a.created_at DESC
LIMIT :limit OFFSET :offset");
$stmt->bindValue(':limit', $articles_per_page, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
if ($is_search) {
$stmt->bindValue(':search', '%' . $search_query . '%', PDO::PARAM_STR);
}
$stmt->execute();
Ключевые моменты:
bindValue(':search', ... , PDO::PARAM_STR)— автоматически экранирует строку,- никакой конкатенации с
$_GET— только параметры, - даже если злоумышленник введёт
' OR '1'='1— он попадёт в строку как текст, а не как SQL-код.
Шаг 3: Защита от XSS при выводе
Даже если данные из БД — свои, при выводе результатов поиска всегда используй htmlspecialchars():
<h3><a href="article.php?id=<?= $row['id'] ?>">
<?= htmlspecialchars($row['title']) ?>
</a></h3>
Это не даст внедрить <script> через заголовок статьи (например, если кто-то когда-то внесёт вредоносные данные в БД).
Шаг 4: Учёт особенностей таблиц
Важно: в таблице code_posts у меня нет поля content, только intro. Поэтому для неё условие другое:
$search_sql_codes = " AND (cp.title LIKE :search OR cp.intro LIKE :search)";
Если бы я не проверил структуру через DESCRIBE code_posts — получил бы ошибку SQL и пустую страницу.
Итог
Теперь у блога есть рабочий, безопасный поиск:
- ✅ Защита от SQL-инъекций — через PDO + bindValue,
- ✅ Защита от XSS — через htmlspecialchars при выводе,
- ✅ Поддержка двух типов записей (статьи и код-посты),
- ✅ Работает даже с кривыми словами вроде «ывавы».
И да — никаких фреймворков, Elasticsearch и JavaScript. Только чистый PHP, здравый смысл и внимание к деталям.
P.S. Блог всё ещё в режиме эксперимента, но этот кусок кода уже работает в продакшене — проверено на реальных запросах и попытках «пощупать» форму.
Комментарии
Пока нет комментариев. Будьте первым!