Files
vacuum-wall/docs/security.md
T

29 KiB
Raw Blame History

Security Model

Privilege Model

Vacuum Wall uses two distinct system users bridged by a shared group (the WebUI user's primary group):

  • vacuum-walld (daemon user): Runs the vacuum-walld background daemon, which is the only process with sudo access. The daemon communicates with the WebUI over a Unix socket at data/daemon.sock. All privileged operations — firewall rule changes, nginx reloads, dnsmasq config writes, WireGuard tunnel management — are executed by the daemon through a restricted sudo whitelist at /etc/sudoers.d/vacuum-walld.
  • WebUI user (default: repo owner in --dev mode): Runs the Flask management WebUI. Has zero sudo access. If the WebUI process is compromised, an attacker cannot invoke sudo directly — they are confined to the sandboxed Flask process with no privilege escalation path.

ACME certificate operations via acme.sh run as the daemon user ({{ USER_DAEMON_NAME }}) — never as root, and never from the WebUI process (the WebUI never invokes acme.sh directly). The automated renewal timer (vacuum-wall-acme.timer) runs acme.sh --cron as {{ USER_DAEMON_NAME }}. Issuance and renewal triggered from the WebUI are executed by the daemon as its own subprocess, using webroot validation that does not require binding to privileged ports; the only sudo call around acme.sh is the chmod g+rwX that reopens group access on the ACME home (see Sudo Whitelist).

This design follows the principle of least privilege: only the daemon process holds sudo access, and only for explicitly enumerated commands. The WebUI user is completely isolated from sudo.

Authentication (JWT validation, token blacklist check, permission verification) is performed at the Flask layer — not the daemon. The daemon only receives requests from the Flask process over the Unix socket, which carries no authentication of its own: access to it is protected purely by the socket's 0660 mode and shared-group ownership. The JWT handshake exists on the daemon's WebSocket endpoint: WebSocket connections to the daemon require a JWT access token, sent as the raw Sec-WebSocket-Protocol subprotocol name (the legacy Bearer <token> subprotocol and an X-Auth-Token header fallback are also accepted), validated before the socket upgrades.

Communication Between WebUI and Daemon

The WebUI communicates with the daemon via synchronous HTTP requests over a Unix socket (data/daemon.sock), owned by vacuum-walld:<group> with mode 0660. The shared group membership allows the WebUI user to connect to the socket. The daemon runs an aiohttp server that routes requests to handler modules (daemon/handlers/*.py), which execute the privileged commands.

Sudo Whitelist

The file /etc/sudoers.d/vacuum-walld grants the daemon user (vacuum-walld) passwordless sudo access to a strict set of commands. The WebUI user has no sudo access. Each daemon entry is scoped to a single binary with allowed arguments:

Category Whitelisted Command Purpose
Firewall firewall-cmd * All firewalld operations (zone management, rules, services, ports)
Nginx nginx -s reload Graceful nginx configuration reload
Nginx nginx -t Nginx configuration syntax validation
Nginx status systemctl is-active nginx Check nginx service status
Nginx file ops cp -- /run/vacuum-wall/include.tmp /etc/nginx/conf.d/vacuum-wall.conf Copy the rendered config include to its system path (pinned source and destination)
Nginx file ops cp -- /run/vacuum-wall/ssl-snippet.tmp /etc/nginx/snippets/vacuum-wall-ssl.conf Copy the rendered SSL snippet to its system path (pinned source and destination)
Nginx file ops rm /etc/nginx/conf.d/vacuum-wall.conf, rm /etc/nginx/snippets/vacuum-wall-ssl.conf Clean up generated nginx config files
Nginx file ops chown root:root /etc/nginx/conf.d/vacuum-wall.conf, chown root:root /etc/nginx/snippets/vacuum-wall-ssl.conf Ensure correct ownership of nginx config files
Dnsmasq systemctl restart dnsmasq Apply updated dnsmasq configuration
Dnsmasq status systemctl is-active dnsmasq Check dnsmasq service status
Dnsmasq file ops mkdir -p /etc/dnsmasq.d Ensure target directory exists
Dnsmasq file ops cp -- /run/vacuum-wall/dnsmasq.tmp /etc/dnsmasq.d/vacuum-wall.conf Copy the rendered dnsmasq fragment to its system path (pinned source and destination)
Dnsmasq leases cat /var/lib/misc/dnsmasq.leases Read dnsmasq lease table
WireGuard wg-quick * WireGuard tunnel lifecycle (up, down, save, show)
WireGuard wg * WireGuard status and peer management
WireGuard file ops cp -- /run/vacuum-wall/wg0.conf.tmp /etc/wireguard/wg0.conf Copy the rendered WG config to its system path (pinned source and destination)
WireGuard file ops chown root:root /etc/wireguard/wg0.conf Ensure correct ownership of WG config
Certificates chmod g+rwX {{ ACME_HOME }}/* Reopen group read/write on ACME home files after acme.sh hardens them to owner-only modes (normalize_acme_home(), run before every daemon acme.sh invocation). Files only: setgid directories already grant group rwx
Network queries ip -o link show List network interfaces
Network queries ip -o addr show List IP addresses on interfaces
Network queries ip -o addr show * Query IP address for a specific interface (DHCP gateway auto-population)
Networkd networkctl status * Query interface status from networkd
Networkd networkctl reload Reload networkd for all interfaces
Networkd networkctl reconfigure * Reconfigure a specific interface
Networkd file ops cp -- /run/vacuum-wall/99-*.network /etc/systemd/network/ Copy rendered network unit files (pinned destination dir, 99-* source pattern)
Networkd file ops rm /etc/systemd/network/*.network Remove stale network unit files
Networkd file ops mkdir -p /etc/systemd/network Ensure target directory exists
Sysctl sysctl -w * Set kernel parameters
Logs journalctl --unit=* -n * Query systemd journal for managed services
Logs cat /var/log/nginx/* Read nginx access and error logs

Key safety properties:

  • Each Cmnd entry specifies the full path to the binary (e.g., /usr/bin/firewall-cmd).
  • Full-argument wildcard entries exist only for commands where the full argument space is needed (firewall-cmd *, wg-quick *, wg *, sysctl -w *, journalctl --unit=* -n *, networkctl status *, networkctl reconfigure *, ip -o addr show *); the remaining wildcard entries target fixed destination paths with a filename pattern (cp -- /run/vacuum-wall/99-*.network /etc/systemd/network/, rm /etc/systemd/network/*.network, chmod g+rwX {{ ACME_HOME }}/*). All file-copy entries are pinned to a single source file under the daemon-owned /run/vacuum-wall runtime dir and a single destination path. None of the entries grant shell access or arbitrary command execution.
  • NOPASSWD is used so the application never prompts for a password. Defaults:<user> restricts the secure path and disables TTY requirement.
  • The sudoers file is rendered from a Jinja2 template at install time, substituting the configured USER_DAEMON_NAME and ACME_HOME variables (the install also renders USER_NAME, USER_GROUP, and PROJECT_DIR for the systemd unit templates).

Daemon Client Path Resolution

The daemon/client.py module resolves <param> placeholders in URL paths before sending requests over the Unix socket. For example, a request to /network/interfaces/<name> with a body containing {"name": "eth0"} is rewritten to /network/interfaces/eth0 before transmission. Parameter values are URL-encoded to handle special characters safely. This eliminates the need for the API layer to construct literal paths and ensures the daemon always receives concrete paths for routing.

Web Security

Management Interface

The Flask WebUI binds exclusively to 127.0.0.1:9090. It is not exposed directly to any network interface. All external access to the management UI is routed through an nginx reverse proxy on the designated management domain, which provides SSL termination. Authentication is handled at the Flask layer via JWT validation — no nginx-level auth_basic is applied to the management domain. Static assets under /static/ are served directly by nginx from webui/static/ (unauthenticated, the same exposure as the Flask static route) with Cache-Control: no-cache, X-Content-Type-Options: nosniff, and a restrictive Content-Security-Policy: default-src 'none'.

JWT tokens are stored in browser sessionStorage and injected as Authorization: Bearer <token> headers. The API never reads cookies — authentication is header-only. This eliminates CSRF concerns: cross-origin requests cannot set custom headers.

Flask sets a full Content-Security-Policy (all sources locked to 'self' with img-src 'self' data:) and X-Content-Type-Options: nosniff on every response via an after_request hook — the CSP includes frame-ancestors 'none', base-uri 'self', and form-action 'self'. X-Frame-Options and HSTS are absent on the management domain; clickjacking protection comes from the CSP frame-ancestors 'none' directive instead. The SPA relies on JWT authentication, SSL termination, and the systemd sandbox for its security boundary.

The auth-exempt public path list covers the SPA root, static and vendor files, POST /api/auth/login, POST /api/auth/refresh, and the two WebAuthn authentication endpoints (POST /api/auth/webauthn/authenticate-begin, POST /api/auth/webauthn/authenticate-finish). nginx writes the management domain's traffic to dedicated wall_mgmt_access.log / wall_mgmt_error.log files; non-management domains get per-domain <domain>_access.log / <domain>_error.log logs.

Proxy Domains

Proxied domains without a management path enforce, at the nginx server level:

  • HTTP-to-HTTPS redirect — rendered only when the domain has force_ssl enabled. All HTTP requests return a 301 Permanent Redirect to the HTTPS equivalent (the HTTP server block also serves the ACME HTTP-01 challenge location /.well-known/acme-challenge/ before the redirect).
  • HTTP Strict Transport Security (HSTS) — The Strict-Transport-Security header is set with max-age=31536000; includeSubDomains to prevent downgrade attacks.
  • Security headers on all responses from the domain:
    • X-Content-Type-Options: nosniff — Prevents MIME-type sniffing.
    • X-Frame-Options: DENY — Prevents clickjacking via iframes.
    • X-XSS-Protection: 1; mode=block — Enables browser XSS filtering.
    • Referrer-Policy: strict-origin-when-cross-origin — Limits referrer information leakage.

Domains that carry a management path get none of the above — the management SPA receives its security headers from Flask instead (see Management Interface).

Basic auth on proxy domains: a domain-level auth block renders auth_basic + auth_basic_user_file on the whole server block, and per-path auth blocks apply it to individual proxied paths. The generated .htpasswd files hash passwords with SHA-256 crypt (mode 0640). The management domain never gets auth_basic — management auth is the Flask-layer JWT middleware.

Additional proxy headers (headers in the path-level config) are delivered to the upstream backend via nginx proxy_set_header directives — they are not sent as response headers to clients.

JWT Authentication Lifecycle

JWT-based authentication replaces HTTP Basic Auth for the management WebUI. The token lifecycle is:

  1. Login: User submits credentials via POST /api/auth/login. The daemon verifies the password hash (Argon2id) against data/auth.db. On success, an access token (5 min — the fresh-install bootstrap writes access_token_ttl: 300; TTLs are configurable in config/auth/config.json) and a refresh token (7 days) are issued, each bound to a fresh session_id.
  2. Validation: Every API request to Flask includes Authorization: Bearer <token> and an X-Session-Id header. The before_request middleware returns 401 without the session header, validates the token signature, checks expiry, verifies the X-Session-Id matches the token's session_id claim (binding the token to the browser session that created it), queries the SQLite token_blacklist table, and verifies per-subsystem permissions.
  3. Auto-refresh: Before the access token expires, the frontend's scheduleRefresh() timer (fires at TTL 60s, minimum 30s) calls POST /api/auth/refresh with the refresh token and session_id — the refresh endpoint requires a matching session_id so a stolen refresh token cannot be rotated without the originating session. The old refresh token is blacklisted and a new pair is issued. At page load/restore, if the stored access token is rejected (401) on the session check, the frontend performs exactly one refresh from the stored refresh token before falling to the login page.
  4. Revocation: The primary revocation mechanism is per-user JWT signing-secret rotation: tokens are signed with a per-user secret (not a global key), and changing the password or resetting it, or changing permissions, rotates the user's secret (deleting the user removes the secret entirely), immediately invalidating every existing access and refresh token. The affected user's active refresh token jti is additionally inserted into token_blacklist, as is the access token's jti on logout (POST /api/auth/logout). On refresh rotation the old refresh token's jti is blacklisted and the new token replaces the stored row in refresh_tokens. One row per user means each user has a single active refresh session: a refresh from a second tab overwrites the first tab's row, and logout blacklists whichever token is currently stored. Expired blacklist entries are cleaned by the daemon's polling loop (every 60s) and by a probabilistic check inside blacklist_token().

Token theft protection:

  • Short-lived access tokens (5 min) limit the window of exploitation
  • Per-user signing-secret rotation on password/permission change plus the token blacklist prevent reuse after credential changes or logout
  • X-Session-Id binding ties access and refresh tokens to the originating browser session
  • XSS mitigations: CSP headers set by Flask on every response

WebSocket session binding limitation: WebSocket connections skip session_id validation. Browsers cannot send custom headers during the WebSocket handshake — the bundled client passes the raw JWT as the Sec-WebSocket-Protocol subprotocol name (a JWT is a valid RFC 6455 token; the Bearer prefix is not, so it cannot be used) (a custom nginx setup may instead inject it as X-Auth-Token). This means a stolen access token can be used to open WebSocket connections for the full 5-minute TTL without session verification. Short-lived TTL and management domain CSP headers mitigate this risk.

WebAuthn Security

WebAuthn (passkeys) provides passwordless authentication via the browser's Web Authentication API. Security properties:

  • Credential binding: Each credential is cryptographically bound to the specific rp_id (management domain) and origin (HTTPS URL). Credentials cannot be phished to a different domain.
  • Private key protection: The private key never leaves the authenticator device. The server stores the username, credential_id, display name, transports, public key, and signature counter in the webauthn_creds table.
  • Assertion verification: Each authentication attempt verifies the signature against the stored public key and checks that the signature count has increased (replay prevention).
  • RP configuration: rp_id and origin are derived from the request (X-Forwarded-Proto/X-Forwarded-Host) and validated against the live management domains, so credentials are bound to the domain the user actually reached. The webauthn section of config/auth/config.json holds only enabled and rp_name (the installer writes rp_id/origin on fresh install, but the runtime never reads them).
  • Fallback: Password authentication always remains available as a fallback. Losing a WebAuthn credential does not lock the user out.

Header-Only Authentication and CSRF

The API exclusively reads the Authorization header — never cookies. This architecture eliminates CSRF risk:

  • Cross-site requests cannot set custom HTTP headers due to browser CORS restrictions
  • No cookie-based session to exploit
  • No SameSite, double-submit, or origin checking needed

XSS as the primary attack surface: With header-only auth, XSS is the primary attack vector since sessionStorage is accessible to page scripts. Mitigations include:

  • CSP headers set by Flask's after_request hook on every API/SPA response (nginx adds a separate default-src 'none' CSP only on /static/)
  • Short-lived access tokens (5 min) with secret rotation and blacklist on logout

TLS Configuration

The default nginx SSL configuration enforces modern TLS only:

  • Protocols: TLSv1.2 and TLSv1.3. Older protocols (SSLv3, TLSv1.0, TLSv1.1) are disabled.
  • Cipher suites: A curated set of AEAD ciphers using ECDHE key exchange (ECDHE-ECDSA and ECDHE-RSA with AES-GCM and CHACHA20-POLY1305). No non-ECDHE ciphers are included.
  • ssl_prefer_server_ciphers defaults to off (client chooses).
  • Session settings: ssl_session_timeout 1d, ssl_session_cache shared:TLS:10m, ssl_session_tickets off.

Brute-Force Protection

Login and WebAuthn authentication attempts are rate-limited in-process with sliding windows that count failures only (a success resets the bucket):

  • Password login: 10 failures per 300s, tracked per username and per client IP (X-Real-IP).
  • WebAuthn: 5 failures per 600s, tracked per username and per client IP.

To prevent username enumeration, password verification for a nonexistent user runs a dummy Argon2id verification against a pre-computed hash, keeping timing uniform. The limiters are in-memory; counts reset on daemon restart (SIGHUP reload, process restart).

Systemd Hardening

Both vacuum-wall.service (WebUI) and vacuum-walld.service (daemon) apply comprehensive systemd sandboxing directives to isolate their processes from the rest of the system:

Directive Value Effect
ProtectSystem strict Mounts the entire file system as read-only, except explicitly allowed paths
ReadWritePaths project dir, /tmp, the generated /etc config dirs, and the volatile /run entries (/run/vacuum-wall, /run/firewalld, /run/nginx, /run/nginx.pid), plus /var/log/nginx and /var/log/vacuum-wall (daemon); (WebUI only) config/, data/ subdirs and /var/log/vacuum-wall The project directory and runtime paths are writable. Every entry must exist when the unit spawns or namespace setup fails (226/NAMESPACE), so volatile /run entries are pre-created by systemd (see below). Only paths the unit genuinely writes are listed — e.g. /run/sudo was historically listed but is now omitted because the NOPASSWD sudo children never need it
RuntimeDirectory vacuum-wall nginx (daemon only) Creates /run/vacuum-wall and /run/nginx owned by the daemon user before namespace setup; removed on stop
RuntimeDirectoryMode 0750 (daemon only) Group-readable runtime dirs (the shared group owns them)
LogsDirectory vacuum-wall (both units) Creates /var/log/vacuum-wall owned by the service user before namespace setup
ExecReload /bin/kill -HUP $MAINPID (WebUI only) SIGHUP triggers the WebUI's auto-reload (reloads webui.*/lib.* modules, then restarts via SIGTERM); the daemon unit has no ExecReload
tmpfiles.d spec system/tmpfiles.d/vacuum-wall.conf (installed to /etc/tmpfiles.d/, applied at early boot by systemd-tmpfiles-setup.service) Pre-creates the root-owned /run/firewalld (0750) and /run/nginx.pid (0644) at early boot so the daemon's ReadWritePaths= entries resolve on a fresh boot (in practice firewalld, which starts first, creates the directory itself; nginx rewrites the pid file on start)
PrivateTmp yes Provides a private /tmp and /var/tmp namespace
NoNewPrivileges yes Prevents the process from gaining new privileges via setuid/setgid
PrivateDevices yes Hides all device files under /dev
ProtectKernelTunables yes Makes /proc/sys, /sys, and /proc/sysrq-trigger read-only
ProtectKernelModules yes Disables init_module and finit_module syscalls
ProtectControlGroups yes Mounts /sys/fs/cgroup as read-only
ProtectHostname yes Prevents the process from changing the system hostname
RestrictNamespaces yes Prevents creating new namespaces
RestrictSUIDSGID yes Removes setuid/setgid bits from newly created files
LockPersonality yes Prevents changing the execution domain
MemoryDenyWriteExecute yes Prevents creating memory regions that are both writable and executable
SystemCallFilter @system-service Allows only a curated set of system calls safe for services
RestrictRealtime yes Prevents the process from acquiring realtime scheduling priorities
RestrictAddressFamilies AF_UNIX AF_INET AF_INET6 (WebUI); AF_UNIX AF_INET AF_INET6 AF_NETLINK (daemon) Restricts available address families; the daemon's extra AF_NETLINK is its only additional network primitive
IPAddressDeny any (both units) Drops all IP traffic by default
IPAddressAllow localhost (both units) Allows only loopback communication (required to reach the other process at 127.0.0.1)

Both units deny all IP traffic except to localhost, so neither can reach any external network interface; the only difference in network access is the daemon's extra AF_NETLINK family (needed for its netlink queries). Both units use template variables ({{ USER_NAME }}, {{ USER_GROUP }}, {{ USER_DAEMON_NAME }}, {{ PROJECT_DIR }}) rendered at install time.

This hardening ensures that even if either process is compromised, the attacker is confined to a sandboxed environment with no direct network access, no ability to escalate privileges through kernel interfaces, and a strictly bounded write scope: outside the project directory the daemon's unit lists only /etc/systemd/network, /etc/nginx, /etc/dnsmasq.d, /etc/wireguard, /var/log/nginx, and /var/log/vacuum-wall (plus /tmp and the /run runtime entries), and the WebUI's unit lists only its config/ and data/ subdirs and /var/log/vacuum-wall.

Network Security

Default Deny

Incoming traffic is denied by default — this is firewalld's built-in behavior for the default zone (no Vacuum Wall code sets a zone target; apply only reconciles targets explicitly present in the config). Only explicitly allowed services and ports are accessible. Outbound traffic is permitted by default.

Zone-Based Traffic Isolation

The lib/firewall module is a generic firewalld parser; zone structure is defined declaratively in config/firewall/config.json at runtime. The only hardcoded zone knowledge is FIREWALLD_BUILTIN_ZONES — the 9 zone names firewalld ships by default (block, dmz, drop, external, home, host, internal, public, trusted) — used so built-in zones are never flagged as unmanaged (not in config). The public zone is additionally special-cased: its masquerade state is not reconciled by apply and cannot be enabled through the masquerade endpoint (see IP Forwarding and NAT). A typical deployment uses:

Zone Interface Purpose Behavior
external WAN (e.g., eth0) Untrusted Internet-facing Only essential services (HTTPS, WireGuard) are open. ICMP echo rate-limiting is typical in this deployment but is not enforced by any Vacuum Wall code.
internal LAN (e.g., eth1) Trusted local network DHCP and DNS served to clients. Masquerade (NAT) enabled for outbound Internet access. All outbound traffic from the LAN is allowed.
vpn-<key> WireGuard (per-access-class interfaces) WireGuard tunnel traffic, per access class Semi-trusted. Created and maintained automatically by the WireGuard→firewall sync: one zone per access class with peers, with the class's WG interface assigned, masquerade enabled, a UDP listen-port accept rule, and inter-zone accept rules for internal subnets when the class has lan_access. A plain vpn zone is managed only as a legacy fallback for peers without an access class.
trusted / loopback lo Localhost communication unrestricted; used for the Flask-to-nginx management proxy.
Custom zones DMZ, guest networks, etc. Additional zones can be created to isolate specific network segments with their own rule sets.

IP Forwarding and NAT

IP forwarding is not auto-enabled by Vacuum Wall — net.ipv4.ip_forward is one of the allowlisted sysctl keys an operator can set through the network API, and actual traffic flow is controlled by firewalld rules. Masquerade is auto-enabled by the WireGuard→firewall sync only on VPN zones (the per-access-class vpn-<key> zones and the legacy vpn zone), not on internal.

The public zone is special-cased around masquerade:

  • Refusal: the masquerade endpoint refuses to enable masquerade on public — masquerade must be enabled on internal or vpn instead.
  • Auto-propagation: at apply time, if any non-public zone has masquerade enabled, apply propagates masquerade to the public zone (and removes it when no non-public zone needs it), writing the propagated state back to the declarative config. Under the nftables backend, traffic exiting through a public-zoned WAN interface hits public's POSTROUTING chain rather than the internal zone's, so NAT would silently fail without this propagation.

Management Lockout Guard

The firewalld default zone is the catch-all for unassigned interfaces (normally the WAN), so removing both https (management access via nginx) and ssh (remote recovery) from it would leave no path back except a physical console. The config apply path and the per-zone services endpoint refuse such a change with HTTP 409 unless the request passes {"force": true}. The guard fails closed: if the default zone cannot be determined, the operation is treated as a lockout and refused.

Interface-Coverage Invariant

Every interface managed by the network subsystem (lo and wg* excluded) must be covered by a zone in config/firewall/config.json or listed under the top-level unmanaged key. The config is the source of truth for zone interfaces — an omitted interfaces key counts as empty — so the check is computed from the config alone with no live-state fallback. Violations are rejected with HTTP 400 at save time (POST/PATCH /firewall/config) and HTTP 409 at apply time (POST /firewall/config/apply, overridable with force: true). Live drift is advisory only (the uncovered_interfaces state field).

Input Validation

Interface names provided via the API are validated at two layers before any file system access or subprocess invocation:

  • API layer (webui/api/network.py): The Flask route calls validate_interface_name() from lib/common.py, rejecting any name that doesn't match ^[a-zA-Z0-9][a-zA-Z0-9._-]*$. Names containing /, .., spaces, or other disallowed characters return HTTP 400.
  • Daemon handler layer (daemon/handlers/network.py): Each handler re-validates the name from the request body using the same function. An invalid name raises ValueError, which the daemon converts to an error response before any sudo call.

This defense-in-depth approach ensures that even if a request bypasses the API layer, the daemon will still reject malicious interface names.

Certificate Security

acme.sh Integration

Certificate management is handled by acme.sh, which stores all certificates and private keys under PROJECT_DIR/data/acme/ (set via the ACME_HOME environment variable). The directory is owned by and writable only by the service users.

Private Key Protection

Private key material is never exposed through the WebUI API. The API returns certificate metadata such as domain names, validity dates, file paths, and renewal status. File paths are returned so downstream tooling (nginx, cert management) can reference them. When a domain's certificate is needed by nginx, the rendered nginx configuration references the acme.sh file paths directly via ssl_certificate and ssl_certificate_key directives — no symlinks are created.

HSTS Enforcement

All HTTPS proxy domains (excluding the management interface) have HTTP Strict Transport Security enabled at the nginx layer with a long max-age and the includeSubDomains directive. This ensures browsers always use HTTPS for proxied domains and their subdomains, preventing SSL stripping attacks.

Modern TLS Only

As noted in the Web Security section, the default SSL snippet enforces TLSv1.2 and TLSv1.3 with strong AEAD cipher suites using ECDHE key exchange. The cipher suite list excludes weak ciphers, EXPORT grades, RC4, DES, 3DES, MD5, and null ciphers.