September 17, 2026
I Audited the Fix for a CVE. It Had Another CVE Hiding Inside It | CVE-2026-89267 | Starlette-Admin
The story of CVE-2026-89267 in starlette-admin

By Harsh Raj Singhania
6 min read
The story of CVE-2026-89267 in starlette-admin
Previous posts in this series โ DBI, DBI again, rust-iot-platform, ESP32-audioI2S, mrubyc, Cotonti โ have all come from reading something someone else wrote, carefully enough to notice what it didn't fully address. This one is slightly different. I wasn't reading someone else's bug report. I was reading someone else's fix.
What starlette-admin Is
starlette-admin is an admin framework for FastAPI and Starlette applications, built in Python. It gives you a full administrative interface โ list views, filters, forms, search โ wired up to your models with minimal boilerplate. Think of it as the Django admin panel equivalent for the FastAPI world.
When you define a model view in starlette-admin, you configure things like which fields appear in the list view, which ones the user can sort by, and which ones they can search or filter on. The two relevant configuration options here are sortable_fields and searchable_fields. Setting either to None means the framework defaults to allowing all fields. Setting either to an explicit list means only those fields are allowed. Setting either to an empty list โ [] โ is the natural way to say "disable this entirely."
CVE-2026โ54553, The Bug Before This One
In August 2026, another researcher published CVE-2026โ54553. The finding was that starlette-admin before version 0.16.1 didn't validate the field names supplied through the order_by and where parameters against the configured allowlists at all. An authenticated user could sort or filter by any field they liked, including fields never meant to be exposed through search. This is what's sometimes called an "info-exposure oracle" โ you can't read the values directly, but you can run equality and comparison queries against fields like SSNs or internal notes, and by watching whether results come back you can effectively extract those values one query at a time.
The maintainer fixed it in version 0.16.1, across two commits: d2a25eb and af05b45. I read the second fix commit carefully.
The Two Functions That Should Have Been Symmetric
The fix added two validation functions to views.py: _validate_order_by and _validate_where. Here's both of them, copied verbatim from commit af05b45cd90944b726949fd650ab9d19f1abafc3, the final commit closing the CVE:
def _validate_order_by(self, request, order_by):
list_field_names = {f.name for f in self._all_fields if not f.exclude_from_list}
sortable: Set[str] = set(self.sortable_fields or [])
for clause in order_by:
...
if field_name not in list_field_names or field_name not in sortable:
return f"Unknown field or field is not sortable in order_by: '{field_name}'"
return None
def _validate_where(self, request, where):
list_field_names = {f.name for f in self._all_fields if not f.exclude_from_list}
searchable = set(self.searchable_fields or [])
for field_name in self._extract_fields_from_where(where):
if field_name not in list_field_names or (
searchable and field_name not in searchable
):
return f"Unknown field or field is not searchable in where: '{field_name}'"
return Nonedef _validate_order_by(self, request, order_by):
list_field_names = {f.name for f in self._all_fields if not f.exclude_from_list}
sortable: Set[str] = set(self.sortable_fields or [])
for clause in order_by:
...
if field_name not in list_field_names or field_name not in sortable:
return f"Unknown field or field is not sortable in order_by: '{field_name}'"
return None
def _validate_where(self, request, where):
list_field_names = {f.name for f in self._all_fields if not f.exclude_from_list}
searchable = set(self.searchable_fields or [])
for field_name in self._extract_fields_from_where(where):
if field_name not in list_field_names or (
searchable and field_name not in searchable
):
return f"Unknown field or field is not searchable in where: '{field_name}'"
return NoneThese two functions are meant to do parallel jobs. _validate_order_by checks whether a field name can be sorted by. _validate_where checks whether a field name can be searched on. Their logic should be symmetric, and almost is. But the check in _validate_where has one extra word that breaks it.
The One Word
Look at the condition in _validate_where:
searchable and field_name not in searchablesearchable and field_name not in searchableNow compare it to the equivalent in _validate_order_by:
field_name not in sortablefield_name not in sortableThe difference is searchable and. That prefix is the bug.
In Python, an empty set is falsy. When you evaluate bool(set()), you get False. So if searchable_fields is configured as [], then searchable becomes set([]), which is an empty set, which is falsy. The expression searchable and field_name not in searchable short-circuits at searchable. The right-hand side โ the actual check โ never runs. Python sees False and ... and stops right there.
The result is that the entire searchable-fields restriction is silently skipped whenever a developer explicitly sets searchable_fields = [].
_validate_order_by doesn't have this problem. field_name not in sortable gets evaluated regardless of whether sortable is empty. An empty set is still a valid set. 'ssn' not in set() evaluates to True, which triggers the rejection correctly. The extra searchable and prefix in _validate_where is the only reason the behavior diverges.
Why searchable_fields = [] Is A Natural And Real Configuration
You might wonder: would a developer actually write searchable_fields = []? Isn't None the more natural choice for "no preference"?
Not quite. In starlette-admin's API, None means "default to all fields searchable." An explicit empty list is the only way to say "I have thought about this and I am deliberately disabling search." It's the natural expression of the intention. And it's specifically the intention of locking down a model with sensitive fields โ SSNs, internal status flags, hashed data, anything that appears in the list view but that an admin explicitly decided shouldn't be queryable.
That's exactly the configuration the original CVE's fix was supposed to protect. The new code silently undoes the protection for anyone who wrote it the most obvious way.
Proving The Asymmetry
The harness uses both functions copied character-for-character from commit af05b45. A model with an ssn field visible in the list view, sortable_fields = [], searchable_fields = []:
--- _validate_order_by(['ssn asc']) ---
Result: "Unknown field or field is not sortable in order_by: 'ssn'"
Correctly rejected? True
--- _validate_where({'ssn': {'eq': '123-45-6789'}}) ---
Result: None
Correctly rejected? False--- _validate_order_by(['ssn asc']) ---
Result: "Unknown field or field is not sortable in order_by: 'ssn'"
Correctly rejected? True
--- _validate_where({'ssn': {'eq': '123-45-6789'}}) ---
Result: None
Correctly rejected? FalseSame field. Same "disable entirely" configuration on both. One function rejects it, the other allows it. The asymmetry is right there in the output.
The root cause in one line:
>>> searchable = set([] or []) # set(self.searchable_fields or [])
>>> searchable
set()
>>> bool(searchable)
False
>>> searchable and "ssn" not in searchable
False # right-hand side never evaluated>>> searchable = set([] or []) # set(self.searchable_fields or [])
>>> searchable
set()
>>> bool(searchable)
False
>>> searchable and "ssn" not in searchable
False # right-hand side never evaluatedAnd the fix, dropping the short-circuit:
--- Fixed _validate_where ---
Result: "Unknown field or field is not searchable in where: 'ssn'"
Correctly rejected? True--- Fixed _validate_where ---
Result: "Unknown field or field is not searchable in where: 'ssn'"
Correctly rejected? TrueOne word removed, behavior matches _validate_order_by exactly.
What This Means In Practice
Same impact class as the original CVE: an authenticated user with access to a list endpoint can query by fields the developer explicitly excluded from search. The difference is the original CVE affected every deployment before 0.16.1. This one only affects the specific configuration where a developer set searchable_fields = [] intending to lock down a sensitive model. That's a narrower population, which is why VulnCheck rated this Medium rather than the original's higher impact.
But narrower isn't zero. Any admin panel protecting sensitive columns with an explicit empty searchable allowlist โ and that is a realistic thing to do, not an edge case โ is running with that protection silently disabled through 0.17.1.
Disclosure
I emailed the maintainer, Jocelin, directly with the full technical report, the harness, and a suggested fix. No response. I went to VulnCheck, who published it as CVE-2026โ89267, affecting versions 0.16.1 through 0.17.1.
The fix itself is simple: remove searchable and from the condition, mirroring _validate_order_by's structure exactly:
# Vulnerable (0.16.1 โ 0.17.1):
if field_name not in list_field_names or (
searchable and field_name not in searchable
):
# Fixed:
if field_name not in list_field_names or field_name not in searchable:# Vulnerable (0.16.1 โ 0.17.1):
if field_name not in list_field_names or (
searchable and field_name not in searchable
):
# Fixed:
if field_name not in list_field_names or field_name not in searchable:If you're running starlette-admin 0.16.1 through 0.17.1 with searchable_fields = [] on any model view that has sensitive fields, the protection you think you configured isn't enforced. Upgrade to a fixed version when one is available, or patch the check manually in the interim.
Why Reading Fixes Is Worth Doing
Most people read CVE write-ups when they come out and move on. I've started reading the fix commits too, specifically because the fix is where the interesting part often happens. A developer who just fixed a bug wrote that fix quickly, under some time pressure, possibly without a comprehensive view of every similar pattern in the codebase. The question worth asking after every fix is: did this solve the whole problem, or just the visible part of it?
In this case, the fix was careful enough to cover _validate_order_by correctly and careful enough to add _validate_where at all. It just introduced one extra operator that made the new function's behavior wrong in one specific case. That's not carelessness โ it's the kind of mistake that happens when you're writing parallel logic by hand. It's also exactly the kind of thing that shows up when you slow down and read the diff.
Eight posts now, seven confirmed CVEs. If you're reading this and you work on starlette-admin, the fix is one line โ find me in the comments or open a PR.
References
- VulnCheck advisory for CVE-2026โ89267: https://www.vulncheck.com/advisories/starlette-admin-0.16.1-through-0.17.1-searchable-fields-allowlist-bypass
- Vulnerable source line at 0.17.1: https://github.com/jowilf/starlette-admin/blob/0.17.1/starlette_admin/views.py#L971-L987
- CVE-2026โ54553 / GHSA-6753-gr46โ6wpr (the original bug, not mine): https://github.com/jowilf/starlette-admin/security/advisories/GHSA-6753-gr46-6wpr
- The fix commit this CVE was found in: https://github.com/jowilf/starlette-admin/commit/af05b45cd90944b726949fd650ab9d19f1abafc3
- starlette-admin source repository: https://github.com/jowilf/starlette-admin
- My previous post, Cotonti CommentsWidget: https://medium.com/@harshrajsinghania/i-found-a-fourth-unserialize-sink-in-the-same-cms
- My post on CVE-2026โ86547 in mrubyc: https://medium.com/@harshrajsinghania/i-read-someone-elses-bug-report-then-found-the-same-missing-check-in-the-next-function-over
- My post on CVE-2026โ87961 in ESP32-audioI2S: https://medium.com/@harshrajsinghania/the-bug-was-real-the-one-filed-next-to-it-wasn-t
- My post on CVE-2026โ82452 and CVE-2026โ82453: https://medium.com/@harshrajsinghania/i-found-two-more-cves-by-checking-one-word-in-someone-else-s-bug-report
- My first post, CVE-2026โ73194: https://medium.com/@harshrajsinghania/i-found-my-first-cve-by-trying-to-understand-someone-elses-bug-the-story-of-cve-2026-73194-1702f8bde5c1