# 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 ten
> source files against the **SC-*** rules in
> `mgf-standard/docs/standards/SECURITY_STANDARD.md` plus the
> sibling-specific concerns (HMAC verification, bearer-session
> handling, IP allowlist proxy-trust, exception-translation
> response shape, test API server). This sibling has the
> **densest** security surface of the federation siblings — the
> webhook verifier + bearer-session resolver are
> security-critical primitives.
>
> **Scope:** mgf-fastapi's OWN security posture — the FastAPI-
> adapter surface including the security primitives shipped
> here. NOT a re-audit of mgf-common's redactor / vault /
> crash reporter (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 FastAPI / Starlette / pydantic — those have
> their own upstream security processes.
>
> **Audience:** the maintainer + future AI agents.
> **Cross-references:**
> [`RELIABILITY_AND_ACCURACY.md`](RELIABILITY_AND_ACCURACY.md)
> (sibling tracker — several security-adjacent items live there
> under the RA lens),
> [`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).

The sibling carries four security-critical primitives:
**HmacWebhookVerifier** (constant-time HMAC over Svix-shaped
signatures), **bearer_session** (PAPER-29 token resolution),
**IpAllowlist** (PAPER-27 CIDR gate with proxy-trust footgun),
**ExceptionTranslationMiddleware** (response-body shape on
error). All four were closed with their own paper retrospectives;
this audit verifies they hold and surfaces the next-pass items.

---

## §1 You are here

mgf-fastapi ships ~15 public names; `mypy --strict` clean;
the security-critical primitives carry their PAPER-26..29
test suites + retrospective coverage; HMAC verification uses
`hmac.compare_digest`; bearer-session translates every
verifier failure to `HttpUnauthorized` (no info-leak);
IpAllowlist parses CIDRs eagerly + documents the proxy-trust
footgun.

What this tracker exposes is the **edge-of-primitive shape**
— the boundaries around each security primitive that aren't
the primitive itself: how data flows IN to the verifier, how
errors flow OUT of the translator, how operator misconfigurations
surface, how the test API server fits into security testing.

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

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

**Do first:** SH-01..04 closed (P1). SH-05 closed (P2 — bearer
token control-char filter — same code change as RA-08). SH-06
(test-app CORS docs) deferred to v1.1+. SH-07 (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 | Same pattern as mgf-django SH-01: `RequestIdMiddleware` echoes inbound header unchecked. The two siblings share the contextvar; close both together with a uniform validation rule. | S | ✅ closed |
| [**SH-02**](#sh-02-hmac-verifier-prefix-collision-edge) | Crypto primitive boundary | `HmacWebhookVerifier.verify` iterates `candidates = sig_header.split(" ")` (or `.split()` per RA-02). Each candidate is matched against `signature_prefix` (default `"v1,"`). A malicious signature with a prefix that's a SUBSTRING of the configured prefix could match incorrectly if `signature_prefix` is configured very loose. Defensive validation at construction. | S | ✅ closed |
| [**SH-03**](#sh-03-ipallowlist-trust-proxy-ci-lint) | Mis-configuration class | `IpAllowlist(trust_proxy=True)` honours `X-Forwarded-For[0]` as the client IP. Per PAPER-27, this is correct only behind a trusted reverse proxy. Today the footgun lives only in docstrings; no mechanical check that the trust_proxy flag isn't set without an upstream-proxy-trust comment. | S | ✅ closed |
| [**SH-04**](#sh-04-exception-message-leak-via-translation-middleware) | Information disclosure | `_default_response_factory` returns `{"type": "...", "message": str(exc)}` in the JSON body. If a consumer-side exception carries sensitive data in args (`raise OperationError(f"failed to fetch user {user_id} with token {token}")`), the token reaches the HTTP response body. Documented limitation; defence-in-depth would scrub. | S | ✅ closed |
| [**SH-05**](#sh-05-bearer-session-cookie-cookie-attribute-not-validated) | Token-handling edge | When `cookie_name` is set for bearer-session fallback, the cookie value is passed to `verify_session(token)` without checking the cookie's `HttpOnly` / `Secure` / `SameSite` attributes — those are browser-set, but a Worker / non-browser client could pass cookies that browsers would have rejected. Documented carve-out is enough; surface in docs. | XS | ✅ closed |
| [**SH-06**](#sh-06-test-api-server-cors-defaults) | Test surface hardening | `run_test_app` runs a real uvicorn on a free port (PAPER-28). The default CORS shape inherits from the consumer's FastAPI config. If a test consumer's app has permissive CORS for development, the test server is reachable from arbitrary origins — usually fine for `127.0.0.1`, but a CI runner with shared network exposes the test endpoints. | XS | ⏳ v1.1+ — docs item; not v1.0-blocking because `run_test_app` is test-harness scope only |
| [**SH-07**](#sh-07-sibling-sbom-and-vuln-scan-in-ci) | Supply chain | Same gap as mgf-django SH-04. The sibling inherits mgf-common's SBOM + vuln-scan discipline; needs its own CI shape. | S | ⏳ v1.1+ — federation-wide SBOM workflow tracked separately |

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** — HMAC verification uses **constant-time
  compare**. `hmac.compare_digest(received, expected)` at
  [`webhooks/_verifier.py:185`](../../src/mgf/fastapi/webhooks/_verifier.py#L185).
  No timing-oracle leak on the signature comparison.
- **SH-INV-2** — Replay window is **symmetric**. `abs(self._now() - ts)`
  ([`webhooks/_verifier.py:164`](../../src/mgf/fastapi/webhooks/_verifier.py#L164))
  rejects future-clock-skewed timestamps as well as stale
  ones.
- **SH-INV-3** — Bearer-session **never logs the token**.
  Only the exception type name on rejection
  ([`security.py:278`](../../src/mgf/fastapi/security.py#L278)).
  Per PAPER-29's "no info-leak" contract.
- **SH-INV-4** — `bearer_session` translates **every non-
  HttpUnauthorized failure** to `HttpUnauthorized`. Verifier
  500s, network blips, expired JWTs — all become 401.
  Callers cannot distinguish "invalid token" from "IdP
  outage" — PAPER-29.
- **SH-INV-5** — `IpAllowlist` parses CIDRs **eagerly at
  construction** ([`security.py:119-123`](../../src/mgf/fastapi/security.py#L119)).
  Misconfigured CIDR raises at app startup, not at first
  request.
- **SH-INV-6** — `trust_proxy=False` is the **default** for
  `IpAllowlist` ([`security.py:112`](../../src/mgf/fastapi/security.py#L112)).
  Operators MUST explicitly opt in to honour `X-Forwarded-For`.
- **SH-INV-7** — Redactor + traceback scrubbing **inherited
  from mgf-common**. FastAPI app log records go through the
  same redactor pass as any other mgf-common consumer.
  Per SC-09 + SC-11.
- **SH-INV-8** — `ExceptionTranslationMiddleware` never logs
  the exception body or args before responding. Only the
  type name is exposed in the response body (plus `str(exc)`
  — see SH-04).

---

## §4 Work units

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

**Why:** Same defect as mgf-django SH-01. The request-id
middleware echoes inbound header without sanitization
([`_request_id.py:67`](../../src/mgf/fastapi/_request_id.py#L67)):

```python
existing = request.headers.get(REQUEST_ID_HEADER.lower())
request_id = existing or _generate_request_id()
...
response.headers[REQUEST_ID_HEADER] = request_id
```

mgf-django + mgf-fastapi share the `mgf.common._request_id`
contextvar — they MUST validate the same way to keep the
cross-framework contract uniform.

**Concrete output:** Same fix shape as mgf-django SH-01.
Reject CR/LF/NUL; cap at 128 chars; regenerate UUID on
rejection. Close in a single coordinated commit pair across
both siblings. Unit test asserts the response has no
injected headers.

**Locked in by:** the unit test; the cross-sibling close
preserves the contract.

**Cross-references:** mgf-django SH-01 (twin); RA-01 (the
correctness twin in this sibling's RA tracker).

---

### SH-02 HMAC verifier prefix-collision edge

**Why:** `HmacWebhookVerifier.verify`
([`webhooks/_verifier.py:179-187`](../../src/mgf/fastapi/webhooks/_verifier.py#L179)):

```python
candidates = sig_header.split(" ")
for candidate in candidates:
    if not candidate.startswith(self._signature_prefix):
        continue
    received = candidate[len(self._signature_prefix):]
    if hmac.compare_digest(received, expected):
        return event_id, ts
```

The default `signature_prefix="v1,"` is tight (single
versioning byte + comma). The constructor accepts any prefix
string. If a consumer mis-configures:

```python
HmacWebhookVerifier(secret=..., signature_prefix="v")  # too permissive
```

then EVERY candidate that starts with `v` matches. Combined
with a malicious sender who knows the prefix:

```
v<our-expected-signature-bytes>
```

— the signature value is the full base64 sig with no
versioning. Defeats the version-namespace + key-rotation
shape.

This is a misuse case, not an attack against the default —
but the constructor MUST validate.

**Concrete output:**

- Add a constructor-time validation:
  ```python
  if not signature_prefix or len(signature_prefix) < 2:
      raise ApiprobeConfigError(
          "signature_prefix must be at least 2 chars to avoid "
          "ambiguity with the signature bytes (got %r)" % signature_prefix
      )
  ```
- Document the minimum length in the docstring.
- Add a unit test that asserts the constructor raises on
  short prefixes.

**Locked in by:** the test pins the validation; a regression
that drops it fails.

**Cross-references:** PAPER-26 retrospective; SC-09; mgf-common
SH-03 (Redactor robustness — similar "consumer-misconfig
defence-in-depth" pattern).

---

### SH-03 IpAllowlist trust_proxy CI lint

**Why:** `IpAllowlist`
([`security.py:76-168`](../../src/mgf/fastapi/security.py#L76))
ships with `trust_proxy=False` as the safe default. Setting
`trust_proxy=True` is correct **only** when:

- A trusted reverse proxy (Envoy, nginx, Cloudflare, AWS
  ALB) is in front.
- That proxy STRIPS any client-supplied `X-Forwarded-For`
  before forwarding.

If neither holds, an attacker controls the source IP just by
sending `X-Forwarded-For: 10.0.0.1` — completely defeating
the allowlist.

The docstring is loud about this. But a consumer who copies
+ pastes a code snippet (or AI-generates a similar shape)
might land:

```python
admin_only = IpAllowlist(
    cidrs=["10.0.0.0/8"],
    trust_proxy=True,  # easily becomes True without the proxy story
)
```

Mechanical defence:

- Lint the consumer's codebase for `trust_proxy=True` without
  an adjacent `# Trusted: behind <proxy>` comment.
- Or: log a WARNING audit event the first time
  `IpAllowlist(trust_proxy=True)` is constructed — operator
  awareness at startup.

**Concrete output:**

- Add a startup-time audit log:
  ```python
  if trust_proxy:
      LOG.warning(
          "IpAllowlist constructed with trust_proxy=True. "
          "Confirm that an upstream reverse proxy strips "
          "client-supplied X-Forwarded-For. PAPER-27."
      )
  ```
- Optionally: ship a `ruff` rule snippet in the recipe doc
  that lints `trust_proxy=True` calls without a comment.
- Add a unit test that asserts the log fires on
  `trust_proxy=True` and is silent on the default.

**Locked in by:** the unit test pins the log emission.

**Cross-references:** PAPER-27; SC-09 (audit logging).

---

### SH-04 Exception message leak via translation middleware

**Why:** `_default_response_factory`
([`_exceptions.py:179-193`](../../src/mgf/fastapi/_exceptions.py#L179)):

```python
def _default_response_factory(exc, status, request_id):
    body = {
        "error": {
            "type": type(exc).__name__,
            "message": str(exc),
            ...
        }
    }
    return JSONResponse(body, status_code=status)
```

The `str(exc)` reaches the HTTP response body. If a
consumer-side exception is constructed with sensitive data
in args:

```python
raise OperationError(
    f"failed to validate token {token!r} for user {user_email}"
)
```

— then `str(exc)` becomes the response body. The TOKEN +
EMAIL reach the client (or whoever can read the response).

This is a documented limitation: the message goes to the
client; consumers control what's in `exc.args`. But:

- A general best-practice violation (LG-04 redaction-before-
  egress) is breached.
- Defence-in-depth says the middleware should redact via
  mgf-common's `Redactor` before placing the message in the
  response body.

**Concrete output:**

- Add a `redact_response_body: bool = True` kwarg to
  `ExceptionTranslationMiddleware`.
- When True, run `Redactor.default().scrub_string(str(exc))`
  before placing in the body.
- When False (consumer opt-out for performance OR for cases
  where the exception args are known safe), the current
  behaviour stands.
- Document in the docstring: "By default the response body
  goes through the same redaction pass as log records. Set
  `redact_response_body=False` to opt out."
- Add a unit test: an exception with `args=("secret=abc",)`
  produces a response with `<redacted>` (or whatever the
  redactor's placeholder is) in the message.

**Locked in by:** the unit test pins the redaction; a
regression in the default behaviour fails it.

**Cross-references:** LG-04 (sink redaction), SC-09; SC-11
(traceback scrubbing — same principle on a sibling surface).

---

### SH-05 Bearer-session cookie attribute not validated

**Why:** `_extract_bearer_token`
([`security.py:287-309`](../../src/mgf/fastapi/security.py#L287))
falls back to a cookie value when the Authorization header
is missing:

```python
if cookie_name:
    cookie = request.cookies.get(cookie_name)
    if cookie:
        token = cookie.strip()
        if token:
            return token
```

For browser clients, the cookie attributes (`HttpOnly`,
`Secure`, `SameSite`) are set by the SERVER when the session
is created and ENFORCED by the browser when sending. So a
cookie present in the request was previously set with
correct attributes — the security model holds.

For non-browser clients (curl, Python httpx scripts, the
test API server, malicious clients spoofing browser
behaviour), the cookie attributes mean nothing. The fallback
trusts whatever's in the cookie jar.

This is documented behaviour — bearer-token-by-header is
the canonical path; cookie fallback exists for browser apps
that store the session in an HttpOnly cookie. But the
docstring doesn't call out the trust assumption.

**Concrete output:**

- Expand the docstring on `bearer_session`'s `cookie_name=`
  parameter:
  > **Trust model:** The cookie attributes
  > (`HttpOnly`/`Secure`/`SameSite`) are set by the
  > consumer's auth provider when the session is established
  > and enforced by the browser. Non-browser clients can
  > pass arbitrary cookie values; the dependency does not
  > validate the cookie's attributes. This is correct for
  > the browser-client case but means a malicious non-
  > browser client can probe the auth surface via the
  > cookie path as easily as via the header path. Treat
  > both as equivalent attack surface in your AuthVerifier.

**Locked in by:** docstring change is review-level; no
behaviour change.

**Cross-references:** PAPER-29; SC-11 (info-leak avoidance).

---

### SH-06 Test API server CORS defaults

**Why:** `run_test_app`
([`testing.py:47-…`](../../src/mgf/fastapi/testing.py#L47))
runs a real uvicorn on a free port + yields the URL for
e2e tests. The FastAPI app's CORS configuration is
preserved as-is (the helper doesn't override it).

For a CI runner with shared network access (e.g., Docker-
network test fixtures, multi-tenant CI), the test server
is reachable from any container on the same network. If
the FastAPI app's CORS allows `*` (or `localhost` —
wildcard-prefix matching), a malicious co-tenant can probe.

This is unlikely to bite a single-tenant operator CI. But
for shared-CI (GitHub Actions self-hosted runners shared
across projects, an organization with many concurrent test
runs), worth thinking about.

**Concrete output:**

- Add an explicit note to `run_test_app`'s docstring:
  > **CI hardening:** The test server inherits the
  > consumer's CORS config. For shared-CI environments,
  > consider passing a hardened FastAPI app instance with
  > strict CORS for the test run.
- Optionally: add a `harden_cors_for_tests: bool = False`
  flag that wraps the app with a strict-CORS middleware
  before serving.
- Document in `docs/recipes/`.

**Locked in by:** the docstring + (optional) the flag; no
breaking behaviour change.

**Cross-references:** PAPER-28; DEP-08 (observability —
CORS misconfig surfaces in access logs).

---

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

**Why:** Same gap as mgf-django SH-04. The sibling doesn't
ship its own CycloneDX SBOM or vuln-scan CI gate.

**Concrete output:** Same shape as mgf-django SH-04.
Generate SBOM at release time; run `pip-audit --strict`
against dist/.

**Locked in by:** the CI step.

**Cross-references:** SC-10, SC-18; mgf-django SH-04 (twin).

---

## §5 Closed items

- **SH-01** — X-Request-Id header-injection guard. Twin of
  mgf-django SH-01. Added `_sanitise_request_id` helper in
  `_request_id.py` rejecting CR/LF/NUL/control-chars + values
  longer than 128 chars; rejected values fall through to a
  fresh UUID4. Same fix shape as mgf-django's — the two
  siblings share the `mgf.common._request_id` contextvar and
  use identical validation to keep the cross-framework contract
  uniform. 5 new tests pin the rejection (CR/LF, LF-only, NUL,
  overlong, + clean-value passthrough). (2026-05-16, commit
  pending.)
- **SH-02** — HMAC verifier prefix-length validation. Added a
  `ValueError` at `HmacWebhookVerifier.__init__` if
  `signature_prefix` is empty or shorter than 2 chars. The
  default `"v1,"` (3 chars) is unaffected; a misconfigured
  `signature_prefix="v"` (single-char) is rejected loudly at
  construction so the version-namespace ambiguity is caught
  at the moment it's introduced, not at first request. 4 new
  tests pin the validation. (2026-05-16, commit pending.)
- **SH-03** — IpAllowlist trust_proxy audit log. Added a
  startup-time WARNING log emission in
  `IpAllowlist.__init__` when `trust_proxy=True`. Message
  names the flag + cross-references PAPER-27 for the threat
  model. The safe default (`trust_proxy=False`) is silent —
  operators only see the warning when they take the risky
  shape, so they don't learn to ignore it. 2 new tests pin
  the emission (warning fires on True; silent on False).
  (2026-05-16, commit pending.)
- **SH-04** — Exception message redaction. Added a
  `redact_response_body: bool = True` kwarg to
  `ExceptionTranslationMiddleware.__init__`. When True (the
  default), the dispatch loop scrubs `exc.args` through
  mgf-common's Redactor before calling the response factory —
  a consumer-side `raise OperationError(f"... token={t}")`
  no longer leaks the token to the HTTP response body. Mutates
  `exc.args` in place (safe: the exception is about to be
  discarded). Custom response factories are opt-out by passing
  `redact_response_body=False`. 3 new tests pin the default-on
  scrub, the opt-out preserves-message, + the non-secret-
  unaltered sanity case. (2026-05-16, commit pending.)

---

## §6 Notes on methodology

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

- **SC** rules from `mgf-standard/docs/standards/SECURITY_STANDARD.md`
  — every applicable SC-* (especially SC-09 sink redaction,
  SC-11 traceback scrubbing, SC-10 vuln scan, SC-18 SBOM).
- The four PAPER-26..29 retrospectives — each shipped paper
  carries its own threat model + test corpus; this audit
  verifies each holds + surfaces follow-up items.
- 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.
4. Cross-reference against the cornerstone's SH tracker —
   inherited invariants don't get re-tracked.
5. Cross-reference against the sister-sibling tracker
   (mgf-django) — items that appear in BOTH (SH-01 here =
   SH-01 in mgf-django) close together.

Seven items + eight invariants. The sibling has the densest
security surface of the federation siblings (auth + HMAC +
IP allowlist + error translation). Future passes should look
for:

- **WebSocket-shape extensions.** FastAPI WebSocket
  endpoints would introduce per-connection auth + rate
  limiting concerns not covered by request/response
  primitives today.
- **Async-context propagation security.** The contextvar
  shape (request-id + AppContext) crosses await boundaries
  cleanly; future async-task children inherit it. If a
  consumer-side `asyncio.create_task` leaks state across
  task scope, the inherited contextvar could surface in
  unexpected places.
- **Bumping FastAPI / starlette major version.** Each major
  ships its own deprecations + occasional security fixes;
  the sibling's pin floor (`fastapi>=0.110`) needs periodic
  review.
- **PAPER-26 follow-up on Stripe / GitHub / Slack
  signing variants.** Each provider has its own HMAC shape
  (combined-header vs separate, hex vs base64, alternate
  prefix). When mgf-fastapi adds a second verifier, the
  SH audit should look for parity invariants.

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