Please do not open a public GitHub issue for security findings.
Instead, open a private vulnerability report using GitHub's
Security Advisories
flow, or email the maintainer at martincastroalvarez@gmail.com with
the subject [SECURITY] django-admin-rest-api.
You can expect:
- An acknowledgement within 5 business days.
- A target patch ETA within 10 business days for issues with a fix path. Coordinated disclosure for issues that require one.
django-admin-rest-api is a JSON wrapper over the Django admin's
existing security model. It deliberately does not introduce new
auth, permission, validation, or audit surfaces. Every property
below is delegated to Django itself or to your ModelAdmin:
| Surface | Owner |
|---|---|
| Authentication | django.contrib.auth (session + authenticate) |
| Per-model authorization | ModelAdmin.has_view/add/change/delete_permission |
| Per-object authorization | Same — invoked with the resolved instance |
| Per-field validation | ModelAdmin.get_form(request, obj) (i.e. your ModelForm) |
| Search | ModelAdmin.get_search_results |
| Filters | ModelAdmin.list_filter |
| Audit log | Django's LogEntry |
| CSRF | django.middleware.csrf.CsrfViewMiddleware |
| Session expiry | SessionMiddleware |
If your HTML admin is correctly configured, this API inherits its posture. If your HTML admin is misconfigured, this API surfaces the same exposure.
These rules are enforced by the local pre-commit hooks
(.pre-commit-config.yaml) — a violation fails the commit and the
build:
- No
@csrf_exemptanywhere in the package. - No
Model.objects.all(...)/Model.objects.filter(...)in theapi/subpackage — all querysets must originate fromModelAdmin.get_queryset(request)so consumer overrides apply. - No
user.has_perm(...)direct calls in theapi/subpackage — permission checks must go throughModelAdmin.has_*_permission. - No partial token redactions (
ghp_…XYZ-style) in source files — the only way to fail this hook is to actually paste a real or partial token, which gitleaks then catches.
MAX_PAGE_SIZE(default200) hard-caps the?page_sizequery parameter on list endpoints, regardless of the model'slist_per_page. Override only if your dataset genuinely supports it and you have monitoring for slow queries.MAX_BULK_UPDATEScaps the number of rows in a singlePATCH .../bulk/batch. It is single-sourced: when unset it tracksMAX_PAGE_SIZE(so loweringMAX_PAGE_SIZEfor DoS reasons tightens the bulk cap too), and0disables it.- Bulk endpoints (
bulk,actions,delete-preview) apply the same per-object permission gate over the selection — there is no "skip permissions for batches" code path.
The form-spec endpoint
(GET /api/v1/<app>/<model>/[<pk>|add]/form-spec/) detects whether a
ModelAdmin renders a custom change/add page so it can render that page
server-side and return it as an html-fragment (#75). When — and only
when — the admin overrides change_view or add_view, the resolver
invokes that override with the live GET request to inspect the template
it returns (api/form_spec._renders_custom_template).
Because the override runs on a GET, it must stay GET-idempotent: a GET
must not mutate state. This is already Django's own contract for those
views (a GET renders the form; writes happen on POST), so a well-behaved
override is unaffected. An override that performs a side effect on GET (an
anti-pattern) would have that side effect triggered by a form-spec read,
and any exception it raises is swallowed to the JSON-spec fallback. Keep
change_view / add_view overrides read-only on GET.
When a ModelAdmin renders a custom change_form_template / add_form_template
(or an overridden change_view / add_view), the form-spec endpoint renders
that template server-side and returns its content-block HTML under
{"renderer": "html-fragment", "html": "…"}. The SPA injects this html
as trusted HTML (e.g. dangerouslySetInnerHTML), including any inline
<script> / <style> the template emits inside {% block content %} — those
are preserved verbatim because they are part of the integrator's form contract
(custom-widget JS/CSS).
This is the same trust boundary as the integrator's own legacy admin: the
HTML is produced by their own server-side template code, not by any
SPA-supplied or end-user-supplied input, and the endpoint is gated behind the
same staff + AdminSite.has_permission + per-object permission checks as
the rest of form-spec (the POST round-trip additionally requires per-object
has_change_permission). The renderer does not sanitise the fragment —
sanitising would silently break the integrator's widgets — so the security
property rests on the template being integrator-authored, exactly as it is when
served by the legacy /admin/ page. No auth or permission gate is weakened;
nothing in the html-fragment path bypasses a check the legacy admin enforces.
The package emits one structured record on the dedicated
django_admin_rest_api.security logger at each authorization-denial
boundary — a 403 permission/session-expiry denial
(api/permissions.forbidden_response) and a failed login
(api/views/auth). Each record carries {user, path, method, decision},
where user is the surrogate pk (or "anon") and decision is one of
forbidden / session_expired / login_failed. The password and any
other request-body PII are never logged. Wire the logger into your
project's LOGGING config to alert on credential-stuffing,
permission-probing, and IDOR-scan patterns:
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"security": {"class": "logging.StreamHandler"}},
"loggers": {
"django_admin_rest_api.security": {
"handlers": ["security"],
"level": "INFO",
"propagate": False,
},
},
}Successful (allowed) requests are intentionally not logged here — only denials — so the channel stays signal-rich for alerting.
- Upstream threat model: https://github.com/MartinCastroAlvarez/django-admin-react/blob/main/SECURITY.md (the API surface and guarantees are identical).