Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Junior: питання на співбесіді з теми «Контроль доступу»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

5 питань

Автентифікація відповідає на питання «хто ви», авторизація - «що вам дозволено». Більшість реальних витоків даних - не злам паролів, а відсутня чи неповна перевірка доступу: OWASP Top 10 ставить «Broken Access Control» на перше місце.

Заборонено за замовчуванням (deny by default): доступ дозволяється, лише якщо є явне правило, що його дозволяє. Будь-яка непередбачена ситуація - відмова.

// погано: дозволено всім, крім явно заборонених
if ($user->is_banned) {
    abort(403);
}

// добре: заборонено всім, крім явно дозволених
if (! $user->can('update', $post)) {
    abort(403);
}

Чому це важливо: при «дозволено за замовчуванням» кожна забута перевірка - вразливість. При «заборонено за замовчуванням» забута перевірка - лише непрацююча функція, яку помітять і виправлять.

Перевірки - лише на сервері. Приховати кнопку «Видалити» в інтерфейсі - це UX, а не захист:

  • запит можна відправити напряму (curl, консоль браузера, змінений клієнт);
  • мобільний застосунок можна декомпілювати й побачити всі ендпойнти;
  • у Livewire будь-який публічний метод компонента можна викликати з браузера.

Сервер має перевіряти права на кожну дію, незалежно від того, що показує інтерфейс.

Практичні принципи:

  • перевірка на кожен запит, а не лише при вході на сторінку: права могли змінитися, а запит - бути підробленим;
  • централізовано: у Laravel - політики й гейти, а не розкидані if ($user->role === 'admin') по контролерах;
  • найменші права: користувач і сервіс отримують лише ті можливості, що потрібні для роботи;
  • перевіряти конкретний об'єкт, а не лише тип дії: «може редагувати пости» ≠ «може редагувати цей пост»;
  • відмова - без зайвих деталей: 403 чи 404 без пояснення, які саме права відсутні.

Тестування: для кожної дії - тест, що користувач без прав отримує відмову. Позитивні сценарії перевіряються природно під час розробки, а відсутність заборони помічають лише тоді, коли її шукають.

Докладніше в документації: OWASP: авторизація

Вертикальне підвищення привілеїв - звичайний користувач отримує можливості вищої ролі: адміністратора, модератора, менеджера.

Приклади:

  • адмінка доступна за адресою /admin, і перевіряється лише вхід, а не роль;
  • ендпойнт POST /users/7/role не перевіряє, хто змінює роль;
  • поле is_admin можна передати у формі профілю (масове призначення);
  • роль зберігається на клієнті (у cookie без підпису, у localStorage) - і її можна змінити.

Горизонтальне підвищення привілеїв - користувач отримує доступ до даних чи дій іншого користувача того самого рівня.

Приклади:

  • /invoices/1041 - змінив число в адресі й бачиш чужий рахунок;
  • PATCH /addresses/88 змінює адресу іншого клієнта;
  • завантаження файлу за прямим посиланням без перевірки власника.

Горизонтальні вразливості трапляються частіше: перевірку ролі зазвичай пам'ятають (адмінка - очевидна мішень), а перевірку власності конкретного запису забувають у кожному новому ендпойнті.

Як захищатися:

Від вертикального:

  • маршрути адмінки - в окремій групі з перевіркою ролі на рівні групи (middleware, політики);
  • зміна ролей і прав - окремі ендпойнти з суворою авторизацією й журналом аудиту;
  • роль - на сервері, а не в даних від клієнта.

Від горизонтального:

  • пошук через власника: $request->user()->invoices()->findOrFail($id) - чужий запис просто не знайдеться;
  • політики з перевіркою $invoice->user_id === $user->id (чи належності до команди/організації);
  • обмеження вкладених маршрутів (scopeBindings) - дочірній запис має належати батьківському.

Комбінований випадок: менеджер однієї компанії (рівень «менеджер» - вертикально все гаразд) бачить дані іншої компанії (горизонтально - ні). У багатоорендних застосунках перевірка належності до орендаря (tenant) потрібна для кожної ролі, включно з адміністраторами клієнтів.

Тест-матриця: для кожного ендпойнта - запит від анонімного користувача, від звичайного користувача-власника, від іншого користувача того самого рівня й від нижчої ролі. Очікувані відповіді - явно в тестах.

Докладніше в документації: PortSwigger: контроль доступу

Гейти (gates) - замикання для дій, не прив'язаних до конкретної моделі:

// AppServiceProvider::boot()
Gate::define('view-reports', fn (User $user) => $user->hasRole('analyst'));
Gate::define('access-admin', fn (User $user) => $user->is_admin);

Політики (policies) - клас з правилами для однієї моделі:

php artisan make:policy PostPolicy --model=Post
class PostPolicy
{
    public function update(User $user, Post $post): bool
    {
        return $user->id === $post->author_id;
    }

    public function delete(User $user, Post $post): bool
    {
        return $user->id === $post->author_id || $user->is_moderator;
    }
}

Laravel знаходить політику за назвою (App\Models\Post → App\Policies\PostPolicy) або за атрибутом на моделі.

Як перевіряти:

// у контролері
Gate::authorize('update', $post);           // 403, якщо заборонено
if ($request->user()->cannot('delete', $post)) { abort(403); }

// у Blade - лише для відображення
@can('update', $post) <a href="...">Редагувати</a> @endcan

// у Form Request
public function authorize(): bool { return $this->user()->can('update', $this->route('post')); }

