Запрос прошёл авторизацию. Задача была поставлена в очередь. Пользователь потерял доступ. А приложение продолжило работать так, будто ничего не изменилось.
Большинство обсуждений безопасности в Laravel начинаются с HTTP-запросов.
Разработчики проверяют middleware, валидируют входные данные, настраивают защиту от CSRF, экранируют вывод в Blade и проверяют, авторизован ли контроллер для доступа к чувствительным ресурсам.
Это важно. Но с таким подходом есть одна проблема:
Современное приложение на Laravel делает гораздо больше, чем просто отвечает на HTTP-запросы.
Запрос может поставить в очередь задачу, которая выполнится через несколько минут. Событие может запустить несколько слушателей. Уведомление может доставить информацию пользователю, чьи права уже изменились. Кэшированный ответ может переживёт решение об авторизации, которое его создало.
Исходный запрос мог быть абсолютно безопасным.
Операции, которые он запустил — возможно, нет.
В этой статье рассматриваются пять менее очевидных границ безопасности в приложениях на Laravel и показывается, как сделать их явными, тестируемыми и надежными.
1. Проблема устаревшей авторизации: когда права меняются после постановки в очередь
Рассмотрим SaaS-приложение, в котором менеджеры могут экспортировать клиентские отчёты.
Реализация выглядит вполне разумно:
public function export(
Request $request,
Report $report
): JsonResponse {
Gate::authorize('export', $report);
ExportReportJob::dispatch(
$report->id,
$request->user()->id
);
return response()->json([
'message' => 'Export started.',
]);
}Пользователь авторизован. Задача поставлена в очередь. Всё выглядит хорошо.
Но представьте следующую последовательность:
- Алиса имеет разрешение на экспорт отчёта.
- Алиса запрашивает экспорт.
- Laravel помещает задачу в Redis.
- Администратор отзывает разрешение Алисы.
- Воркер очереди подбирает задачу.
- Экспорт всё равно генерируется.
Приложение проверило авторизацию в момент постановки в очередь, а не во время выполнения.
Это различие имеет значение.
Задача из очереди — это не просто продолжение исходного HTTP-запроса. Это отдельный контекст выполнения, который может запуститься после того, как первоначальные предположения безопасности потеряли силу.
Лучший подход
Передавайте в задачу идентификаторы и получайте соответствующие сущности непосредственно во время выполнения задачи.
final class ExportReportJob implements ShouldQueue
{
public function __construct(
private readonly int $reportId,
private readonly int $userId,
) {}
public function handle(): void
{
$user = User::findOrFail($this->userId);
$report = Report::findOrFail($this->reportId);
Gate::forUser($user)->authorize('export', $report);
app(ReportExporter::class)->export($report);
}
}Теперь решение об авторизации принимается в момент выполнения.
Политика может оценить текущие разрешения пользователя, статус аккаунта, принадлежность к тенанту и отношение к отчёту.
Это особенно важно для:
- Финансовых экспортов.
- Обработки персональных данных.
- Административных операций.
- Приостановки действия аккаунта.
- Отзыва разрешений.
- Длительных фоновых задач.
Разрешение, выданное пять минут назад, необязательно должно существовать прямо сейчас.
Есть ещё один нюанс: некоторые задачи представляют собой операции, которые были явно одобрены в момент постановки в очередь. В таком случае приложение должно моделировать это одобрение как постоянную запись бизнес-авторизации, а не полагаться случайно на устаревшие права пользователя.
2. Загрязнение кэша из-за некорректного контекста безопасности
Кэширование — один из самых простых способов повысить производительность Laravel.
Но это также простой способ допустить утечку данных между пользователями.
Представьте эндпоинт дашборда:
public function dashboard(Request $request): JsonResponse
{
$data = Cache::remember(
'dashboard',
now()->addMinutes(10),
fn () => app(DashboardService::class)
->forUser($request->user())
);
return response()->json($data);
}Код работает.
До тех пор, пока два разных пользователя не запросят дашборд.
Ключ кэша одинаков для всех.
Если первый пользователь получает конфиденциальные данные, второй пользователь может получить точно такой же кэшированный ответ.
Проблема не в Redis.
Проблема в том, что ключ кэша не отражает контекст безопасности кэшируемых данных.
Более безопасная реализация
public function dashboard(Request $request): JsonResponse
{
$user = $request->user();
$cacheKey = sprintf(
'dashboard:tenant:%d:user:%d',
$user->tenant_id,
$user->id
);
$data = Cache::remember(
$cacheKey,
now()->addMinutes(10),
fn () => app(DashboardService::class)
->forUser($user)
);
return response()->json($data);
}Так лучше, но это не универсальное решение.
В зависимости от приложения, ключ кэша также может включать в себя:
- Версию ролей или разрешений.
- Выбранную организацию.
- Область видимости ресурсов.
- Локаль.
- Конфигурацию функций.
- Версию данных.
Есть ещё одна неочевидная проблема: от отзыв разрешений.
Допустим, пользователь теряет доступ к проекту, но кэшированный дашборд остается валидным еще десять минут.
Система авторизации может быть корректной, в то время как кэшированное представление — нет.
Для чувствительных данных рассмотрите возможность использования версионируемых ключей кэша или явного сброса кэша при изменении доступов.
Например:
$cacheKey = sprintf(
'dashboard:user:%d:permissions:%d',
$user->id,
$user->permissions_version
);Всякий раз, когда разрешения меняются, увеличивайте номер версии.
Старые записи в кэше становятся недоступными без необходимости выполнять ресурсоемкий поиск по каждому возможному ключу кэша.
Ключ кэша — это часть вашей модели безопасности. Относитесь к нему соответствующе.
3. События и уведомления могут стать каналами утечки данных
События в Laravel поощряют слабую связанность архитектуры.
Контроллер генерирует событие, а несколько слушателей реагируют на него.
Например:
event(new InvoiceApproved($invoice));Возможные слушатели могут:
- Генерировать PDF.
- Отправлять email.
- Уведомлять менеджера аккаунта.
- Обновлять аналитику.
- Публиковать событие бродкастинга.
- Синхронизировать данные с внешним сервисом.
Эта архитектура удобна, но она порождает вопрос:
Кому разрешено получать информацию, производимую этими слушателями?
Рассмотрим уведомление о выставлении счета.
В момент утверждения счета менеджер имеет к нему доступ.
Уведомление ставится в очередь.
До того, как воркер обработает его, менеджера удаляют из организации.
Если уведомление содержит конфиденциальную информацию о счете, приложение может доставить данные тому, у кого больше нет к ним доступа.
Не путайте исходное событие с постоянным правом доступа
Событие описывает то, что уже произошло.
Оно не автоматически наделяет каждого слушателя постоянным правом на раскрытие связанных данных.
Для чувствительных уведомлений определяйте получателя и проверяйте доступ как можно ближе к моменту доставки.
final class InvoiceApprovedNotification extends Notification
implements ShouldQueue
{
public function __construct(
private readonly int $invoiceId,
) {}
public function toMail(User $notifiable): MailMessage
{
$invoice = Invoice::findOrFail($this->invoiceId);
Gate::forUser($notifiable)->authorize(
'view',
$invoice
);
return (new MailMessage())
->subject('Invoice approved')
->line('Your invoice has been approved.')
->action(
'View invoice',
route('invoices.show', $invoice)
);
}
}Точный дизайн зависит от канала уведомлений и от того, рендерится ли сообщение до или во время доставки.
Для особо конфиденциальной информации избегайте включения секретных данных прямо в полезную нагрузку (payload) очереди уведомлений. Предпочтительнее сохранять идентификатор и извлекать информацию только после авторизации.
Также пересмотрите каналы бродкастинга.
Callback авторизации канала не должен просто устанавливать, что пользователь аутентифицирован. Он должен проверять, что пользователю в данный момент разрешено подписываться на конкретный ресурс.
Broadcast::channel(
'invoices.{invoice}',
function (User $user, Invoice $invoice): bool {
return Gate::forUser($user)->allows('view', $invoice);
}
);Это делает решение об авторизации на уровне ресурсов явным.
4. Вебхуки: то же самое событие может стать другой уязвимостью
Вебхуки — важнейший механизм интеграции.
Платежные провайдеры, CRM-системы, службы доставки и внешние сервисы отправляют события вашему приложению на Laravel.
Типичная реализация выглядит следующим образом:
public function handle(Request $request): Response
{
$payload = $request->all();
PaymentProcessedJob::dispatch($payload);
return response()->noContent();
}Но что произойдет, если провайдер отправит одно и то же событие дважды?
Или десять раз?
Или если злоумышленник выполнит повторную отправку (replay) ранее валидного запроса?
Подпись вебхука доказывает, что запрос пришел от доверенного отправителя и не был изменен, при условии, что проверка подписи реализована правильно.
Она не доказывает, что это событие никогда раньше не обрабатывалось.
Встройте защиту от повторных атак (replay protection) в модель данных
Простая таблица событий может выглядеть так:
Schema::create('webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider');
$table->string('external_event_id');
$table->timestamp('processed_at')->nullable();
$table->timestamps();
$table->unique([
'provider',
'external_event_id',
]);
});Уникальное ограничение (unique constraint) имеет важное значение.
Проверки только на уровне приложения недостаточно, когда два одинаковых запроса вебхука поступают одновременно.
Оба запроса могут проверить, что событие еще не существует, прежде чем любое из них вставит его.
База данных должна обеспечивать уникальность.
Упрощенный процесс обработки:
DB::transaction(function () use ($provider, $eventId) {
$event = WebhookEvent::query()->firstOrCreate([
'provider' => $provider,
'external_event_id' => $eventId,
]);
if ($event->processed_at !== null) {
return;
}
Payment::query()
->where('provider_event_id', $eventId)
->update([
'status' => PaymentStatus::Completed,
]);
$event->update([
'processed_at' => now(),
]);
});Это лишь наглядная отправная точка. Реализации для продакшена также должны обрабатывать параллельно выполняющиеся воркеры, нарушения уникальности в базе данных, специфичный для провайдера порядок событий и внешние побочные эффекты.
Например, отправка электронной почты или вызов другого API платежной системы внутри транзакции базы данных не делает эту внешнюю операцию транзакционной.
Надежная реализация часто сочетает в себе:
- Проверку подписи.
- Идентификаторы событий провайдера.
- Ограничения уникальности в базе данных.
- Атомарные переходы между состояниями.
- Идемпотентные бизнес-операции.
- Паттерн transactional outbox для внешних побочных эффектов.
Доставка ровно один раз (exactly-once) никогда не должна быть вашей исходной предпосылкой. Идемпотентная обработка должна быть вашей целью при проектировании.
5. Бизнес-состояние — это тоже граница безопасности
Разработчики часто сосредоточены на том, кто может выполнить действие.
Но возникает другой вопрос:
Действительно ли это действие валидно в текущем бизнес-состоянии?
Рассмотрим рабочий процесс утверждения заказа.
Менеджер может утверждать заказы.
Базовая политика может проверять:
return $user->hasPermissionTo('orders.approve');Это проверяет права пользователя.
Но это ничего не говорит о том, действительно ли заказ подходит для утверждения.
Что если заказ:
- Уже отменен?
- Уже утвержден?
- Находится на рассмотрении?
- Ожидает другого утверждения?
- Принадлежит другому тенанту?
Авторизация и валидация бизнес-состояния — это разные зоны ответственности.
Делайте переходы явными
Вместо того чтобы разрешать произвольные обновления статуса:
$order->update([
'status' => $request->input('status'),
]);Используйте явные доменные операции:
final class ApproveOrder
{
public function execute(
User $user,
int $orderId
): Order {
return DB::transaction(function () use (
$user,
$orderId
) {
$order = Order::query()
->lockForUpdate()
->findOrFail($orderId);
Gate::forUser($user)->authorize(
'approve',
$order
);
if ($order->status !== OrderStatus::Pending) {
throw new DomainException(
'Only pending orders can be approved.'
);
}
$order->update([
'status' => OrderStatus::Approved,
'approved_by' => $user->id,
'approved_at' => now(),
]);
return $order;
});
}
}Транзакция базы данных и блокировка строк помогают предотвратить ситуацию, когда конкурирующие операции утверждают один и тот же заказ на основе устаревшего состояния.
Для более сложных рабочих процессов рассмотрите возможность использования явных конечных автоматов (state machines) или сервисов управления переходами на уровне домена.
Хороший переход должен отвечать на три вопроса:
- Кому разрешено его выполнять?
- Является ли переход валидным из текущего состояния?
- Могут ли параллельные запросы нарушить инвариант?
Это особенно важно в финансовых приложениях, маркетплейсах, системах бронирования и корпоративных рабочих процессах согласования.
6. Превращайте предположения безопасности в негативные тесты
Одно из самых полезных изменений, которые может сделать команда, — перестать тестировать только успешные сценарии.
Типичный тест функционала может проверять:
$this->actingAs($manager)
->postJson('/api/orders/123/approve')
->assertOk();Это необходимо, но недостаточно.
Безопасность часто доказывается подтверждением того, что что-то произойти не может.
Для каждой критически важной операции тестируйте негативный сценарий.
СценарийОжидаемый результатПользователь теряет права после отправки задачи в очередьЗадача не может выполнить защищенную операциюПользователь из другого тенанта запрашивает отчетДоступ запрещенПрибывают два одинаковых вебхукаБизнес-операция происходит один разПользователь теряет доступ после заполнения кэшаЧувствительные данные из кэша не возвращаютсяПолучатель уведомления теряет доступЧувствительная информация не раскрываетсяДва запроса утверждают один и тот же заказУспешным оказывается только один валидный переход
Например, кросс-тенантый тест:
public function test_user_cannot_export_another_tenants_report(): void
{
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$user = User::factory()
->for($tenantA)
->create();
$report = Report::factory()
->for($tenantB)
->create();
$this->actingAs($user)
->postJson("/api/reports/{$report->id}/export")
->assertForbidden();
}Важна не конкретная проверка (assertion).
Важен инвариант безопасности, который тестируется.
Для мульти-тенантыне систем этому стоит уделить особое внимание: изоляция тенантов должна сохраняться не только в HTTP-запросах, но и в очередях, кэше, бродкастинге, хранилищах и других общих ресурсах.
Руководство OWASP по мульти-тенантой безопасности описывает эти риски на нескольких уровнях приложения: Multi-Tenant Security Cheat Sheet.
7. Практический аудит безопасности для команд Laravel
Вместо того чтобы проверять только контроллеры и middleware, проследите путь чувствительных данных по всему приложению.
Для каждого критически важного ресурса — счетов, платежей, отчетов, документов, клиентских записей — задайте вопросы:
HTTP-слой
- Прошел ли пользователь аутентификацию?
- Применяется ли авторизация на уровне ресурсов?
- Проверяется ли принадлежность ресурса тенанту?
Слой очередей
- Проверяется ли авторизация заново при выполнении задачи?
- Может ли устаревшая задача выполнить операцию после отзыва доступа?
- Могут ли дублирующиеся задачи привести к дублированию бизнес-операций?
Слой событий и уведомлений
- Могут ли слушатели (listeners) передать данные неавторизованным получателям?
- Защищены ли подписки на бродкастинг авторизацией на уровне ресурсов?
- Не хранятся ли чувствительные данные в сообщениях очереди без необходимости?
Слой кэширования
- Включает ли ключ кэша соответствующий контекст безопасности?
- Что происходит при изменении прав или принадлежности к тенанту?
- Может ли один пользователь получить закэшированный ответ другого пользователя?
Слой интеграции
- Проверяются ли подписи вебхуков?
- Безопасно ли обрабатываются дублирующиеся события?
- Защищены ли переходы состояний от повторного воспроизведения (replay) и гонок данных (concurrency)?
Бизнес-логика
- Отклоняются ли недействительные переходы состояний?
- Могут ли два параллельных запроса нарушить бизнес-инвариант?
- Подлежат ли критические операции аудиту?
Если ответ на любой из этих вопросов неясен, у вас, вероятно, есть допущение безопасности, которое необходимо сделать явным.
Итоги: безопасность — это свойство всего потока выполнения
Laravel предоставляет отличные строительные блоки.
Политики, очереди, события, транзакции, драйверы кэша, бродкастинг и ограничения базы данных позволяют создавать безопасные приложения, не изобретая велосипед.
Но фреймворк не может автоматически понимать каждое бизнес-правило.
Он не может знать, что менеджер только что был удален из организации, что счет уже был утвержден или что вебхук уже инициировал платеж.
Это зона ответственности уровня приложения.
Наиболее опасные бреши в безопасности не всегда находятся в коде, принимающем запрос.
Иногда они возникают на несколько шагов позже — в фоновом воркере, закэшированном ответе, уведомлении или при параллельном изменении состояния.
Полезное правило для каждой команды Laravel:
Никогда не предполагайте, что решение об авторизации остается действительным только потому, что операция началась с авторизованного запроса.
Проверяйте весь поток выполнения. Тестируйте то, чего никогда не должно происходить. Делайте инварианты безопасности явными.
Потому что безопасный контроллер не делает приложение безопасным автоматически.
Комментарии (0)
Пока нет комментариев — будьте первым.