fastadmin 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {fastadmin-0.5.0 → fastadmin-0.6.0}/PKG-INFO +1 -1
  2. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/django/app/api.py +7 -1
  3. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/fastapi/api.py +7 -1
  4. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/fastapi/app.py +3 -1
  5. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/flask/api.py +7 -1
  6. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/helpers.py +2 -9
  7. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/service.py +78 -23
  8. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/base.py +47 -6
  9. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/orms/ponyorm.py +39 -30
  10. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/orms/sqlalchemy.py +32 -12
  11. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/settings.py +13 -1
  12. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/index.min.js +115 -115
  13. {fastadmin-0.5.0 → fastadmin-0.6.0}/pyproject.toml +1 -1
  14. {fastadmin-0.5.0 → fastadmin-0.6.0}/LICENSE.md +0 -0
  15. {fastadmin-0.5.0 → fastadmin-0.6.0}/README.md +0 -0
  16. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/__init__.py +0 -0
  17. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/__init__.py +0 -0
  18. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/exceptions.py +0 -0
  19. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/__init__.py +0 -0
  20. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/django/__init__.py +0 -0
  21. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/django/app/__init__.py +0 -0
  22. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/django/app/urls.py +0 -0
  23. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/django/app/views.py +0 -0
  24. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/fastapi/__init__.py +0 -0
  25. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/fastapi/views.py +0 -0
  26. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/flask/__init__.py +0 -0
  27. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/flask/app.py +0 -0
  28. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/frameworks/flask/views.py +0 -0
  29. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/api/schemas.py +0 -0
  30. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/__init__.py +0 -0
  31. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/decorators.py +0 -0
  32. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/helpers.py +0 -0
  33. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/orms/__init__.py +0 -0
  34. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/orms/django.py +0 -0
  35. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/orms/tortoise.py +0 -0
  36. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/models/schemas.py +0 -0
  37. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/assets/worker-6Z7niv9l.js +0 -0
  38. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/assets/worker-C151k0-L.js +0 -0
  39. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/assets/worker-C9KPOOKD.js +0 -0
  40. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/assets/worker-D1fCgYB7.js +0 -0
  41. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/assets/worker-D3TWcJOI.js +0 -0
  42. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/assets/worker-DRNcaZ-V.js +0 -0
  43. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/assets/worker-MF2p-l5_.js +0 -0
  44. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/images/favicon.png +0 -0
  45. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/images/header-logo.svg +0 -0
  46. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/images/sign-in-logo.svg +0 -0
  47. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/index.html +0 -0
  48. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/static/index.min.css +0 -0
  49. {fastadmin-0.5.0 → fastadmin-0.6.0}/fastadmin/templates/index.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastadmin
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: FastAdmin is an easy-to-use Admin Dashboard App for FastAPI/Flask/Django inspired by Django Admin.
5
5
  Keywords: admin,dashboard,fastapi,flask,django,tortoise-orm,sqlalchemy,pony,admin-dashboard,django-admin
6
6
  Author: Seva D
@@ -78,7 +78,13 @@ async def sign_in(request: HttpRequest) -> JsonResponse:
78
78
  )
79
79
 
80
80
  response = JsonResponse({})
81
- response.set_cookie(settings.ADMIN_SESSION_ID_KEY, value=session_id, httponly=True)
81
+ response.set_cookie(
82
+ settings.ADMIN_SESSION_ID_KEY,
83
+ value=session_id,
84
+ httponly=True,
85
+ secure=settings.ADMIN_SESSION_COOKIE_SECURE,
86
+ samesite=settings.ADMIN_SESSION_COOKIE_SAMESITE.capitalize(),
87
+ )
82
88
  return response
83
89
 
84
90
  except AdminApiException as e:
@@ -49,7 +49,13 @@ async def sign_in(
49
49
  request=request,
50
50
  )
51
51
 
52
- response.set_cookie(settings.ADMIN_SESSION_ID_KEY, value=session_id, httponly=True)
52
+ response.set_cookie(
53
+ settings.ADMIN_SESSION_ID_KEY,
54
+ value=session_id,
55
+ httponly=True,
56
+ secure=settings.ADMIN_SESSION_COOKIE_SECURE,
57
+ samesite=settings.ADMIN_SESSION_COOKIE_SAMESITE, # ty: ignore[invalid-argument-type]
58
+ )
53
59
  except AdminApiException as e:
