Skip to content

Security: MartinCastroAlvarez/django-admin-rest-api

Security

SECURITY.md

Security policy

Reporting a vulnerability

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.

Threat model summary

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.

Hardening invariants (enforced in CI / pre-commit)

These rules are enforced by the local pre-commit hooks (.pre-commit-config.yaml) — a violation fails the commit and the build:

  • No @csrf_exempt anywhere in the package.
  • No Model.objects.all(...) / Model.objects.filter(...) in the api/ subpackage — all querysets must originate from ModelAdmin.get_queryset(request) so consumer overrides apply.
  • No user.has_perm(...) direct calls in the api/ subpackage — permission checks must go through ModelAdmin.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.

Defaults that exist as DoS guards

  • MAX_PAGE_SIZE (default 200) hard-caps the ?page_size query parameter on list endpoints, regardless of the model's list_per_page. Override only if your dataset genuinely supports it and you have monitoring for slow queries.
  • MAX_BULK_UPDATES caps the number of rows in a single PATCH .../bulk/ batch. It is single-sourced: when unset it tracks MAX_PAGE_SIZE (so lowering MAX_PAGE_SIZE for DoS reasons tightens the bulk cap too), and 0 disables 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.

Form-spec introspection probe (GET-idempotency requirement)

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.

html-fragment is trusted HTML (#75)

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.

Security logging

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.

Cross-references

There aren't any published security advisories