Files
sessiongurad/docs/EDGE-SECURITY.md
jbergner 7c69432097
Some checks failed
release-tag / release-image (push) Successful in 2m3s
release-main / release-images (push) Failing after 3m9s
RC-2 0.5.2
2026-08-24 05:46:42 +02:00

5.9 KiB

Public Edge Security (Caddy + SessionGuard EdgeGuard)

SessionGuard EdgeGuard is a small Go service for the public reverse-proxy host. It is not another login and it is not a WAF replacement. It runs locally on 127.0.0.1:9081 and is called by Caddy using forward_auth before traffic is sent to SessionGuard or a Guacamole worker.

Threat model

EdgeGuard is intended to reduce the cost of common Internet abuse:

  • repeated scanners and exploit probes;
  • excessive requests from one source;
  • request floods intended to exhaust SessionGuard/Guacamole rather than the physical Internet link;
  • malformed/oversized URIs and unexpected HTTP methods;
  • accidental exposure of backend-only SessionGuard endpoints.

It cannot stop a volumetric attack that saturates the VPS uplink. Provider-side DDoS filtering/firewalling remains necessary for that class of attack.

Request flow

Browser
  -> Caddy TLS
      -> EdgeGuard /check (localhost only)
          -> 204: continue
          -> 403/404/405/421/429: stop at edge
      -> SessionGuard Access Auth
      -> Guacamole worker

Caddy's forward_auth sends a lightweight GET subrequest. EdgeGuard therefore checks the initial WebSocket handshake, but it is not in the byte path of the established Guacamole WebSocket/RDP stream.

Controls

Static blacklist

blacklist.txt accepts one IPv4/IPv6 address or CIDR per line. Comments start with #. Changes are automatically picked up.

203.0.113.44
198.51.100.0/24
2001:db8:1234::/48

Global overload limit

The global token bucket is intentionally high and protects the application backends when a distributed HTTP flood reaches the VPS. It does not prevent network saturation because TLS and the incoming packets already reached Caddy.

Default production example:

"global_limit": {
  "rate_per_second": 2500,
  "burst": 5000
}

Per-IP limit

The default is deliberately NAT-friendly:

"per_ip_limit": {
  "rate_per_second": 200,
  "burst": 500
}

For environments where many staff share one public NAT IP, do not aggressively lower this value. If a known source really needs exemption, put its address in rate-exempt.txt. Exempt sources still pass blacklist, scanner, method, host and global-overload checks.

Endpoint-specific limits

The example uses tighter limits for OIDC login/callback paths. These endpoints do not contain the PocketID password check itself; the limits are intended to protect state/session allocation and redirect processing from floods.

Bounded per-IP memory

max_tracked_ips bounds the in-memory table used for per-IP token buckets and offense state. The production example allows 100,000 active source addresses; when the table is full, previously unseen sources receive HTTP 429 instead of causing unbounded memory growth. Stale entries are cleaned up automatically.

Scanner detection and temporary bans

Known irrelevant exploit/scanner paths are denied before they reach the backends. The defaults include /.env, /.git, WordPress, phpMyAdmin, CGI, Spring Actuator and several common automated exploit probes.

The example auto-ban weights are:

  • scanner path: 5 points;
  • blocked method: 3 points;
  • malformed URI: 3 points;
  • ordinary rate-limit violation: 0 points.

At 10 points within 120 seconds, the IP is banned for 900 seconds. Bans are persisted in /var/lib/sessionguard-edgeguard/state.json with a short write debounce, so a container restart does not normally remove them while scanner floods cannot force one synchronous disk write per request.

Rate-limit violations deliberately have weight 0 by default to avoid banning a whole corporate NAT during a legitimate burst.

Blocked HTTP methods

CONNECT, TRACE and TRACK are rejected. SessionGuard/Guacamole continue to use their normal GET/POST/PUT/PATCH/DELETE/OPTIONS behavior.

Host allowlist

Only configured public host names are accepted by EdgeGuard. Caddy additionally uses strict_sni_host on, requiring the TLS SNI host and HTTP Host header to match.

Caddy hardening

The supplied public Caddyfile also configures:

servers {
    protocols h1 h2
    strict_sni_host on
    max_header_size 64KB
    timeouts {
        read_header 10s
    }
}

The short header timeout and smaller header ceiling reduce slow-header/resource exhaustion risk. HTTP/3 is intentionally disabled in the example to reduce the public protocol surface; Guacamole works with HTTP/1.1/WebSocket and HTTP/2.

Access logs are written to size-limited rotating files. Sampling keeps all normal traffic but reduces log amplification once a single logger exceeds 200 entries per second.

The public SessionGuard host denies /metrics and /api/v1/broker/* at Caddy. The Caddy admin API is disabled (admin off) on this dedicated public edge; configuration changes are applied by restarting the Caddy container. Guacamole workers call broker APIs directly over NetBird instead.

Metrics

EdgeGuard exposes Prometheus text metrics only on localhost:

http://127.0.0.1:9081/metrics

Counters include total checks, allows, static-blacklist denies, temporary-ban denies, rate-limit denies, scanner denies and auto-bans.

Configuration reload

edgeguard.json, blacklist.txt and rate-exempt.txt are re-read on the configured interval (15 seconds in the example). An invalid replacement config is logged and the previous working configuration remains active.

Operational recommendations

  1. Keep provider/network DDoS protection enabled.
  2. Expose only TCP 80/443 publicly; keep EdgeGuard on localhost.
  3. Keep NetBird peer/backend ports private.
  4. Pin Caddy to a currently patched release rather than an old major-only image during controlled production rollouts.
  5. Monitor HTTP 429 and EdgeGuard auto-ban counters before tightening limits.
  6. Do not put broad office/country CIDRs on the static blacklist without first checking whether legitimate remote users may originate there.