54
60
  raise HTTPException(e.status_code, detail=e.detail) from None
55
61
 
@@ -25,7 +25,9 @@ app.include_router(views_router)
25
25
 
26
26
  @app.exception_handler(Exception)
27
27
  async def exception_handler(_: Request, exc: Exception):
28
+ # Log the real error server-side but never leak internals to the client.
29
+ logger.error("Unhandled admin error: %s", exc)
28
30
  return JSONResponse(
29
31
  status_code=500,
30
- content={"exception": str(exc)},
32
+ content={"exception": "Internal server error."},
31
33
  )
@@ -42,7 +42,13 @@ async def sign_in() -> Response:
42
42
  request=request,
43
43
  )
44
44
  response = make_response({})
45
- response.set_cookie(settings.ADMIN_SESSION_ID_KEY, value=session_id, httponly=True)
45
+ response.set_cookie(
46
+ settings.ADMIN_SESSION_ID_KEY,
47
+ value=session_id,
48
+ httponly=True,
49
+ secure=settings.ADMIN_SESSION_COOKIE_SECURE,
50
+ samesite=settings.ADMIN_SESSION_COOKIE_SAMESITE.capitalize(),
51
+ )
46
52
  return response
47
53
  except AdminApiException as e:
48
54
  http_exception = HTTPException(e.detail)
@@ -84,15 +84,8 @@ def is_valid_id(id: UUID | int | str) -> bool:
84
84
  :return: True if id is a valid id, False otherwise.
