Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion system/BaseModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ abstract class BaseModel
*/
protected $protectFields = true;

/**
* Whether Model should throw instead of silently discarding
* fields that are not in $allowedFields.
*/
protected bool $throwOnDisallowedFields = false;

/**
* An array of field names that are allowed
* to be set by the user in inserts/updates.
Expand Down Expand Up @@ -1067,7 +1073,7 @@ public function update($id = null, $row = null): bool

// Must be called first, so we don't
// strip out updated_at values.
$row = $this->doProtectFields($row);
$row = $this->doProtectFieldsForUpdate($row);

// doProtectFields() can further remove elements from
// $row, so we need to check for empty dataset again
Expand Down Expand Up @@ -1157,6 +1163,7 @@ public function updateBatch(?array $set = null, ?string $index = null, int $batc

// Must be called first so we don't
// strip out updated_at values.
$this->ensureNoDisallowedFields($row, $index === null ? [] : [$index]);
$row = $this->doProtectFields($row);

// Restore updateIndex value in case it was wiped out
Expand Down Expand Up @@ -1382,6 +1389,19 @@ public function protect(bool $protect = true)
return $this;
}

/**
* Sets whether or not disallowed fields should throw an exception
* instead of being discarded.
*
* @return $this
*/
public function throwOnDisallowedFields(bool $throw = true)
{
$this->throwOnDisallowedFields = $throw;

return $this;
}

/**
* Ensures that only the fields that are allowed to be updated are
* in the data array.
Expand Down Expand Up @@ -1414,6 +1434,35 @@ protected function doProtectFields(array $row): array
return $row;
}

/**
* Throws when configured to detect fields that would be discarded.
*
* @param row_array $row
* @param list<string> $ignoredFields
*
* @throws DataException
*/
protected function ensureNoDisallowedFields(array $row, array $ignoredFields = []): void
{
if (! $this->throwOnDisallowedFields || ! $this->protectFields || $this->allowedFields === []) {
return;
}

$disallowedFields = [];

foreach (array_keys($row) as $key) {
if (in_array($key, $this->allowedFields, true) || in_array($key, $ignoredFields, true)) {
continue;
}

$disallowedFields[] = $key;
}

if ($disallowedFields !== []) {
throw DataException::forDisallowedFields(static::class, $disallowedFields);
}
}

/**
* Ensures that only the fields that are allowed to be inserted are in
* the data array.
Expand All @@ -1429,6 +1478,27 @@ protected function doProtectFields(array $row): array
*/
protected function doProtectFieldsForInsert(array $row): array
{
$this->ensureNoDisallowedFields($row);

return $this->doProtectFields($row);
}

/**
* Ensures that only the fields that are allowed to be updated are in
* the data array.
*
* @used-by update() to protect against mass assignment vulnerabilities.
*
* @param row_array $row
*
* @return row_array
*
* @throws DataException
*/
protected function doProtectFieldsForUpdate(array $row): array
{
$this->ensureNoDisallowedFields($row);

return $this->doProtectFields($row);
}

Expand Down
1 change: 1 addition & 0 deletions system/Commands/Generators/Views/model.tpl.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class {class} extends Model
protected $protectFields = true;
protected $allowedFields = [];

protected bool $throwOnDisallowedFields = false;
protected bool $allowEmptyInserts = false;
protected bool $updateOnlyChanged = true;

Expand Down
10 changes: 10 additions & 0 deletions system/Database/Exceptions/DataException.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ public static function forInvalidAllowedFields(string $model)
return new static(lang('Database.invalidAllowedFields', [$model]));
}

/**
* @param list<string> $fields
*
* @return DataException
*/
public static function forDisallowedFields(string $model, array $fields)
{
return new static(lang('Database.disallowedFields', [$model, implode(', ', $fields)]));
}

