--- category: spec tags: [security, caddy, rate-limiting, headers, plan] last_updated: 2026-03-17 confidence: high --- # Rate Limiting and Security Headers Plan 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). ## Phase 1: Security Headers (no module change) ### Caddyfile snippet 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. ```caddyfile (security_headers) { header { # Remove server fingerprinting -Server # HSTS — 1 year. No preload (irreversible without HSTS preload list submission). # 1 month — conservative while project is pre-launch. Bump to 31536000 (1 year) post-launch. Strict-Transport-Security "max-age=2592000; includeSubDomains" # Prevent MIME sniffing X-Content-Type-Options "nosniff" # Block clickjacking. Use SAMEORIGIN if iframing within the same site is needed. X-Frame-Options "DENY" # Disable legacy XSS filter (modern browsers; CSP is the real mitigation) X-XSS-Protection "0" # Referrer: send origin on same-site, origin-only on cross-site HTTPS Referrer-Policy "strict-origin-when-cross-origin" # Permissions policy: disable sensors/hardware not used by the wiki Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" # CSP — see notes below for rationale 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'" } } ``` Apply it to the wiki site blocks: ```caddyfile # Otterwiki web UI wiki.robot.wtf { import security_headers import wiki_auth reverse_proxy 3gw.lan:8080 } # Auth, management UI (if served separately) *.robot.wtf { import security_headers # ... other directives } ``` **Do not import `security_headers` in the `mcp.robot.wtf` site block.** ### CSP rationale 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: - `layout.html` has inline `<script>` blocks (flash message rendering) — requires `'unsafe-inline'` for scripts. - `editor.html` has an inline `<style>` block — requires `'unsafe-inline'` for styles. - `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:`. - Font CSS uses local `data:` URIs for IE compat woff, so `font-src 'self' data:` is needed. - `frame-ancestors 'none'` enforces no-iframe (equivalent to `X-Frame-Options: DENY` for modern browsers; keep both for compatibility). Dropping `'unsafe-inline'` from `script-src` would require nonce injection in the app — that's an application change, out of scope for Phase 1. ### Verification After reload, verify with: ```bash curl -sI https://wiki.robot.wtf/ | grep -E '(strict-transport|x-content|x-frame|referrer|permissions|content-security|x-xss)' ``` Expected: all six response headers present. Confirm `Server:` header is absent. Also open browser DevTools → Console on the wiki to confirm no CSP violations are logged. ## Phase 2: Rate Limiting ### The problem 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: - **Flask-Limiter** in the app for per-route, per-IP limits (login, OAuth, API) - **fail2ban** on proxy-1 for reactive IP banning after auth failures (already running) - **nftables** as a volumetric DoS backstop (optional, future) ### Rate limits by endpoint | Endpoint | Limit | Rationale | |---|---|---| | `POST /auth/login` | 5/min per IP | Triggers external ATProto resolution | | `POST /auth/signup` | 3/min per IP | Creates user records | | `GET /auth/*` (general) | 60/min per IP | Mostly redirects/JSON | | MCP OAuth (`/authorize*`, `/token`, `/register`) | 5/min per IP | Consent logic | | MCP tool calls (`/mcp/*`) | 60/min per IP | AI agent bursts (30-60 calls/conversation) | | Wiki writes (`POST */save`, `*/attachments*`) | 20/min per IP | Content modification | | Wiki reads | No explicit limit (200/min if needed) | Low risk, cacheable | All per-IP using `{remote_host}`. User-level limits deferred to application layer. ### MCP considerations - SSE connections are long-lived — don't rate limit `/mcp` itself (the stream endpoint) - Rate limit `/mcp/*` (tool call messages) at 60/min — one AI conversation can make 30-60 calls - MCP OAuth flow gets auth-level limits (5/min) ## Implementation sequence 1. **Phase 1 (immediate):** Add header blocks to Caddyfile, reload. Verify with `curl -I`. 2. **Phase 2a:** Build xcaddy binary with rate_limit module, add to proxy Ansible repo. 3. **Phase 2b:** Add rate_limit directives to Caddyfile template, deploy to proxy-1. 4. **Validate:** `caddy list-modules` confirms module; curl loops verify 429s after threshold. ## Rollback - Headers: remove block, reload. Strictly additive, minimal risk. - Rate limits: remove directives or raise `events` values, reload. No restart needed. - Monitor with `journalctl -u caddy -f | grep '" 429 '` after deployment. ## Important notes - Caddy config is on proxy-1, managed by a **separate Ansible repo** (not robot.wtf) - proxy-1 is the outermost proxy — `{remote_host}` is the real client IP - `rate_limit` zone names must be globally unique across the Caddyfile
