Skip to content

Commit e3b405a

Browse files
authored
Fix 23 correctness and security issues from full-codebase review (#142)
* Fix 23 correctness and security issues from full-codebase review Security: - Enforce has_change_permission on the action endpoint (authz bypass) - Skip empty password values on edit so a blank field no longer overwrites the stored hash with hash("") - Refuse to sign/verify JWTs with an unset/empty ADMIN_SECRET_KEY Correctness: - Fix falsy-zero PK/user-id traps in auth guards and orm_save_obj across all five ORM adapters (a legitimate id of 0 no longer misroutes) - exclude now wins over list_display in serialization - Malformed date/datetime and empty-condition filters return 422, not 500 - SQLAlchemy: real PK-name fallback for non-autoincrement PKs, cast-to-text for contains/icontains, PK excluded from required, no post-commit expired attribute read - Falsy DB defaults no longer force required (Tortoise/Django/Yara); Tortoise enum options emit .value; Django choice label/value un-swapped - Reject unsupported/null export format up front; add widget_action and configuration None/exception guards (FastAPI/Flask/Django); unify Django error responses under detail; 422 on malformed sign-in body - Safe int parsing for ADMIN_QUERY_MAX_LIMIT and ADMIN_SESSION_EXPIRED_AT * Add tests for new branches and restore 100% coverage - Cover the empty ADMIN_SECRET_KEY guards in sign_in and get_user_id_from_session_id - Cover the unsupported/null export format 422 - Cover the invalid Date/DateTime deserialize 422 branches - Cover the Django malformed sign-in body 422 and the FastAPI/Flask configuration AdminApiException handling - Cover the _env_int blank/garbage fallbacks - Revert the proactive SQLAlchemy m2m falsy-id guard (not a reported finding; kept the original truthiness check to preserve behavior) * Address review observations - Add a has_action_permission hook (defaults to has_change_permission) and gate the action endpoint on it, so a read-only admin can be allowed to run a non-mutating action without granting change permission - Remove the now-unreachable text/plain export default (format is guaranteed CSV or JSON by the up-front guard) - Sweep the malformed-body 422 handling across all Django handlers via a shared _load_json_body helper (was previously only on sign_in) - Tests: action 403 without change permission
1 parent 5f07c08 commit e3b405a

20 files changed

Lines changed: 332 additions & 77 deletions

File tree

fastadmin/api/frameworks/django/app/api.py

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from dataclasses import asdict
44
from datetime import datetime, time
55
from functools import wraps
6+
from typing import Any
67
from uuid import UUID
78

89
from django.core.files.uploadedfile import UploadedFile
@@ -59,6 +60,20 @@ async def wrapped_view(*args, **kwargs):
5960
return wraps(view_func)(wrapped_view)
6061

6162

63+
def _load_json_body(request: HttpRequest, schema: type | None = None) -> Any:
64+
"""Parse the JSON request body, returning a clean 422 (not a 500) on bad input.
65+
66+
Unlike FastAPI/Flask, the Django views parse the body by hand, so malformed
67+
JSON or (when ``schema`` is given) a missing/extra field would otherwise
68+
surface as an unhandled 500. Raising AdminApiException keeps it a 422.
69+
"""
70+
try:
71+
data = json.loads(request.body)
72+
return schema(**data) if schema is not None else data
73+
except (json.JSONDecodeError, TypeError) as e:
74+
raise AdminApiException(422, detail="Invalid request body.") from e
75+
76+
6277
@csrf_exempt
6378
async def sign_in(request: HttpRequest) -> JsonResponse:
6479
"""This method is used to sign in.
@@ -70,7 +85,7 @@ async def sign_in(request: HttpRequest) -> JsonResponse:
7085
if request.method != "POST":
7186
return JsonResponse({"detail": "Method not allowed"}, status=405)
7287
try:
73-
payload = SignInInputSchema(**json.loads(request.body))
88+
payload = _load_json_body(request, SignInInputSchema)
7489
session_id = await api_service.sign_in(
7590
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
7691
payload,
@@ -120,12 +135,12 @@ async def me(request: HttpRequest) -> JsonResponse:
120135
:return: A user object.
121136
"""
122137
if request.method != "GET":
123-
return JsonResponse({"error": "Method not allowed"}, status=405)
138+
return JsonResponse({"detail": "Method not allowed"}, status=405)
124139
try:
125140
user_id = await get_user_id_from_session_id(
126141
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
127142
)
128-
if not user_id:
143+
if user_id is None:
129144
raise AdminApiException(401, "User is not authenticated.")
130145
obj = await api_service.get(
131146
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
@@ -152,7 +167,7 @@ async def list_objs(request: HttpRequest, model: str) -> JsonResponse:
152167
:return: A list of objects.
153168
"""
154169
if request.method != "GET":
155-
return JsonResponse({"error": "Method not allowed"}, status=405)
170+
return JsonResponse({"detail": "Method not allowed"}, status=405)
156171
try:
157172
search = request.GET.get("search") or None
158173
sort_by = request.GET.get("sort_by") or None
@@ -195,9 +210,9 @@ async def get(request: HttpRequest, model: str, id: UUID | int | str) -> JsonRes
195210
:return: An object.
196211
"""
197212
if request.method != "GET":
198-
return JsonResponse({"error": "Method not allowed"}, status=405)
213+
return JsonResponse({"detail": "Method not allowed"}, status=405)
199214
if not is_valid_id(id):
200-
return JsonResponse({"error": "Invalid id. It must be a UUID, an integer, or a non-empty string."}, status=422)
215+
return JsonResponse({"detail": "Invalid id. It must be a UUID, an integer, or a non-empty string."}, status=422)
201216
try:
202217
obj = await api_service.get(
203218
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
@@ -220,12 +235,12 @@ async def add(request: HttpRequest, model: str) -> JsonResponse:
220235
:return: An object.
221236
"""
222237
if request.method != "POST":
223-
return JsonResponse({"error": "Method not allowed"}, status=405)
238+
return JsonResponse({"detail": "Method not allowed"}, status=405)
224239
try:
225240
obj = await api_service.add(
226241
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
227242
model,
228-
json.loads(request.body),
243+
_load_json_body(request),
229244
request=request,
230245
)
231246
return JsonResponse(obj)
@@ -242,14 +257,14 @@ async def change_password(request: HttpRequest, id: UUID | int | str) -> JsonRes
242257
:return: An object.
243258
"""
244259
if request.method != "PATCH":
245-
return JsonResponse({"error": "Method not allowed"}, status=405)
260+
return JsonResponse({"detail": "Method not allowed"}, status=405)
246261
if not is_valid_id(id):
247-
return JsonResponse({"error": "Invalid id. It must be a UUID, an integer, or a non-empty string."}, status=422)
262+
return JsonResponse({"detail": "Invalid id. It must be a UUID, an integer, or a non-empty string."}, status=422)
248263
try:
249264
await api_service.change_password(
250265
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
251266
id,
252-
json.loads(request.body),
267+
_load_json_body(request),
253268
request=request,
254269
)
255270
return JsonResponse(id, safe=False)
@@ -268,15 +283,15 @@ async def change(request: HttpRequest, model: str, id: UUID | int | str) -> Json
268283
:return: An object.
269284
"""
270285
if request.method != "PATCH":
271-
return JsonResponse({"error": "Method not allowed"}, status=405)
286+
return JsonResponse({"detail": "Method not allowed"}, status=405)
272287
if not is_valid_id(id):
273-
return JsonResponse({"error": "Invalid id. It must be a UUID, an integer, or a non-empty string."}, status=422)
288+
return JsonResponse({"detail": "Invalid id. It must be a UUID, an integer, or a non-empty string."}, status=422)
274289
try:
275290
obj = await api_service.change(
276291
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
277292
model,
278293
id,
279-
json.loads(request.body),
294+
_load_json_body(request),
280295
request=request,
281296
)
282297
return JsonResponse(obj)
@@ -300,11 +315,11 @@ async def upload_file(
300315
"""
301316

302317
if request.method != "POST":
303-
return JsonResponse({"error": "Method not allowed"}, status=405)
318+
return JsonResponse({"detail": "Method not allowed"}, status=405)
304319
try:
305320
file: UploadedFile = request.FILES.get("file")
306321
if not file:
307-
return JsonResponse({"error": "File not found"}, status=400)
322+
return JsonResponse({"detail": "File not found"}, status=400)
308323
file_name = file.name
309324
file_content = file.read()
310325
obj_id = request.GET.get("id") or None
@@ -334,7 +349,7 @@ async def export(request: HttpRequest, model: str) -> JsonResponse | StreamingHt
334349
:return: A stream of export data.
335350
"""
336351
if request.method != "POST":
337-
return JsonResponse({"error": "Method not allowed"}, status=405)
352+
return JsonResponse({"detail": "Method not allowed"}, status=405)
338353
search = request.GET.get("search") or None
339354
sort_by = request.GET.get("sort_by") or None
340355
list_filters = parse_list_filters_from_query_params(
@@ -343,7 +358,7 @@ async def export(request: HttpRequest, model: str) -> JsonResponse | StreamingHt
343358
exclude={"search", "sort_by", "offset", "limit"},
344359
)
345360
try:
346-
payload = ExportInputSchema(**json.loads(request.body))
361+
payload = _load_json_body(request, ExportInputSchema)
347362
file_name, content_type, stream = await api_service.export(
348363
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
349364
model,
@@ -374,9 +389,9 @@ async def delete(
374389
:return: An id of object.
375390
"""
376391
if request.method != "DELETE":
377-
return JsonResponse({"error": "Method not allowed"}, status=405)
392+
return JsonResponse({"detail": "Method not allowed"}, status=405)
378393
if not is_valid_id(id):
379-
return JsonResponse({"error": "Invalid id. It must be a UUID, an integer, or a non-empty string."}, status=422)
394+
return JsonResponse({"detail": "Invalid id. It must be a UUID, an integer, or a non-empty string."}, status=422)
380395
try:
381396
deleted_id = await api_service.delete(
382397
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
@@ -404,9 +419,9 @@ async def action(
404419
:return: action result.
405420
"""
406421
if request.method != "POST":
407-
return JsonResponse({"error": "Method not allowed"}, status=405)
422+
return JsonResponse({"detail": "Method not allowed"}, status=405)
408423
try:
409-
payload = ActionInputSchema(**json.loads(request.body))
424+
payload = _load_json_body(request, ActionInputSchema)
410425
response = await api_service.action(
411426
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
412427
model,
@@ -438,17 +453,17 @@ async def widget_action(
438453
:return: widget action result.
439454
"""
440455
if request.method != "POST":
441-
return JsonResponse({"error": "Method not allowed"}, status=405)
456+
return JsonResponse({"detail": "Method not allowed"}, status=405)
442457
try:
443-
payload = WidgetActionInputSchema(**json.loads(request.body))
458+
payload = _load_json_body(request, WidgetActionInputSchema)
444459
response = await api_service.widget_action(
445460
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),
446461
model,
447462
widget_action,
448463
payload,
449464
request=request,
450465
)
451-
return JsonResponse(asdict(response))
466+
return JsonResponse(asdict(response) if response is not None else {})
452467
except AdminApiException as e:
453468
return JsonResponse({"detail": e.detail}, status=e.status_code)
454469

@@ -461,7 +476,7 @@ async def configuration(request: HttpRequest) -> JsonResponse:
461476
:return: A configuration.
462477
"""
463478
if request.method != "GET":
464-
return JsonResponse({"error": "Method not allowed"}, status=405)
479+
return JsonResponse({"detail": "Method not allowed"}, status=405)
465480

466481
obj = await api_service.get_configuration(
467482
request.COOKIES.get(settings.ADMIN_SESSION_ID_KEY, None),

fastadmin/api/frameworks/fastapi/api.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ async def widget_action(
398398
payload,
399399
request=request,
400400
)
401-
return asdict(response)
401+
return asdict(response) if response is not None else {}
402402
except AdminApiException as e:
403403
raise HTTPException(e.status_code, detail=e.detail) from None
404404

@@ -412,7 +412,10 @@ async def configuration(
412412
:params user_id: an id of user.
413413
:return: A configuration.
414414
"""
415-
return await api_service.get_configuration(
416-
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
417-
request=request,
418-
)
415+
try:
416+
return await api_service.get_configuration(
417+
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
418+
request=request,
419+
)
420+
except AdminApiException as e:
421+
raise HTTPException(e.status_code, detail=e.detail) from None

fastadmin/api/frameworks/flask/api.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ async def widget_action(
412412
payload,
413413
request=request,
414414
)
415-
return make_response(asdict(response))
415+
return make_response(asdict(response) if response is not None else {})
416416
except AdminApiException as e:
417417
http_exception = HTTPException(e.detail)
418418
http_exception.code = e.status_code
@@ -426,8 +426,13 @@ async def configuration() -> dict:
426426
:params user_id: an id of user.
427427
:return: A configuration.
428428
"""
429-
obj = await api_service.get_configuration(
430-
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
431-
request=request,
432-
)
429+
try:
430+
obj = await api_service.get_configuration(
431+
request.cookies.get(settings.ADMIN_SESSION_ID_KEY, None),
432+
request=request,
433+
)
434+
except AdminApiException as e:
435+
http_exception = HTTPException(e.detail)
436+
http_exception.code = e.status_code
437+
raise http_exception from e
433438
return asdict(obj)

fastadmin/api/helpers.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,12 @@ def sanitize_filter_key(key: str, fields: list[ModelFieldWidgetSchema]) -> tuple
8888
:param fields: A list of fields.
8989
:return: A tuple of sanitized key and condition.
9090
"""
91-
if "__" not in key:
92-
key += "__exact"
9391
field_name, _, condition = key.partition("__")
92+
# No suffix ("name") or a trailing "__" with an empty condition ("name__")
93+
# both mean an exact lookup; without this the empty condition reaches the ORM
94+
# as a broken lookup (e.g. Django ``name__``) and 500s the request.
95+
if not condition:
96+
condition = "exact"
9497
field: ModelFieldWidgetSchema | None = next((field for field in fields if field.name == field_name), None)
9598
if field and field.filter_widget_props.get("parentModel") and not field.is_m2m:
9699
field_name += "_id"

fastadmin/api/service.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ async def get_user_id_from_session_id(session_id: str | None) -> UUID | int | No
5959
if not admin_model:
6060
return None
6161

62+
# An empty/unset secret makes HS256 signatures trivially forgeable, so refuse
63+
# to validate any token rather than accept one signed with a blank key.
64+
if not settings.ADMIN_SECRET_KEY:
65+
return None
66+
6267
try:
6368
token_payload = jwt.decode(session_id, settings.ADMIN_SECRET_KEY, algorithms=["HS256"])
6469
except jwt.PyJWTError:
@@ -72,7 +77,7 @@ async def get_user_id_from_session_id(session_id: str | None) -> UUID | int | No
7277
return None
7378

7479
user_id = token_payload.get("user_id")
75-
if not user_id:
80+
if user_id is None:
7681
return None
7782

7883
if not await admin_model.get_obj(user_id):
@@ -166,6 +171,9 @@ async def sign_in(
166171
if not admin_model:
167172
raise AdminApiException(401, detail=f"{model} model is not registered.")
168173

174+
if not settings.ADMIN_SECRET_KEY:
175+
raise AdminApiException(500, detail="Server misconfiguration: ADMIN_SECRET_KEY is not set.")
176+
169177
if inspect.iscoroutinefunction(admin_model.authenticate):
170178
authenticate_fn = admin_model.authenticate
171179
else:
@@ -174,7 +182,7 @@ async def sign_in(
174182
self._bind_admin_context(admin_model, request=request, user=None)
175183
user_id = await authenticate_fn(payload.username, payload.password)
176184

177-
if not user_id or not isinstance(user_id, int | UUID):
185+
if isinstance(user_id, bool) or not isinstance(user_id, int | UUID):
178186
raise AdminApiException(401, detail="Invalid credentials.")
179187

180188
now = datetime.now(UTC)
@@ -449,6 +457,11 @@ async def export(
449457
self._bind_admin_context(admin_model, request=request, user=current_user)
450458
await self._require_permission(admin_model, "has_export_permission", current_user_id)
451459

460+
# Reject an unsupported/null format up front: otherwise get_export returns
461+
# None and the framework layer wraps None in a StreamingResponse and 500s.
462+
if payload.format not in (ExportFormat.CSV, ExportFormat.JSON):
463+
raise AdminApiException(422, detail="Unsupported export format.")
464+
452465
# validations
453466
fields = set(admin_model.get_fields_for_serialize())
454467

@@ -475,12 +488,11 @@ async def export(
475488
if not is_allowed_field_or_path(ordering_field.strip("-"), fields):
476489
raise AdminApiException(422, detail=f"Sort by {ordering_field} is not allowed")
477490

478-
content_type = "text/plain"
479-
file_name = f"{model}.txt"
491+
# payload.format is guaranteed to be CSV or JSON by the guard above.
480492
if payload.format == ExportFormat.CSV:
481493
content_type = "text/csv"
482494
file_name = f"{model}.csv"
483-
elif payload.format == ExportFormat.JSON:
495+
else:
484496
content_type = "text/plain"
485497
file_name = f"{model}.json"
486498
return (
@@ -528,12 +540,16 @@ async def action(
528540
payload: ActionInputSchema,
529541
request: Any | None = None,
530542
) -> ActionResponseSchema | None:
531-
_current_user_id, current_user = await self._get_authenticated_user(session_id)
543+
current_user_id, current_user = await self._get_authenticated_user(session_id)
532544

533545
admin_model = get_admin_or_admin_inline_model(model)
534546
if not admin_model:
535547
raise AdminApiException(404, detail=f"{model} model is not registered.")
536548
self._bind_admin_context(admin_model, request=request, user=current_user)
549+
# Actions run bulk mutations over the selected ids, so they must be gated
550+
# server-side — otherwise a read-only admin could mutate records through a
551+
# registered action. has_action_permission defaults to has_change_permission.
552+
await self._require_permission(admin_model, "has_action_permission", current_user_id)
537553

538554
if action not in admin_model.actions:
539555
raise AdminApiException(422, detail=f"{action} action is not in actions setting.")

0 commit comments

Comments
 (0)