<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
БЕЗПЕКА · КРИТИЧНІСТЬ: ВИСОКА OWASP 2025 A08 · Software or Data Integrity Failures

Mass assignment

Модель заповнюють масивом з запиту одним викликом. Форма показує імʼя й пошту, а користувач дописує в запит is_admin=1, і модель його приймає.

11 хв читання Junior → Senior
01 · ЩО ЦЕ

Коли в запиті більше полів, ніж у формі

Mass assignment означає заповнити модель масивом за один виклик: User::create($request->all()), $user->update($request->all()), fill(). Це зручно, але масив складає клієнт, а не ваша форма. У формі імʼя й пошта, а через devtools чи curl можна додати is_admin=1, role=admin або company_id=7. Якщо модель приймає кожен ключ, поле запишеться, і людина сама видасть собі права чи переїде в чужу компанію.

Eloquent вирішує, які ключі приймати, за двома списками. перелічує дозволене, решта ключів тихо відкидається. перелічує заборонене, решта приймається. За замовчуванням модель має $guarded = ['*'], і create з будь-яким ключем кидає MassAssignmentException. $guarded = [] дозволяє все. У Laravel 13 ті самі списки можна задати атрибутами класу: #[Fillable([...])], #[Guarded([...])] і #[Unguarded]. Блок-лист гірший за allow-list: нова колонка is_verified стане доступною для запиту в день міграції.

СЛОВНИК$fillable. Allow-list моделі: ключі, які fill, create і update приймають з масиву. Ключі поза списком відкидаються мовчки або з винятком у строгому режимі. У Laravel 13 те саме задає атрибут #[Fillable].
СЛОВНИК$guarded. Блок-лист моделі: ключі, які fill не приймає. ['*'] за замовчуванням блокує все, [] не блокує нічого. Атрибути #[Guarded] і #[Unguarded] роблять те саме на класі.

$fillable працює лише на шляху fill. forceFill і forceCreate обходять його за задумом. Model::unguard() вимикає захист для всіх моделей до reguard(), а Model::unguarded(fn) на час виклику; так працюють сидери й фабрики. update на рівні запиту, як User::whereKey($id)->update($data) чи $user->posts()->update($data), іде прямо в SQL і на $fillable не дивиться взагалі. Присвоєння по одному полю, $user->role = ..., масовим не є: його видно в коді.

Тому надійний захист стоїть на вході. повертає лише поля, для яких є правила, safe()->only([...]) звужує їх далі, а поля, які вирішує сервер (user_id, role, company_id, status), беруться з $request->user() і логіки, ніколи з запиту. Вкладений масив потребує переліку ключів: з правилом 'profile' => 'array' validated() поверне profile з усім, що прийшло. Потрібно 'array:bio,site' або правила для profile.bio і profile.site.

СЛОВНИКvalidated(). Метод FormRequest і Validator: повертає лише ті поля запиту, для яких є правила валідації. Поле без правила в результат не потрапляє, навіть якщо прийшло.

Помітити дірку допомагає . Model::preventSilentlyDiscardingAttributes() змушує fill кинути MassAssignmentException, коли ключ не пройшов $fillable, замість мовчки його викинути. Model::shouldBeStrict() вмикає це разом із preventLazyLoading і preventAccessingMissingAttributes. Зазвичай так: Model::shouldBeStrict(! app()->isProduction()) у сервіс-провайдері. У Livewire те саме з публічними властивостями: клієнт може змінити будь-яку з них у запиті оновлення, а #[Locked] це забороняє.

СЛОВНИКСтрогий режим Eloquent. Model::shouldBeStrict(): заборона лінивого завантаження звʼязків, доступу до відсутніх атрибутів і тихого відкидання ключів у fill. Вмикають поза production, щоб помилки падали в тестах і локально.
02 · ЯК ДУМАЄ АТАКУВАЛЬНИК

Три кроки від форми профілю до прав адміністратора

ДІАГРАМА · УСЯ СХЕМА
Кроки: 3
дописав is_admin$request->all()update()записаноКористувач:POST /profileКонтролерUser:is_admin = 1
Користувач:POST /profileКонтролерUser:is_admin = 1
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Клієнт Ключовий учасник Інфраструктура
  • Користувач створює POST /profile.
  • POST /profile використовує Контролер ($request->all()).
  • Контролер використовує User (update()).
  • User надсилає подію записано, її отримує is_admin = 1.
