FastAPI Attack Probe
Authorized probe of a FastAPI 0.100+ app the user owns. Follow shared probing conventions — discover base URL from env (UVICORN_PORT, PORT), pyproject.toml task definitions, Dockerfile EXPOSE, or default uvicorn 8000. Never hardcode.
FastAPI-specific attack surface
/docs and /redoc are public by default — they expose the full route inventory and request schemas.
/openapi.json is the most efficient enumeration target — fetch it once and you have every route, method, parameter type, and security requirement.
Depends/Security is opt-in per route. A single missing Depends(get_current_user) on a mutating route is "anonymous admin" by default.
- Pydantic v2 default
extra='ignore' silently drops unknown fields; combined with response models it can leak fields not declared in the request schema.
- JWT verification misconfig is endemic in FastAPI tutorials (
jwt.decode without algorithms=).
Procedure
- Authorization preflight + base URL discovery.
- Fetch the OpenAPI spec once:
GET /openapi.json (also try /api/openapi.json, /v1/openapi.json). Use it to drive the rest of the scan.
- Probe per rule table.
Rules
| ID |
Severity |
Probe |
Confirmed when |
| FA-DOC-001 |
medium |
GET /docs, GET /redoc, GET /openapi.json |
200 = docs publicly exposed (medium because it accelerates other attacks; not directly exploitable) |
| FA-AUTH-001 |
critical |
For each operation in /openapi.json that lacks a security requirement and is POST/PUT/PATCH/DELETE, send a request without auth |
2xx = Depends(get_current_user) missing |
| FA-AUTH-002 |
high |
For routes declaring security: [HTTPBearer], send Authorization: Bearer <obviously-invalid> |
2xx = dependency returns None instead of raising 401 |
| FA-JWT-001 |
critical |
Send Authorization: Bearer eyJhbGciOiJub25lIn0.<payload>. (alg=none); also send token signed with public key as secret using HS256 |
2xx = jwt.decode without algorithms= |
| FA-JWT-002 |
high |
Send token with iss=https://evil.test, aud=evil |
2xx = no audience/issuer checks |
| FA-PYD-001 |
high |
Find a PATCH/PUT route with body schema; send {...valid..., "is_admin": true, "role": "admin"} |
Updated record reflects extra field = dict/Any body OR custom assignment without model_dump(exclude_unset=True, by_alias=...) |
| FA-PYD-002 |
medium |
Same route with field types intentionally wrong (string for int) |
500 with traceback (instead of 422) = exception not handled |
| FA-CORS-001 |
high |
OPTIONS /api/* with Origin: https://evil.test + Access-Control-Request-Method: POST |
ACAO reflected with ACAC: true = allow_origins=["*"] + credentials, or origin reflection |
| FA-FILE-001 |
high |
If a FileResponse route exists (GET /files/{name}), request ?name=..%2F..%2F.env, ?name=..%2F..%2Fpyproject.toml |
Response body matches the file = path traversal |
| FA-DBG-001 |
medium |
Trigger an error via malformed JSON / bad type |
500 response with full Python traceback (file paths, line numbers) = app = FastAPI(debug=True) in this env |
| FA-RATE-001 |
medium |
10 rapid POST /login (or schema-discovered auth route) |
All 200/401 without 429 = no slowapi/fastapi-limiter |
| FA-WS-001 |
medium |
If /ws exists in OpenAPI, open WebSocket without auth headers/cookies |
Accepted + receives messages = WebSocket bypass |
Wrong vs. right
FA-AUTH-001 (missing dependency)
# ❌
@router.delete("/users/{user_id}")
async def delete_user(user_id: int):
await users.delete(user_id)
# ✅
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
current_user: User = Depends(get_current_user),
):
if current_user.id != user_id and not current_user.is_admin:
raise HTTPException(status_code=403)
await users.delete(user_id)
FA-JWT-001 (alg confusion)
# ❌
payload = jwt.decode(token, options={"verify_signature": False})
# or
payload = jwt.decode(token, SECRET) # no algorithms= → defaults vary by lib
# ✅
payload = jwt.decode(
token,
PUBLIC_KEY,
algorithms=["RS256"],
audience="my-api",
issuer="https://issuer.example.com",
)
FA-PYD-001 (extra-field bypass)
# ❌
class UserUpdate(BaseModel):
name: str | None = None
email: str | None = None
@router.patch("/users/me")
async def patch_me(body: dict, user: User = Depends(...)): # dict, not UserUpdate
for k, v in body.items():
setattr(user, k, v)
await user.save()
# ✅
class UserUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = None
email: str | None = None
@router.patch("/users/me")
async def patch_me(body: UserUpdate, user: User = Depends(...)):
for k, v in body.model_dump(exclude_unset=True).items():
setattr(user, k, v)
await user.save()
References
1---2name: fastapi-attack-probe3description: Authorized self-pentest probe targeting FastAPI-specific weaknesses. Tests /docs and /redoc auth, OpenAPI schema enumeration, Pydantic boundary bypass via extra fields, missing Depends/Security on routes, JWT alg confusion, and unsafe file responses. Use when the user asks to "pentest" their own FastAPI app.4---56# FastAPI Attack Probe78Authorized probe of a FastAPI 0.100+ app the user owns. Follow [shared probing conventions](../../../PROBING.md) — discover base URL from env (`UVICORN_PORT`, `PORT`), `pyproject.toml` task definitions, `Dockerfile EXPOSE`, or default `uvicorn` `8000`. Never hardcode.910## FastAPI-specific attack surface1112- **`/docs` and `/redoc`** are public by default — they expose the full route inventory and request schemas.13- **`/openapi.json`** is the most efficient enumeration target — fetch it once and you have every route, method, parameter type, and security requirement.14- **`Depends`/`Security`** is opt-in per route. A single missing `Depends(get_current_user)` on a mutating route is "anonymous admin" by default.15- **Pydantic v2 default `extra='ignore'`** silently drops unknown fields; combined with response models it can leak fields not declared in the request schema.16- **JWT verification** misconfig is endemic in FastAPI tutorials (`jwt.decode` without `algorithms=`).1718## Procedure19201. Authorization preflight + base URL discovery.212. Fetch the OpenAPI spec once: `GET /openapi.json` (also try `/api/openapi.json`, `/v1/openapi.json`). Use it to drive the rest of the scan.223. Probe per rule table.2324## Rules2526| ID | Severity | Probe | Confirmed when |27|----|----------|-------|----------------|28| FA-DOC-001 | medium | `GET /docs`, `GET /redoc`, `GET /openapi.json` | 200 = docs publicly exposed (medium because it accelerates other attacks; not directly exploitable) |29| FA-AUTH-001 | critical | For each operation in `/openapi.json` that lacks a `security` requirement and is `POST/PUT/PATCH/DELETE`, send a request without auth | 2xx = `Depends(get_current_user)` missing |30| FA-AUTH-002 | high | For routes declaring `security: [HTTPBearer]`, send `Authorization: Bearer <obviously-invalid>` | 2xx = dependency returns `None` instead of raising 401 |31| FA-JWT-001 | critical | Send `Authorization: Bearer eyJhbGciOiJub25lIn0.<payload>.` (alg=none); also send token signed with public key as secret using HS256 | 2xx = `jwt.decode` without `algorithms=` |32| FA-JWT-002 | high | Send token with `iss=https://evil.test`, `aud=evil` | 2xx = no `audience`/`issuer` checks |33| FA-PYD-001 | high | Find a `PATCH`/`PUT` route with body schema; send `{...valid..., "is_admin": true, "role": "admin"}` | Updated record reflects extra field = `dict`/`Any` body OR custom assignment without `model_dump(exclude_unset=True, by_alias=...)` |34| FA-PYD-002 | medium | Same route with field types intentionally wrong (string for int) | 500 with traceback (instead of 422) = exception not handled |35| FA-CORS-001 | high | `OPTIONS /api/*` with `Origin: https://evil.test` + `Access-Control-Request-Method: POST` | `ACAO` reflected with `ACAC: true` = `allow_origins=["*"]` + credentials, or origin reflection |36| FA-FILE-001 | high | If a `FileResponse` route exists (`GET /files/{name}`), request `?name=..%2F..%2F.env`, `?name=..%2F..%2Fpyproject.toml` | Response body matches the file = path traversal |37| FA-DBG-001 | medium | Trigger an error via malformed JSON / bad type | 500 response with full Python traceback (file paths, line numbers) = `app = FastAPI(debug=True)` in this env |38| FA-RATE-001 | medium | 10 rapid `POST /login` (or schema-discovered auth route) | All 200/401 without 429 = no `slowapi`/`fastapi-limiter` |39| FA-WS-001 | medium | If `/ws` exists in OpenAPI, open WebSocket without auth headers/cookies | Accepted + receives messages = WebSocket bypass |4041## Wrong vs. right4243### FA-AUTH-001 (missing dependency)4445```python46# ❌47@router.delete("/users/{user_id}")48async def delete_user(user_id: int):49 await users.delete(user_id)50```5152```python53# ✅54@router.delete("/users/{user_id}")55async def delete_user(56 user_id: int,57 current_user: User = Depends(get_current_user),58):59 if current_user.id != user_id and not current_user.is_admin:60 raise HTTPException(status_code=403)61 await users.delete(user_id)62```6364### FA-JWT-001 (alg confusion)6566```python67# ❌68payload = jwt.decode(token, options={"verify_signature": False})69# or70payload = jwt.decode(token, SECRET) # no algorithms= → defaults vary by lib71```7273```python74# ✅75payload = jwt.decode(76 token,77 PUBLIC_KEY,78 algorithms=["RS256"],79 audience="my-api",80 issuer="https://issuer.example.com",81)82```8384### FA-PYD-001 (extra-field bypass)8586```python87# ❌88class UserUpdate(BaseModel):89 name: str | None = None90 email: str | None = None9192@router.patch("/users/me")93async def patch_me(body: dict, user: User = Depends(...)): # dict, not UserUpdate94 for k, v in body.items():95 setattr(user, k, v)96 await user.save()97```9899```python100# ✅101class UserUpdate(BaseModel):102 model_config = ConfigDict(extra="forbid")103 name: str | None = None104 email: str | None = None105106@router.patch("/users/me")107async def patch_me(body: UserUpdate, user: User = Depends(...)):108 for k, v in body.model_dump(exclude_unset=True).items():109 setattr(user, k, v)110 await user.save()111```112113## References114115- FastAPI Security: https://fastapi.tiangolo.com/tutorial/security/116- OpenAPI: https://fastapi.tiangolo.com/advanced/openapi-callbacks/117- PyJWT: https://pyjwt.readthedocs.io/en/stable/usage.html