Что именно приходит через GET и POST

В PHP $_GET и $_POST — это не «безопасные данные формы», а уже распарсенный внешний ввод. $_GET берётся из query string: /search?q=php&page=2. $_POST обычно заполняется для HTML-форм с application/x-www-form-urlencoded и multipart/form-data. Если клиент отправил JSON, $_POST чаще всего будет пустым: тело читают через php://input и затем разбирают через json_decode(), что ближе к теме JSON, сериализация и форматы данных.

Важно не путать источник данных с HTTP-методом. У POST-запроса может быть query string, а GET-запрос не должен менять состояние приложения. Для действий вроде «удалить», «оплатить», «сменить email» нужна отдельная защита, связанная с CSRF и state-changing запросы.

flowchart TD A[HTTP-запрос] --> B{Где лежат данные?} B --> C[Query string<br/>$_GET / INPUT_GET] B --> D[HTML form body<br/>$_POST / INPUT_POST] B --> E[JSON body<br/>php://input + json_decode] C --> F[Проверить наличие<br/>missing vs empty] D --> F E --> F F --> G[Валидация формы значения<br/>тип, диапазон, allowlist] G --> H[Преобразование в нормальные типы<br/>int, string, DTO] H --> I[Бизнес-логика] I --> J[Отдельная защита на выходе<br/>escaping, prepared statements, CSRF]
flowchart TD
    A[HTTP-запрос] --> B{Где лежат данные?}
    B --> C[Query string<br/>$_GET / INPUT_GET]
    B --> D[HTML form body<br/>$_POST / INPUT_POST]
    B --> E[JSON body<br/>php://input + json_decode]
    C --> F[Проверить наличие<br/>missing vs empty]
    D --> F
    E --> F
    F --> G[Валидация формы значения<br/>тип, диапазон, allowlist]
    G --> H[Преобразование в нормальные типы<br/>int, string, DTO]
    H --> I[Бизнес-логика]
    I --> J[Отдельная защита на выходе<br/>escaping, prepared statements, CSRF]
Путь пользовательского ввода: источник, проверка наличия, валидация, приведение типов и отдельные защиты на выходе.
Быстрое повторение
Почему нельзя считать `$_GET` и `$_POST` «безопасными данными формы»?

Валидация, sanitization и экранирование

Валидация отвечает на вопрос: «соответствует ли значение правилам?». Например, page должен быть целым числом от 1, sort — одним из разрешённых значений, email — синтаксически похожим на email.

Sanitization меняет значение: удаляет, кодирует или преобразует символы. Это полезно в отдельных случаях, но опасно как универсальная привычка. Если пользователь ввёл bo gus@example.com, FILTER_SANITIZE_EMAIL может убрать пробел и получить другую строку; это не значит, что пользователь действительно ввёл корректный адрес. Обычно правильнее: сначала trim(), потом валидировать, а при ошибке показать понятное сообщение.

Отдельно стоит экранирование вывода. Оно не заменяет валидацию. Значение может быть валидным именем пользователя и всё равно требовать htmlspecialchars() при выводе в HTML. Для SQL нужны prepared statements, а не ручная «очистка» строки; это уже тема Prepared statements и SQL injection. Для HTML-контекста — XSS, экранирование вывода и шаблоны.

Быстрое повторение
Чем валидация отличается от sanitization на примере email?

filter_input: полезный, но не магический API

filter_input() читает внешнюю переменную из источника вроде INPUT_GET, INPUT_POST, INPUT_COOKIE, INPUT_SERVER или INPUT_ENV и применяет фильтр. По умолчанию используется FILTER_DEFAULT, который является алиасом FILTER_UNSAFE_RAW, то есть фактически не фильтрует данные. Поэтому фильтр лучше указывать явно.

Типичный пример для страницы поиска:

<?php

declare(strict_types=1);

$page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT, [
    'options' => ['min_range' => 1],
]);

if ($page === false || $page === null) {
    $page = 1;
}

$sortRaw = filter_input(INPUT_GET, 'sort', FILTER_UNSAFE_RAW);
$allowedSorts = ['relevance', 'date', 'title'];
$sort = in_array($sortRaw, $allowedSorts, true) ? $sortRaw : 'relevance';

$q = filter_input(INPUT_GET, 'q', FILTER_UNSAFE_RAW);
$query = is_string($q) ? trim($q) : '';

if ($query === '') {
    // Пустой поиск можно обработать отдельно: показать форму, подсказки или 400.
}

Здесь page проходит числовую валидацию, sort проверяется через allowlist, а q остаётся обычной строкой, потому что «любая поисковая строка» не становится безопасной от случайного sanitizer. При выводе $query в HTML всё равно нужен htmlspecialchars().

Есть тонкость с возвращаемыми значениями: filter_input() возвращает значение при успехе, false при провале фильтра и null, если переменная не установлена. С флагом FILTER_NULL_ON_FAILURE поведение для failure становится null, а отсутствие переменной — false. Это удобно не всегда, поэтому в прикладном коде часто пишут маленькие функции-обёртки, чтобы не размазывать эти проверки по контроллерам.

Ещё одна деталь: filter_input() фильтрует исходные данные, полученные от SAPI, а не то, что вы позже руками записали в $_GET или $_POST. Если вы уже собрали массив сами, используйте filter_var() или обычную явную проверку.

Быстрое повторение
Какой фильтр фактически использует `filter_input()`, если не указать фильтр явно?

Missing, empty и значение "0"

В PHP легко перепутать «поля нет», «поле есть, но пустое» и «поле равно нулю». Это особенно заметно в формах.

<?php

// /items?category=&page=0

var_dump(array_key_exists('category', $_GET)); // true
var_dump($_GET['category'] === '');            // true

var_dump(array_key_exists('page', $_GET));     // true
var_dump(empty($_GET['page']));                // true: строка "0" считается empty

empty() удобен для быстрых проверок, но для request data он часто слишком грубый. Строка "0" может быть валидным значением: номером страницы в API, ID, флагом, количеством. Поэтому лучше явно проверять наличие через array_key_exists() и отдельно валидировать значение.

Для формы профиля это выглядит так:

<?php

declare(strict_types=1);

$errors = [];

if (!array_key_exists('display_name', $_POST)) {
    $errors['display_name'] = 'Поле обязательно.';
} elseif (!is_string($_POST['display_name'])) {
    $errors['display_name'] = 'Некорректный формат.';
} else {
    $displayName = trim($_POST['display_name']);

    if ($displayName === '') {
        $errors['display_name'] = 'Имя не должно быть пустым.';
    } elseif (mb_strlen($displayName) > 80) {
        $errors['display_name'] = 'Имя слишком длинное.';
    }
}

Здесь код отдельно различает отсутствующее поле, нестроковое значение и пустую строку. Нестрочное значение возможно, если клиент отправит display_name[]=x: PHP превратит такой параметр в массив. Для скалярных полей это надо отбрасывать, а не молча приводить к строке Array.

Массивы в query string и form data

HTML-формы и query string могут передавать массивы: tags[]=php&tags[]=http. Это нормальная часть PHP, но она требует явного контракта. Если поле должно быть строкой, проверяйте is_string(). Если поле должно быть массивом, проверяйте каждый элемент.

<?php

$tagsRaw = $_GET['tags'] ?? [];

if (!is_array($tagsRaw)) {
    $tagsRaw = [$tagsRaw];
}

$allowedTags = ['php', 'http', 'security', 'database'];
$tags = [];

foreach ($tagsRaw as $tag) {
    if (!is_string($tag)) {
        continue;
    }

    $tag = trim($tag);
    if (in_array($tag, $allowedTags, true)) {
        $tags[] = $tag;
    }
}

$tags = array_values(array_unique($tags));

Для таких полей allowlist обычно надёжнее regex. Regex нужен для структурированных форматов, но его стоит писать так, чтобы он покрывал всю строку, а не искал «подходящий кусок» внутри мусора.

Безопасное преобразование user input

Хорошая схема короткая: прочитать внешний ввод на границе, проверить наличие, проверить форму, проверить бизнес-смысл, привести к нужному типу и передать дальше уже нормальные значения. Не сервис с $_POST внутри, а явный объект или массив.

<?php

declare(strict_types=1);

final class SearchInput
{
    public function __construct(
        public readonly string $query,
        public readonly int $page,
        public readonly string $sort,
    ) {}
}

function searchInputFromGet(array $get): SearchInput
{
    $query = isset($get['q']) && is_string($get['q']) ? trim($get['q']) : '';

    $page = filter_var($get['page'] ?? null, FILTER_VALIDATE_INT, [
        'options' => ['min_range' => 1],
    ]);
    $page = is_int($page) ? $page : 1;

    $sortRaw = isset($get['sort']) && is_string($get['sort']) ? $get['sort'] : 'relevance';
    $sort = in_array($sortRaw, ['relevance', 'date', 'title'], true)
        ? $sortRaw
        : 'relevance';

    return new SearchInput($query, $page, $sort);
}

Такой код легко тестировать без реального HTTP-запроса и без изменения суперглобалов. Он хорошо сочетается с подходом из SAPI и суперглобалы: суперглобалы остаются на краю приложения, а внутрь передаются явные данные. В проектах с middleware эту роль часто берёт ServerRequestInterface из PSR-7, middleware и HTTP-клиенты.

Практическая карта

Для $_GET обычно валидируют параметры навигации и фильтрации: page, q, sort, tag, from, to. Для $_POST — данные формы, которые часто меняют состояние и поэтому требуют не только валидации, но и CSRF-защиты. Для JSON body — отдельное чтение php://input, проверка ошибки json_decode() и затем такая же валидация структуры.

Не пытайтесь «санитизировать всё на входе» одним вызовом. На входе нужно понять, что пользователь имел в виду и допустимо ли это значение. На выходе — экранировать под конкретный контекст. В базе — использовать параметры запросов. В HTTP-ответе — корректные статус-коды и заголовки, о чём отдельно говорит HTTP-заголовки, ответы и редиректы.

См. также

Источники

  1. PHP Manual: filter_input
  2. PHP Manual: Data Filtering
  3. PHP Manual: Filter predefined constants
  4. OWASP Cheat Sheet Series: Input Validation