Properties
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.htmlhas inline<script>blocks (flash message rendering) — requires'unsafe-inline'for scripts.editor.htmlhas an inline<style>block — requires'unsafe-inline'for styles.editor.jsusespreview_block.innerHTML = data.preview_contentto 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 arbitraryhttps:src URLs (user-uploaded or markdown-linked images). Henceimg-src 'self' data: blob: https:.- Font CSS uses local
data:URIs for IE compat woff, sofont-src 'self' data:is needed. frame-ancestors 'none'enforces no-iframe (equivalent toX-Frame-Options: DENYfor 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:
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
/mcpitself (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
- Phase 1 (immediate): Add header blocks to Caddyfile, reload. Verify with
curl -I. - Phase 2a: Build xcaddy binary with rate_limit module, add to proxy Ansible repo.
- Phase 2b: Add rate_limit directives to Caddyfile template, deploy to proxy-1.
- Validate:
caddy list-modulesconfirms module; curl loops verify 429s after threshold.
Rollback
- Headers: remove block, reload. Strictly additive, minimal risk.
- Rate limits: remove directives or raise
eventsvalues, 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_limitzone names must be globally unique across the Caddyfile
