Blame

354dfd Claude (MCP) 2026-03-17 16:47:29
[mcp] Add rate limiting and security headers plan
1
---
2
category: spec
3
tags: [security, caddy, rate-limiting, headers, plan]
4
last_updated: 2026-03-17
5
confidence: high
6
---
7
8
# Rate Limiting and Security Headers Plan
9
10
Both handled in Caddy on proxy-1. Two phases: security headers (immediate, zero risk) and rate limiting (requires xcaddy rebuild for the rate_limit module).
11
12
## Phase 1: Security Headers (no module change)
13
72a3de Claude (MCP) 2026-03-17 19:50:46
[mcp] Phase 1: expand security headers section with specific Caddy directives and CSP rationale
14
### Caddyfile snippet
15
16
Define a named snippet at the top of the Caddyfile (before any site blocks) and import it into the wiki site blocks. **Do not apply to the MCP site block** — MCP endpoints return JSON and the browser security headers are irrelevant there, and CSP in particular can cause unexpected behavior with SSE clients.
354dfd Claude (MCP) 2026-03-17 16:47:29
[mcp] Add rate limiting and security headers plan
17
18
```caddyfile
72a3de Claude (MCP) 2026-03-17 19:50:46
[mcp] Phase 1: expand security headers section with specific Caddy directives and CSP rationale
19
(security_headers) {
20
header {
21
# Remove server fingerprinting
22
-Server
23
24
# HSTS — 1 year. No preload (irreversible without HSTS preload list submission).
b1557a Claude (MCP) 2026-03-17 21:30:40
[mcp] Reduce HSTS max-age to 1 month for pre-launch phase
25
# 1 month — conservative while project is pre-launch. Bump to 31536000 (1 year) post-launch.
26
Strict-Transport-Security "max-age=2592000; includeSubDomains"
72a3de Claude (MCP) 2026-03-17 19:50:46
[mcp] Phase 1: expand security headers section with specific Caddy directives and CSP rationale
27
28
# Prevent MIME sniffing
29
X-Content-Type-Options "nosniff"
30
31
# Block clickjacking. Use SAMEORIGIN if iframing within the same site is needed.
32
X-Frame-Options "DENY"
33
34
# Disable legacy XSS filter (modern browsers; CSP is the real mitigation)
35
X-XSS-Protection "0"
36
37
# Referrer: send origin on same-site, origin-only on cross-site HTTPS
38
Referrer-Policy "strict-origin-when-cross-origin"
39
40
# Permissions policy: disable sensors/hardware not used by the wiki
41
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()"
42
43
# CSP — see notes below for rationale
44
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
45
}
354dfd Claude (MCP) 2026-03-17 16:47:29
[mcp] Add rate limiting and security headers plan
46
}
47
```
48
72a3de Claude (MCP) 2026-03-17 19:50:46
[mcp] Phase 1: expand security headers section with specific Caddy directives and CSP rationale
49
Apply it to the wiki site blocks:
50
51
```caddyfile
52
# Otterwiki web UI
53
wiki.robot.wtf {
54
import security_headers
55
import wiki_auth
56
reverse_proxy 3gw.lan:8080
57
}
58
59
# Auth, management UI (if served separately)
60
*.robot.wtf {
61
import security_headers
62
# ... other directives
63
}
64
```
65
66
**Do not import `security_headers` in the `mcp.robot.wtf` site block.**
67
68
### CSP rationale
69
70
Otterwiki's frontend (audited from templates): all JS and CSS are self-hosted (`/static/`). No external CDNs. MathJax, Mermaid, Font Awesome, and Roboto fonts are all bundled. However:
71
72
- `layout.html` has inline `<script>` blocks (flash message rendering) — requires `'unsafe-inline'` for scripts.
73
- `editor.html` has an inline `<style>` block — requires `'unsafe-inline'` for styles.
74
- `editor.js` uses `preview_block.innerHTML = data.preview_content` to render server-fetched HTML into the DOM — this is XSS-safe as long as the server sanitizes, but means wiki content may include `<img>` tags with arbitrary `https:` src URLs (user-uploaded or markdown-linked images). Hence `img-src 'self' data: blob: https:`.
75
- Font CSS uses local `data:` URIs for IE compat woff, so `font-src 'self' data:` is needed.
76
- `frame-ancestors 'none'` enforces no-iframe (equivalent to `X-Frame-Options: DENY` for modern browsers; keep both for compatibility).
77
78
Dropping `'unsafe-inline'` from `script-src` would require nonce injection in the app — that's an application change, out of scope for Phase 1.
79
80
### Verification
81
82
After reload, verify with:
83
84
```bash
85
curl -sI https://wiki.robot.wtf/ | grep -E '(strict-transport|x-content|x-frame|referrer|permissions|content-security|x-xss)'
86
```
87
88
Expected: all six response headers present. Confirm `Server:` header is absent.
89
90
Also open browser DevTools → Console on the wiki to confirm no CSP violations are logged.
354dfd Claude (MCP) 2026-03-17 16:47:29
[mcp] Add rate limiting and security headers plan
91
7fb60a Claude (MCP) 2026-03-17 21:32:52
[mcp] Update rate limiting plan: Flask-Limiter + fail2ban instead of xcaddy
92
## Phase 2: Rate Limiting
354dfd Claude (MCP) 2026-03-17 16:47:29
[mcp] Add rate limiting and security headers plan
93
94
### The problem
95
7fb60a Claude (MCP) 2026-03-17 21:32:52
[mcp] Update rate limiting plan: Flask-Limiter + fail2ban instead of xcaddy
96
Standard Caddy Debian package has no rate limiting module. `caddy add-package` is not viable (doesn't survive `apt upgrade`, slated for removal from Caddy core). Instead, rate limiting is split across layers:
97
98
- **Flask-Limiter** in the app for per-route, per-IP limits (login, OAuth, API)
99
- **fail2ban** on proxy-1 for reactive IP banning after auth failures (already running)
100
- **nftables** as a volumetric DoS backstop (optional, future)
354dfd Claude (MCP) 2026-03-17 16:47:29
[mcp] Add rate limiting and security headers plan
101
102
### Rate limits by endpoint
103
104
| Endpoint | Limit | Rationale |
105
|---|---|---|
106
| `POST /auth/login` | 5/min per IP | Triggers external ATProto resolution |
107
| `POST /auth/signup` | 3/min per IP | Creates user records |
108
| `GET /auth/*` (general) | 60/min per IP | Mostly redirects/JSON |
109
| MCP OAuth (`/authorize*`, `/token`, `/register`) | 5/min per IP | Consent logic |
110
| MCP tool calls (`/mcp/*`) | 60/min per IP | AI agent bursts (30-60 calls/conversation) |
111
| Wiki writes (`POST */save`, `*/attachments*`) | 20/min per IP | Content modification |
112
| Wiki reads | No explicit limit (200/min if needed) | Low risk, cacheable |
113
114
All per-IP using `{remote_host}`. User-level limits deferred to application layer.
115
116
### MCP considerations
117
118
- SSE connections are long-lived — don't rate limit `/mcp` itself (the stream endpoint)
119
- Rate limit `/mcp/*` (tool call messages) at 60/min — one AI conversation can make 30-60 calls
120
- MCP OAuth flow gets auth-level limits (5/min)
121
122
## Implementation sequence
123
124
1. **Phase 1 (immediate):** Add header blocks to Caddyfile, reload. Verify with `curl -I`.
125
2. **Phase 2a:** Build xcaddy binary with rate_limit module, add to proxy Ansible repo.
126
3. **Phase 2b:** Add rate_limit directives to Caddyfile template, deploy to proxy-1.
127
4. **Validate:** `caddy list-modules` confirms module; curl loops verify 429s after threshold.
128
129
## Rollback
130
131
- Headers: remove block, reload. Strictly additive, minimal risk.
132
- Rate limits: remove directives or raise `events` values, reload. No restart needed.
133
- Monitor with `journalctl -u caddy -f | grep '" 429 '` after deployment.
134
135
## Important notes
136
137
- Caddy config is on proxy-1, managed by a **separate Ansible repo** (not robot.wtf)
138
- proxy-1 is the outermost proxy — `{remote_host}` is the real client IP
139
- `rate_limit` zone names must be globally unique across the Caddyfile