Overview
Broken Object Level Authorization (BOLA) occurs when an application fails to enforce access controls on a per-object basis, allowing an attacker to access or manipulate resources they should not. In FastAPI-based admin tooling like FastApiAdmin, insufficient object-level checks can lead to information disclosure, unauthorized file access, or unrestricted uploads. The CVEs below demonstrate how FastApiAdmin up to 2.2.0 allowed such behavior: CVE-2026-2975 (Custom Documentation Endpoint) could disclose internal API docs; CVE-2026-2976 (Download Endpoint) enabled reading arbitrary files via manipulated file_path; and CVE-2026-2977 (Scheduled Task API Upload) allowed unbounded uploads. These issues align with CWE-200 (Information Disclosure), CWE-284 (Improper Access Control), and CWE-434 (Unrestricted Upload of Files). In practice, attackers may exploit these remotely, especially when auth checks are missing or input is unsafely used to derive file paths or destinations. To mitigate, implement per-object authorization checks, validate and normalize all inputs, restrict file system operations to a known base directory, and protect sensitive endpoints with strong authentication and role-based access control. The fixes shown are concrete FastAPI patterns you can adopt in real code to close these gaps.
Affected Versions
FastApiAdmin <= 2.2.0
Code Fix Example
FastAPI API Security Remediation
VULNERABLE PATTERN (illustrative, insecure):
from fastapi import FastAPI, Depends, HTTPException
from fastapi.responses import FileResponse
from pathlib import Path
from fastapi import UploadFile
app = FastAPI()
BASE_DIR = Path("/app/files")
class User:
def __init__(self, username: str, is_admin: bool = False):
self.username = username
self.is_admin = is_admin
def get_current_user():
# Placeholder for real authentication
return User("alice", is_admin=False)
# Vulnerable: reset_api_docs without proper auth and unsafe docs handling
@app.post("/admin/reset_api_docs")
def reset_api_docs():
# resets internal docs without verifying privileges
return {"status": "docs reset"}
# Vulnerable: direct file download using unvalidated path
@app.get("/download/{file_path}")
def download_controller(file_path: str, user: User = Depends(get_current_user)):
target = BASE_DIR / file_path # no path traversal protection
if not target.exists():
raise HTTPException(status_code=404)
return FileResponse(target)
# Vulnerable: file upload without validation
@app.post("/upload")
async def upload_controller(file: UploadFile, dest: str = ""):
location = BASE_DIR / dest
with open(location / file.filename, "wb") as f:
content = await file.read()
f.write(content)
return {"status": "uploaded"}
# FIXED PATTERN: with per-object authorization, path validation, and restricted uploads
from fastapi import Depends, HTTPException
import uuid
import os
# Reusable admin check
def get_current_admin():
user = get_current_user()
if not user.is_admin:
raise HTTPException(status_code=403)
return user
def is_safe_path(base: Path, target: Path) -> bool:
try:
return str(target.resolve()).startswith(str(base.resolve()))
except Exception:
return False
OWNER_MAP = {
"secret/secret.txt": "admin",
"public/readme.txt": "alice",
}
@app.post("/admin/reset_api_docs_secure")
def reset_api_docs_secure(user: User = Depends(get_current_admin)):
return {"status": "docs reset"}
@app.get("/download_secure/{file_path}")
def download_controller_secure(file_path: str, user: User = Depends(get_current_user)):
target = (BASE_DIR / file_path).resolve()
if not is_safe_path(BASE_DIR, target):
raise HTTPException(status_code=403)
rel = target.relative_to(BASE_DIR).as_posix()
owner = OWNER_MAP.get(rel)
if owner and owner != user.username and not user.is_admin:
raise HTTPException(status_code=403)
if not target.exists():
raise HTTPException(status_code=404)
return FileResponse(target)
ALLOWED_EXT = {".txt", ".pdf", ".png"}
@app.post("/upload_secure")
async def upload_controller_secure(file: UploadFile, dest_dir: str = "", user: User = Depends(get_current_user)):
if file.filename == "":
raise HTTPException(status_code=400)
if Path(file.filename).suffix not in ALLOWED_EXT:
raise HTTPException(status_code=400)
target_dir = (BASE_DIR / dest_dir).resolve()
if not is_safe_path(BASE_DIR, target_dir):
raise HTTPException(status_code=400)
filename = uuid.uuid4().hex + Path(file.filename).suffix
target = target_dir / filename
with open(target, "wb") as f:
content = await file.read()
f.write(content)
return {"saved_to": str(target)}