Skip to content

Commit 8dda5a5

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
fix(security): redact PasswordInput-masked field values from detail payload (#504) (#522)
* fix(security): redact PasswordInput-masked field values from the detail payload (#504) A CharField the admin routes through forms.PasswordInput (e.g. via formfield_overrides) was serialized with its stored value intact, so a secret kept on that field shipped as plaintext in the detail JSON — even though Django's own admin renders PasswordInput with render_value=False and never echoes the value back into the page. Match Django's behaviour at the wire boundary: when the bound form widget is a PasswordInput, redact `value` to null unless the admin opted into render_value=True, and emit a `widget: "password"` hint. The SPA renders a masked <input type="password"> (autoComplete="new-password") for that hint. Read-only password fields also stop leaking, since the value is redacted before FieldValueView renders it. Also types the previously-untyped `widget` descriptor hint (radio/raw_id/password) in the wire contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(form): cover the masked password input (#504) The security contract: the backend redacts the value (it arrives null), so the SPA never receives the secret — assert the input renders type=password with autoComplete=new-password and stays empty, and that typed characters propagate via onChange. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9bed8e3 commit 8dda5a5

7 files changed

Lines changed: 199 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ version section at release.
1414

1515
## [Unreleased]
1616

17+
### Security
18+
- A `CharField` the admin masks with `forms.PasswordInput` (e.g. via
19+
`formfield_overrides`) no longer leaks its stored value in the detail
20+
payload: the backend redacts `value` to `null` (matching Django admin's
21+
`render_value=False`) and emits a `widget: "password"` hint, and the SPA
22+
renders a masked `<input type="password">` (#504).
23+
1724
## [0.2.0a8] — 2026-05-28
1825
[GitHub Release](https://github.com/MartinCastroAlvarez/django-admin-react/releases/tag/v0.2.0a8)
1926

django_admin_react/api/views/detail.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from django.db.models import ForeignKey
2727
from django.db.models import ManyToManyField
2828
from django.db.models import Model
29+
from django.forms.widgets import PasswordInput
2930
from django.forms.widgets import Textarea
3031
from django.forms.widgets import TextInput
3132
from django.http import HttpRequest
@@ -380,12 +381,28 @@ def _apply_widget_override(descriptor: dict[str, Any], form_field: Any) -> None:
380381
Reuses the form widget (source of truth) so ``formfield_overrides``
381382
has a visible effect, mapping only to the existing ``string`` /
382383
``text`` vocabulary so no new wire type is introduced (#446).
384+
385+
A ``PasswordInput`` override is handled first and separately (#504):
386+
it is a security boundary, not a layout hint. Django's admin renders
387+
a ``PasswordInput`` with ``render_value=False`` by default, so the
388+
stored value is never echoed back into the page. The SPA reads its
389+
value over the wire, so the equivalent is to **redact the value from
390+
the payload** unless the admin explicitly opted into echoing it
391+
(``render_value=True``), and to hint the SPA to mask the input
392+
(``widget: "password"``). Without this, a secret stored on a
393+
``CharField`` the admin masked with ``PasswordInput`` would be sent
394+
as plaintext in the detail JSON.
383395
"""
384396
if form_field is None:
385397
return
386398
widget = getattr(form_field, "widget", None)
387399
if widget is None:
388400
return
401+
if isinstance(widget, PasswordInput):
402+
descriptor["widget"] = "password"
403+
if not getattr(widget, "render_value", False):
404+
descriptor["value"] = None
405+
return
389406
if descriptor["type"] == "string" and isinstance(widget, Textarea):
390407
descriptor["type"] = "text"
391408
elif (

docs/api-contract.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -548,13 +548,22 @@ Rules:
548548
`get_fieldsets`). Anything in `exclude`/`get_exclude` is omitted.
549549
- `readonly: true` corresponds to membership in
550550
`ModelAdmin.get_readonly_fields(request, obj)`.
551-
- `widget` is an optional **presentational** hint (`#251`): `"radio"` when
552-
the admin lists the field in `ModelAdmin.radio_fields` (render the
553-
choice/FK as radio buttons), or `"raw_id"` when it's in
554-
`ModelAdmin.raw_id_fields` (render a pk input + lookup for a
555-
high-cardinality FK/M2M, instead of a select). `radio_fields` wins if a
556-
field is in both. Absent when the field is in neither; it changes no
557-
value, type, or permission gate.
551+
- `widget` is an optional hint: `"radio"` when the admin lists the field in
552+
`ModelAdmin.radio_fields` (render the choice/FK as radio buttons), or
553+
`"raw_id"` when it's in `ModelAdmin.raw_id_fields` (render a pk input +
554+
lookup for a high-cardinality FK/M2M, instead of a select). `radio_fields`
555+
wins if a field is in both. These two are purely **presentational**
556+
they change no value, type, or permission gate (`#251`).
557+
- `"password"` is a **security** hint, not a layout one (`#504`): the admin
558+
routed the field through `forms.PasswordInput` (e.g. via
559+
`formfield_overrides`). Django's admin renders `PasswordInput` with
560+
`render_value=False`, so the stored value never re-enters the page; the
561+
SPA reads its value over the wire, so the backend matches that by
562+
**redacting `value` to `null`** in this descriptor unless the admin set
563+
`render_value=True`. The SPA renders a masked `<input type="password">`.
564+
A consumer storing a secret on a `CharField` masked with `PasswordInput`
565+
therefore never sees it leave the server in the detail payload.
566+
- Absent when the field is in none of these.
558567
- `empty_value_display` (top-level, also on the **list** response §3) is the
559568
admin's placeholder for empty/null values — `ModelAdmin.empty_value_display`
560569
if set, else the `AdminSite` default (`"-"`) (`#251`). The SPA renders this

frontend/packages/api/src/contract.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ export type FieldType =
2323
| 'manytomany'
2424
| 'unsupported';
2525

26+
/**
27+
* Presentational widget hint that overrides the default control the SPA
28+
* would pick from `type`. `radio` / `raw_id` come from `radio_fields` /
29+
* `raw_id_fields` (#251). `password` is a security boundary, not a layout
30+
* choice: it marks a field the admin routed through `PasswordInput`, whose
31+
* stored value the backend redacts from the payload (matching Django's
32+
* `render_value=False`) — the SPA masks the input (#504).
33+
*/
34+
export type WidgetHint = 'radio' | 'raw_id' | 'password';
35+
2636
export interface Permissions {
2737
view: boolean;
2838
add: boolean;
@@ -346,6 +356,12 @@ export interface FieldDescriptor {
346356
decimal_places?: number;
347357
choices?: FieldChoice[];
348358
to?: { app_label: string; model_name: string };
359+
/**
360+
* Optional control override (see `WidgetHint`). Absent for the default
361+
* control implied by `type`. `password` additionally means the backend
362+
* has redacted `value` (it ships `null`).
363+
*/
364+
widget?: WidgetHint;
349365
}
350366

351367
export interface FieldsetDescriptor {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import '@testing-library/jest-dom/vitest';
2+
3+
import { fireEvent, render, screen } from '@testing-library/react';
4+
import { describe, expect, it, vi } from 'vitest';
5+
6+
import type { FieldDescriptor } from '@dar/data';
7+
8+
import { FieldInput } from './FieldInput';
9+
10+
// A field the admin masked with PasswordInput. The backend redacts the
11+
// stored value (#504), so the descriptor arrives with `value: null` and
12+
// `widget: 'password'` — the SPA never receives the secret.
13+
function pwField(overrides: Partial<FieldDescriptor> = {}): FieldDescriptor {
14+
return {
15+
type: 'string',
16+
label: 'API key',
17+
required: false,
18+
readonly: false,
19+
help_text: '',
20+
value: null,
21+
widget: 'password',
22+
...overrides,
23+
};
24+
}
25+
26+
describe('FieldInput password widget (#504)', () => {
27+
it('renders a masked input; the redacted value is never shown', () => {
28+
render(
29+
<FieldInput
30+
name="api_key"
31+
field={pwField()}
32+
value={null}
33+
error={undefined}
34+
onChange={() => {}}
35+
/>,
36+
);
37+
const input = screen.getByLabelText('API key') as HTMLInputElement;
38+
expect(input).toHaveAttribute('type', 'password');
39+
expect(input).toHaveAttribute('autocomplete', 'new-password');
40+
// Backend redacted the value to null, so the field starts empty.
41+
expect(input.value).toBe('');
42+
});
43+
44+
it('reports typed characters via onChange', () => {
45+
const onChange = vi.fn();
46+
render(
47+
<FieldInput
48+
name="api_key"
49+
field={pwField()}
50+
value=""
51+
error={undefined}
52+
onChange={onChange}
53+
/>,
54+
);
55+
fireEvent.change(screen.getByLabelText('API key'), { target: { value: 'new-secret' } });
56+
expect(onChange).toHaveBeenCalledWith('new-secret');
57+
});
58+
});

frontend/packages/form/src/FieldInput.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,22 @@ export function FieldInput({ name, field, value, error, onChange }: FieldInputPr
5050

5151
let control: React.ReactNode;
5252

53-
if (field.type === 'boolean') {
53+
if (field.widget === 'password') {
54+
// Field the admin routed through PasswordInput (#504). The backend
55+
// redacts the stored value (it ships `null`, matching Django's
56+
// `render_value=False`), so the box starts empty. Mask the input and
57+
// keep the browser from offering saved credentials for a secret field.
58+
control = (
59+
<input
60+
id={id}
61+
type="password"
62+
autoComplete="new-password"
63+
value={value == null ? '' : String(value)}
64+
onChange={(e) => onChange(e.target.value)}
65+
className={base}
66+
/>
67+
);
68+
} else if (field.type === 'boolean') {
5469
control = (
5570
<Checkbox id={id} checked={value === true} onChange={(e) => onChange(e.target.checked)} />
5671
);

tests/test_detail.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,3 +786,72 @@ def reconcile(type_: str, widget: object) -> str:
786786
none_field = {"type": "string"}
787787
_apply_widget_override(none_field, None)
788788
assert none_field["type"] == "string"
789+
790+
791+
def test_apply_widget_override_passwordinput_redacts_value() -> None:
792+
"""A ``PasswordInput`` widget hints ``password`` and redacts the value
793+
unless the admin opted into ``render_value=True`` (#504)."""
794+
from django import forms
795+
796+
from django_admin_react.api.views.detail import _apply_widget_override
797+
798+
def apply(widget: object, value: object = "s3cret") -> dict:
799+
d = {"type": "string", "value": value}
800+
_apply_widget_override(d, type("F", (), {"widget": widget})())
801+
return d
802+
803+
# Default PasswordInput (render_value=False): value redacted, hinted.
804+
masked = apply(forms.PasswordInput())
805+
assert masked["widget"] == "password"
806+
assert masked["value"] is None
807+
# render_value=True: value preserved, still hinted.
808+
echoed = apply(forms.PasswordInput(render_value=True))
809+
assert echoed["widget"] == "password"
810+
assert echoed["value"] == "s3cret"
811+
# Password wins over the string/text reconciliation (it returns early).
812+
assert "type" in masked and masked["type"] == "string"
813+
814+
815+
@pytest.mark.django_db
816+
def test_formfield_overrides_passwordinput_masks_and_redacts() -> None:
817+
"""A CharField the admin masks with ``PasswordInput`` via
818+
``formfield_overrides`` never ships its stored value in the detail
819+
payload, and carries the ``widget: "password"`` hint so the SPA masks
820+
the input (#504). A plain admin ships the value unmasked."""
821+
from django import forms
822+
from django.contrib import admin
823+
from django.contrib.auth import get_user_model
824+
from django.contrib.auth.models import Permission
825+
from django.db import models
826+
from django.test import RequestFactory
827+
828+
from django_admin_react.api.views.detail import _descriptor_for
829+
830+
class _PasswordAdmin(admin.ModelAdmin):
831+
formfield_overrides = {models.CharField: {"widget": forms.PasswordInput}}
832+
833+
request = RequestFactory().get("/")
834+
request.user = get_user_model().objects.create_superuser(
835+
username="pwd-su", email="pwd@example.com", password="x" # noqa: S106
836+
)
837+
838+
def descriptor(model_admin: admin.ModelAdmin) -> dict:
839+
form = model_admin.get_form(request, obj=None)()
840+
return _descriptor_for(
841+
model=Permission,
842+
model_admin=model_admin,
843+
obj=Permission(name="top-secret-value"), # the field's stored value
844+
name="name", # CharField
845+
form=form,
846+
is_readonly=False,
847+
admin_site=admin.site,
848+
request=request,
849+
)
850+
851+
masked = descriptor(_PasswordAdmin(Permission, admin.site))
852+
assert masked["widget"] == "password"
853+
assert masked["value"] is None # secret never leaves the server
854+
855+
plain = descriptor(admin.ModelAdmin(Permission, admin.site))
856+
assert plain.get("widget") != "password"
857+
assert plain["value"] == "top-secret-value" # unmasked field is unchanged

0 commit comments

Comments
 (0)