Concepts

Security model

How EXMER protects credentials, prevents shell injection, enforces access control, and logs every sensitive action.

Credential encryption

SSH passwords and private keys are encrypted with AES-256-GCM before being stored in SQLite. The encryption key is derived from JWT_SECRET via scrypt. Format: iv:authTag:ciphertext, all base64. No plaintext is ever written to disk.

Decryption is backward-compatible with plaintext stored by pre-v1.0 deployments — if the stored value doesn't look encrypted, it's returned as-is and re-encrypted on next update.

Key rotation

Rotating JWT_SECRET without re-encrypting existing credentials will make them unreadable. There is no graceful rotation flow yet — if you need it, open an issue. For now, treat JWT_SECRET as immutable once set.

Shell injection prevention

Every user-provided string that goes into an SSH command passes through shellEscape() — single-quote escaping with proper handling of embedded single quotes. For file contents (which would blow up as shell args), we base64-encode them and decode on the remote side via base64 -d.

No heredocs. Heredocs are too fragile for user-controlled input — any unescaped delimiter in the content terminates them early.

Input validation

CORS

In production, CORS is restricted to https://$APP_DOMAIN. In dev, it's open (origin: true) so Vite's proxy works. Always set APP_DOMAIN in production.

Rate limiting

Two layers:

  1. Global (IP-based) — express-rate-limit, 120 req/min on /api, 10 req/min on /api/auth, 20 req/min on /api/servers/:id/exec.
  2. Per-user (JWT-based token bucket) — capacity 30 + refill 0.5/s for expensive endpoints (install, reindex, analyze). Prevents one misbehaving user from exhausting the global IP limit.

Authentication

Telegram initData → HMAC-SHA256 validation → JWT (24h) → Authorization: Bearer on every API request. JWTs are signed with HS256 using JWT_SECRET.

Authorization

Two dimensions that combine: super-admin (via ADMIN_USER_IDS) and per-server roles (owner / admin / viewer). See Access control.

Audit log

Every destructive or sensitive action gets a row in audit_log: who, when, what, result, request ID, IP. Super-admins can query it via GET /api/admin/audit. Actions covered:

Request tracing

Every request gets a UUID as X-Request-Id, both in the response header and in every log line / audit row generated by that request. Users can include the ID when reporting bugs to find the exact trace instantly.

Database backups

Daily automatic backups of data/exmer.db via SQLite's online backup API (safe to run while the app is using the DB). Gzipped, stored in data/backups/, 30-day retention. Super-admins can trigger manual backups and download them via /api/admin/backups.

Secrets in API responses

Passwords, private keys, and API keys are NEVER returned in API responses — always stripped or masked. Masking uses first 8 + last 4 chars with ... in between.

Per-agent RAG isolation

Each agent has its own query.py with AGENT_ID baked into the file. No global script, no --agent flag. See RAG memory.

AI safety rules

Claude-proposed actions require backup before execution and explicit user confirmation. The system prompt forbids destructive commands without backups. See AI Analysis.