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).
        Strict-Transport-Security "max-age=31536000; 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:

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 (requires xcaddy)

The problem

Standard Caddy Debian package has no rate limiting module. Need to build with xcaddy build --with github.com/mholt/caddy-ratelimit.

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