/**
* @return DataException
*/
Expand Down
1 change: 1 addition & 0 deletions system/Language/en/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
'invalidEvent' => '"{0}" is not a valid Model Event callback.',
'invalidArgument' => 'You must provide a valid "{0}".',
'invalidAllowedFields' => 'Allowed fields must be specified for model: "{0}"',
'disallowedFields' => 'Fields are not allowed for model "{0}": {1}',
'emptyDataset' => 'There is no data to {0}.',
'emptyPrimaryKey' => 'There is no primary key defined when trying to make {0}.',
'failGetFieldData' => 'Failed to get field data from database.',
Expand Down
9 changes: 9 additions & 0 deletions system/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,8 @@ protected function doProtectFieldsForInsert(array $row): array
throw DataException::forInvalidAllowedFields(static::class);
}

$this->ensureNoDisallowedFields($row, $this->useAutoIncrement === false ? [$this->primaryKey] : []);

foreach (array_keys($row) as $key) {
// Do not remove the non-auto-incrementing primary key data.
if ($this->useAutoIncrement === false && $key === $this->primaryKey) {
Expand All @@ -781,6 +783,13 @@ protected function doProtectFieldsForInsert(array $row): array
return $row;
}

protected function doProtectFieldsForUpdate(array $row): array
{
$this->ensureNoDisallowedFields($row, [$this->primaryKey]);

return $this->doProtectFields($row);
}

/**
* Finds the first row matching attributes or inserts a new row.
*
Expand Down
196 changes: 196 additions & 0 deletions tests/system/Models/ThrowOnDisallowedFieldsModelTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\Models;

use CodeIgniter\Database\Exceptions\DataException;
use PHPUnit\Framework\Attributes\Group;
use Tests\Support\Models\UserModel;
use Tests\Support\Models\ValidModel;
use Tests\Support\Models\WithoutAutoIncrementModel;

/**
* @internal
*/
#[Group('DatabaseLive')]
final class ThrowOnDisallowedFieldsModelTest extends LiveModelTestCase
{
public function testDefaultFieldProtectionStillDiscardsDisallowedFields(): void
{
$this->createModel(UserModel::class)->insert([
'name' => 'Disallowed Default',
'email' => 'disallowed-default@example.com',
'country' => 'US',
'timezone' => 'UTC',
]);

$this->seeInDatabase('user', [
'email' => 'disallowed-default@example.com',
]);
}

public function testThrowOnDisallowedFieldsThrowsOnInsertDisallowedFields(): void
{
$this->expectException(DataException::class);
$this->expectExceptionMessage('Fields are not allowed for model "Tests\Support\Models\UserModel": timezone');

$this->createModel(UserModel::class)->throwOnDisallowedFields()->insert([
'name' => 'Disallowed Insert',
'email' => 'disallowed-insert@example.com',
'country' => 'US',
'timezone' => 'UTC',
]);
}

public function testThrowOnDisallowedFieldsThrowsOnInsertBatchDisallowedFields(): void
{
$this->expectException(DataException::class);
$this->expectExceptionMessage('Fields are not allowed for model "Tests\Support\Models\UserModel": timezone');

$this->createModel(UserModel::class)->throwOnDisallowedFields()->insertBatch([
[
'name' => 'Disallowed Batch',
'email' => 'disallowed-batch@example.com',
'country' => 'US',
'timezone' => 'UTC',
],
]);
}

public function testThrowOnDisallowedFieldsThrowsOnUpdateDisallowedFields(): void
{
$this->expectException(DataException::class);
$this->expectExceptionMessage('Fields are not allowed for model "Tests\Support\Models\UserModel": timezone');

$this->createModel(UserModel::class)->throwOnDisallowedFields()->update(1, [
'name' => 'Disallowed Update',
'timezone' => 'UTC',
]);
}

public function testThrowOnDisallowedFieldsThrowsOnSaveDisallowedFields(): void
{
$this->expectException(DataException::class);
$this->expectExceptionMessage('Fields are not allowed for model "Tests\Support\Models\UserModel": timezone');

$this->createModel(UserModel::class)->throwOnDisallowedFields()->save([
'name' => 'Disallowed Save',
'email' => 'disallowed-save@example.com',
'country' => 'US',
'timezone' => 'UTC',
]);
}

public function testThrowOnDisallowedFieldsAllowsPrimaryKeyDuringUpdate(): void
{
$result = $this->createModel(UserModel::class)->throwOnDisallowedFields()->update(1, [
'id' => 1,
'name' => 'Disallowed Primary Key',
]);

$this->assertTrue($result);
$this->seeInDatabase('user', [
'id' => 1,
'name' => 'Disallowed Primary Key',
]);
}

public function testThrowOnDisallowedFieldsAllowsPrimaryKeyDuringConditionalUpdate(): void
{
$result = $this->createModel(UserModel::class)->throwOnDisallowedFields()
->where('id', 1)
->update(null, [
'id' => 1,
'name' => 'Disallowed Conditional Primary Key',
]);

$this->assertTrue($result);
$this->seeInDatabase('user', [
'id' => 1,
'name' => 'Disallowed Conditional Primary Key',
]);
}

public function testThrowOnDisallowedFieldsAllowsBatchIndexDuringUpdateBatch(): void
{
$result = $this->createModel(UserModel::class)->throwOnDisallowedFields()->updateBatch([
[
'id' => 1,
'name' => 'Disallowed Batch One',
],
[
'id' => 2,
'name' => 'Disallowed Batch Two',
],
], 'id');

$this->assertSame(2, $result);
$this->seeInDatabase('user', [
'id' => 1,
'name' => 'Disallowed Batch One',
]);
$this->seeInDatabase('user', [
'id' => 2,
'name' => 'Disallowed Batch Two',
]);
}

public function testThrowOnDisallowedFieldsThrowsOnUpdateBatchDisallowedFields(): void
{
$this->expectException(DataException::class);
$this->expectExceptionMessage('Fields are not allowed for model "Tests\Support\Models\UserModel": timezone');

$this->createModel(UserModel::class)->throwOnDisallowedFields()->updateBatch([
[
'id' => 1,
'name' => 'Disallowed Batch',
'timezone' => 'UTC',
],
], 'id');
}

public function testThrowOnDisallowedFieldsAllowsNonAutoIncrementPrimaryKeyDuringInsert(): void
{
$result = $this->createModel(WithoutAutoIncrementModel::class)->throwOnDisallowedFields()->insert([
'key' => 'disallowed-key',
'value' => 'disallowed value',
]);

$this->assertSame('disallowed-key', $result);
$this->seeInDatabase('without_auto_increment', [
'key' => 'disallowed-key',
'value' => 'disallowed value',
]);
}

public function testProtectFalseBypassesThrowOnDisallowedFields(): void
{
$result = $this->createModel(UserModel::class)->throwOnDisallowedFields()->protect(false)->update(1, [
'name' => 'Disallowed Disabled',
'created_at' => '2026-01-01 12:00:00',
]);

$this->assertTrue($result);
}

public function testValidationRunsBeforeThrowOnDisallowedFields(): void
{
$model = $this->createModel(ValidModel::class)->throwOnDisallowedFields();

$this->assertFalse($model->insert([
'description' => 'Missing required name',
'extra' => 'discarded after validation',
]));
$this->assertArrayHasKey('name', $model->errors());
}
}
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ Model

- Added new ``chunkRows()`` method to ``CodeIgniter\Model`` for processing large datasets in smaller chunks.
- Added new ``firstOrInsert()`` method to ``CodeIgniter\Model`` that finds the first row matching the given attributes or inserts a new one. See :ref:`model-first-or-insert`.
- Added ``$throwOnDisallowedFields`` and ``throwOnDisallowedFields()`` to ``CodeIgniter\Model`` to throw a ``DataException`` when write data contains fields that would otherwise be discarded by ``$allowedFields``. See :ref:`model-throw-on-disallowed-fields`.

Libraries
=========
Expand Down
Loading
Loading