Sqids в Laravel: практическое руководство по обфускации ваших ID
Одна из опасностей, которую многие разработчики упускают из виду, — это перебор идентификаторов (key enumeration).
Когда злоумышленник находит брешь в контроле доступа вашего приложения, ему становится тривиально пройтись по всем последовательным ID, которые отдаёт эндпоинт, и собрать все записи этого ресурса. Один архивариус в 2021 году знаменито выкачал терабайты публичного контента Parler — и всё благодаря предсказуемым последовательным идентификаторам, отсутствию требования авторизации для публичного API и отсутствию нормального рейт-лимитирования.
Решение проблемы — сделать ID неинкрементными. Тогда злоумышленник сможет вытащить данные только из тех эндпоинтов, для которых у него есть валидные ID. То есть неинкрементные ID не защищают приложение, а лишь усложняют атаку. Считайте это ограничением ущерба, а не защитой.
Цена вопроса не ограничивается потенциальной утечкой данных. Последовательные ID могут раскрыть примерное количество ресурсов и темпы роста — а это уже коммерчески полезная информация для конкурентов, даже если каждый ресурс защищён корректно.
Один из способов добиться этого — использовать глобально уникальные идентификаторы. Они отлично подходят для распределённых систем, где полагаться на централизованный автоинкремент затруднительно. MongoDB ObjectId, UUIDv7 и ULID к тому же содержат неявную временную метку. Но у такого подхода есть свои минусы.
Во-первых, ваши аккуратные URL вида
/projects/637/comments/23434
превращаются в
/projects/1631f000-cbbe-4396-bdaa-5cd15a5124f1/comments/dc2c6243-ea8a-47ae-a921-61c60ea09235
UUID в стандартной текстовой форме занимает 36 символов. ULID чуть короче — 26 символов, но тоже не назовёшь красивым. Для вас это может быть не проблемой — так живёт немало корпоративного софта, особенно у продуктов Microsoft.
Тем не менее, у нас, простых смертных, есть вторая цена вопроса — дополнительное место в базе. Хелперы миграций Laravel uuid() и ulid() создают текстовые колонки CHAR(36) и CHAR(26) соответственно, а не восьмибайтовое значение BIGINT. Первая часть — та самая временная метка, которая делает UUIDv7 и ULID дружелюбными к индексам, но становится функционально избыточной, если вы уже используете timestamps(). Оба варианта можно хранить как 16-байтовое значение BINARY, но это дорого обходится в плане эргономики разработки — придётся жонглировать типами в Eloquent.
Но что такое пара лишних байт в наш век? На миллионе строк сами значения идентификаторов занимают примерно 8 МБ для BIGINT, 26 МБ для текстовых ULID и 36 МБ для текстовых UUID — без учёта накладных расходов на индексы и строки. Точная физическая цена зависит от движка БД и кодировки. Но вы наверняка будете джойнить эту таблицу с одной или несколькими другими. Индекс первичного ключа и индексы внешних ключей повторяют эти более широкие значения снова и снова, особенно если у вас глубоко иерархичная реляционная схема, как у нас. Опасность здесь не в нехватке места на диске — а в оперативной памяти. Базы данных летают, когда рабочий набор индекса помещается в память. Когда не помещается, приходится подгружать страницы с диска, и производительность падает.
Так что это всего лишь шаг оптимизации. Если вы не ожидаете, что у вашей системы когда-либо будут миллионы строк, или у вас сервер БД с запасом по памяти — возможно, глобально уникальных идентификаторов вам будет вполне достаточно. Раскрытие ключей базы данных обычно мало что даёт атакующему, хотя упорядоченные по времени форматы вроде UUIDv7 и ULID всё же раскроют, когда была создана запись (если это важно).
Третий вариант, который стоит рассмотреть, — суррогатный ключ: дополнительная колонка для публичного UUID или ULID. Скажите роутеру использовать эту колонку вместо внутреннего ключа. Цена — более длинные URL плюс немного больше места под колонки и их индексы. Внутри Eloquent по-прежнему будет использовать целочисленные ключи для связей. Если выбираете этот путь, переходите сразу к разделу «Где идентификаторы могут утечь» — вам всё равно нужно будет следить, чтобы внутренние ключи не утекали в неожиданных местах.
Обфускация целочисленных ключей
Не так давно ответом на эту проблему были Hashids. Они перемешивали числа в непредсказуемые буквенно-цифровые последовательности. Позже их переименовали в Sqids, чтобы явно подчеркнуть: это не про безопасность.
Не стоит полагаться на обфусцированные ID, чтобы предотвратить нежелательный доступ к ресурсам. Пишите код так, будто вы всё ещё используете сырые ID.
Обфускация целочисленных ключей — это про то, чтобы затруднить атаку, а не про защиту от неё. Sqids использует перемешанный алфавит как вход для своего алгоритма кодирования. Это не криптографический ключ, и хранение его в секрете не делает ваши эндпоинты безопаснее. Любой, у кого есть это значение, может генерировать и декодировать ID, что делает перебор возможным. Часть или весь алфавит может раскрыться при наличии достаточного количества пар ключ/Sqid. Поэтому важно, чтобы реальные ID не утекали вместе со своими Sqid — этому будет посвящена значительная часть статьи.
Иногда реальные ID можно вывести и без утечки. Если ресурсы сортируются по дате создания, вывести пары — плёвое дело. Предположите, что автоинкремент начинается с 1, и вот у вас уже есть пары для стольких ресурсов, сколько их есть на этом эндпоинте. Старт автоинкремента со случайного значения даёт атакующему огромное пространство перебора, но и это, опять же, не средство защиты.
Реализация
PHP-пакет Sqids небольшой и ставится через Composer:
composer require sqids/sqids
В Spectacular SqidsServiceProvider биндит один общий экземпляр SqidsInterface для удобного доступа.
Наша конфигурация Sqids определена в config/spectacular.php и может быть переопределена переменными окружения.
'sqids' => [
'alphabet' => env('SPECTACULAR_SQIDS_ALPHABET', 'abcdefghijklmnopqrstuvwxyz0123456789'),
'length' => env('SPECTACULAR_SQIDS_LENGTH', 6),
],
Самая важная настройка здесь — алфавит. На сайте Sqids есть удобный инструмент для его подбора. Кастомный алфавит стоит держать вне системы контроля версий.
Настройка длины — это минимум, а не точная или максимальная длина. Маленькие числа могут дополняться до минимальной длины, а ID растут по мере роста входного значения. Изменение этих значений после запуска в продакшн сделает недействительными все существующие URL и всё, что их использует.
У Sqids также есть блок-лист, чтобы не генерировать неприличные слова. Мы используем стандартный словарь в нашем приложении, но вы можете реализовать свой собственный — особенно если нужно избегать нежелательных слов на других языках. Если для вас важны постоянные URL, явно закрепите свой блок-лист версией: иначе будущее обновление пакета может поменять список по умолчанию и сделать существующие ID недействительными.
Route model binding
Трейт HasSqid — сердце этой интеграции. Он меняет имя ключа маршрута, декодирует параметры маршрута перед запросом к БД, поддерживает биндинг с учётом soft delete и предоставляет виртуальный атрибут sqid. Ничего из этого не меняет первичный ключ в базе данных.
public function getRouteKeyName(): string
{
return 'sqid';
}
protected function sqid(): Attribute
{
$id = $this->getKey();
return Attribute::make(
get: fn () => $id ? app(SqidsInterface::class)->encode([$id]) : null,
);
}
Обратите внимание, что encode() принимает массив. decode() тоже возвращает массив. Это потому, что Sqids, как и Hashids до них, умеет кодировать и декодировать сразу несколько ID, если это понадобится.
Метод resolveRouteBinding() декодирует входящее значение и принимает его, только если оно содержит ровно одно число и повторное кодирование этого числа даёт идентичное значение. Эта каноническая проверка важна, потому что произвольные строки иногда тоже декодируются в число. Затем вызывается обычный поиск через Eloquent:
public function resolveRouteBinding($value, $field = null)
{
if ($field === null) {
$ids = $this->sqids->decode($value);
if (count($ids) !== 1 || $this->sqids->encode($ids) !== $value) {
throw (new ModelNotFoundException())->setModel(static::class, $value);
}
return $this->findOrFail($ids[0]);
}
return parent::resolveRouteBinding($value, $field);
}
Трейт также реализует resolveSoftDeletableRouteBinding(), если он понадобится.
Кодирование внешних ключей
Следующая проблема — внешние ключи модели. Мы используем каст AsSqid, чтобы создавать виртуальные поля вроде project_sqid, feature_sqid и commentable_sqid из соответствующих целочисленных колонок.
protected $casts = [
'project_sqid' => AsSqid::class,
];
return [
'id' => $this->sqid,
'project_id' => $this->project_sqid,
];
Соглашение об именовании здесь не случайно. Каст, объявленный как project_sqid, выводится из соответствующего поля project_id. Чтение виртуального атрибута кодирует целое число. Запись в него декодирует переданный Sqid и записывает целочисленную колонку.
Благодаря этому показать публичные Sqid клиентам API становится тривиально. Всё, что нужно — использовать _sqid вместо _id в ресурсах API.
{
"id": "4nxk2a",
"project_id": "b91q0c",
"name": "Invite collaborators"
}
При этом в базе данных по-прежнему хранится целочисленный project_id с обычным индексом и связью. Этот паттерн можно увидеть в FeatureResource и CommentResource. Spectacular применяет тот же подход к requirements, tasks, unknowns, assignments, invitations и collaborations, включая полиморфные цели комментариев.
Декодирование входящих данных запроса
Сериализация публичных ID — только половина дела. Клиентам также нужно отправлять эти ID обратно на сервер. Вместо того, чтобы учить каждый экшен и каждое правило валидации декодировать строку, Spectacular использует middleware DecodeSqids, который закрывает большинство случаев. Он зарегистрирован под алиасом sqids в bootstrap/app.php.
Экшены декларируют, какие поля содержат публичные ID:
->middleware('sqids:project_id,commentable_id');
// Массивы
->middleware('sqids:feature_id,actor_ids.*');
// Вложенные массивы
->middleware('sqids:feature_id,actor_ids.*,unknowns.*.id,tasks.*.id');
// Пейлоад с организацией проекта
->middleware('sqids:actors.*.id,features.*.id,requirements.*.id,requirements.*.feature_id');
Жизненный цикл запроса выглядит просто:
- Клиент отправляет Sqid в JSON или в query-данных.
- Middleware находит настроенные скалярные, массивные и вложенно-массивные поля.
- Каждое значение декодируется в одно целое число или
null. - Эти изменения вливаются в запрос до того, как отработают валидация и авторизация экшена.
- Существующие правила вроде
exists, проверки связей и проверки принадлежности к проекту продолжают работать с обычными целочисленными колонками.
Это даёт некорректным значениям запроса предсказуемый путь обработки. Значение вроде not-an-id в middleware декодируется не в одно валидное целое число, поэтому заменяется на null ещё до валидации. Правило вроде required|exists:projects,id затем вернёт обычную ошибку валидации (убедитесь, что кастомные сообщения валидации не раскрывают декодированные значения). С параметрами маршрута всё иначе: некорректное значение проваливает биндинг маршрута и приводит к 404. В обоих случаях невалидный ввод обрабатывается на границе приложения, а не доходит до базы данных как случайный поиск или невнятная SQL-ошибка.
Этот middleware — явная и неглубокая нормализация запроса, а не магия. Он декодирует только перечисленные поля и поддерживает один уровень маски: скалярные поля, массивы ID и один уровень массивов объектов. Более глубокие пути не поддерживаются, и для них потребуется собственная обработка. Если вы следуете подходу CRUDdy By Design, эта проблема вас никогда не коснётся.
Где идентификаторы могут утечь
Драйвер сессий Laravel на базе базы данных хранит идентификатор авторизованного пользователя на сервере; браузер же обычно получает лишь непрозрачную сессионную куку, а не сам user_id.
Однако Passport всё же раскрывает идентификатор пользователя в claim’е sub своего JWT. Чтобы этого избежать, мы вынуждены возвращать sqid из Account::getAuthIdentifierName(). Кастомный auth-провайдер, зарегистрированный под ключом eloquent в Laravel, заменяет провайдер Eloquent по умолчанию, а затем резолвит этот идентификатор через биндинг маршрутов с поддержкой Sqids — для обычной аутентификации и поиска по remember-токену.
Это вынуждает нас нарушить собственную границу и хранить Sqid прямо в базе данных. Стандартные таблицы Passport придётся модифицировать, чтобы хранить строковые ID пользователей. Если вы используете сессии на базе БД, таблицу сессий тоже придётся поменять. Необходимые изменения можно посмотреть в нашей директории с миграциями.
Ещё одно место, где можно случайно раскрыть внутренние идентификаторы, — названия broadcast-каналов. То же касается ID, хранящихся в JSON-колонках, — они тоже могут проскользнуть. У нас есть хелпер obfuscateIdentifiers() в трейте HasSqid, который используется при выводе истории изменений модели.
Итоги
Надеюсь, эта статья дала вам достаточно оснований, чтобы решить, нужны ли вам Sqids и что для этого потребуется. Помните: обфусцировать нужно только ID публично адресуемых сущностей.
Sqids дают приложениям на Laravel практичную золотую середину: короткие публичные ID и чистые URL без суррогатных ключей. Модель Spectacular, описанная здесь, включает вложенные ресурсы, множество связей, клиентов API, живые события, подписанные приглашения, аутентификацию и экспорты. Если держать целые числа, Sqid и UUID каждый на своём месте, о таких границах становится проще рассуждать.
Источник: Sqids in Laravel: A Practical Guide to Obfuscating Your IDs

