Skip to main content

Terminal Security

Session Token Authentication​

Every terminal session requires a cryptographically signed token. The backend generates a short-lived JWT-like token before sending the terminal_start command to the agent. The agent verifies the token before allocating a PTY.

Token Format​

Compact base64url(payload).base64url(signature) using ed25519 (NATS account keypair).

Token Claims​

ClaimDescription
session_idUUID tying the token to one specific session
agent_idAgent verifies this matches its own registered agent ID (not host_id)
loginLinux user the PTY should run as
user_idWho initiated the session (audit trail)
expExpiration — 1 minute from issuance
agent_id vs host_id

The token uses agent_id (the UUID assigned to the enrolled agent) rather than host_id. This means a replacement agent on the same host with a different agent_id will reject tokens issued for the old agent — ensuring session tokens are tightly bound to the specific agent process.

Verification Flow (Agent-Side)​

  1. Verify ed25519 signature against the NATS account public key
  2. Check token is not expired (exp > now)
  3. Check agent_id matches the agent's own registered ID
  4. Check login matches the requested Linux user
  5. If any check fails → reject, log warning, send error ack — no PTY allocated

Key Properties​

PropertyHow It's Enforced
No unauthorized shellsAgent requires valid token signature from trusted account key
No cross-host replayToken embeds agent_id, agent rejects mismatches
No login substitutionToken embeds login, agent rejects mismatches
No replay after expiry1-minute TTL, checked before PTY allocation
No token forgeryed25519 private key (account seed) never leaves the backend

Reconnect Security​

When the frontend auto-reconnects after an unexpected disconnect, the backend issues a new session token for the reconnect attempt. The reconnect is only permitted if all of the following hold:

  1. The reconnecting user is the same user who started the original session (user_id matches)
  2. The target host matches the original session's host_id
  3. The original session has not ended — the PTY must still be alive within the disconnect_timeout linger window

If any check fails, the reconnect is rejected and a new session must be initiated from scratch. This prevents session hijacking by ensuring that even during the linger window, only the authenticated session owner can resume the shell.

First-Message WebSocket Auth​

The session token is transmitted as the first WebSocket message after the connection is established — it is never included in the URL as a query parameter.

Why Not a Query Parameter?​

Embedding tokens in URLs causes them to appear in:

  • Web server access logs (logged by default)
  • Browser history and bookmarks
  • Referrer headers sent to third-party resources
  • Proxies and load-balancer logs

By sending the token as the first binary WebSocket message, the token travels only over the encrypted WebSocket payload and is never exposed in any URL.

Auth Flow​

Browser → Backend: WebSocket upgrade (no token in URL)
Backend: Accept connection, wait for first message
Browser → Backend: First message = base64(session_token)
Backend: Validate token, look up session state
Backend → Browser: Auth OK → begin I/O relay
Auth fail → close WebSocket with 4001 code

The WebSocket connection is terminated immediately if the first message is not a valid session token, or if it arrives after the 10-second auth timeout.

Transport Security​

Encryption in Transit​

All traffic is encrypted via TLS:

HopEncryption
Browser → BackendWSS (WebSocket over TLS) via Cloudflare
Backend → NATSTLS with strong cipher suites
NATS → AgentTLS with strong cipher suites

NATS Subject Isolation​

Terminal I/O uses scoped NATS subjects that prevent cross-tenant data leakage:

proxima.{clientSlug}.{envSlug}.{hostID}.terminal.out.{sessionID}
proxima.{clientSlug}.{envSlug}.{hostID}.terminal.ctrl.{sessionID}

Agent JWT permissions restrict each agent to publish/subscribe only under its own host prefix. The NATS server enforces this at the protocol level.

What the Backend Can See​

The backend relays PTY traffic but does not store it. An operator with access to the backend process could theoretically observe terminal I/O in memory. This is equivalent to Teleport's proxy model, where the proxy terminates SSH and re-encrypts to the node.

RBAC​

See the RBAC & Permissions page for:

  • Role-based terminal access configuration
  • Allow/deny rules with label matching
  • Multi-role merge semantics
  • Validation constraints

Multi-Party Session Access​

  • Observers require terminal_sessions:read permission
  • Participants require terminal_sessions:write permission
  • Client scoping enforced — peers must have access to the session's client
  • Peer join/leave events are recorded in the audit trail
  • Session owner always sees who is connected — no silent observation
  • Maximum 10 peers per session prevents resource exhaustion

