# Security Hardening

> **Status:** v0.1 — first pass, finalized 2026-05-15
> (review-pass verified all file:line citations against the
> live source). Last updated 2026-05-15.
> A federation-sibling-scoped security audit: walked the five
> source files (`_apps.py`, `_middleware.py`, `_logging.py`,
> `_settings.py`, `apps.py`) against the **SC-*** rules in
> `mgf-standard/docs/standards/SECURITY_STANDARD.md` + the
> sibling-specific concerns (header reflection, settings-bridge
> hygiene, middleware-order discipline). The library's surface
> is small — five public names — so the SH list is short
> *because of that*, not despite it. Most secret-handling lives
> in `mgf-common` (redactor, vault, crash reporter) and is
> inherited as invariants here.
>
> **Scope:** `mgf-django`'s OWN security posture — the
> framework-adapter surface between Django and `mgf.common`.
> NOT a re-audit of mgf-common's redactor / vault / crash
> reporter; those are tracked in
> [`mgf-common/docs/inprogress/SECURITY_HARDENING.md`](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/docs/inprogress/SECURITY_HARDENING.md).
> NOT an audit of Django itself — that's the Django security
> team's job.
>
> **Audience:** the maintainer + future AI agents doing
> security work on this sibling.
> **Cross-references:**
> [`RELIABILITY_AND_ACCURACY.md`](RELIABILITY_AND_ACCURACY.md)
> (sibling tracker — several security-adjacent items live there
> under the RA lens; see §3 for the split),
> [`mgf-standard/docs/standards/SECURITY_STANDARD.md`](https://codeberg.org/magogi-admin/mgf-standard/src/branch/main/docs/standards/SECURITY_STANDARD.md)
> (the SC-* rules), repo-root
> [`SECURITY.md`](../../SECURITY.md) (vulnerability-reporting
> policy — distinct from this tracker).

The sibling sits on the request path of every Django consumer.
The audit's main concern is **input reflected back into
responses** (the request-id round-trip) and **state visibility
through the bridge** (DjangoSettingsBridge MUST never carry
SECRET_KEY itself).

---

## §1 You are here

mgf-django ships five public names; all secret-handling
primitives (Redactor, vault, crash reporter, traceback
scrubbing) are inherited from `mgf-common` and verified
elsewhere; the sibling's surface is the Django-shaped adapter
layer.

What this tracker exposes is the **request-boundary shape** —
where untrusted input crosses into the request lifecycle
(headers from clients, Django settings the consumer wrote)
and how it's handled before being reflected back, logged, or
exposed via the bridge.

**Counts (2026-05-17):**

| Priority | Open | Closed | Total |
|---|---|---|---|
| P0 — pre-v1.0 blockers | 0 | 0 | 0 |
| P1 — should land before/with v1.0 | 0 | 3 | 3 |
| P2 — post-v1.0 hardening | 1 | 1 | 2 |
| **Total** | **1** | **4** | **5** |

**Do first:** SH-01..03 closed (P1). SH-05 closed (P2 — env-var
extra-redaction seam). SH-04 (SBOM) deferred to v1.1+ — federation-
wide cross-sibling gap.

---

## §2 Priority table

| ID | Concern | One-line | Effort | Status |
|---|---|---|---|---|
| [**SH-01**](#sh-01-x-request-id-header-injection) | Header injection / response smuggling | `MgfRequestIdMiddleware` echoes the incoming `X-Request-Id` value unchecked. A client-supplied `\r\nSet-Cookie: ...` could taint the response. Django's `BadHeaderError` catches at response time but only AFTER side-effects (DB writes, emails). The request should never reach the view. Twin of [RA-01](RELIABILITY_AND_ACCURACY.md#ra-01-header-injection-on-x-request-id-reflect). | S | ✅ closed |
| [**SH-02**](#sh-02-middleware-install-order-csrf-composability) | Composability discipline | `MgfRequestIdMiddleware` + `MgfContextMiddleware` install order matters with Django's `CsrfViewMiddleware`. The recipe doc shows install BEFORE security middleware, but doesn't call out the CSRF + Session ordering rules explicitly. A consumer who installs CSRF first then ours sees `request.mgf_context` populated only after CSRF runs — fine for the context, but the request-id contextvar isn't bound when CSRF logs an audit event. | XS | ✅ closed |
| [**SH-03**](#sh-03-settings-bridge-secret-key-pin) | Secret-key containment | `DjangoSettingsBridge.from_django` reads `settings.SECRET_KEY` only to compute `bool(secret_key)`. RA-INV-3 documents this; no test pins it. A future refactor that adds `secret_key: str` to the bridge would silently regress. | S | ✅ closed |
| [**SH-04**](#sh-04-sibling-sbom-and-vuln-scan-in-ci) | Supply chain | The sibling doesn't ship its own SBOM at release time and has no vuln-scan CI gate (`pip-audit` / similar). mgf-common ships both (per SC-10 + SC-18); the sibling inherits the discipline but not the CI shape. | S | ⏳ v1.1+ — federation-wide SBOM workflow tracked separately |
| [**SH-05**](#sh-05-consumer-extra-redaction-discipline) | Documentation / extension contract | `JsonFormatter` uses `Redactor.default()`. Domain-specific secret-bearing headers (a project-internal token, an OAuth nonce) require a custom redactor — the docstring shows the subclass pattern but doesn't show how to feed env-var-driven extra keys cleanly. | XS | ✅ closed |

Effort scale: XS (≤ 15 min); S (≤ 1 h); M (≤ 1 session).

---

## §3 Invariants we hold

The list of "closed elsewhere; do not re-open":

- **SH-INV-1** — `SECRET_KEY` itself is **never** carried in
  the bridge. Only `secret_key_present: bool` is exposed
  ([`_settings.py:79 + :87`](../../src/mgf/django/_settings.py#L79)).
  Per SC-01 (secrets in vault, not in observable surfaces).
- **SH-INV-2** — The contextvar is **reset in `finally`**
  ([`_middleware.py:60-65`](../../src/mgf/django/_middleware.py#L60))
  even on exception. A failing view doesn't leak the request
  id into the next request's context.
- **SH-INV-3** — Redactor + JsonFormatter + traceback scrubbing
  are **inherited from `mgf-common`**. The Django consumer's
  log records go through the same redactor pass as any other
  mgf-common consumer. Per SC-09 + SC-11.
- **SH-INV-4** — The Django app **label** `mgf_django` (not
  `mgf.common`) — renaming at the v0.1 sibling extraction
  prevents an old-label collision attack where a consumer's
  fake `mgf_common` app registers ahead of ours.
- **SH-INV-5** — Bootstrap is **single-shot per process** via
  module-level guard ([`_apps.py:33-34`](../../src/mgf/django/_apps.py#L33)).
  Multiple `ready()` calls don't re-bootstrap, so an attacker
  who could trigger a second `ready()` (e.g., via `override_settings`
  in a malicious test fixture) can't double-install excepthooks
  or duplicate the OTel pipeline.

---

## §4 Work units

### SH-01 X-Request-Id header injection

**Why:** Security-shaped framing of
[RA-01](RELIABILITY_AND_ACCURACY.md#ra-01-header-injection-on-x-request-id-reflect).
The same defect is two things at once:

- **Reliability** — bad input traverses the view and triggers
  500 at response-build time (RA framing).
- **Security** — a `\r\nSet-Cookie:` injection class. Most
  WSGI servers reject inbound CR/LF; some don't. Django's
  `BadHeaderError` catches outbound but only after the view
  has executed (DB writes, emails sent).

The security framing changes the priority:

- A 500 due to bad input is a UX issue.
- An accepted CR/LF in a header is a **response-splitting**
  precondition. If httpx/uwsgi/gunicorn accepts it inbound +
  Django echoes it AND something downstream of Django (a
  caching proxy) parses it differently, smuggling becomes
  possible.

**Concrete output:**

- A guard at the inbound read in
  [`_middleware.py:54-57`](../../src/mgf/django/_middleware.py#L54)
  that:
  - Rejects values containing `\r`, `\n`, `\0`, or any
    control character (the cookie-value rule from
    RFC 6265 §4.1.1 is a stricter shape — bytes 0x21-0x7E
    minus `;` `\` `"`).
  - Caps length at 128 chars.
  - On rejection: generate a fresh UUID4 (don't abort).
- The fix is THE SAME bytes as RA-01 — close in a single
  commit and add the test that BOTH the RA test (round-trip
  shape) and the SH test (response-splitting candidate
  rejected) cover.
- Add a unit test in `tests/unit/test_middleware_security.py`
  that:
  - Sends `X-Request-Id: foo\r\nSet-Cookie: m=1` and asserts:
    (a) the response's `X-Request-Id` is a fresh UUID;
    (b) the response has NO `Set-Cookie` header from the
    injection;
    (c) the response status is 200 (the bad input didn't
    abort the request).

**Locked in by:** the unit test pins the response-splitting
precondition.

**Cross-references:** [RA-01](RELIABILITY_AND_ACCURACY.md#ra-01-header-injection-on-x-request-id-reflect)
(twin item — close together); SC-09 (sink redaction
discipline at input boundaries); mgf-fastapi SH-01 (same
defect on the sister-sibling — close cross-sibling).

---

### SH-02 Middleware install-order — CSRF composability

**Why:** Django's `CsrfViewMiddleware` reads/writes session +
cookie state during request processing. If a CSRF audit log
fires (token mismatch, token missing) and the request-id
contextvar isn't bound, the audit log line has no
correlation id → operator can't trace the failed CSRF
attempt to a specific request.

The recipe doc shows install order:

```python
MIDDLEWARE = [
    "mgf.django.MgfRequestIdMiddleware",  # earliest
    "mgf.django.MgfContextMiddleware",
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    ...
]
```

`MgfRequestIdMiddleware` first is correct — every downstream
middleware (including CSRF) sees the contextvar bound. But
the docstring on the middleware classes doesn't explicitly
say "MUST install before any middleware that emits audit logs."

A consumer who installs CSRF first then ours sees:

- CSRF logs without `request_id`.
- View runs with `request_id` correctly bound.
- Response has `X-Request-Id`.

So the CSRF audit log is the broken case.

**Concrete output:**

- Expand the docstring on `MgfRequestIdMiddleware` with a
  "Composability" section:
  > **Install order:** This middleware MUST come BEFORE any
  > middleware that emits audit log lines (`CsrfViewMiddleware`,
  > `LocaleMiddleware`'s 404, etc.) — otherwise those log
  > records won't carry the request id.
- Optionally: a Django check (registered via `apps.checks`)
  that warns at startup if `MgfRequestIdMiddleware` is later
  in `MIDDLEWARE` than `CsrfViewMiddleware`. Self-diagnosing.
- Update the recipe doc with the explicit ordering rationale.

**Locked in by:** the docstring + (optional) Django check
make the rule visible at the right surface.

**Cross-references:** LG-03 (OTel correlation),
[`mgf.django.MgfContextMiddleware`](../../src/mgf/django/_middleware.py)
docstring.

---

### SH-03 Settings-bridge SECRET_KEY pin

**Why:** `DjangoSettingsBridge.from_django`
([`_settings.py:79 + :87`](../../src/mgf/django/_settings.py#L79)):

```python
secret_key = getattr(settings, "SECRET_KEY", "") or ""
return cls(
    ...
    secret_key_present=bool(secret_key),
    ...
)
```

The string `secret_key` is read into a local, then DISCARDED
after the `bool(...)` call. RA-INV-3 documents this. But:

- No test pins the invariant.
- A future refactor that adds `secret_key: str = ""` to the
  bridge fields would silently start carrying the secret in
  `model_dump_json` output.
- `mgf-common explain` (which dumps the bridge for the
  Django shape) is exactly the surface where a leak would
  reach an operator's screen or logs.

**Concrete output:**

- Add `tests/unit/test_settings_bridge_security.py` with:
  - A test that constructs the bridge via `from_django()`
    in a Django config that has `SECRET_KEY = "test-secret-12345"`,
    then asserts:
    - `bridge.secret_key_present is True`
    - `"test-secret-12345" not in bridge.model_dump_json()`
    - `"test-secret-12345" not in repr(bridge)`
    - The substring search runs against EVERY exposed field
      (use a parametrised version that iterates `model_fields`).
- An import-linter contract that `_settings.py` may NOT import
  anything from `mgf.common.vault` — prevents a future refactor
  that "helpfully" stores SECRET_KEY in the bridge from
  the vault path.

**Locked in by:** the unit test fails if the secret ever
reaches the bridge's output. The import-linter contract
prevents a class of accidental refactor.

**Cross-references:** SC-01 (secrets in vault); RA-INV-3
(the documented invariant).

---

### SH-04 Sibling SBOM and vuln scan in CI

**Why:** `mgf-common` ships a CycloneDX SBOM with every
release ([`docs/standards/RELEASING.md`](https://codeberg.org/magogi-admin/mgf-standard/src/branch/main/docs/standards/RELEASING.md) §2.3 + the
`.woodpecker.yml` `sbom` step). The sibling inherits the
DISCIPLINE but doesn't have its own SBOM-emission step.
Similarly for vuln scanning (`pip-audit`).

Impact:

- A downstream Django consumer that audits its supply chain
  via SBOM ingestion sees `mgf-common`'s SBOM but no
  `mgf-django`'s — partial coverage.
- A `pip-audit --strict` against the consumer's environment
  catches CVEs in mgf-common's deps, but a CVE in mgf-django's
  own dep set (django itself, the asgi shims) only surfaces
  if `pip-audit` runs against the consumer's full env.

**Concrete output:**

- Add a `.woodpecker.yml` (or `.github/workflows/ci.yml`)
  with:
  - `vuln-scan` step running `pip-audit --strict` against
    `dist/*.whl`.
  - `sbom` step running `cyclonedx-py` against `dist/*.whl`,
    emitting `dist/mgf-django-<version>.cdx.json`.
- Update the release flow in `docs/claude/CLAUDE.md` to
  include the SBOM step.
- Document the SBOM artefact path in `SECURITY.md` so
  downstream consumers know where to look.

**Locked in by:** the CI step is the mechanical check; a PR
that lands without `pip-audit` clean fails.

**Cross-references:** SC-10 (vuln scan), SC-18 (SBOM),
mgf-common's own SBOM emission as the reference pattern.

---

### SH-05 Consumer extra-redaction discipline

**Why:** `JsonFormatter`
([`_logging.py:58-59`](../../src/mgf/django/_logging.py#L58)):

```python
def __init__(self) -> None:
    super().__init__(redactor=Redactor.default())
```

`Redactor.default()` includes the well-known secret-shaped
header names (`Authorization`, `Cookie`, `X-Api-Key`, etc.).
But:

- A consumer using a custom auth header name (e.g.,
  `X-Project-Auth-Token`) doesn't get it redacted by default.
- The docstring shows the subclass pattern for adding
  `extra_keys`. But subclassing requires choosing a name +
  updating the Django `LOGGING` config — friction for the
  common case.

**Concrete output:**

- Option 1: support env-var-driven extra keys.
  - `MGF_DJANGO_REDACT_EXTRA_KEYS=foo,bar,baz` →
    `Redactor(extra_keys=frozenset({"foo", "bar", "baz"}))`.
  - Document the env var in the docstring.
  - Add a unit test that sets the env var + asserts the
    extra key is redacted.
- Option 2: support a class-attribute override.
  - Subclass example with `class extra_keys = frozenset({"foo"})`.
- Either works; env-var is more discoverable for the common
  case.
- Update the docstring with the chosen pattern explicit.

**Locked in by:** the unit test exercises the redaction;
a regression that drops env-var support fails it.

**Cross-references:** LG-04 (sensitive-field redaction
configurable per project), SC-09 (sink redaction).

---

## §5 Closed items

- **SH-01** — X-Request-Id header-injection guard. Added a
  `_sanitise_request_id` helper in `_middleware.py` that
  rejects inbound values containing CR/LF/NUL/any control
  character (ord < 0x20 or 0x7F) or longer than 128 chars.
  Rejected values fall through to a fresh `_generate_request_id()`
  UUID4 — no abort, no 5xx. Wired at the inbound read in
  `MgfRequestIdMiddleware.__call__`. Defence-in-depth: Django's
  `BadHeaderError` catches outbound, but only AFTER the view has
  executed (DB writes, emails). 5 new tests pin the rejection
  + the clean-value passthrough. Twin of RA-01 — same bytes,
  two trackers. (2026-05-16, commit pending.)
- **SH-02** — Middleware composability documented. Expanded the
  `MgfRequestIdMiddleware` docstring with a new "Composability
  (SH-02)" section explicitly stating that this middleware MUST
  install BEFORE any middleware that emits audit log lines
  (CsrfViewMiddleware, LocaleMiddleware, SessionMiddleware,
  consumer audit middleware). Without that ordering, the audit
  log line has no request-id correlation. The fix is purely
  documentation — the install-order rule was implicit in the
  recipe; now it's surfaced at the class docstring (the source
  of truth a future maintainer reads). (2026-05-16, commit
  pending.)
- **SH-03** — SECRET_KEY containment pin. Added a new
  `TestSecretKeyContainment` class with 6 tests that exercise
  every surface where the secret could leak: no `secret_key`
  attribute exists; secret absent from `model_dump_json()`,
  `model_dump()`, `repr()`, `str()`; and an iteration over
  every declared field asserting the secret never appears in
  any value's string representation. A future refactor that
  adds a `secret_key: str` field or a derived field embedding
  the secret (a hash, a JWT issuer string) fails the test at
  PR time. Error messages name the offending surface so the
  regression is fast to triage. (2026-05-16, commit pending.)

---

## §6 Notes on methodology

This first pass walked the five source files (~440 LOC
total) end-to-end against:

- **SC** rules from `mgf-standard/docs/standards/SECURITY_STANDARD.md`
  — SC-01 (secrets in vault), SC-09 (sink redaction),
  SC-10 (vuln scan), SC-11 (traceback scrubbing), SC-18
  (SBOM).
- **LG-04** (sensitive-field redaction configurable).
- mgf-common's own SECURITY_HARDENING.md as the methodology
  template.

The audit method:

1. Read each source file end-to-end with the SC rule set in
   mind.
2. For every concern surfaced, verify `file:line` against the
   live source.
3. Cross-reference against the sibling's RA tracker — when an
   item appears in both, this tracker reframes it in the
   security lens; the RA tracker stays as the correctness
   record.
4. Cross-reference against mgf-common's SH tracker — confirm
   inherited invariants don't need re-tracking.
5. Triage: items that don't tie to an SC rule or a concrete
   secret-handling concern are observations.

Five items + five invariants. The sibling's security shape
is intentionally thin — most secret-handling delegates to
mgf-common. Future passes should look for:

- **Async middleware variants.** When the v0.3+ async
  middleware lands (per RA-05's deferred follow-up), it
  introduces new contextvar-propagation paths; the redaction
  + audit-log shape needs re-verification.
- **Django version bumps.** Django security advisories
  (`django-security-list`) need to land in this sibling's
  CHANGELOG when they bump the floor pin (`django>=4.2`
  today).
- **Webhook-shape extensions.** If mgf-django ever grows
  webhook adapters (Django callable views handling Stripe /
  Clerk / similar), the HMAC verifier from mgf-fastapi
  becomes a candidate to share — the audit pattern lifts.

When an item closes, move it to §5 with the closing-commit
SHA.
