Skip to content

Commit 949aaa9

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
docs(readme): honest hooks-carry-through + hardening + cross-origin sections (#640)
Five audit findings, all README-only — no code change: - **#623 / #624 / #625 / #626 / #627 / #628 / #630 / #622:** Add a "Stock-Django ModelAdmin hooks that do NOT carry through to the SPA" table. Documents the silent-noop hooks (template overrides, formfield_overrides, filter_horizontal, GFK, i18n) and the partially-honoured hints (raw_id_fields + radio_fields — the API emits the hint, the SPA still renders autocomplete / dropdown). Corrects the existing feature-status table where `raw_id_fields` was incorrectly marked ✅. - **#633:** "Writing safe `list_display` callables" — bans the `mark_safe(f"...")` foot-gun + shows the `format_html` auto-escape equivalent. Same advice applies on legacy `/admin/` too, but the SPA's `dangerouslySetInnerHTML` render path makes the consequence visible end-to-end. - **#634:** "Hardening" section with a worked `django-axes` integration pointed at the SPA's JSON login endpoint. - **#635:** "Mounting the API on a different origin (CORS + cookies)" — the three-setting matrix consumers have to configure together (`SESSION_COOKIE_SAMESITE = None` + secure, CORS, CSRF_TRUSTED_ORIGINS). Tracking issues stay open until each underlying gap closes; the README now sets honest expectations so consumers don't ship a release thinking these all work invisibly. Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 00ffa77 commit 949aaa9

1 file changed

Lines changed: 118 additions & 1 deletion

File tree

README.md

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -585,7 +585,9 @@ all share the v1 wire contract. Per-feature live status below.
585585
| `date_hierarchy` ||
586586
| `list_editable` + bulk PATCH ||
587587
| `actions` — batch + detail (signature-classified) ||
588-
| `autocomplete_fields` / `raw_id_fields` ||
588+
| `autocomplete_fields` ||
589+
| `raw_id_fields` (pk text input + lookup popup) | 🟡 [#626](https://github.com/MartinCastroAlvarez/django-admin-react/issues/626) (API emits the hint; SPA still renders autocomplete) |
590+
| `radio_fields` (inline radio buttons vs `<select>`) | 🟡 [#626](https://github.com/MartinCastroAlvarez/django-admin-react/issues/626) (API emits the hint; SPA still renders dropdown) |
589591
| `ManyToManyField` read + write ||
590592
| `inlines` (TabularInline / StackedInline) — read + write ||
591593
| `FileField` / `ImageField` — read ||
@@ -601,6 +603,121 @@ all share the v1 wire contract. Per-feature live status below.
601603

602604
✅ = shipped. 🟡 = not yet built (tracked).
603605

606+
### Stock-Django `ModelAdmin` hooks that do NOT carry through to the SPA
607+
608+
The SPA renders from the JSON wire — it never sees the consumer's
609+
Django HTML templates, custom widgets, or `get_urls()` views. The
610+
hooks below are stock-Django extension points the SPA cannot honour
611+
today; if your admin uses any of them, the surface behaves
612+
differently on the SPA than on the legacy `/admin/`. Tracking
613+
issues link the work to close each gap.
614+
615+
| Stock-Django hook | SPA behaviour | Tracked |
616+
|---|---|---|
617+
| `change_form_template` / `change_list_template` / `add_form_template` / `change_password_template` / `object_history_template` overrides | Silently ignored — the SPA renders entirely from the JSON wire. | [#624](https://github.com/MartinCastroAlvarez/django-admin-react/issues/624) |
618+
| `formfield_overrides = {Field: {"widget": CustomWidget}}` | Custom widget invisible — the SPA picks its own control from the field's `type`. No React-side widget-registration API yet. | [#625](https://github.com/MartinCastroAlvarez/django-admin-react/issues/625) |
619+
| `raw_id_fields` | Falls back to the autocomplete picker (same as `autocomplete_fields`). Defeats the purpose for FKs with 10M+ rows where autocomplete `get_search_results` is too expensive. | [#626](https://github.com/MartinCastroAlvarez/django-admin-react/issues/626) |
620+
| `radio_fields = {"status": admin.HORIZONTAL}` | Renders a `<select>` (default choice control) instead of inline radio buttons. | [#626](https://github.com/MartinCastroAlvarez/django-admin-react/issues/626) |
621+
| `filter_horizontal` / `filter_vertical` (M2M shuttle widget) | Renders the generic multi-select checkbox list, not Django's two-pane shuttle. Switch the field to `autocomplete_fields` for a workable SPA UX. | [#627](https://github.com/MartinCastroAlvarez/django-admin-react/issues/627) |
622+
| `GenericForeignKey` / `GenericInlineModelAdmin` | Support gap — verify per-model before relying on the SPA. | [#628](https://github.com/MartinCastroAlvarez/django-admin-react/issues/628) |
623+
| `LANGUAGE_CODE` / `gettext` / `Accept-Language` | The SPA chrome stays English; translated `verbose_name` / `help_text` / `@admin.action(description=_("..."))` are not surfaced per-request. | [#630](https://github.com/MartinCastroAlvarez/django-admin-react/issues/630) |
624+
| `ModelAdmin.get_urls()` custom views | Opens as a popout (`<a target="_blank">`) into the Django-rendered HTML page — no SPA chrome, no breadcrumb. The link IS surfaced; the UX is just outside the SPA. | [#623](https://github.com/MartinCastroAlvarez/django-admin-react/issues/623) |
625+
| Django 4.2 LTS support | Not yet — the package pins `django >= 5.0,<7.0`. | [#622](https://github.com/MartinCastroAlvarez/django-admin-react/issues/622) |
626+
627+
If your admin relies on any "silently ignored" hook above, the
628+
typical workaround is to keep that model on the legacy
629+
`/admin/` surface via the
630+
[experience-toggle strip](#experience-toggle-strip-optional) — the
631+
SPA + legacy admin happily coexist.
632+
633+
---
634+
635+
## Writing safe `list_display` callables
636+
637+
This applies on **both** the legacy `/admin/` and the SPA — but the
638+
SPA renders any `format_html` / `mark_safe` value via React's
639+
`dangerouslySetInnerHTML`, so misuse is reflected XSS the same way
640+
the legacy admin would be.
641+
642+
**Do not** interpolate user-controlled data into a `mark_safe(...)`
643+
string. The whole point of `mark_safe` is "I have already escaped
644+
this," and `f"<span>{obj.user_input}</span>"` has not — so a
645+
`user_input` of `<script>alert(1)</script>` runs.
646+
647+
```python
648+
# WRONG — copy-paste-from-StackOverflow XSS hazard.
649+
@admin.display(description="Status")
650+
def status_badge(self, obj):
651+
return mark_safe(f'<span class="badge">{obj.user_input}</span>')
652+
653+
# RIGHT — format_html auto-escapes every interpolated arg.
654+
@admin.display(description="Status")
655+
def status_badge(self, obj):
656+
return format_html('<span class="badge">{}</span>', obj.user_input)
657+
```
658+
659+
Same rule for `readonly_fields` callables. See
660+
[#633](https://github.com/MartinCastroAlvarez/django-admin-react/issues/633)
661+
for the optional defense-in-depth `STRICT_HTML` setting tracking
662+
issue (bleach-clean every rendered HTML value with a tight allow-list).
663+
664+
---
665+
666+
## Hardening
667+
668+
### Brute-force defense on `/api/v1/login/`
669+
670+
The package's React login endpoint (`<mount>/api/v1/login/`) reuses
671+
Django's session auth, so the canonical brute-force defenses work
672+
unchanged. The recommended layer is
673+
[`django-axes`](https://pypi.org/project/django-axes/):
674+
675+
```python
676+
# settings.py
677+
INSTALLED_APPS = [..., "axes", "django_admin_react", "django_admin_rest_api"]
678+
679+
AUTHENTICATION_BACKENDS = [
680+
"axes.backends.AxesStandaloneBackend",
681+
"django.contrib.auth.backends.ModelBackend",
682+
]
683+
MIDDLEWARE = [..., "axes.middleware.AxesMiddleware"]
684+
685+
AXES_FAILURE_LIMIT = 5
686+
AXES_COOLOFF_TIME = 1 # hour
687+
```
688+
689+
Axes intercepts via `AUTHENTICATION_BACKENDS`, not URL middleware, so
690+
lockouts apply to both the legacy admin login and the SPA's JSON
691+
login automatically. Tracked: [#634](https://github.com/MartinCastroAlvarez/django-admin-react/issues/634).
692+
693+
### Mounting the API on a different origin (CORS + cookies)
694+
695+
`DJANGO_ADMIN_REACT["API_URL_PREFIX"]` lets the SPA point at a
696+
separately-mounted REST API — e.g. SPA at `admin.example.com`
697+
talking to an API at `api.example.com`. The session-cookie auth
698+
across origins needs three settings configured together; if any
699+
one is missing, every API call silently 401s after login.
700+
701+
```python
702+
# settings.py — required when SPA and API are on different origins.
703+
SESSION_COOKIE_SAMESITE = "None" # default "Lax" drops cookies cross-origin
704+
SESSION_COOKIE_SECURE = True # required by browsers when SameSite=None
705+
CSRF_COOKIE_SAMESITE = "None"
706+
CSRF_COOKIE_SECURE = True
707+
708+
# pip install django-cors-headers
709+
INSTALLED_APPS = [..., "corsheaders", ...]
710+
MIDDLEWARE = ["corsheaders.middleware.CorsMiddleware", ...]
711+
712+
CORS_ALLOW_CREDENTIALS = True
713+
CORS_ALLOWED_ORIGINS = ["https://admin.example.com"] # NEVER "*" with credentials
714+
CSRF_TRUSTED_ORIGINS = ["https://admin.example.com"]
715+
```
716+
717+
The SPA's HTTP client already sends `credentials: "include"`, so no
718+
frontend change is needed — only the Django-side cookie + CORS
719+
config above. Tracked: [#635](https://github.com/MartinCastroAlvarez/django-admin-react/issues/635).
720+
604721
---
605722

606723
## The API surface

0 commit comments

Comments
 (0)