Rolling Upgrades​

The session token system supports rolling upgrades:

  • Backend deployed first (no agent update): Backend sends signed tokens, but old agent ignores them (no AccountPubKey configured). Terminal works without verification.
  • Agent deployed first (no backend update): Agent has AccountPubKey but backend sends empty tokens. Agent skips verification when AccountPubKey is empty.
  • Both deployed: Full verification active.

After both are deployed, set AccountPubKey on the agent (extracted automatically from the user JWT issuer) and verification is enforced.

Login Template Sandboxing​

Login templates in terminal specs use Go's text/template with restricted execution:

  • Restricted function set: Only lower, upper, replace, trimPrefix, trimSuffix, default, and printf are available. No call, no OS access, no file I/O.
  • Execution timeout: Templates must complete within 1 second.
  • Save-time validation: Templates are parsed and dry-run with dummy data when the role is saved. Invalid templates are rejected before they reach production.
  • Output validation: Resolved logins must match the Linux username regex. Invalid results are filtered with a warning log.

File Transfer Security​

  • User isolation: File operations run as the session's Linux user, so OS file permissions are enforced. When the agent runs as root (the production configuration) the transfer is refused if that user does not exist on the target host, or if the request names no user at all — the agent does not fall back to running as root, because a missing login is an error, not a privilege upgrade. A non-root agent has no privileges to drop and so has nothing to refuse; it can only ever act with its own uid.
  • SHA-256 integrity: Every transfer is hashed and logged in the audit trail
  • Path validation: Path traversal (..) is rejected
  • Size limit: 100MB per file, enforced on browser, backend, and agent
  • Audit trail: Every upload and download is recorded with filename, path, size, and SHA-256 hash
  • Dedicated NATS subject: File data flows on a separate subject from terminal I/O, preventing interference

K8s Node Terminal Security​

The DaemonSet agent does not serve terminals, so there is no K8s-node terminal attack surface to model. The nsenter-based node terminal was built and withdrawn; the agent detects DaemonSet mode and registers no terminal handler. Terminal access to a Kubernetes node comes from the standalone host agent installed on that node, and is governed by the same model as any other host — see RBAC and the host-user sections below.

The DaemonSet's elevated privileges outlived the feature

The chart still requests hostPID: true, SYS_PTRACE, SYS_ADMIN and allowPrivilegeEscalation: true, and its comments still attribute them to nsenter. Nothing in the agent exercises them any more. Metrics collection needs runAsUser: 0 and the hostPath mounts, not the host PID namespace or those capabilities. Treat this as a hardening opportunity — see Node Agent K8s Deployment.

Host User Security​

When auto-creating host users:

  • Password expiry — chage -E 1 expires the password immediately. Users can only connect via Proxima terminal, never via local password login.
  • Tracking groups — proxima-keep and proxima-drop system groups identify managed users.
  • Root required — the agent must run as root to create users (useradd, chage, etc.).
  • Home directory — created manually with 0700 permissions, /etc/skel files copied (symlinks skipped).
  • Cleanup — drop-mode users are deleted by a background loop every 5 minutes, with a 30-second grace period and linger-awareness.

Sudoers File Management​

When host_sudoers is configured:

  • Files are written to /etc/sudoers.d/proxima-<username> with mode 0440
  • Validated with visudo -cf before activation — invalid files are deleted immediately
  • In drop mode, sudoers files are cleaned up with the user
  • Rules are prefixed with the login username

Session Recording Security​

  • Encryption at rest: Recordings stored in Cloudflare R2 with server-side encryption (AES256)
  • Presigned URLs: Download links expire after 1 hour — no permanent direct access to storage
  • Per-client retention: Each client configures retention (default 90 days). Expired recordings are automatically deleted from R2
  • Access control: Users can view their own session recordings. Admins with terminal_sessions:read can view all recordings
  • Agent-side recording: Recordings are written on the agent during the session, transferred to backend via NATS, then uploaded to R2. Agent never has storage credentials
  • Size cap: 10MB gzipped per recording prevents runaway storage from heavy output sessions

UID Range Security​

  • System UIDs (below 1000) are excluded — UID ranges must start at 1000+
  • Minimum range size of 100 prevents trivial hash collisions
  • UID allocation is deterministic (FNV-1a hash) — no central state to corrupt
  • On collision, falls back to natural allocation with a warning log