---
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
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9