85
85
  """
86
86
  if isinstance(id, str):
87
- if not id:
88
- return False
89
- if is_valid_uuid(id):
90
- return True
91
- try:
92
- int(id)
93
- return True
94
- except ValueError:
95
- return True # string PK (e.g. non-numeric)
87
+ # Any non-empty string is a valid PK (UUID, numeric, or opaque string PK).
88
+ return bool(id)
96
89
  if is_valid_uuid(str(id)):
97
90
  return True
98
91
  try:
@@ -75,13 +75,47 @@ async def get_user_id_from_session_id(session_id: str | None) -> UUID | int | No
75
75
  if not user_id:
76
76
  return None
77
77
 
78
- if not user_id or not await admin_model.get_obj(user_id):
78
+ if not await admin_model.get_obj(user_id):
79
79
  return None
80
80
 
81
81
  return user_id
82
82
 
83
83
 
84
+ # Filter lookups a request may use. Anything else (e.g. regex, startswith, or a
85
+ # relation-spanning "groups__name__icontains") is rejected so it cannot become a
86
+ # side-channel oracle over columns the admin never displays.
87
+ ALLOWED_FILTER_CONDITIONS = frozenset({"exact", "in", "lt", "lte", "gt", "gte", "contains", "icontains"})
88
+
89
+
84
90
  class ApiService:
91
+ @staticmethod
92
+ def _clamp_query_limits(query_params: ListQuerySchema) -> None:
93
+ """Bound offset/limit so a crafted request cannot dump/DoS a table."""
94
+ if query_params.offset is None or query_params.offset < 0:
95
+ query_params.offset = 0
96
+ max_limit = settings.ADMIN_QUERY_MAX_LIMIT
97
+ if query_params.limit is None or query_params.limit < 0 or query_params.limit > max_limit:
98
+ query_params.limit = max_limit
99
+
100
+ @staticmethod
101
+ def _validate_filters(admin_model: Any, filters: dict, exclude_filter_fields: tuple, fields: set) -> None:
102
+ """Validate filter keys against an allowlist and permitted lookups.
103
+
104
+ The filterable field set is ``list_filter`` when the admin defines it,
105
+ otherwise the serialized field set; the lookup suffix must be one of
106
+ ALLOWED_FILTER_CONDITIONS.
107
+ """
108
+ list_filter = getattr(admin_model, "list_filter", ())
109
+ allowlist = set(list_filter) if list_filter else fields
110
+ for k in filters:
111
+ if k in exclude_filter_fields:
112
+ continue
113
+ field, _, condition = k.partition("__")
114
+ if condition and condition not in ALLOWED_FILTER_CONDITIONS:
115
+ raise AdminApiException(422, detail=f"Filter by {k} is not allowed")
116
+ if field not in allowlist:
117
+ raise AdminApiException(422, detail=f"Filter by {k} is not allowed")
118
+
85
119
  @staticmethod
86
120
  def _bind_admin_context(
87
121
  admin_model: ModelAdmin | InlineModelAdmin | Any, request: Any | None, user: Any | None
@@ -102,6 +136,19 @@ class ApiService:
102
136
  )
103
137
  return current_user_id, current_user
104
138
 
139
+ @staticmethod
140
+ async def _require_permission(admin_model: Any, permission: str, user_id: UUID | int | None) -> None:
141
+ """Enforce a model-admin permission hook server-side.
142
+
143
+ The has_*_permission hooks gate the UI, but every mutating endpoint must
144
+ also enforce them so a crafted request cannot bypass a disabled action.
145
+ """
146
+ check = getattr(admin_model, permission, None)
147
+ if check is None:
148
+ return
149
+ if not await check(user_id=user_id):
150
+ raise AdminApiException(403, detail="You do not have permission to perform this action.")
151
+
105
152
  async def sign_in(
106
153
  self,
107
154
  session_id: str | None,
@@ -167,6 +214,7 @@ class ApiService:
167
214
  offset=offset,
168
215
  limit=limit,
169
216
  )
217
+ self._clamp_query_limits(query_params)
170
218
 
171
219
  admin_model = get_admin_or_admin_inline_model(model)
172
220
  if not admin_model:
@@ -184,12 +232,7 @@ class ApiService:
184
232
  exclude_filter_fields = ("search", "sort_by", "offset", "limit")
185
233
  query_filters: dict[tuple[str, str], bool | str | None | list] | None = None
186
234
  if query_params.filters:
187
- for k in query_params.filters:
188
- if k in exclude_filter_fields:
189
- continue
190
- field = k.split("__", 1)[0]
191
- if field not in fields:
192
- raise AdminApiException(422, detail=f"Filter by {k} is not allowed")
235
+ self._validate_filters(admin_model, query_params.filters, exclude_filter_fields, fields)
193
236
  query_filters = {
194
237
  sanitize_filter_key(k, admin_model.get_model_fields_with_widget_types()): sanitize_filter_value(v)
195
238
  for k, v in query_params.filters.items()
@@ -235,7 +278,7 @@ class ApiService:
235
278
  obj = await admin_model.get_obj(id)
236
279
  except Exception as e:
237
280
  logger.error("Error getting %s %s: %s", model, id, e)
238
- raise AdminApiException(500, detail=f"Error getting {model} {id}: {e}") from e
281
+ raise AdminApiException(500, detail=f"Error getting {model}.") from e
239
282
  if not obj:
240
283
  raise AdminApiException(404, detail=f"{model} not found.")
241
284
  return obj
@@ -247,17 +290,20 @@ class ApiService:
247
290
  payload: dict,
248
291
  request: Any | None = None,
249
292
  ) -> dict:
250
- _current_user_id, current_user = await self._get_authenticated_user(session_id)
293
+ current_user_id, current_user = await self._get_authenticated_user(session_id)
251
294
 
252
295
  admin_model = get_admin_or_admin_inline_model(model)
253
296
  if not admin_model:
254
297
  raise AdminApiException(404, detail=f"{model} model is not registered.")
255
298
  self._bind_admin_context(admin_model, request=request, user=current_user)
299
+ await self._require_permission(admin_model, "has_add_permission", current_user_id)
256
300
  try:
257
301
  return await admin_model.save_model(None, payload) # ty: ignore[invalid-return-type]
302
+ except AdminApiException:
303
+ raise
258
304
  except Exception as e:
259
305
  logger.error("Error adding %s: %s", model, e)
260
- raise AdminApiException(500, detail=f"Error adding {model}: {e}") from e
306
+ raise AdminApiException(500, detail=f"Error adding {model}.") from e
261
307
 
262
308
  async def change_password(
263
309
  self,
@@ -266,13 +312,19 @@ class ApiService:
266
312
  payload: dict,
267
313
  request: Any | None = None,
268
314
  ) -> None:
269
- _current_user_id, current_user = await self._get_authenticated_user(session_id)
315
+ current_user_id, current_user = await self._get_authenticated_user(session_id)
270
316
 
271
317
  admin_model = get_admin_model(settings.ADMIN_USER_MODEL)
272
318
  if not admin_model:
273
319
  raise AdminApiException(404, detail=f"{settings.ADMIN_USER_MODEL} model is not registered.")
274
320
  self._bind_admin_context(admin_model, request=request, user=current_user)
275
321
 
322
+ # Users may change their own password; changing anyone else's requires the
323
+ # user-model change permission. Without this a signed-in user could reset a
324
+ # superuser's password (account takeover).
325
+ if str(id) != str(current_user_id):
326
+ await self._require_permission(admin_model, "has_change_permission", current_user_id)
327
+
276
328
  payload = ChangePasswordInputSchema(**payload)
277
329
  if payload.password != payload.confirm_password:
278
330
  raise AdminApiException(422, detail="Passwords do not match")
@@ -285,7 +337,8 @@ class ApiService:
285
337
  try:
286
338
  user = await admin_model.get_obj(id)
287
339
  except Exception as e:
288
- raise AdminApiException(500, detail=f"Error getting {settings.ADMIN_USER_MODEL} {id}: {e}") from e
340
+ logger.error("Error getting %s %s: %s", settings.ADMIN_USER_MODEL, id, e)
341
+ raise AdminApiException(500, detail=f"Error getting {settings.ADMIN_USER_MODEL}.") from e
289
342
  if not user:
290
343
  raise AdminApiException(404, detail=f"{settings.ADMIN_USER_MODEL} not found.")
291
344
 
@@ -306,18 +359,21 @@ class ApiService:
306
359
  payload: dict,
307
360
  request: Any | None = None,
308
361
  ) -> dict:
309
- _current_user_id, current_user = await self._get_authenticated_user(session_id)
362
+ current_user_id, current_user = await self._get_authenticated_user(session_id)
310
363
 
311
364
  admin_model = get_admin_or_admin_inline_model(model)
312
365
  if not admin_model:
313
366
  raise AdminApiException(404, detail=f"{model} model is not registered.")
314
367
  self._bind_admin_context(admin_model, request=request, user=current_user)
368
+ await self._require_permission(admin_model, "has_change_permission", current_user_id)
315
369
 
316
370
  try:
317
371
  obj = await admin_model.save_model(id, payload)
372
+ except AdminApiException:
373
+ raise
318
374
  except Exception as e:
319
375
  logger.error("Error changing %s %s: %s", model, id, e)
320
- raise AdminApiException(500, detail=f"Error changing {model} {id}: {e}") from e
376
+ raise AdminApiException(500, detail=f"Error changing {model}.") from e
321
377
  if not obj:
322
378
  raise AdminApiException(404, detail=f"{model} not found.")
323
379
  return obj
@@ -358,7 +414,8 @@ class ApiService:
358
414
  except AdminApiException:
359
415
  raise
360
416
  except Exception as e:
361
- raise AdminApiException(500, detail=f"Error uploading file for {model}: {e}") from e
417
+ logger.error("Error uploading file for %s: %s", model, e)
418
+ raise AdminApiException(500, detail=f"Error uploading file for {model}.") from e
362
419
 
363
420
  async def export(
364
421
  self,
@@ -370,7 +427,7 @@ class ApiService:
370
427
  filters: dict | None = None,
371
428
  request: Any | None = None,
372
429
  ) -> tuple[str, str, StringIO | BytesIO | None]:
373
- _current_user_id, current_user = await self._get_authenticated_user(session_id)
430
+ current_user_id, current_user = await self._get_authenticated_user(session_id)
374
431
 
375
432
  query_params = ListQuerySchema(
376
433
  search=search,
@@ -379,11 +436,13 @@ class ApiService:
379
436
  offset=payload.offset,
380
437
  limit=payload.limit,
381
438
  )
439
+ self._clamp_query_limits(query_params)
382
440
 
383
441
  admin_model = get_admin_or_admin_inline_model(model)
384
442
  if not admin_model:
385
443
  raise AdminApiException(404, detail=f"{model} model is not registered.")
386
444
  self._bind_admin_context(admin_model, request=request, user=current_user)
445
+ await self._require_permission(admin_model, "has_export_permission", current_user_id)
387
446
 
388
447
  # validations
389
448
  fields = set(admin_model.get_fields_for_serialize())
@@ -396,12 +455,7 @@ class ApiService:
396
455
  exclude_filter_fields = ("search", "sort_by", "offset", "limit")
397
456
  query_filters: dict[tuple[str, str], bool | str | None | list] | None = None
398
457
  if query_params.filters:
399
- for k in query_params.filters:
400
- if k in exclude_filter_fields:
401
- continue
402
- field = k.split("__", 1)[0]
403
- if field not in fields:
404
- raise AdminApiException(422, detail=f"Filter by {k} is not allowed")
458
+ self._validate_filters(admin_model, query_params.filters, exclude_filter_fields, fields)
405
459
  query_filters = {
406
460
  sanitize_filter_key(k, admin_model.get_model_fields_with_widget_types()): sanitize_filter_value(v)
407
461
  for k, v in query_params.filters.items()
@@ -450,6 +504,7 @@ class ApiService:
450
504
  if not admin_model:
451
505
  raise AdminApiException(404, detail=f"{model} model is not registered.")
452
506
  self._bind_admin_context(admin_model, request=request, user=current_user)
507
+ await self._require_permission(admin_model, "has_delete_permission", current_user_id)
453
508
 
454
509
  if str(current_user_id) == str(id) and model == settings.ADMIN_USER_MODEL:
455
510
  raise AdminApiException(403, detail="You cannot delete yourself.")
@@ -457,7 +512,7 @@ class ApiService:
457
512
  await admin_model.delete_model(id)
458
513
  except Exception as e:
459
514
  logger.error("Error deleting %s %s: %s", model, id, e)
460
- raise AdminApiException(500, detail=f"Error deleting {model} {id}: {e}") from e
515
+ raise AdminApiException(500, detail=f"Error deleting {model}.") from e
461
516
  return id
462
517
 
463
518
  async def action(
@@ -16,6 +16,21 @@ from fastadmin.models.schemas import ModelFieldWidgetSchema, WidgetType
16
16
 
17
17
  Model = Any
18
18
 
19
+ # Leading characters a spreadsheet interprets as the start of a formula.
20
+ _CSV_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
21
+
22
+
23
+ def _neutralize_csv_value(value: Any) -> Any:
24
+ """Prevent CSV formula injection in exported data.
25
+
26
+ A cell like ``=HYPERLINK(...)`` or ``=cmd|...`` is executed by Excel/Sheets
27
+ when the export is opened. Prefix any string starting with a formula trigger
28
+ with a single quote so it is treated as text; non-strings pass through.
29
+ """
30
+ if isinstance(value, str) and value and value[0] in _CSV_FORMULA_PREFIXES:
31
+ return "'" + value
32
+ return value
33
+
19
34
 
20
35
  class BaseModelAdmin:
21
36
  """Base class for model admin"""
@@ -358,6 +373,24 @@ class BaseModelAdmin:
358
373
  fields_for_serialize |= set(self.list_display)
359
374
  return fields_for_serialize
360
375
 
376
+ def get_writable_field_names(self) -> set[str]:
377
+ """Model field names the admin form is allowed to write.
378
+
379
+ Enforced server-side in :meth:`save_model` so a crafted payload cannot
380
+ set fields hidden by ``fields``/``exclude`` or marked ``readonly_fields``
381
+ (e.g. ``is_superuser``). ``list_display``-only computed columns are not
382
+ included since they are not real model fields.
383
+
384
+ :return: A set of writable model field names.
385
+ """
386
+ writable = {field.name for field in self.get_model_fields_with_widget_types()}
387
+ if self.fields:
388
+ writable &= set(self.fields)
389
+ if self.exclude:
390
+ writable -= set(self.exclude)
391
+ writable -= set(self.readonly_fields)
392
+ return writable
393
+
361
394
  def resolve_sort_by(self, sort_by: str) -> str:
362
395
  """Resolve sort_by to the actual ORM ordering expression.
