# Fastapi Security Review

**Creator:** Wasu
**Version:** 1
**Version date:** 2026-06-18
**Exported from:** Skill Hub

---

# FastAPI Security Review — Skill

## Triggers
- **"security review"** / **"review my code"** / **"audit this code"** / **"check security"**
  → ask for code input if not already provided, then run full review

---

## Step 0 — Collect input (ALWAYS do this first)

Check whether code is already present in context (pasted inline, uploaded file, or prior message).

**If no code is present**, ask:

> "กรุณาแชร์โค้ดที่ต้องการ review ครับ รับได้หลายรูปแบบ:
> 1. **Paste โค้ด** ลงในช่องแชทโดยตรง
> 2. **อัปโหลดไฟล์** .py หรือ .zip ของทั้ง project
> 3. **วาง GitHub URL** ของ repo หรือ file ที่ต้องการ"

**If code is present**, confirm scope before starting:

> "จะ review ไฟล์ทั้งหมดที่ให้มานะครับ รวมถึง:
> - [list detected files or modules]
> มีส่วนไหนที่อยากให้เน้นเป็นพิเศษไหมครับ? (เช่น auth, database, API endpoints)"

---

## Step 1 — Inventory & map the codebase

Before checking vulnerabilities, build a mental map of the project:

```
Identify:
- Entry point: where app = FastAPI() is defined
- Routers: all APIRouter instances and included routes
- Auth mechanism: OAuth2, JWT, API Key, session, or none
- Database layer: SQLAlchemy, Tortoise ORM, raw SQL, or none
- External calls: httpx, requests, boto3, etc.
- Config/secrets: .env, pydantic Settings, os.environ usage
- Middleware: CORSMiddleware, TrustedHostMiddleware, custom
- File handling: UploadFile, open(), file paths
- Background tasks: BackgroundTasks, Celery, APScheduler
```

Print a brief **Codebase Map** before findings:

```
📁 Codebase Map
├── Entry point   : main.py  (app = FastAPI())
├── Routers       : auth.py, users.py, items.py
├── Auth          : JWT (python-jose) + OAuth2PasswordBearer
├── Database      : SQLAlchemy + PostgreSQL
├── Config        : pydantic BaseSettings (.env)
├── Middleware    : CORSMiddleware
└── File upload   : UploadFile (detected in /items)
```

---

## Step 2 — Run security checks across 10 categories

For each category below, scan every relevant code section and collect findings.
Each finding must include: **severity**, **location** (file + line if known), **description**, **evidence** (code snippet), and **fix**.

---

### Category 1 — Authentication & Authorization

**Checks:**
- [ ] All sensitive endpoints have a `Depends(get_current_user)` or equivalent guard
- [ ] No endpoint accidentally missing auth dependency (especially after router refactoring)
- [ ] JWT: algorithm explicitly set to `HS256` or `RS256` — not `"none"` or unset
- [ ] JWT: `exp` claim validated; tokens not accepted after expiry
- [ ] JWT: secret key is not hardcoded, not a default value (`"secret"`, `"changeme"`)
- [ ] Password hashing uses `bcrypt` or `argon2` — not `md5`, `sha1`, or plain text
- [ ] `OAuth2PasswordBearer(tokenUrl=...)` scope properly enforced
- [ ] Role-based access: admin routes not accessible by regular users
- [ ] Password reset tokens: single-use, time-limited, stored as hash

**FastAPI-specific pitfalls:**
```python
# ❌ DANGEROUS — dependency in router but not enforced per-route
router = APIRouter(prefix="/admin")           # no dependencies= here
@router.get("/users")                          # no Depends() here either
async def get_all_users(): ...

# ✅ CORRECT
router = APIRouter(prefix="/admin", dependencies=[Depends(require_admin)])
```

---

### Category 2 — Injection Vulnerabilities

**SQL Injection:**
- [ ] No f-string or `.format()` string building in SQL queries
- [ ] All queries use ORM methods or parameterised queries (`text()` with `:param`)
- [ ] `execute(text(...))` calls always pass `params={}` separately

```python
# ❌ DANGEROUS
query = f"SELECT * FROM users WHERE email = '{email}'"
db.execute(query)

# ✅ CORRECT
db.execute(text("SELECT * FROM users WHERE email = :email"), {"email": email})
```

