Summary
I reported and coordinated CVE-2026-22814, an ORM state injection vulnerability in Lucid, the AdonisJS-native SQL ORM. The maintainers are awesome and immediately issued a patch.
Severity: High - 8.2 (CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N)
A Mass Assignment (CWE-915) vulnerability in AdonisJS Lucid may allow a remote attacker who can influence data that is passed into Lucid model assignments to overwrite the internal ORM state. This may lead to logic bypasses and unauthorized record modification within a table or model. This affects @adonisjs/lucid through version 21.8.1 and 22.x pre-release versions prior to 22.0.0-next.6. This has been patched in @adonisjs/lucid versions 21.8.2 and 22.0.0-next.6.
Technical Analysis
Lucid is a SQL query builder and an Active Record ORM built on top of Knex. It is maintained by the AdonisJS core team and is the native SQL ORM for the AdonisJS core framework. An ORM is a library to bridge the gap between object-oriented programming languages (JavaScript) and a relational database that uses SQL.
Lucid uses JavaScript classes to define data models. A model class can register lifecycle hooks, add computed properties and methods to encapsulate domain logic, and control how instances are serialized. Typically, applications define one model per database table and use the model’s APIs to query and persist records.
Mass-assignment vulnerabilities arise when request data is mapped onto object fields without strict allowlisting. In this case, the ORM’s assignment logic relies on a this.hasOwnProperty(key) check to determine whether a key is assignable. Because parts of the ORM’s internal state are stored as instance properties, those internal fields also satisfy the hasOwnProperty check.
if (this.hasOwnProperty(key)) {
;(this as any)[key] = value
return
}
The vulnerability primarily resides in the merge() method of the BaseModelImpl class, but it propagates to almost every method in the library used for creating or updating records. The merge() method is designed to bulk-assign attributes from a plain object (typically user input) to the model instance.
// adonisjs/lucid/src/orm/BaseModel/index.ts
merge(values: any, allowExtraProperties: boolean = false): this {
const Model = this.constructor as typeof BaseModel
if (isObject(values)) {
Object.keys(values).forEach((key) => {
const value = values[key]
...
if (this.hasOwnProperty(key)) {
;(this as any)[key] = value
return
}
...
})
}
return this
}
This enables attackers to overwrite ORM internals via model assignment, potentially redirecting write behavior and bypassing validations. There is a large attack surface of internal properties that allows attackers to get creative:
$columns: Type reference distinguishing columns from properties.$attributes: Current model data sent to the adapter.$original: Original data snapshot used for dirty checking.$preloaded: Object storing loaded relationship data.$extras: Non-persisted, dynamic properties (e.g. aggregates).$sideloaded: Metadata shared across instances and relationships.$isPersisted: True if the record exists in the database.$isDeleted: True if the record has been deleted.$isLocal: True if created locally rather than fetched.modelOptions: Instance-specific config (e.g. connection name).modelTrx: Active transaction client for this instance.transactionListener: Cleans up transaction reference on commit/rollback.fillInvoked: Tracks if attributes were replaced via.fill().cachedGetters: Cache to prevent re-running attribute getters.forceUpdate: Forces an update query even if data hasn’t changed.
For example, if a developer passes user-controllable properties to Model.create(), the ORM’s internal logic can be hijacked to update an existing record, rather than create a new one.
{
"id": 1,
"$isPersisted": true,
"email": "admin_hacked@websmite.com"
}
Because the Model.create() > save() decision is based on $isPersisted, and merge() can assign to the own-property $isPersisted, an attacker who can inject "$isPersisted": true into the payload can force save() to take the UPDATE branch rather than the INSERT branch, while setting $attributes can bypass validators or field restrictions (e.g. id is deny-listed).
async save(): Promise<this> {
this.ensureIsntDeleted()
const Model = this.constructor as typeof BaseModel
if (!this.$isPersisted) {
await Model.$adapter.insert(this, this.prepareForAdapter(this.$attributes))
this.$isPersisted = true
return this
}
const updatePayload = this.prepareForAdapter(this.$dirty)
if (Object.keys(updatePayload).length > 0) {
await Model.$adapter.update(this, updatePayload)
}
return this
}
...
static async create(values: any, options?: ModelAssignOptions): Promise<any> {
const instance = this.newUpWithOptions(values, options, options?.allowExtraProperties)
await instance.save()
return instance
}
Unvalidated user input can otherwise reach models via methods like request.all(), request.except() or a schema with the allowUnknownProperties enabled, which is disabled by default. If a developer passes request.all() to Model.create(), the expected failure mode is that columns might be wrongly set. The unexpected and dangerous failure mode is that the ORM’s internal logic is hijacked. Even with allowExtraProperties disabled, internal keys are not treated as extra because they already exist as instance properties and therefore pass hasOwnProperty. While not ideal, if a developer were to implement a deny-list to sanitize sensitive field names on assignment, they would not know their sanitization can be bypassed.
While merge() provides the ability to overwrite the ORM state, there is a read path as well. The vulnerability is also present in $consumeAdapterResult(). This method is responsible for hydrating model instances from database query results (used internally by find(), fetch(), and raw() query mapping). Similar to merge(), the vulnerable version iterates over the input keys and performs a this.hasOwnProperty(key) check before assignment, bypassing the model’s attribute handling for any key that exists as an instance property.
// adonisjs/lucid/src/orm/BaseModel/index.ts
$consumeAdapterResult(adapterResult: ModelObject, sideloadedAttributes?: ModelObject) {
...
if (isObject(adapterResult)) {
Object.keys(adapterResult).forEach((key) => {
...
if (this.hasOwnProperty(key)) {
;(this as any)[key] = adapterResult[key]
return
}
this.$extras[key] = adapterResult[key]
})
}
}
While $consumeAdapterResult() typically consumes data from the database driver, it presents a distinct attack vector, exposing applications to column alias injection and schema-based attacks. If an attacker can influence the SQL selection clause (e.g. user-controlled column aliases or schema-provided column names), they can inject SQL aliases that map arbitrary values to internal property names. Alternatively, applications connecting to untrusted databases or user-provisioned schemas are compromised if a table contains columns named after internal Lucid properties. In both cases, the ORM blindly hydrates these values into the model’s internal state.
Even when developers use SQL parameter bindings for identifiers, the ORM remains vulnerable. This is because the attack targets the collision between database column names and ORM internal properties during the hydration phase, rather than attempting to break the SQL syntax itself.
// GET /users/1?ui_handle=$isPersisted
const alias = request.input('ui_handle')
const user = await User.query()
.select('id', 'email')
.select(Database.raw('1 as ??', [alias]))
.firstOrFail()
Traditional SQL injection is prevented, but the ORM state is hijacked as user.$isPersisted is now 1.
SELECT "id", "email", 1 as "$isPersisted"
More broadly, this vulnerability reflects a recurring framework design risk. When data-plane assignment APIs can mutate control-plane state, core framework invariants can become externally influenceable, and security guarantees start to depend on perfect input handling. This creates sharp edges that are easy to hit, and, in this particular case, a critical vulnerability in documented usage patterns.
Assuming developers do everything right, strictly validating user input is not a complete solution in the Adonis ecosystem for two documented reasons (and one undocumented):
- The AdonisJS core team created a framework-agnostic data validation library, VineJS, which strictly validates inputs. However, the AdonisJS core validation guide explicitly says teams may use any validation library, so Lucid cannot rely on a specific validator’s output semantics to protect its internals.
- Even when using VineJS correctly (i.e. using the validator output), Vine explicitly supports validated payloads that retain unknown keys via
allowUnknownProperties, and it also provides record specifically for objects with unknown keys. In those supported configurations, the validated output may still include attacker-controlled keys like “$isPersisted” unless the developer separately sanitizes Lucid internals. The ORM internals are undocumented, so developers would not know to sanitize them during read or write operations.
Impact
Applications utilizing strict allow lists for input validation that discard unknown properties and that only hydrate models from trusted sources are not affected.
Applications are vulnerable if they pass unvalidated data or validated data that retains unknown properties (from request.all(), request.except(), allowUnknownProperties or similar) directly to Model assignment methods such as create(), fill(), or merge(). Applications may also be vulnerable if they hydrate models from untrusted sources. This occurs because internal keys exist as instance properties, causing them to pass the hasOwnProperty check and bypass Lucid’s default rejection of unknown properties.
An attacker can supply a JSON payload containing Lucid internal keys (e.g. $isPersisted, $attributes, $original) alongside legitimate data. Because the ORM blindly merges these keys into the model instance, the attacker can overwrite the control-plane state of the active record.
The most critical consequences include:
- Logic manipulation e.g. overwriting
$isPersistedto true forces thesave()method to execute an UPDATE query instead of an INSERT. If the attacker also controls the primary key (or targets an existing one), this allows them to overwrite existing database records rather than creating new ones. - Validation bypass e.g. by injecting data directly into
$attributes, attackers can bypass setters, computed properties, and lifecycle hooks that would normally validate or sanitize input during standard assignment. - Query-based state injection e.g. if an attacker can influence column selection, they can map arbitrary values to internal keys. This allows them to trigger denial of service or corrupt persistence logic even in read-only query paths
- State corruption e.g. modifying flags like
$isDeletedor$forceUpdatecan result in unpredictable application behavior, data corruption, or denial of service by crashing the application logic handling the model.
Patches
This has been patched in @adonisjs/lucid versions 21.8.2 (v6) and 22.0.0-next.6 (v7).
The patch deny-lists internal properties in merge() and $consumeAdapterResult(). Developers can mitigate CVE-2026-22814 on the affected versions by using a strict allow list that drops unknown properties, or manually deny listing the internal properties before assignment.
Below is the example from the patch.
const INTERNAL_INSTANCE_PROPERTIES = new Set([
'$columns',
'$attributes',
'$original',
'$preloaded',
'$extras',
'$sideloaded',
'$isPersisted',
'$isDeleted',
'$isLocal',
'modelOptions',
'modelTrx',
'transactionListener',
'fillInvoked',
'cachedGetters',
'forceUpdate',
])
...
if (INTERNAL_INSTANCE_PROPERTIES.has(key)) {
return
}
Timeline
| Date | Event |
|---|---|
| 2026-01-07 | Initial Report via GitHub Private Vulnerability Report |
| 2026-01-08 | Maintainer Acknowledgement |
| 2026-01-11 | Patch Released (21.8.2, 22.0.0-next.6) |
| 2026-01-12 | GitHub Staff Assigns CVE-2026-22814 |
| 2026-01-12 | Maintainer Releases GHSA-g5gc-h5hp-555f |
| 2026-01-13 | Published to GitHub Advisory Database |
| 2026-01-13 | Published to National Vulnerability Database |
| 2026-01-15 | Published to websmite.com |