03 · ВРАЗЛИВИЙ І ЗАХИЩЕНИЙ КОД

Що проходить у модель

PHP 8.5
1<?php
2
3// Модель без захисту, як $guarded = [].
4final class User
5{
6 public array $attributes = ['is_admin' => false];
7
8 public function fill(array $input): self
9 {
10 $this->attributes = $input + $this->attributes;
11
12 return $this;
13 }
14}
15
16// Було: у fill іде все, що прийшло з форми.
17$input = ['name' => 'Олена', 'email' => 'olena@example.com', 'is_admin' => '1'];
18$user = (new User)->fill($input);
19
20echo json_encode($user->attributes, JSON_UNESCAPED_UNICODE), "\n";
1<?php
2
3// Як fill() в Eloquent: з $fillable решта ключів відкидається.
4final class User
5{
6 public static bool $strict = false;
7
81 private const FILLABLE = ['name', 'email'];
9
10 public array $attributes = ['is_admin' => false];
11
12 public function fill(array $input): self
13 {
142 $extra = array_diff_key($input, array_flip(self::FILLABLE));
15
16 if ($extra !== [] && self::$strict) {
17 throw new LogicException('Add fillable property ['.implode(', ', array_keys($extra)).'] to allow mass assignment on [User].');
18 }
19
203 $this->attributes = array_intersect_key($input, array_flip(self::FILLABLE)) + $this->attributes;
21
22 return $this;
23 }
24}
25
264// Як $request->validated(): лише ключі, для яких є правила.
27function validated(array $input, array $rules): array
28{
29 return array_intersect_key($input, $rules);
30}
31
32$input = ['name' => 'Олена', 'email' => 'olena@example.com', 'is_admin' => '1'];
33$rules = ['name' => 'required|string|max:120', 'email' => 'required|email'];
34
35echo 'validated(): ', json_encode(validated($input, $rules), JSON_UNESCAPED_UNICODE), "\n";
36
37$user = (new User)->fill($input);
38echo '$fillable мовчки відкинув зайве: ', json_encode($user->attributes, JSON_UNESCAPED_UNICODE), "\n";
39
405User::$strict = true;
41
42try {
43 (new User)->fill($input);
44} catch (LogicException $e) {
45 echo 'Строгий режим: ', $e->getMessage(), "\n";
46}
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 8–8

    Allow-list: те саме, що protected $fillable = ['name', 'email'] або #[Fillable(['name', 'email'])] у Laravel 13.

  2. 2 рядки 14–18

    Так поводиться fill після Model::preventSilentlyDiscardingAttributes(): ключ поза $fillable кидає MassAssignmentException з таким повідомленням, лише з повним іменем класу.

  3. 3 рядки 20–20

    Без строгого режиму is_admin просто зникає, а is_admin моделі лишається false. Безпечно, але помилку у формі ніхто не помітить.

  4. 4 рядки 26–30

    Перший рубіж стоїть до моделі: validated() бере лише поля з правилами, тож is_admin не доходить навіть до fill.

  5. 5 рядки 40–46

    Строгий режим перетворює тихе відкидання на виняток. Вмикайте його поза production, і зайве поле впаде в тесті, а не в продакшені.

  6. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · ПАСТКИ ФРЕЙМВОРКУ

$fillable захищає, крім цих місць

LARAVEL · ALL()
User::create($request->all());

Чому це дірка: all() віддає все, що надіслав клієнт, разом з полями, яких немає у формі. Від is_admin вас тоді береже лише $fillable. Передавайте $request->validated().

LARAVEL · EXCEPT
$post->update($request->except(['user_id', 'is_featured']));

Чому це дірка: Список заборонених полів старіє: додали колонку is_pinned, і вона вже приймається з запиту. Беріть only([...]) або safe()->only([...]).

LARAVEL · $GUARDED = []
protected $guarded = [];

Чому це дірка: Модель приймає будь-який ключ. Це нормально, лише коли fill і create отримують масиви, які склав код. Щойно туди піде масив з запиту, захисту немає.

LARAVEL · UPDATE ЗАПИТОМ
User::whereKey($id)->update($request->all());

Чому це дірка: update на query builder або на звʼязку, як $user->posts()->update(...), іде прямо в SQL і $fillable не дивиться. Сюди потрапляють лише перевірені масиви.

LARAVEL · ВКЛАДЕНИЙ МАСИВ
'profile' => ['array'],

Чому це дірка: validated() поверне profile з усіма ключами, які прийшли, бо правил для вкладених немає. Перелічіть ключі: 'array:bio,site', або задайте правила для profile.bio і profile.site.

LARAVEL · FORCEFILL
$user->forceFill($request->only('name', 'role'))->save();

Чому це дірка: forceFill і forceCreate обходять $fillable за задумом. Для полів, які вирішує сервер, це нормально, але не для даних з запиту.

LARAVEL · UNGUARD
Model::unguard();

Чому це дірка: Вимикає захист для всіх моделей до виклику reguard(). Сидери й фабрики вже працюють під Model::unguarded(). У сервіс-провайдері чи middleware це знімає $fillable з усього застосунку.

LIVEWIRE · ВЛАСТИВІСТЬ
public int $companyId;

Чому це дірка: Публічну властивість компонента клієнт може змінити в запиті оновлення, як поле форми. Ідентифікатори й прапорці, які ставить сервер, позначайте #[Locked].

SYMFONY · SERIALIZER
$serializer->deserialize($json, User::class, 'json', ['object_to_populate' => $user]);

Чому це дірка: Serializer заповнить кожну властивість, яку може записати, зокрема roles. Обмежте поля групою: 'groups' => ['user:write'], або десеріалізуйте в окремий DTO.

05 · ЗНАЙДИ ПРОБЛЕМУ

Де тут mass assignment

Натисніть на рядки, у яких бачите вразливість, і перевірте. Тут чотири проблеми.

app/Http/Controllers/ProfileController.php натисніть на підозрілі рядки
Тут усе гаразд: у цьому рядку модель не заповнюється з запиту.
Тут усе гаразд: у цьому рядку модель не заповнюється з запиту.
Тут усе гаразд: у цьому рядку модель не заповнюється з запиту.
Пропущено. Так, тут mass assignment. all() передає в модель усе, що прийшло, і від is_admin чи role захищає лише $fillable. Потрібно $request->validated().
Пропущено. Теж вразливо. update на звʼязку іде прямо в SQL і $fillable не дивиться, а масив profile прийшов з запиту без перевірки ключів.
Пропущено. І тут. forceFill свідомо обходить $fillable, а role взято з запиту: людина сама обирає собі роль.
Тут усе гаразд: поля з validated(), а user_id ставить сервер після них, тож запит його не перепише.
Тут усе гаразд: лише перевірені name і bio.
Пропущено. Блок-лист: заборонено лише approved, а user_id, post_id чи будь-яке нове поле приймається з запиту. Беріть validated() і ставте автора з $request->user().
Тут усе гаразд: у цьому рядку модель не заповнюється з запиту.
Тут усе гаразд: у цьому рядку модель не заповнюється з запиту.
Тут усе гаразд: у цьому рядку модель не заповнюється з запиту.
06 · ЧЕКЛІСТ ДЛЯ PR

Вставте у свій шаблон пул-реквесту

## Захист від mass assignment
  • create, update і fill отримують $request->validated(), safe()->only([...]) або масив, складений у коді, і ніколи all() чи except().
  • Поля, які вирішує сервер (user_id, role, company_id, status, is_admin), беруться з $request->user() чи логіки, а не з запиту.
  • Модель має $fillable або #[Fillable], а $guarded = [] лише там, де fill отримує масиви з коду.
  • Вкладені масиви в правилах мають перелік ключів: array:bio,site або правило для кожного ключа.
  • update() на запиті чи звʼязку, forceFill і forceCreate не отримують даних з запиту.
  • Поза production увімкнено Model::shouldBeStrict() або preventSilentlyDiscardingAttributes().
  • Публічні властивості Livewire з ідентифікаторами й прапорцями позначено #[Locked].
  • Feature-тест надсилає зайве поле, як role чи is_admin, і перевіряє, що воно не змінилося.
