Skip to content

Commit c2174c5

Browse files
fix(security): redact PasswordInput-masked field values from inline rows (#504)
The inline half of the #504 detail-view fix (#522). A field an inline masks with `forms.PasswordInput` (typically `formfield_overrides = {CharField: {"widget": PasswordInput}}`) was serialized into the inline row payload **with its stored value intact** — so a secret kept on that field shipped as plaintext in the detail JSON (visible in the network tab), even though Django's admin renders a PasswordInput with `render_value=False` and never echoes it back. - `inlines.py`: `_password_redacted_fields` reads the inline's own bound form widgets (the source of truth Django already applied via `get_formset`, so `formfield_overrides` and a custom inline `form` are both honoured) and `_rows_for_inline` ships `null` for those fields — never reading or serializing the secret. `render_value=True` opts back in, matching the top-level fix. Degrades to no-redaction on any error (never 500s the parent detail). This is the value-redaction (security) half — no wire-contract change (the value was already nullable). Masking the inline input itself (`widget: "password"` on `InlineFieldMeta`) is a wire-contract addition left as a separate Tier-5 follow-up; the secret no longer leaks either way. Tests (`tests/test_inlines.py`): a CharField masked via formfield_overrides ships `null` for every row; a plain inline still ships the value (no read-path regression); `render_value=True` preserves it. Inline + detail + security + create + update suites green (102). ruff / mypy / black clean. Refs #504, #522 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bc9a001 commit c2174c5

2 files changed

Lines changed: 122 additions & 0 deletions

File tree

django_admin_react/api/inlines.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from django.db.models import ForeignKey
3333
from django.db.models import ManyToManyField
3434
from django.db.models import Model
35+
from django.forms.widgets import PasswordInput
3536
from django.http import HttpRequest
3637

3738
from django_admin_react.api.serializers import field_type_for
@@ -293,6 +294,36 @@ def _fields_meta(
293294
return out
294295

295296

297+
def _password_redacted_fields(
298+
inline: InlineModelAdmin, parent: Model, request: HttpRequest
299+
) -> set[str]:
300+
"""Inline field names whose stored value must be redacted from the row
301+
payload because the admin masks them with ``forms.PasswordInput`` (#504
302+
— the inline half of the detail-view fix in #522).
303+
304+
Django's admin renders a ``PasswordInput`` with ``render_value=False``
305+
by default, so a secret kept on a field the inline routes through it
306+
(typically ``formfield_overrides = {CharField: {"widget":
307+
PasswordInput}}``) is never echoed back into the form. The SPA reads
308+
inline values over the wire, so the equivalent is to drop the value
309+
from the payload. Detection reads the inline's own bound form widgets
310+
(the source of truth Django already applied), so ``formfield_overrides``
311+
and a custom inline ``form`` are both honoured; a field the admin opted
312+
into echoing (``render_value=True``) is left alone. Degrades to an empty
313+
set on any error — never 500s the parent detail.
314+
"""
315+
try:
316+
base_fields = inline.get_formset(request, parent).form.base_fields
317+
except Exception: # pragma: no cover - defensive, mirrors this module
318+
return set()
319+
redacted: set[str] = set()
320+
for name, field in base_fields.items():
321+
widget = getattr(field, "widget", None)
322+
if isinstance(widget, PasswordInput) and not getattr(widget, "render_value", False):
323+
redacted.add(name)
324+
return redacted
325+
326+
296327
def _rows_for_inline(
297328
inline: InlineModelAdmin,
298329
parent: Model,
@@ -306,10 +337,18 @@ def _rows_for_inline(
306337
queryset = inline.get_queryset(request).filter(**{fk_name: parent.pk})
307338
except Exception: # pragma: no cover
308339
return []
340+
# Fields the admin masks with PasswordInput: redact their value (#504),
341+
# computed once for the whole inline rather than per row.
342+
redacted = _password_redacted_fields(inline, parent, request)
309343
rows: list[dict[str, Any]] = []
310344
for obj in queryset:
311345
fields_payload: dict[str, Any] = {}
312346
for name in visible_fields:
347+
if name in redacted:
348+
# Never read or serialize a masked field's secret — ship
349+
# null, matching PasswordInput(render_value=False).
350+
fields_payload[name] = None
351+
continue
313352
model_field = None
314353
try:
315354
model_field = inline.model._meta.get_field(name)

tests/test_inlines.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,3 +349,86 @@ class _LinkedInline(TabularInline):
349349
admin.site.unregister(Permission)
350350
assert spec is not None
351351
assert spec["show_change_link"] is False
352+
353+
354+
@pytest.mark.django_db
355+
def test_inline_passwordinput_field_value_is_redacted() -> None:
356+
"""A field an inline masks with ``forms.PasswordInput`` (via
357+
``formfield_overrides``) must NOT ship its stored value in the inline
358+
row payload — the inline half of the #504 detail-view fix (#522).
359+
360+
Mirrors Django's default ``render_value=False``: the secret is never
361+
echoed back. A plain inline (no override) still ships the value, so
362+
there's no read-path regression.
363+
"""
364+
from django import forms
365+
from django.contrib.auth import get_user_model
366+
from django.contrib.auth.models import Permission
367+
from django.contrib.contenttypes.models import ContentType
368+
from django.db import models
369+
from django.test import RequestFactory
370+
371+
from django_admin_react.api.inlines import _rows_for_inline
372+
373+
ct = ContentType.objects.create(app_label="dar_test", model="vault")
374+
Permission.objects.create(content_type=ct, codename="open", name="top-secret-value")
375+
376+
request = RequestFactory().get("/")
377+
request.user = get_user_model().objects.create_superuser(
378+
username="inline-pwd-su", email="pwd@example.com", password="x" # noqa: S106
379+
)
380+
381+
class _MaskedInline(TabularInline):
382+
model = Permission
383+
fk_name = "content_type"
384+
formfield_overrides = {models.CharField: {"widget": forms.PasswordInput}}
385+
386+
masked = _rows_for_inline(
387+
_MaskedInline(ContentType, admin.site), ct, "content_type", ["name", "codename"], request
388+
)
389+
assert len(masked) == 1
390+
# The secret never leaves the server — value redacted to null.
391+
assert masked[0]["fields"]["name"] is None
392+
assert masked[0]["fields"]["codename"] is None
393+
394+
class _PlainInline(TabularInline):
395+
model = Permission
396+
fk_name = "content_type"
397+
398+
plain = _rows_for_inline(
399+
_PlainInline(ContentType, admin.site), ct, "content_type", ["name", "codename"], request
400+
)
401+
# No override → value ships unredacted (no regression).
402+
assert plain[0]["fields"]["name"] == "top-secret-value"
403+
404+
405+
@pytest.mark.django_db
406+
def test_inline_passwordinput_render_value_true_preserves_value() -> None:
407+
"""An inline that opts into ``PasswordInput(render_value=True)`` keeps
408+
the value — same escape hatch as the top-level detail view (#522)."""
409+
from django import forms
410+
from django.contrib.auth import get_user_model
411+
from django.contrib.auth.models import Permission
412+
from django.contrib.contenttypes.models import ContentType
413+
from django.db import models
414+
from django.test import RequestFactory
415+
416+
from django_admin_react.api.inlines import _rows_for_inline
417+
418+
ct = ContentType.objects.create(app_label="dar_test", model="echo")
419+
Permission.objects.create(content_type=ct, codename="show", name="kept-value")
420+
421+
request = RequestFactory().get("/")
422+
request.user = get_user_model().objects.create_superuser(
423+
username="inline-echo-su", email="echo@example.com", password="x" # noqa: S106
424+
)
425+
426+
class _EchoInline(TabularInline):
427+
model = Permission
428+
fk_name = "content_type"
429+
formfield_overrides = {models.CharField: {"widget": forms.PasswordInput(render_value=True)}}
430+
431+
rows = _rows_for_inline(
432+
_EchoInline(ContentType, admin.site), ct, "content_type", ["name"], request
433+
)
434+
assert rows[0]["fields"]["name"] == "kept-value"

0 commit comments

Comments
 (0)