---
plan: 06-05
phase: 06-performance-production-hardening
status: complete
completed_at: 2026-06-04
commits:
  - a826738
self_check: PASSED
---

## What was done

### D-11 — Trusted-proxy CIDR check in `get_client_ip`

**Before:** `get_client_ip` unconditionally returned the first value of
`X-Forwarded-For` if present, falling back to `request.client.host`. Any
external caller could spoof their IP by adding an `X-Forwarded-For` header.

**After:** `get_client_ip` only honours `X-Forwarded-For` when the direct peer
(`request.client.host`) is in one of the trusted proxy CIDRs:
- `127.0.0.0/8` (loopback)
- `172.16.0.0/12` (Docker / internal)
- `192.168.0.0/16` (LAN)
- `::1/128` (IPv6 loopback)

Untrusted peers always return their own address. The CIDR list is a module-level
constant `_TRUSTED_PROXY_NETS` in `backend/deps/utils.py`. No new public function
was added; only the body and docstring of `get_client_ip` changed.

### D-12 — Per-account rate limiter (`account_limiter`)

Created `backend/services/rate_limiting.py` (new file, 18 lines):
- `_account_key(request)`: reads `request.state.current_user.id` if set,
  falls back to `request.client.host`, then `"anonymous"`.
- `account_limiter = Limiter(key_func=_account_key)`: singleton exported for
  use in routers.

### Wiring

- `backend/api/auth.py`: removed `from slowapi.util import get_remote_address`;
  changed `limiter = Limiter(key_func=get_remote_address)` to
  `limiter = Limiter(key_func=get_client_ip)`.
- `backend/main.py`: added `from services.rate_limiting import account_limiter`.
- `backend/api/documents.py`: 9 endpoints decorated with
  `@account_limiter.limit("100/minute")`; each handler's first line sets
  `request.state.current_user = current_user` (A1 ordering invariant).
- `backend/api/cloud.py`: 7 endpoints decorated identically (same pattern).

**Decorator counts:**
- `documents.py`: 9 `@account_limiter.limit` decorators, 9 assignments
- `cloud.py`: 7 `@account_limiter.limit` decorators, 7 assignments

### D-13 — 8 xfail tests promoted

All 8 stubs in `backend/tests/test_rate_limiting.py` promoted from `xfail` to
real assertions. No `@pytest.mark.xfail` markers remain.

A cross-test contamination issue was also fixed: `backend/tests/conftest.py`
gained an autouse fixture `reset_rate_limiter` that calls
`account_limiter._storage.reset()` before and after each test, preventing
in-memory counters from accumulating across tests and causing spurious 429s.

## Final pytest result

```
352 passed, 5 skipped, 7 xfailed
1 pre-existing failure (test_extract_docx — ModuleNotFoundError: No module named 'docx', unrelated to this plan)
```

All 8 rate limiting tests pass (8 passed, 0 xfailed).