**Command Injection:**
- [ ] No `os.system()`, `subprocess.run(shell=True)` with user input
- [ ] `subprocess` always uses list form: `["cmd", arg1, arg2]`

**Template/Expression Injection:**
- [ ] No `eval()`, `exec()`, `compile()` on user-supplied strings
- [ ] Jinja2 templates use `autoescape=True`

---

### Category 3 — Input Validation & Pydantic Models

**Checks:**
- [ ] All request bodies use Pydantic models — no raw `request.body()` or `request.json()` without validation
- [ ] Pydantic fields use `constr`, `conint`, `EmailStr`, `HttpUrl` where appropriate
- [ ] String fields have `max_length` set (prevent oversized payloads)
- [ ] File uploads: `content_type` whitelisted, file size limited
- [ ] Path parameters validated — no directory traversal via `../`
- [ ] No `orm_mode = True` (v1) / `from_attributes = True` (v2) exposing unintended fields
- [ ] Response models defined — not returning raw DB objects

```python
# ❌ DANGEROUS — no size/type restriction
@router.post("/upload")
async def upload(file: UploadFile):
    content = await file.read()  # could be gigabytes

# ✅ CORRECT
MAX_SIZE = 5 * 1024 * 1024  # 5 MB
ALLOWED_TYPES = {"image/jpeg", "image/png", "application/pdf"}

@router.post("/upload")
async def upload(file: UploadFile):
    if file.content_type not in ALLOWED_TYPES:
        raise HTTPException(400, "File type not allowed")
    content = await file.read(MAX_SIZE + 1)
    if len(content) > MAX_SIZE:
        raise HTTPException(413, "File too large")
```

---

### Category 4 — Sensitive Data Exposure

**Checks:**
- [ ] Passwords, tokens, card numbers never returned in API responses
- [ ] Response models explicitly exclude sensitive fields (`password`, `hashed_password`, `secret`)
- [ ] No `print()` or `logger.debug()` logging sensitive values
- [ ] Error responses do not expose stack traces, SQL errors, or internal paths
- [ ] `HTTPException` detail messages are user-safe (no DB error strings)
- [ ] `.env` file in `.gitignore`; no secrets committed to repo
- [ ] `SECRET_KEY`, `DATABASE_URL` loaded from environment — not hardcoded

```python
# ❌ DANGEROUS — leaks hash in response
class UserResponse(BaseModel):
    id: int
    email: str
    hashed_password: str   # never expose this

# ✅ CORRECT
class UserResponse(BaseModel):
    id: int
    email: str
    model_config = ConfigDict(from_attributes=True)
```

---

### Category 5 — CORS & Security Headers

**Checks:**
- [ ] `CORSMiddleware` not using `allow_origins=["*"]` in production
- [ ] `allow_credentials=True` never combined with `allow_origins=["*"]` (browser blocks + security risk)
- [ ] Trusted origins list is explicit and reviewed
- [ ] Security headers added (manually or via `secure` library):
  - `X-Content-Type-Options: nosniff`
  - `X-Frame-Options: DENY`
  - `Strict-Transport-Security` (HSTS)
  - `Content-Security-Policy`
  - `Referrer-Policy: no-referrer`
- [ ] `TrustedHostMiddleware` configured if app is behind a proxy

```python
# ❌ DANGEROUS
app.add_middleware(CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True)   # browsers will reject this AND it's a security risk

# ✅ CORRECT
app.add_middleware(CORSMiddleware,
    allow_origins=["https://app.example.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST"],
    allow_headers=["Authorization", "Content-Type"])
```

---

### Category 6 — Rate Limiting & Denial of Service

**Checks:**
- [ ] Rate limiting applied to auth endpoints (`/login`, `/token`, `/register`, `/password-reset`)
- [ ] `slowapi` or equivalent middleware present
- [ ] No unbounded query results — all list endpoints use `limit`/`offset` or pagination
- [ ] File upload size limits enforced (see Category 3)
- [ ] No synchronous blocking calls inside `async def` routes (`time.sleep`, `requests.get`, heavy CPU)
- [ ] Background tasks used for long-running work, not blocking the event loop