На маршрутах - middleware can:

Route::put('/posts/{post}', [PostController::class, 'update'])->can('update', 'post');
Route::get('/admin/reports', ReportController::class)->middleware('can:view-reports');
Route::post('/posts', [PostController::class, 'store'])->can('create', Post::class);

'post' - назва параметра маршруту: після прив'язки моделі в політику потрапить конкретний пост. Для дій без екземпляра (create) передається клас.

Що обрати:

  • політика - для всього, що стосується моделі (CRUD і власні дії). Логіка доступу до сутності - в одному місці;
  • гейт - для загальних можливостей (доступ до адмінки, звітів, функцій тарифу).

Пастки:

  • @can у шаблоні лише ховає кнопку - перевірка на сервері (контролер, маршрут) обов'язкова;
  • методи політики повинні повертати bool (або Response): забутий return дає null - і доступ заборонено, що безпечно, але збиває з пантелику;
  • гості: для неавтентифікованого користувача гейти й політики за замовчуванням повертають false, якщо параметр користувача не оголошено як ?User.

Докладніше в документації: Laravel: авторизація через middleware

За замовчуванням Laravel автоматично відмовляє неавтентифікованим користувачам у будь-якому гейті чи методі політики: метод навіть не викликається, а результат - false. Це безпечне значення за замовчуванням.

Щоб гість міг пройти перевірку, параметр користувача оголошують необов'язковим:

class PostPolicy
{
    public function view(?User $user, Post $post): bool
    {
        if ($post->is_published) {
            return true;   // опубліковане бачать усі, включно з гостями
        }

        return $user !== null && $user->id === $post->author_id;   // чернетку - лише автор
    }
}
Gate::define('view-pricing', fn (?User $user) => true);

Типові сценарії:

  • публічний перегляд опублікованих матеріалів і приватний - чернеток;
  • каталог для всіх, а ціни для партнерів - лише після входу;
  • форма зворотного зв'язку для гостей з обмеженням частоти.

Пастки:

1. ?User і забута перевірка на null:

public function view(?User $user, Post $post): bool
{
    return $user->id === $post->author_id || $post->is_published;   // помилка для гостя
}

Для гостя $user->id - звернення до властивості null: у PHP 8 це помилка (Attempt to read property "id" on null), а не false. Завжди перевіряти null першим.

2. Гостьовий доступ, відкритий занадто широко. Додали ?User у view, щоб показувати опубліковані пости, - і забули, що той самий метод використовується для перегляду чернеток у прев'ю. Кожен метод з ?User варто переглядати окремо: які гілки повертають true для гостя.

3. Неявна довіра до параметрів: «опубліковане» має визначатися полем у базі (is_published), а не параметром запиту (?preview=1).

4. Авторизація ≠ автентифікація маршруту. Middleware auth на маршруті відсіює гостей ще до політики. Якщо маршрут має бути доступним гостям частково, auth на ньому не ставлять, а розрізнення робить політика.

Тести для гостьових сценаріїв: перевірити, що гість бачить лише дозволене ($this->get(...)->assertOk() для опублікованого, assertForbidden()/assertNotFound() для чернетки), - саме тут найчастіше випадково відкривають приватні дані.

Докладніше в документації: Laravel: гості в авторизації

Проблема: рахунки, договори, вкладення листування лежать у сховищі, а посилання на них мають вигляд /storage/invoices/1041.pdf. Хто знає чи вгадає адресу, той завантажить файл - без перевірки прав.

Перше правило: приватні файли - не в публічній папці (public/, storage/app/public з посиланням storage:link), а на приватному диску. Віддає їх контролер з перевіркою прав:

Route::get('/invoices/{invoice}/download', function (Invoice $invoice) {
    Gate::authorize('download', $invoice);

    return Storage::disk('private')->download($invoice->path, "invoice-{$invoice->number}.pdf");
})->middleware('auth');

Підписані URL - коли файл треба віддати без входу (посилання в листі, вбудування в сторонній сервіс) або на обмежений час:

$url = URL::temporarySignedRoute(
    'invoices.download',
    now()->plus(hours: 24),
    ['invoice' => $invoice->id],
);
Route::get('/invoices/{invoice}/download', DownloadInvoiceController::class)
    ->name('invoices.download')
    ->middleware('signed');
  • до URL додаються expires і signature - HMAC від усієї адреси з ключем застосунку (APP_KEY);
  • зміна будь-якого параметра (invoice=1042) чи терміну дії робить підпис недійсним - 403;
  • після закінчення терміну посилання перестає працювати.

Що важливо розуміти:

  • підписаний URL - це «пред'явницький» доступ: будь-хто з посиланням отримає файл. Переслане, збережене в історії, потрапило в лог - доступ має кожен. Тому: короткий термін дії, а для чутливих документів - підпис плюс перевірка входу й прав;
  • ключ підпису - APP_KEY: його зміна робить недійсними всі видані посилання;
  • за проксі (Cloudflare, балансувальник) адреса, яку бачить Laravel, може відрізнятися від підписаної (схема, хост) - тоді допомагає signed:relative і підпис відносного URL (absolute: false);
  • одноразовість підписані URL не забезпечують - для одноразових посилань потрібна позначка в базі.

Для файлів у S3/R2 аналог - Storage::temporaryUrl(): підписане посилання сховища, і файл віддається напряму з нього, минаючи сервер застосунку.

Докладніше в документації: Laravel: підписані URL