363
396
 
@@ -523,18 +556,19 @@ class BaseModelAdmin:
523
556
  """
524
557
  fields = self.get_model_fields_with_widget_types(with_m2m=False)
525
558
  m2m_fields = self.get_model_fields_with_widget_types(with_m2m=True)
559
+ writable = self.get_writable_field_names()
526
560
 
527
561
  fields_payload = {
528
562
  field.column_name: self.deserialize_value(field, payload[field.name])
529
563
  for field in fields
530
- if field.name in payload
564
+ if field.name in payload and field.name in writable
531
565
  }
532
566
  obj = await self.orm_save_obj(id, fields_payload)
533
567
  if not obj:
534
568
  return None
535
569
 
536
570
  for m2m_field in m2m_fields:
537
- if m2m_field.name in payload:
571
+ if m2m_field.name in payload and m2m_field.name in writable:
538
572
  await self.orm_save_m2m_ids(obj, m2m_field.column_name, payload[m2m_field.name])
539
573
 
540
574
  return await self._serialize_obj_after_save(obj)
@@ -630,7 +664,7 @@ class BaseModelAdmin:
630
664
  writer.writeheader()
631
665
  for obj in objs:
632
666
  obj_dict = await self.serialize_obj(obj, list_view=True)
633
- obj_dict = {k: v for k, v in obj_dict.items() if k in export_fields}
667
+ obj_dict = {k: _neutralize_csv_value(v) for k, v in obj_dict.items() if k in export_fields}
634
668
  writer.writerow(obj_dict)
635
669
  output.seek(0)
636
670
  return output
@@ -643,8 +677,13 @@ class BaseModelAdmin:
643
677
  except TypeError:
644
678
  return str(o)
645
679
 
680
+ # Emit the same column set as CSV so the two formats are consistent.
681
+ rows = []
682
+ for obj in objs:
683
+ obj_dict = await self.serialize_obj(obj, list_view=True)
684
+ rows.append({k: v for k, v in obj_dict.items() if k in export_fields})
646
685
  output = StringIO()
647
- json.dump([await self.serialize_obj(obj, list_view=True) for obj in objs], output, cls=JSONEncoder)
686
+ json.dump(rows, output, cls=JSONEncoder)
648
687
  output.seek(0)
649
688
  return output
650
689
  case _:
@@ -761,8 +800,10 @@ class ModelAdmin(BaseModelAdmin):
761
800
  obj = await super().save_model(id, payload)
762
801
  fields = self.get_model_fields_with_widget_types(with_m2m=False)
763
802
  password_fields = [field.name for field in fields if field.form_widget_type == WidgetType.PasswordInput]
764
- if obj and id is None and password_fields:
765
- # save hashed password for create
803
+ if obj and password_fields:
804
+ # Hash the submitted password via change_password on both create and
805
+ # edit. On edit the raw value was written by the base save_model, so
806
+ # hashing here overwrites it — the column never keeps a plaintext value.
766
807
  pk_name = self.get_model_pk_name(self.model_cls)
767
808
  pk = obj[pk_name]
768
809
  password_values = [payload[field] for field in password_fields if field in payload]
@@ -12,6 +12,32 @@ from fastadmin.models.schemas import ModelFieldWidgetSchema, WidgetType
12
12
  from fastadmin.settings import settings
13
13
 
14
14
 
15
+ def _pony_filter_expr(condition: str, field: str) -> str:
16
+ """Build a Pony filter string that references the value via the local `fval`.
17
+
18
+ The caller binds ``fval`` (and ``fval.lower()`` for icontains) as a local so
19
+ Pony resolves it as a query parameter. ``field`` is a validated/allowlisted
20
+ path, so interpolating it is safe; the user value is never interpolated.
21
+ """
22
+ match condition:
23
+ case "in":
24
+ return f"m.{field} in fval"
25
+ case "lte":
26
+ return f"m.{field} <= fval"
27
+ case "gte":
28
+ return f"m.{field} >= fval"
29
+ case "lt":
30
+ return f"m.{field} < fval"
31
+ case "gt":
32
+ return f"m.{field} > fval"
33
+ case "contains":
34
+ return f"fval in m.{field}"
35
+ case "icontains":
36
+ return f"fval.lower() in m.{field}.lower()"
37
+ case _: # exact
38
+ return f"m.{field} == fval"
39
+
40
+
15
41
  class PonyORMMixin:
16
42
  @staticmethod
17
43
  def get_model_pk_name(orm_model_cls: Any) -> str:
@@ -235,42 +261,25 @@ class PonyORMMixin:
235
261
  model_pk_name = self.get_model_pk_name(self.model_cls)
236
262
  if field.endswith(f"_{model_pk_name}"):
237
263
  field = field.replace(f"_{model_pk_name}", f".{model_pk_name}")
238
- if condition == "in":
239
- value_set = set(value)
240
- qs = qs.filter(
241
- lambda m, f=field, vs=value_set: getattr(m, f) in vs,
242
- )
243
- continue
244
- pony_condition = "=="
245
- match condition:
246
- case "lte":
247
- pony_condition = ">="
248
- case "gte":
249
- pony_condition = "<="
250
- case "lt":
251
- pony_condition = ">"
252
- case "gt":
253
- pony_condition = "<"
254
- case "exact":
255
- pony_condition = "=="
256
- case "contains":
257
- pony_condition = "in"
258
- case "icontains":
259
- # Pony string filter: value.lower() in m.field.lower() for case-insensitive
260
- filter_expr = f'"{value.lower()}" in m.{field}.lower()'
261
- qs = qs.filter(filter_expr)
262
- continue
263
- filter_expr = f""""{value}" {pony_condition} m.{field}"""
264
- qs = qs.filter(filter_expr)
264
+ # Bind the user-supplied value as a local (`fval`) so Pony
265
+ # resolves it as a query parameter. Only the field path — built
266
+ # from the admin's validated/allowlisted field names — is
267
+ # interpolated; the value is never placed into the filter string
268
+ # (Pony evaluates filter strings as Python expressions).
269
+ fval = set(value) if condition == "in" else value # noqa: F841 (referenced by Pony filter string)
270
+ expr = _pony_filter_expr(condition, field)
271
+ qs = qs.filter(expr)
265
272
 
266
273
  search_fields = list(self.search_fields)
267
274
  if search and search_fields:
268
275
  ids = []
276
+ # Bind the user-supplied search term as a local so Pony resolves it
277
+ # as a parameter. Only the field path (from the admin's trusted
278
+ # search_fields config) is interpolated — never the search value.
279
+ search_term = search.lower() # noqa: F841 (referenced by Pony filter string)
269
280
  for search_field in search_fields:
270
281
  pony_search_field = search_field.replace("__", ".")
271
- # Pony string filter for case-insensitive search
272
- filter_expr = f'"{search.lower()}" in m.{pony_search_field}.lower()'
273
- qs_ids = qs.filter(filter_expr)
282
+ qs_ids = qs.filter(f"search_term in m.{pony_search_field}.lower()")
274
283
  objs = list(qs_ids)
275
284
  ids += [o.id for o in objs]
276
285
  qs = qs.filter(lambda m: m.id in set(ids))
@@ -2,7 +2,7 @@ import contextlib
2
2
  from typing import Any
3
3
  from uuid import UUID
4
4
 
5
- from sqlalchemy import BIGINT, Integer, and_, func, inspect, or_, select, text
5
+ from sqlalchemy import BIGINT, Integer, and_, func, inspect, or_, select
6
6
  from sqlalchemy.orm import selectinload
7
7
 
8
8
  from fastadmin.models.base import InlineModelAdmin, ModelAdmin
@@ -11,6 +11,18 @@ from fastadmin.models.schemas import ModelFieldWidgetSchema, WidgetType
11
11
  from fastadmin.settings import settings
12
12
 
13
13
 
14
+ def _escape_like(value: Any) -> Any:
15
+ """Escape LIKE/ILIKE wildcards so user input matches literally.
16
+
17
+ Without this, ``%`` and ``_`` in a filter/search value act as wildcards
18
+ (matching anything / any single char), letting a caller widen a match or
19
+ force an expensive scan. Used with ``escape="\\"`` on the like/ilike call.
20
+ """
21
+ if not isinstance(value, str):
22
+ return value
23
+ return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
24
+
25
+
14
26
  class SqlAlchemyMixin:
15
27
  def _build_search_condition(self, field_path: str, search: str) -> Any | None:
16
28
  """Build SQLAlchemy search condition for simple and nested fields.