```python
# ❌ DANGEROUS — blocks entire event loop
@router.get("/report")
async def get_report():
    time.sleep(10)          # blocks all other requests
    return generate_report()

# ✅ CORRECT — offload to thread pool
from fastapi.concurrency import run_in_threadpool
@router.get("/report")
async def get_report():
    result = await run_in_threadpool(generate_report)
    return result
```

---

### Category 7 — Dependency & Library Security

**Checks:**
- [ ] `requirements.txt` or `pyproject.toml` present with pinned versions
- [ ] No known-vulnerable packages (check against common CVEs):

| Package         | Risky version range     | Issue                              |
|-----------------|-------------------------|------------------------------------|
| `python-jose`   | < 3.3.0                 | Algorithm confusion attack         |
| `pyjwt`         | < 2.4.0                 | `none` algorithm bypass            |
| `starlette`     | < 0.27.0                | Path traversal via static files    |
| `fastapi`       | < 0.95.0                | Depends() bypass edge cases        |
| `sqlalchemy`    | < 1.4.0                 | Various injection edge cases       |
| `cryptography`  | < 41.0.0                | Multiple CVEs                      |
| `pillow`        | < 10.0.1                | Image bomb / arbitrary code        |
| `requests`      | < 2.31.0                | Proxy auth leak                    |

- [ ] Dev dependencies (`pytest`, `black`) not in production `requirements.txt`
- [ ] No `pip install` of packages from untrusted URLs or local paths

---

### Category 8 — Error Handling & Logging

**Checks:**
- [ ] Global exception handler registered — unhandled errors return 500, not stack trace
- [ ] Custom `HTTPException` handler for consistent error format
- [ ] No sensitive data in log messages (tokens, passwords, PII)
- [ ] Logs include request ID / correlation ID for traceability
- [ ] `422 Unprocessable Entity` responses do not expose internal model structure beyond necessary

```python
# ✅ CORRECT — global handler
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
    logger.error(f"Unhandled error: {exc}", exc_info=True)  # log internally
    return JSONResponse(
        status_code=500,
        content={"detail": "Internal server error"}          # safe for client
    )
```

---

### Category 9 — Configuration & Secrets Management

**Checks:**
- [ ] `pydantic_settings.BaseSettings` used for config (not raw `os.environ.get`)
- [ ] `SECRET_KEY` min 32 bytes, generated with `secrets.token_hex(32)`
- [ ] Database credentials not in source code
- [ ] Debug mode (`app = FastAPI(debug=True)`) disabled in production
- [ ] `reload=True` in `uvicorn.run()` not used in production
- [ ] Docker: secrets not passed as ENV in `Dockerfile` — use Docker secrets or `.env` at runtime
- [ ] No `.env` file committed to git

```python
# ✅ CORRECT config pattern
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    secret_key: str
    database_url: str
    debug: bool = False

    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

settings = Settings()
```

---

### Category 10 — Business Logic & API Design

**Checks:**
- [ ] IDOR (Insecure Direct Object Reference): resource ownership verified before returning/modifying
- [ ] Mass assignment: Pydantic models used for update — not `**request.dict()` directly into ORM
- [ ] Idempotency: POST endpoints protected against double-submit (idempotency key or DB unique constraint)
- [ ] Soft delete vs hard delete: deleted records not accessible via API
- [ ] Pagination: `skip`/`limit` have upper bounds (`limit: int = Query(20, le=100)`)

```python
# ❌ DANGEROUS — IDOR: any authenticated user can fetch any order
@router.get("/orders/{order_id}")
async def get_order(order_id: int, db: Session = Depends(get_db),
                    current_user = Depends(get_current_user)):
    return db.query(Order).filter(Order.id == order_id).first()

# ✅ CORRECT — ownership check
@router.get("/orders/{order_id}")
async def get_order(order_id: int, db: Session = Depends(get_db),
                    current_user = Depends(get_current_user)):
    order = db.query(Order).filter(
        Order.id == order_id,
        Order.user_id == current_user.id   # ownership enforced
    ).first()
    if not order:
        raise HTTPException(404, "Order not found")
    return order
```

---

## Step 3 — Compile findings report

After scanning all 10 categories, produce a structured report.

### Severity levels

