django-admin-react is the React SPA super-layer on top of the
django-admin-rest-api
package. The API package owns every wire-level gate (authn, authz,
queryset, serializer denylist, CSRF, write-form enforcement); this repo
owns the SPA-side concerns (CSP-friendly assets, no secret leakage in
the bundle, safe SPA mount, PWA boundaries, screenshot hygiene). Every
contribution must read this file before changing security-sensitive
behaviour in this repo.
Concern Owned by Reference API authn / authz / CSRF / queryset / denylist / write enforcement django-admin-rest-apirepoits SECURITY.mdSPA: pre-built bundle integrity, no token/ .envleakage in assets, PWA cache-on-logout, SPA mount safetyThis repo the rest of this file Supply chain (Dependabot, SHA-pinned actions, pip-audit)Both repos this file §6 and §13 Release publish (PyPI Trusted Publishing / token) Both repos, owner-gated this file §7 + the corresponding section in the API repo During META #544 the local
django_admin_react/api/tree is still onmainfor a few more PRs; the rules below that mention API endpoints continue to apply here until that code is removed, and in the API repo permanently from that point on. Do not introduce a new API endpoint in this repo — open it in the API repo instead.
Please do not open a public GitHub issue for security problems.
The primary reporting channel is GitHub Security Advisories on this repository:
This route is preferred because:
- It is private end-to-end between reporter and maintainers.
- It allocates a CVE number when warranted.
- It produces the published advisory at fix-time without any manual re-keying of details.
- It does not depend on an email address that could change, expire, or be intercepted in transit.
No additional channel is currently configured. If GitHub Security Advisories are not viable for you (for example, you do not have a GitHub account, or the issue blocks repository access), contact the project maintainer directly via the email listed on their public GitHub profile and request a private channel.
We will acknowledge a report within 5 working days. Critical issues will be patched as soon as possible and a CVE requested where appropriate. See §11 for the full disclosure timeline.
We protect against:
- An unauthenticated attacker discovering models, fields, or data.
- A logged-in non-staff user reaching the API.
- A logged-in staff user using the API to bypass ModelAdmin-level
restrictions (
has_*_permission,exclude,readonly_fields). - A logged-in staff user causing accidental data loss through requests the HTML admin would have prevented (CSRF, unconfirmed deletes via direct links).
- Sensitive fields (passwords, tokens, hashes) leaking through the list or detail responses.
Out of scope for v1:
- Defending an admin user against themselves at the model level (we trust
the consumer's
ModelAdminand Django's user model). If staff is compromised, the admin is compromised — this is also true ofdjango.contrib.admin. - Rate limiting / brute-force protection on login (that's the consumer's job; we never replace the login flow).
These are enforced by code review and by tests. A PR that violates any of them must not merge.
- All API views require authenticated staff users by default.
The default permission class checks
user.is_active and user.is_staff and admin_site.has_permission(request). - CSRF is on for unsafe methods. We use Django's session-backed CSRF.
We never disable CSRF on our endpoints, even "just for testing". The
test client uses
csrf_checks=True. - Never expose models that are not registered in the admin site
resolved via the configured
ADMIN_SITE. Look up models throughadmin.site._registryexclusively. - Never trust client-provided
app_label/model_name/field names. Always look them up in the registry and the resolvedModelAdmin.get_form()fields. Unknown values return404(model) or400(field). - Never bypass
ModelAdminpermissions. Usehas_view_permission,has_add_permission,has_change_permission,has_delete_permission, andhas_module_permission. Per-object checks pass the object instance. - Never expose fields excluded by the admin form. The set of writable
fields = the form's
Meta.fieldsminusreadonly_fieldsminusexclude. The set of readable fields is similarly derived fromget_fields(request, obj)/get_fieldsets(...). - Never serialize passwords, tokens, API keys, or other secret-shaped
fields. A denylist of common patterns lives in the
django-admin-rest-apipackage and is applied on top of theexclude/readonly_fieldsrules (defense in depth). - Writes always go through the admin form. Create and update must
instantiate
ModelAdmin.get_form(request, obj=...)and callform.is_valid(). No manualsetattr(obj, field, ...)from JSON. - Deletes always go through
ModelAdmin.delete_model. This preserves any pre/post delete signals, audit logging, or cleanup the consumer wired up. - No
Model.objects.all()in API code. Start fromModelAdmin.get_queryset(request)and chain further filters from there. - No detail/list mass assignment. If the form does not bind a field,
the API does not accept it. Extra keys in payloads are rejected with
400. HttpResponseForbiddenon permission failures, not404. Except for unregistered or non-existent objects (genuine404).
Every endpoint added must include all of these tests before merging:
- Anonymous request →
302to login or403(no body leakage). - Authenticated non-staff user →
403. - Staff user without per-model permission →
403. - Staff user with permission →
200/201/204as appropriate. - Unregistered
app/model→404. - Bogus
pk→404. - Write attempts to
exclude/readonlyfields →400and value unchanged. - CSRF token missing on unsafe method →
403. - Serializer never returns a field whose name matches the secret denylist
(parametrized over a sample model with a
passwordandapi_keyfield). - Permission booleans returned by list/detail match the truth from the
ModelAdmin(no drift between UI hint and gate).
.env,*.pem,*.key,*.crt, andsecrets/are gitignored.- Never paste a token, password, or API key into any file in this repo,
PR description, Issue, Discussion, or commit message. Partial /
redacted token references (e.g.,
ghp_…XYZ) are also forbidden and detected by the pre-commit hook. - If a secret is accidentally committed: (1) rotate it immediately on
the upstream provider; (2) request approval to rewrite history; (3)
open a GitHub Issue labelled
incident:secret-leakas the durable record. - A pre-commit hook (
.pre-commit-config.yaml) runsgitleaksplus a custompygrepfor partial token patterns. Enable it locally with:pre-commit install.
- Runtime dependencies are deliberately minimal: only Django
(
>=5.0,<7.0), the siblingdjango-admin-rest-api(the JSON API), and the siblingdjango-admin-mcp-api(the MCP exposure of the same API). No DRF, no auth framework, no JWT lib — the API package reuses Django's session + CSRF; the SPA reuses the same. - Dev dependencies are pinned in
pyproject.tomland locked with Poetry. Frontend dev dependencies are locked withpnpm-lock.yaml. - Every new third-party dependency (runtime or dev) goes through PR review explaining why and what alternative was rejected.
- Run
./scripts/audit-deps.shbefore every release.
- The PyPI artifact is built locally via
./scripts/build.sh:pnpm install→pnpm -r typecheck→vite build→ copy bundle intodjango_admin_react/{static,templates}/admin_react/→poetry build. The wheel ships pre-built React assets so consumers do not need Node. - The PyPI token lives in environment variables only
(
POETRY_PYPI_TOKEN_PYPI), never in any file in the repo. The token is never echoed or logged byscripts/deploy.sh. - Releases require a human maintainer. The publish is driven by the
publish.ymlworkflow (OIDC Trusted Publishing — no stored token); the maintainer triggers it by publishing a GitHub Release. - TestPyPI may be used for verification by the maintainer with a separate token; same hygiene rules apply.
The test suites run server-side in CI
(.github/workflows/ci.yml): backend pytest and the frontend pnpm
gate (typecheck + lint + test + build), so a red suite cannot merge.
Enforcing the Python lint gate in CI is a near-term follow-up:
scripts/lint.sh currently runs two formatters (ruff format + black)
whose output conflicts, so it isn't satisfiable on a clean tree yet. The
local script below is still the authoritative lint gate; it gets
de-conflicted and the small existing debt cleared first, then the lint
step is added to CI.
Run via ./scripts/lint.sh:
ruff(includesSbandit-style security rules)ruff format --checkblack --checkisort --check-only(force_single_line=True)flake8pylint --errors-only(withpylint-django)mypy(best-effort; tightening planned for v1.x)bandit -r django_admin_reactpytest -q(includingtests/test_security.py)- Frontend:
pnpm -r typecheck,pnpm lint(eslint--max-warnings 0- stylelint + dark-mode coverage),
pnpm test(vitest),pnpm -r build.
- stylelint + dark-mode coverage),
Making the CI checks required in branch protection is a separate owner action (#452 / #331).
Dependency audit runs separately via ./scripts/audit-deps.sh
(see §6).
These are not enforced by the package — they are best-practice defaults the consumer's Django project should set. The package never overrides them.
# settings.py
# Cookies
SESSION_COOKIE_SECURE = True # ACCEPTANCE §4.14 S-62
CSRF_COOKIE_SECURE = True # S-62
SESSION_COOKIE_HTTPONLY = True # S-62 (CSRF cookie stays readable by JS by design)
SESSION_COOKIE_SAMESITE = "Lax" # CSRF defense in depth
# Transport
SECURE_HSTS_SECONDS = 31_536_000 # S-63
SECURE_HSTS_INCLUDE_SUBDOMAINS = True # S-63
SECURE_HSTS_PRELOAD = True # S-63 — only after HSTS is verified
SECURE_SSL_REDIRECT = True # if behind a TLS-terminating proxy
# Headers
X_FRAME_OPTIONS = "DENY" # clickjacking — S-64
SECURE_CONTENT_TYPE_NOSNIFF = True # S-65
SECURE_BROWSER_XSS_FILTER = True # S-65 (legacy IE; cheap to leave on)
SECURE_REFERRER_POLICY = "same-origin"
# Optional but recommended
SESSION_COOKIE_AGE = 60 * 60 * 8 # 8h staff session (QSEC-05)The SPA shell (templates/admin_react/index.html) loads only
same-origin assets: one external ES-module bundle and its stylesheet
under your STATIC_URL, a data: favicon, the PWA manifest, and the
service worker — no inline <script>. That means a fairly strict
CSP is achievable, and script-src 'self' (no 'unsafe-inline') is the
load-bearing directive: it contains an XSS even if a consumer's
ModelAdmin accidentally mark_safe()s attacker-controlled data into an
HTML field value (the one consumer-trust boundary — see §2.7).
Apply it with django-csp or any
header middleware. A starting policy for the admin mount:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline'; # React/runtime inline style attrs
img-src 'self' data:; # data: favicon
font-src 'self';
connect-src 'self'; # the API is same-origin
manifest-src 'self';
worker-src 'self'; # the PWA service worker
frame-ancestors 'none'; # clickjacking (with X_FRAME_OPTIONS)
base-uri 'self';
form-action 'self';
Caveats — validate before enforcing:
- Roll it out in
Content-Security-Policy-Report-Onlyfirst and watch for violations; only switch to the enforcing header once clean. - If you serve
FileField/ImageFieldmedia or signed download URLs from a different origin (S3, GCS, a CDN), add that origin toimg-srcandconnect-src. style-src 'unsafe-inline'is included because React sets inlinestyleattributes at runtime; it is far lower-risk than allowing inline scripts. Drop it if you verify your build needs no inline styles.- This policy assumes the package is mounted on its own path under your domain. If you already ship a project-wide CSP, merge these directives rather than replacing your policy.
The package emits value.url for FileField and ImageField values
in the detail response. Whether that URL is appropriately gated
depends on the consumer's storage backend — the package never wraps
the URL with its own access check:
- Signed-URL backends (S3 with bucket-level access blocked, GCS
with signed-blob URLs, a custom storage that returns a tokenised
download path, …). The URL is time-bound and per-request; an
unauthenticated reader who captures the URL out-of-band has it for
the signing TTL only. No further consumer action needed beyond
setting the storage's TTL conservatively (matching
SESSION_COOKIE_AGEis a reasonable upper bound). - Local-storage backends with
MEDIA_URLpointing at the same domain as the admin. The URL is just a path under the consumer's static-files server. IfMEDIA_URLis publicly readable (the default Django development configuration), then any logged-in staff user who can view the row can also share the file URL with anyone — including unauthenticated parties — and they can fetch the file. For production deployments where files should stay staff-only, putMEDIA_URLbehind the same staff-gate as the rest of the admin:django-private-storage, annginxauth_requestto the Django session check, or a small Django view that readsMEDIA_ROOTand gates onrequest.user.is_active and request.user.is_staff.
This is the same disclosure surface Django's HTML admin ships with
(its <a href="{{ field.url }}"> does not check the linked URL's
ACL either). The SPA does not change the posture; it does make the
URLs trivially scriptable against /api/v1/<app>/<model>/<pk>/,
which raises the operational stakes of MEDIA_URL configuration.
FileField / ImageField are now writable over multipart/form-data
(create + update). The package stores the file through the field's own
form + configured Storage — it never builds a path from the
client-supplied filename (Storage.get_valid_name / get_available_name
sanitise it, so path traversal can't escape upload_to), and an upload
addressed to a readonly / exclude / unknown field is rejected 400.
Three things remain the consumer's responsibility:
- Size / count limits — keep Django's defaults on. The multipart parse
enforces
DATA_UPLOAD_MAX_MEMORY_SIZE,FILE_UPLOAD_MAX_MEMORY_SIZE, andDATA_UPLOAD_MAX_NUMBER_FIELDS; the package never raises or disables them (an over-limit upload returns a clean400). Do not set these to unbounded values, and tune them for the largest legitimate upload — they are your DoS guard against a worker streaming a multi-GB body. - Content validation is yours, by design. The package stores whatever
the field's validators accept; it does not sniff content (that would
be a parallel system —
ModelAdminstays the source of truth). For untrusted uploads add field validators / extension allowlists / an AV scan, and prefer a storage location that can't execute what it holds. - Stored-file XSS. A file served from the admin's own origin with a
guessable URL and a dangerous content-type (HTML, SVG) is a stored-XSS
vector. Serve media from a separate origin or with
Content-Disposition: attachment, and keepSECURE_CONTENT_TYPE_NOSNIFFon (above).ImageFieldvalidation (Pillow) rejects non-images including SVG; plainFileFieldaccepts anything its validators allow.
- The API package's own
SECURITY.mdinMartinCastroAlvarez/django-admin-api— every API-side concern (each/api/v1/...endpoint, the serializer denylist, the permission gates) lives there. README.md— install + three-repo cross-links.ARCHITECTURE.md— what lives in this repo vs. the API / MCP siblings.- The API package's own
SECURITY.md—MartinCastroAlvarez/django-admin-api— for every API-side gate (each/api/v1/...endpoint, the serializer denylist, the permission checks).
For valid vulnerability reports we aim for:
- Acknowledgement: ≤ 5 working days.
- Triage + reproducer: ≤ 14 calendar days.
- Patched release: depends on severity; critical issues are prioritized immediately.
- Public advisory: published with the fix, crediting the reporter unless they request anonymity.