Fix 23 correctness and security issues from full-codebase review - #142
Conversation
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
vsdudakov
left a comment
There was a problem hiding this comment.
Fable 5 review
Reviewed the full diff against main (12 files, +161/−69). Verdict: looks good to merge. The changes are localized, well-commented, and cross-consistent across the five ORM adapters. CI aside, locally the whole suite (2005 tests) passes and ruff + ty are clean.
Verified closely
if id is not Noneacross all five adapters + SQLAlchemy m2m guard — correct fix for the falsy-0PK trap; the update-vs-insert branch is preserved.- SQLAlchemy insert path — reading the generated PK after
flush()and beforecommit()is the right way to stay safe under the defaultexpire_on_commit=True; the update branch reuses the incomingid, so no post-commit attribute read remains. requiredlogic — thedefault is None(vs falsy) change plusnot is_pknow matches the Yara reference in Tortoise/Django/SQLAlchemy; Django'sNOT_PROVIDEDsentinel handling is preserved.get_fields_for_serializereorder — subtractingexcludelast makes it win overlist_displaywithout changing howlist_displaybypasses thefieldsallowlist. Good.- Enum/choice mappings — Tortoise
v.valueand the Django{label: v, value: k}swap are both correct against the underlying data shapes. actionauthz gate — placed after context binding, before dispatch; default hooks returnTrueso existing deployments are unaffected.
Non-blocking observations
actionis gated onhas_change_permissiononly (upload_file / widget_action still auth-only). This is the right minimal fix for the confirmed bulk-mutation bypass, but it also means a read-only admin can no longer run a non-mutating registered action. If that's a supported use case, a dedicatedhas_action_permissionhook would be cleaner. Fine to defer — just calling it out as a behavior change.- Export
text/plain/.txtdefault is now dead code — with the up-frontformat not in (CSV, JSON)guard, the initialcontent_type = "text/plain"/f"{model}.txt"can never survive. Harmless, but could be tidied. widget_actionreturns{}on aNoneresponse — strictly better than the previousasdict(None)500, but worth a quick confirm that the frontend tolerates an empty object for a widget action that intentionally returns nothing.- Malformed-body 422 was applied to
sign_inonly (the unauthenticated entry point); the other Django handlers thatjson.loads(request.body)still surface a 500 on garbage input. Reasonable scoping given they're authenticated, but the pattern is repeated if you want to sweep it later.
Correctly scoped out
Reverting the prefixed-inline lookup was the right call — the test suite encodes that a prefixed inline must not shadow a deleted top-level admin's 404. Deferring the Pony m2m/search/ordering fixes until they can be exercised against a live Pony backend is also sensible; a wrong query rewrite there would trade a loud 500 for silently wrong results.
No correctness regressions found in the diff.
- 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)
- 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
Addressed the review observations
Coverage stays at 100%; ruff, |
Summary
A full-codebase review of the shipped
fastadmin/package surfaced 137 verified candidate issues; this PR applies the 23 highest-value correctness and security fixes. All 2005 tests pass; ruff lint/format andtytype-check are clean.Security
action— bulk actions now enforcehas_change_permission, matching the change/delete endpoints. Default hooks returnTrue, so default deployments are unaffected; only deliberately restricted (read-only) admins gain the intended restriction.hash("")(which would lock out the user and let anyone sign in with an empty password).ADMIN_SECRET_KEY— the JWT sign/verify paths refuse a blank key (closing the empty-string HS256 forgery) and return a clear config error instead of a 500.Correctness
orm_save_objacross all five ORM adapters (SQLAlchemy, Pony, Tortoise, Django, Yara) now useis not None, so a legitimate primary key / user id of0no longer turns an update into an insert or rejects a valid session.excludewins overlist_displayin serialization, so a field hidden viaexcludeis not re-added to responses.name__), unsupported/null export format, malformed sign-in bodies, andNonewidget-action responses now return proper 4xx / empty responses.contains/icontains(no more int-LIKE errors on Postgres); PK excluded fromrequired; no post-commit expired-attribute read (expire_on_commitsafe).0/False/"") no longer force a field torequired(Tortoise/Django/Yara); Tortoise enum options emit the scalar.value; Django choice option label/value un-swapped.detailkey (matching FastAPI/Flask and the frontend);configurationendpoint wrapsAdminApiExceptionin FastAPI/Flask.ADMIN_QUERY_MAX_LIMITandADMIN_SESSION_EXPIRED_ATso a blank/garbage env value no longer crashes the package at import.Notes / out of scope
has_change_permission; a dedicatedhas_action_permissionhook could be added if read-only admins need to run non-mutating actions.