07 · ЯК У PHP UKRAINE

Як цей сайт створює акаунт колеги

Власник кабінету компанії додає колегу. Валідатор пропускає лише name і email, а User::create отримує масив, складений у коді: роль, компанію й прапорець власника вирішує сервер. Дописане в запит role=admin чи company_id=7 нікуди не потрапить, бо з запиту в масив іде тільки перевірене $data.

php DashboardController.php фрагмент
1$data = $request->validate([← лише name і email
2 'name' => ['required', 'string', 'min:2', 'max:120'],
3 'email' => ['required', 'email', 'max:200', 'unique:users,email'],
4], ['email.unique' => 'Ця пошта вже зареєстрована на платформі.'], ['name' => 'імʼя', 'email' => 'пошта']);
5
6$colleague = User::query()->create([
7 'name' => trim($data['name']),
8 'email' => mb_strtolower(trim($data['email'])),
9 'password' => Str::random(40),
10 'role' => User::ROLE_EMPLOYER,← роль ставить сервер
11 'company_id' => $user->company_id,← компанія власника
12 'is_company_owner' => false,
13]);
app/Models/User.phpМодель перелічує дозволені поля атрибутом #[Fillable] з Laravel 13. role і is_company_owner у списку є, тому в create вони потрапляють лише з масиву, який склав код.
src/Projects/Infrastructure/EloquentProjectRepository.phpProjectModel має $guarded = [], і це свідомо: fill отримує масив з полів доменного обʼєкта Project, а не з запиту. Власника й slug ставлять окремі присвоєння під час створення.
app/Livewire/Developer/Register.phpРеєстрація розробника створює User з явним масивом і роллю developer, а прапорець social позначено #[Locked], щоб браузер не прибрав вимогу пароля.
08 · СЛОВНИК
$fillable
Allow-list моделі: ключі, які fill, create і update приймають з масиву. Ключі поза списком відкидаються мовчки або з винятком у строгому режимі. У Laravel 13 те саме задає атрибут #[Fillable].
$guarded
Блок-лист моделі: ключі, які fill не приймає. ['*'] за замовчуванням блокує все, [] не блокує нічого. Атрибути #[Guarded] і #[Unguarded] роблять те саме на класі.
validated()
Метод FormRequest і Validator: повертає лише ті поля запиту, для яких є правила валідації. Поле без правила в результат не потрапляє, навіть якщо прийшло.
Строгий режим Eloquent
Model::shouldBeStrict(): заборона лінивого завантаження звʼязків, доступу до відсутніх атрибутів і тихого відкидання ключів у fill. Вмикають поза production, щоб помилки падали в тестах і локально.
09 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з безпеки →

$fillable це allow-list: модель приймає лише перелічені ключі. $guarded це блок-лист: модель приймає все, крім перелічених. Краще $fillable, бо нова колонка не стане доступною для запиту сама. За замовчуванням модель має $guarded = ['*'] і на будь-який ключ кидає MassAssignmentException.

$fillable захищає лише шлях fill і create. update на запиті чи звʼязку, forceFill, Model::unguard обходять його. До того ж role чи company_id часто законно стоять у $fillable, бо їх ставить код, і тоді all() дасть клієнту їх змінити. validated() відсікає зайве ще на вході, а поля, які вирішує сервер, беруться з $request->user().

Увесь масив profile з будь-якими ключами, які надіслав клієнт: правил для вкладених ключів немає, тож нічого не відсікається. Виправлення: 'profile' => 'array:bio,site', тоді зайвий ключ провалить валідацію, або правила для profile.bio і profile.site, тоді validated() поверне лише їх.

Model::shouldBeStrict(! app()->isProduction()): fill кидає MassAssignmentException замість тихого відкидання, і зайве поле падає в тестах. Правило рев'ю чи статичний аналіз проти all() і except() у create, update і fill. #[Fillable] або $fillable на моделях, $guarded = [] лише в інфраструктурі, де масиви складає код. Feature-тести, що надсилають role чи is_admin і перевіряють, що поле не змінилося. У Livewire #[Locked] на властивостях, які ставить сервер.