@@ -25,7 +37,7 @@ class SqlAlchemyMixin:
25
37
  return None
26
38
 
27
39
  if len(parts) == 1:
28
- return attr.ilike(f"%{search}%")
40
+ return attr.ilike(f"%{_escape_like(search)}%", escape="\\")
29
41
 
30
42
  rel_property = getattr(attr, "property", None)
31
43
  if rel_property is None:
@@ -303,11 +315,15 @@ class SqlAlchemyMixin:
303
315
  :return: A tuple of list of objects and total count.
304
316
  """
305
317
 
306
- def convert_sort_by(sort_by: str) -> str:
307
- sort_by = self._resolve_ordering_field(sort_by)
308
- if sort_by.startswith("-"):
309
- return sort_by[1:] + " desc"
310
- return sort_by
318
+ def order_column(ordering_field: str):
319
+ # Build an ordering expression from a real Column instead of raw
320
+ # text() SQL, so a sort key can never reach the SQL string.
321
+ resolved = self._resolve_ordering_field(ordering_field)
322
+ descending = resolved.startswith("-")
323
+ column = getattr(self.model_cls, resolved.lstrip("-"), None)
324
+ if column is None:
325
+ return None
326
+ return column.desc() if descending else column.asc()
311
327
 
312
328
  sessionmaker = self.get_sessionmaker()
313
329
  async with sessionmaker() as session:
@@ -340,9 +356,9 @@ class SqlAlchemyMixin:
340
356
  value = [int(x) for x in value] # ty: ignore[not-iterable]
341
357
  q.append(model_field.in_(value))
342
358
  case "contains":
343
- q.append(model_field.like(f"%{value}%"))
359
+ q.append(model_field.like(f"%{_escape_like(value)}%", escape="\\"))
344
360
  case "icontains":
345
- q.append(model_field.ilike(f"%{value}%"))
361
+ q.append(model_field.ilike(f"%{_escape_like(value)}%", escape="\\"))
346
362
  qs = qs.where(and_(*q))
347
363
 
348
364
  search_fields = list(self.search_fields)
@@ -356,11 +372,15 @@ class SqlAlchemyMixin:
356
372
  return [], 0
357
373
  qs = qs.where(or_(*q))
358
374
 
375
+ order_columns = []
359
376
  if sort_by:
360
- qs = qs.order_by(text(convert_sort_by(sort_by)))
377
+ column = order_column(sort_by)
378
+ if column is not None:
379
+ order_columns.append(column)
361
380
  elif self.ordering:
362
- sort_by_text = ", ".join([convert_sort_by(f) for f in self.ordering])
363
- qs = qs.order_by(text(sort_by_text))
381
+ order_columns = [c for c in (order_column(f) for f in self.ordering) if c is not None]
382
+ if order_columns:
383
+ qs = qs.order_by(*order_columns)
364
384
 
365
385
  count_stmt = select(func.count()).select_from(qs.subquery())
366
386
  total = (await session.execute(count_stmt)).scalar_one()
@@ -29,7 +29,19 @@ class Settings:
29
29
  ADMIN_SESSION_ID_KEY: str = os.getenv("ADMIN_SESSION_ID_KEY", "admin_session_id")
30
30
 
31
31
  # This value is the expired_at period (in sec) for session id.
32
- ADMIN_SESSION_EXPIRED_AT: int = os.getenv("ADMIN_SESSION_EXPIRED_AT", 144000) # in sec
32
+ ADMIN_SESSION_EXPIRED_AT: int = int(os.getenv("ADMIN_SESSION_EXPIRED_AT", 144000)) # in sec
33
+
34
+ # Set the Secure flag on the session cookie so it is only sent over HTTPS.
35
+ # Enabled by default; set ADMIN_SESSION_COOKIE_SECURE=false for local HTTP dev.
36
+ ADMIN_SESSION_COOKIE_SECURE: bool = os.getenv("ADMIN_SESSION_COOKIE_SECURE", "true").lower() != "false"
37
+
38
+ # SameSite policy for the session cookie ("lax", "strict" or "none").
39
+ # "lax" blocks the cross-site POST/PATCH/DELETE requests behind CSRF.
40
+ ADMIN_SESSION_COOKIE_SAMESITE: str = os.getenv("ADMIN_SESSION_COOKIE_SAMESITE", "lax")
41
+
42
+ # Hard upper bound on the number of rows a single list/export request may
43
+ # return. Caps memory/CPU use from a crafted limit=100000000 request.
44
+ ADMIN_QUERY_MAX_LIMIT: int = int(os.getenv("ADMIN_QUERY_MAX_LIMIT", 1000))
33
45
 
34
46
  # This value is the date format for JS widgets.
35
47
  ADMIN_DATE_FORMAT: str = os.getenv("ADMIN_DATE_FORMAT", "YYYY-MM-DD")