Skip to content

fix: guarded could be skipped with a changed key - #40

Merged
necessarylion merged 1 commit into
mainfrom
fix-guarded-bypass
Nov 6, 2026
Merged

necessarylion merged 1 commit into
mainfrom
fix-guarded-bypass

Conversation

@mydoxie

@mydoxie mydoxie commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Problem

A model with guarded => ['is_admin'] did not block every key that sets is_admin.
These keys got through and set the column:

  • " is_admin" (space in front)
  • "is_admin\t" (tab at the end)
  • "IS_ADMIN" (upper case)
  • "users.is_admin" (table prefix, a risk on MySQL)

The guarded check used the raw map key. But the SQL builder trims the name. SQLite and MySQL also match column names without case.

Effect

A user could send {"IS_ADMIN": 1} in a request body and make themselves an admin. This was true for a query update(), update() on a loaded model, upsert(), insertOrIgnore() and insertMultiple(). create() was not affected.

Fix

Only _onlyFillable in lib/src/model.dart changes.

  • The guarded check now compares a clean name: trimmed, without a table prefix, lower case.
  • With only guarded set, a key must also be exactly one of tableColumns, like Laravel's isGuardableColumn. A model with no tableColumns uses only the clean name check.
  • A fillable key must still match the name exactly. A changed key never passes fillable.

No public API change. Normal exact keys work as before.

Tests

  • New test: on a guarded model, each changed key goes through query update, loaded model update, insertMultiple, upsert and insertOrIgnore. is_admin stays 0 and name still saves. It fails without the fix.
  • New test: on a fillable model, NAME, name and IS_ADMIN do not pass.
  • dart test on SQLite: 555 passed, 2 skipped. dart analyze --fatal-infos and dart format are clean. Postgres and MySQL run in CI.

@mydoxie

mydoxie commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Bot review

Blocking: none.

The fix is correct. Every write path that takes a user map goes through _onlyFillable: query update(), update() on a loaded model, create() / insert() / firstOrCreate() / firstOrNew() / updateOrCreate(), upsert(), insertOrIgnore(), insertMultiple(), and the extra map of increment() / decrement(). create() was already safe, because fromMap() reads only exact column names. Other key forms do not work either: x as y and quoted names make invalid SQL or are refused by wrap(), and Postgres matches quoted names with case. guarded: [] and unguarded() keep every key, like before. Fillable models do not change.

Non-blocking:

  1. model.dart:151 (_columnName) has no test. The new test uses GuardedUser, which has generated tableColumns. So the exact tableColumns check drops every changed key before the normalized name is used. I changed _columnName to return key; and security_test.dart still passed. Please add one case with a guarded model that has empty tableColumns (a hand-written model).
  2. Same path (no tableColumns, MySQL only): wrap() accepts any Unicode letter. MySQL compares column names with utf8_general_ci, so a key like 'İS_ADMIN' may match is_admin. Dart lower-cases İ to i̇, so the guarded check would miss it. I did not test this on MySQL. It only matters for models without generated columns. A simple guard: in guarded-only mode with no tableColumns, drop keys that are not plain ASCII.
  3. A small change in behavior: with only guarded set, a key that is not exactly in tableColumns is now dropped without an error (for example 'users.name', or a DB column that the model does not declare). Before, it was sent to the database. This matches Laravel, and the CHANGELOG says it. It is OK as a security fix.

Checks (local): DRIVER=sqlite dart test passes (555 tests). dart analyze --fatal-infos finds no issues. dart format changes nothing. The new guarded test fails without the fix (Expected: 0, Actual: 1). The CHANGELOG entry is under ## Unreleased, with no version bump. CI: lint passes, test was still pending when I checked.

A key like " is_admin", "IS_ADMIN" or "users.is_admin" set a guarded
column. The SQL builder trims the name, and SQLite and MySQL match names
without case. So the guarded check, which used the raw key, missed it.

Now the guarded check compares the trimmed, lower case name without a
table prefix. With only guarded set, a key must also be one of the model
columns. A fillable key must still match exactly.
@necessarylion
necessarylion merged commit f340f06 into main Nov 6, 2026
2 checks passed
@necessarylion
necessarylion deleted the fix-guarded-bypass branch November 6, 2026 00:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants