Files
vacuum-wall/docs/security.md
T
mteehan 183904faad fix: seed builtin admin only on empty DB; recover page-load sessions with one refresh
Auth seeding (last-resort guard)
- `_seed_builtin_admin()` in get_db() now skips when
  VACUUM_WALL_SEED_BUILTIN_ADMIN=0 or when the users table already
  contains any user — previously a fresh service start after a non-default
  bootstrap (e.g. --mgmt-user alice) seeded a hard-coded `admin` with an
  unrecoverable random password, shadowing the operator's account
- bootstrap_auth.py sets VACUUM_WALL_SEED_BUILTIN_ADMIN=0: bootstrap
  creates the operator user itself on a fresh install, so exactly one
  account exists and no seeded admin can appear

Frontend (session recovery)
- on page load/restore the in-memory TTL timer is gone, so a valid
  7-day refresh token could sit in sessionStorage while the access token
  is already expired server-side: the session `check` now attempts
  exactly one refresh (POST /api/auth/refresh with the stored refresh
  token) on 401 before treating the session as dead
- extract shared `_doRefresh()` used by both the `check` 401 fallback and
  the `refresh` action (removes the duplicated rotation logic)

Tests
- update seeding tests to the new any-user-present check; add
  test_seed_skipped_when_users_exist, test_seed_skipped_via_env,
  test_bootstrap_flow_creates_exactly_one_user, and the auth-model JS
  test suite (tests/test-auth-model.js)

Docs
- AGENTS.md: document VACUUM_WALL_SEED_BUILTIN_ADMIN
- architecture.md / hoover.md / security.md: describe the bootstrap
  check 401 → one-refresh fallback path
2026-08-18 00:00:09 +00:00

19 KiB

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 — not as root. The automated renewal timer (vacuum-wall-acme.timer) runs acme.sh --cron as {{ USER_NAME }}. When triggered from the WebUI or daemon, acme.sh runs as the daemon process invoking it, using webroot validation that does not require binding to privileged ports.

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 via authenticated Unix socket connections. WebSocket connections to the daemon require a JWT access token as a query parameter for validation before upgrade.

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 * /etc/nginx/* Copy rendered config files to system paths
Nginx file ops cp * /etc/nginx/conf.d/* Copy rendered config files to system paths
Nginx file ops cp * /etc/nginx/snippets/* Copy rendered config files to system paths
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 * /etc/dnsmasq.d/* Copy rendered config files
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 * /etc/wireguard/* Copy rendered config files
WireGuard file ops chown root:root /etc/wireguard/wg0.conf Ensure correct ownership of WG config
Certificates (none) acme.sh runs as the non-root daemon user directly; no sudo escalation is needed (webroot validation is used)
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 * /etc/systemd/network/* Copy rendered network unit files
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).
  • Wildcard entries exist only for commands where the full argument space is needed (firewall-cmd *, wg-quick *, wg *), but none 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 name.

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.

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.

The management interface does not set security hardening headers (e.g., X-Content-Type-Options, X-Frame-Options, HSTS) on proxied responses, as the SPA requires flexibility for its operation. It relies on JWT authentication, SSL termination, and the systemd sandbox for its security boundary.

Proxy Domains

Every proxied domain configured in Vacuum Wall enforces:

  • HTTP-to-HTTPS redirect — All HTTP requests return a 301 Permanent Redirect to the HTTPS equivalent.
  • HTTP Strict Transport Security (HSTS) — The Strict-Transport-Security header is set with a long max-age and includeSubDomains to prevent downgrade attacks.
  • Security headers on all proxied responses:
    • 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.

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 (15 min) and refresh token (7 days) are issued.
  2. Validation: Every request to Flask includes Authorization: Bearer <token>. The before_request middleware validates the token signature, checks expiry, queries the SQLite token_blacklist table, and verifies per-subsystem permissions.
  3. Auto-refresh: Before the access token expires, the frontend's refreshScheduler() calls POST /api/auth/refresh with the refresh token. 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. Blacklist: On logout (POST /api/auth/logout), password change, or user deletion, the affected token's jti is inserted into token_blacklist. 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 (default 60s) and by a probabilistic check inside blacklist_token().

Token theft protection:

  • Short-lived access tokens (15 min) limit the window of exploitation
  • Token blacklist prevents reuse after logout or password change
  • XSS mitigations: CSP headers, X-XSS-Protection header on management domain

WebSocket session binding limitation: WebSocket connections skip session_id validation. Browsers cannot send custom headers during the WebSocket handshake — the bundled nginx config passes the token via the Sec-WebSocket-Protocol subprotocol header (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 15-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 only stores the 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 configurable per deployment in config/auth/config.json.
  • 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 on the management domain (configured in nginx)
  • X-XSS-Protection header
  • Short-lived access tokens (15 min) with 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.

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, /run/vacuum-wall, and (WebUI only) config/, data/ subdirs The project directory and runtime paths are writable
RuntimeDirectory vacuum-wall (daemon only) Creates /run/vacuum-wall owned by the daemon user; removed on stop
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 Restricts available address families
IPAddressDeny any Drops all network traffic by default
IPAddressAllow localhost Allows only loopback communication (required to reach the other process at 127.0.0.1)

The WebUI unit additionally restricts address families and denies all IP traffic except to localhost — it cannot reach any external network interface. 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 write access outside the project directory, and no ability to escalate privileges through kernel interfaces.

Network Security

Default Deny

The firewalld default zone policy is set to deny all incoming traffic. 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 with no hardcoded zone definitions. Zone structure is defined declaratively in config/firewall/config.json at runtime. 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 is rate-limited.
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 WireGuard (wg0) WireGuard tunnel traffic Semi-trusted. Firewall rules control which internal services VPN peers can reach. Traffic to the LAN is restricted to specific services and ports.
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 (net.ipv4.ip_forward = 1) is enabled system-wide to allow routing between zones (LAN to Internet, VPN to LAN). However, actual traffic flow is controlled by firewalld rules. Masquerade is enabled on the internal zone so that LAN clients get NAT translation when accessing the Internet through the Vacuum Wall router.

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.