Security hardening
Security hardening
This guide summarizes TunnelMesh’s transport, token, identity, policy, RBAC, and logging controls. The Chinese documentation remains the authoritative deep reference.
TLS and allowed hosts/origins
- Use HTTPS and WSS for all public traffic.
- Terminate TLS at a reverse proxy or directly on the Server.
- Allow only TLS 1.2 or newer.
- Restrict
security.allowed_hoststo the production hostname. - When WebSSH or SFTP is enabled, restrict
security.allowed_originsto the exact admin-console origin. - Public ingress is HTTP/HTTPS/WSS. The VPN gateway (ADR 0002, built only with
-tags vpn) adds exactly one public UDP port that bypasses the reverse proxy. Default builds, including the release binaries and images, carry no public UDP listener, so that port exists only in a-tags vpndeployment; allow it separately in security groups and host firewalls there.
Scoped Agent and Client tokens
Service tokens are typed as agent, client, or server_node.
- Bind an Agent token to a specific Agent.
- Bind a Client token to one or more explicitly allowed Agents when practical.
- Limit token scope by protocol, target CIDR, and target port.
- Set an expiration time for short-lived or operator-specific tokens.
- Rotate tokens instead of reusing long-lived shared credentials.
Token plaintext is shown once at creation or rotation. Store it in a secret manager, not in a repository or ticket.
Agent CIDR and port policy
Agent policy is enforced on the Server and revalidated by the Agent before dialing a target.
- Permit only required protocols.
- Allow only necessary CIDRs.
- Restrict target ports to the minimum set.
- Keep private, loopback, and link-local ranges blocked unless a specific route needs them.
- Review policies when an internal network changes.
This defense in depth prevents a compromised token from becoming unrestricted access to every internal service.
Admin RBAC and audit logs
- Assign the
adminrole only to operators who need global control. - Regular users should see only their own Agents, routes, and tokens.
- Use structured audit logs for create, update, delete, reveal, and authorization-denied events.
- Review audit logs during incident response and periodic access reviews.
The admin console and API enforce authorization server-side. Do not trust client-supplied ownership or role fields.
Console identity: SSO, MFA, and trusted devices
The management console supports OIDC single sign-on, TOTP multi-factor authentication with one-time recovery codes, and revocable trusted devices. The operating guide is Single sign-on and multi-factor authentication; this section states the security model.
Secret storage fails closed. OIDC client secrets, TOTP shared secrets, and every auth_challenges
payload (OIDC state, nonce, PKCE verifier, and the post-callback login ticket) are sealed with AES-GCM
under TUNNELMESH_TOKEN_ENCRYPTION_KEY before they are written. When the key is absent the Server still
starts and password login keeps working, but MFA enrollment, OIDC provider creation with a non-empty
client secret, and every OIDC login return 503 with data.error=secret_storage_unavailable. There is
no plaintext fallback path, and a provider whose row already holds a sealed secret cannot be resolved
at all when the key is missing or the key id no longer matches.
Every node in a cluster must carry the same key and key id, or a challenge issued on one node cannot be
opened on another.
Bypass credentials are stored one-way. Recovery codes and trusted-device tokens are persisted only as
SHA-256 digests (user_recovery_codes.code_hash, user_devices.token_hash), both under a uniqueness
constraint. A database leak cannot be replayed, and no API — including the administrator ones — can read
a code or token back. Login throttle buckets are keyed by SHA-256(lower(username) + "|" + clientIP), so
auth_login_attempts stores neither usernames nor IP addresses. This is deliberately different from the
service-token and TOTP-secret path, which must remain recoverable and is therefore encrypted rather than
hashed.
alg=none and every HS* are rejected unconditionally. id_token signature verification accepts only
RS256/384/512, PS256/384/512, ES256/384/512, and EdDSA. The none algorithm and all HMAC
algorithms are refused twice: when an operator saves a provider allowlist, and again while verifying a
token. No configuration value can enable them. The reason is that an HMAC key is the client secret, so
anyone able to read the oidc_providers row already holds it — accepting HS* would turn readable
configuration into a token-forging capability.
The relying party does not trust the IdP’s URL space. The issuer must be an absolute https URL and
must match the id_token iss claim, with only a trailing slash normalized on either side. Discovery,
JWKS, token, and userinfo endpoints must be
absolute http(s) URLs, which stops a relative endpoint from being resolved against an
attacker-influenced base. Every relying-party response body is size-capped by
security.auth.oidc.max_discovery_body_bytes — despite the name it covers discovery, JWKS,
userinfo, and the token endpoint alike, and an oversized body fails the step instead of being
truncated and parsed. Every outbound call is bounded by security.auth.oidc.http_timeout.
Note the deliberate asymmetry with tunnel targets: the SSRF policy that blocks loopback, private, and
link-local addresses applies to Agent dial targets, not to operator-configured identity providers. An
on-premises IdP legitimately lives on an internal address, and only an administrator can register one.
The compensating controls are that registration is administrator-only, the issuer must be https, and
the callback must fall inside the deployment’s own allowed base. Treat the ability to create an OIDC
provider as the ability to make the Server issue outbound requests to an administrator-chosen host, and
restrict egress accordingly.
The callback cannot become an open redirect. redirectUri must be https and must fall inside a
base derived from security.allowed_origins and security.allowed_hosts; with both empty, every value
is rejected. After the callback the browser is redirected to exactly one fixed relative path (/login)
with a URL-escaped value, so neither the provider row nor the IdP can influence the destination. An IdP
that returns an error parameter produces only the stable oidc_provider_error code — the
IdP-controlled error_description is never echoed, so it cannot become reflected content on this origin.
The authorization code flow is hardened by construction. PKCE is always used with
code_challenge_method=S256; plain is not offered and grant_type is fixed to authorization_code.
State, nonce, and verifier live in the database rather than in process memory, so any cluster node can
complete the redirect. The state is single-use (max_attempts=1) and is consumed on every terminal
callback path, success and failure alike, so a captured state can never be presented a second time.
The post-callback hand-off uses a single-use login ticket exchanged by POST, capped at 300
seconds, so this bearer credential never lands in browser history, a Referer header, or a proxy access
log.
A verified IdP assertion is one factor, not a session. Account policy is re-evaluated after the
callback, so mfaMode: required and per-account users.mfa_required still demand a second factor from
an SSO-authenticated user.
Role authority is explicit and bounded. With authoritativeRoles: true the IdP is the single source
of truth for role, and every login rewrites it from the ordered claim mappings. Demoting the last active
administrator fails with last_admin_protected instead of locking the deployment out. Only admin and
user are assignable; an invalid mapping role fails the request rather than being skipped, so an
operator can never believe a rule is in force when it is not.
Trusted devices are revocable, bounded, and immediately killable. The credential is delivered once in
an HttpOnly cookie, and the cookie Max-Age is the smaller of the configured TTL and the remaining
lifetime of the stored row, so a cookie can never outlive its authorization. Device count per account is
capped by auth_settings.max_trusted_devices; at the cap the least recently seen active device is
evicted, ordered by COALESCE(last_seen_at, trusted_at). Disabling
device_trust_enabled invalidates every bypass on the next login without waiting for cookies to expire,
because policy is re-read from the database on each decision. Disabling MFA also revokes every trusted
device on that account, since each was trusted on the strength of the factor being removed.
Account enumeration and throttle probing are both closed off. Every credential failure collapses into
a single invalid_credentials. A missing device or identity link returns 404 rather than 403, so an
identifier cannot be used to discover which accounts have an external login or a trusted browser. The
request that trips the login throttle still returns an ordinary 401; only the next request receives
429, so the block boundary cannot be probed. X-Forwarded-For is believed only when the direct peer is
listed in server.trusted_proxies, which is empty by default — otherwise any client could pick its own
throttle bucket.
Secrets never reach logs, metrics, or audit rows. Identity metric labels are a closed enumeration
(method, result, step) that excludes usernames, client IPs, provider ids, challenge ids, and device
tokens; anything outside the set is normalized to unknown. /metrics is readable without a management
session, so putting account names in a label would be both a disclosure and an attacker-controlled
cardinality problem. Audit rows record outcomes and counts, never challenge ids, tickets, device tokens,
TOTP secrets, recovery-code plaintext, client secrets, or IdP error_description.
Disable without deploying. Because policy lives in the database, an incident can be contained by
setting auth_settings.mfa_mode='disabled' and disabling every OIDC provider — no restart, no schema
change, no binary rollback. Both actions are audited. See
Schema Upgrade Guide for the procedure.
Token encryption and reveal risk
With TUNNELMESH_TOKEN_ENCRYPTION_KEY configured, newly created or rotated service tokens are stored as AES-256-GCM ciphertext, not plaintext. Token validation still uses a hash.
Reveal is a privileged, audited operation:
- Only administrators may call the reveal API.
- The request must include the explicit confirmation header and a unique idempotency key.
- The response is marked
Cache-Control: no-store. - The action is written to the audit log.
Legacy tokens created before encryption cannot be recovered; rotate them first.
The same key and key id also seal the console identity secrets described above: the OIDC
client_secret, the TOTP shared secret, and every authentication challenge payload. Rotating the key
without a re-seal plan orphans all of them at once — service-token reveal, browser SSH/SFTP
auto-authentication, MFA verification, and OIDC login all stop working. Rotate deliberately, keep every
node on the same key id, and treat the key as a tier-one secret with the same custody as the database
backup.
Safe logging and secret handling
Do not log or expose:
- Bearer tokens or complete
Authorizationheaders. - Passwords, private keys, or database DSNs.
- WebSSH terminal bytes or SFTP file contents.
- Unredacted Agent metadata values.
- Full target addresses when a summarized or redacted form is enough.
- TOTP shared secrets, recovery codes, trusted-device tokens, OIDC client secrets, authorization codes, PKCE verifiers, login tickets, or id_token values.
Restrict access to Server logs, audit logs, metrics, and database backups. Use short log retention where allowed, and forward security-relevant events to a controlled audit system.