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
- Agent IDs:
/^[a-z0-9][a-z0-9-]*$/ - Filenames: alphanumerics + dash/underscore/dot, max 200 chars, whitelisted extensions only
- Config field updates: allowlisted against a JSON schema
- Commands in
/execroute: max 4096 chars - File uploads: max 2 MB per file, text formats only
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:
- 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. - 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:
server.create,server.update,server.deleteaccess.grant,access.update_role,access.revokeexec.command(with command preview and exit code)backup.run,backup.download- ...and others as they get instrumented
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.