| Severity | Label      | Meaning                                               |
|----------|------------|-------------------------------------------------------|
| 🔴 CRIT  | Critical   | Exploitable immediately, data breach or takeover risk |
| 🟠 HIGH  | High       | Significant risk, should fix before production        |
| 🟡 MED   | Medium     | Exploitable under certain conditions                  |
| 🔵 LOW   | Low        | Best practice gap, low direct risk                    |
| ⚪ INFO  | Info       | Observation, no immediate risk                        |

### Report format (print in chat)

```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔐 FastAPI Security Review Report
Generated: YYYY-MM-DD
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

📁 Codebase Map
[... map from Step 1 ...]

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 Summary
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔴 Critical  : X
🟠 High      : X
🟡 Medium    : X
🔵 Low       : X
⚪ Info      : X
─────────────────
   Total     : X findings

Overall Risk : CRITICAL / HIGH / MEDIUM / LOW
               (highest severity present)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔴 CRITICAL FINDINGS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[CRIT-01] JWT Algorithm Not Validated
Category  : Authentication & Authorization
Location  : auth/jwt.py, line 24
Description:
  The JWT decode call does not specify allowed algorithms,
  allowing an attacker to forge tokens using the "none" algorithm.

Evidence:
  ```python
  payload = jwt.decode(token, SECRET_KEY)   # ❌ no algorithms= arg
  ```

Fix:
  ```python
  payload = jwt.decode(
      token, SECRET_KEY,
      algorithms=["HS256"]   # ✅ explicit allowlist
  )
  ```

Impact    : Full authentication bypass — any user can forge any token
CVSS-like : 9.8 (Critical)

─────────────────────────────────────

[CRIT-02] ...

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🟠 HIGH FINDINGS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[HIGH-01] ...

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🟡 MEDIUM FINDINGS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[MED-01] ...

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔵 LOW / ⚪ INFO
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[LOW-01] ...

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ PASSED CHECKS (no issues found)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- Password hashing: bcrypt detected ✓
- Pydantic models on all endpoints ✓
- ...

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🛠 REMEDIATION PRIORITY
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Fix immediately (before any deployment):
  1. [CRIT-01] JWT algorithm validation
  2. [CRIT-02] ...

Fix this sprint:
  3. [HIGH-01] ...

Fix next sprint:
  5. [MED-01] ...

Backlog / best practice:
  8. [LOW-01] ...
```

---

## Step 4 — Offer follow-up actions

After the report, always offer:

> "ต้องการให้ช่วยอะไรเพิ่มเติมไหมครับ?
> 1. **แก้โค้ด** — ให้เขียนโค้ดที่แก้ไขแล้วสำหรับ finding ที่เลือก
> 2. **Export report** — บันทึก report เป็นไฟล์ .md หรือ .txt
> 3. **Deep dive** — อธิบาย finding ใดเพิ่มเติม พร้อม exploit scenario
> 4. **Re-scan** — รับโค้ดที่แก้แล้วและยืนยันว่า fix ถูกต้อง"

---

## Reviewer mindset — principles to apply throughout

- **Assume attacker perspective**: for each endpoint, ask "what happens if I send unexpected input?"
- **Check defaults**: FastAPI/Starlette defaults are often permissive — explicit is safer
- **Follow the data**: trace user input from request → validation → business logic → DB → response
- **Dependency chain**: a secure endpoint can be broken by an insecure shared dependency
- **Context matters**: a `LOW` finding in a public API may be `HIGH` in an internal admin panel
- **Don't just find — fix**: every finding must include a concrete, copy-pasteable fix

## Reference: OWASP Top 10 mapping

| OWASP 2021          | Covered in category       |
|---------------------|---------------------------|
| A01 Broken Access Control | Cat 1, Cat 10       |
| A02 Cryptographic Failures | Cat 4, Cat 9       |
| A03 Injection        | Cat 2                    |
| A04 Insecure Design  | Cat 10                   |
| A05 Security Misconfiguration | Cat 5, Cat 9    |
| A06 Vulnerable Components | Cat 7                |
| A07 Auth Failures    | Cat 1                    |
| A08 Software Integrity | Cat 7                  |
| A09 Logging Failures | Cat 8                    |
| A10 SSRF             | Cat 2 (external calls)   |
