Blame
|
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 | ||||||||
|
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. |
|||||||
|
17 | |||||||
| 18 | ```caddyfile |
|||||||
|
19 | (security_headers) { |
||||||
| 20 | header { |
|||||||
| 21 | # Remove server fingerprinting |
|||||||
| 22 | -Server |
|||||||
| 23 | ||||||||
| 24 | # HSTS — 1 year. No preload (irreversible without HSTS preload list submission). |
|||||||
|
25 | # 1 month — conservative while project is pre-launch. Bump to 31536000 (1 year) post-launch. |
||||||
| 26 | Strict-Transport-Security "max-age=2592000; includeSubDomains" |
|||||||
|
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 | } |
|||||||
|
46 | } |
||||||
| 47 | ``` |
|||||||
| 48 | ||||||||
|
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. |
|||||||
|
91 | |||||||
|
92 | ## Phase 2: Rate Limiting |
||||||
|
93 | |||||||
| 94 | ### The problem |
|||||||
| 95 | ||||||||
|
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) |
|||||||
|
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 |
|||||||
