Security¶
Threat Model¶
The orchestrator is a self-hosted, single-tenant application. It runs inside your infrastructure, not as a SaaS. The primary threats it defends against are:
- Credential theft — if an attacker gains read access to the database file, they should not be able to recover secrets in plaintext.
- Session hijacking — stolen session cookies should not be replayable indefinitely.
- Password brute-force — the password hashing scheme must be slow enough to make offline cracking infeasible.
- SSRF / path traversal — user-supplied URLs and file paths must be validated to prevent server-side request forgery or directory escapes.
The orchestrator does not attempt to defend against:
- An attacker with root access to the host (they can read /data/secret.key directly).
- Bugs in underlying OS, Python runtime, or the databases being backed up.
Authentication¶
Session Cookies¶
Sessions use Starlette SessionMiddleware backed by itsdangerous signed cookies:
- The signing key is read from
OB_SESSION_SECRET. - Sessions expire after 12 hours of inactivity (sliding window).
SameSite=LaxandHttpOnlyflags are set; setOB_SESSION_COOKIE_SECURE=truewhen the app is served through HTTPS to also addSecure.
Password Hashing¶
Passwords are hashed with Argon2id using OWASP-recommended parameters:
| Parameter | Value |
|---|---|
time_cost |
3 |
memory_cost |
65536 KiB (64 MiB) |
parallelism |
1 |
| Hash length | 32 bytes |
These parameters make offline brute-force attacks against a leaked database computationally expensive (tens of milliseconds per attempt on modern hardware).
First-Login Password Change¶
The default admin/admin credentials are flagged with must_change_password=True. Every API endpoint (except auth routes) returns 403 Forbidden while this flag is set. The frontend router enforces a redirect to the Change Password page. The flag is cleared once the password is successfully updated.
Password Reset¶
Password reset tokens are generated as:
- A 32-byte cryptographically random value (plaintext) — sent in the email link.
- A SHA-256 hash of the plaintext — stored in the database.
When a reset link is visited, the token is hashed and compared against the stored hash. This means a compromise of the database alone does not yield usable reset tokens.
Tokens expire after 15 minutes. The /auth/request-reset endpoint always returns 200 OK regardless of whether the email is registered, to prevent user enumeration.
Encryption at Rest¶
All credentials stored in the database are encrypted with Fernet symmetric encryption (AES-128-CBC + HMAC-SHA256):
- SMTP passwords
- Telegram bot tokens
- Cloud account credentials (service account JSON, API tokens, client secrets)
- Odoo master passwords
- PostgreSQL passwords
The encryption key is generated on first boot and written to /data/secret.key. This file must be:
- Backed up independently from the database. Losing the key means all stored credentials are unrecoverable.
- Kept secret. Anyone who has both the database file and the key file has full access to all credentials.
- Not committed to version control. The
.gitignoreexcludes thedata/directory.
Key Rotation¶
Key rotation is not yet automated. To rotate manually:
- Stop all services.
- Write a migration script that decrypts each
*_enccolumn with the old key and re-encrypts with the new key. - Replace
/data/secret.key. - Restart services.
Audit Log¶
Every significant action is recorded in the audit_logs table:
| Event type | Examples |
|---|---|
auth |
Login, logout, password change, reset |
instance |
Create, update, delete |
job |
Create, update, delete, enable/disable |
backup |
Run started, completed, failed, manually deleted |
retention |
Files deleted, safety net triggered |
cloud |
Upload completed, upload failed |
settings |
Timezone, SMTP config changed |
Audit log entries include the timestamp, actor, event name, and a JSON payload with relevant details. Entries are append-only — the application has no endpoint to delete audit log records.
Input Validation¶
URL Parsing¶
Instance URLs are parsed and normalized server-side:
- Raw IP with non-443 port →
http://scheme - Hostname without scheme →
https:// - Paths and query strings are stripped
- Non-ASCII hostnames are rejected
Path Traversal¶
All user-supplied file names (backup file paths) are validated against path traversal sequences (../, absolute paths). The backup directory is resolved to an absolute path before any file operations.
SQL Injection¶
All database queries use SQLAlchemy ORM or parameterized Core queries. String concatenation into SQL is never used.
Forbidden Patterns¶
The codebase enforces the following constraints via static analysis (Ruff S rules + Bandit):
| Pattern | Reason |
|---|---|
pickle.load |
Arbitrary code execution on deserialization |
yaml.load without SafeLoader |
Arbitrary code execution |
eval / exec |
Arbitrary code execution |
subprocess.*(shell=True) |
Shell injection via user-controlled input |
verify=False in HTTP clients |
Disables TLS certificate validation |
tempfile.mktemp |
Race condition (TOCTOU); use NamedTemporaryFile |
| MD5/SHA1 for security | Collision-vulnerable; use SHA-256+ |
| Hardcoded secrets | Rotate secrets via env vars, not source changes |
| HTTP timeout omitted | Allows indefinite hang on network calls |
Dependency Security¶
- pip-audit scans
requirements.txtfor known CVEs on every CI run. - Trivy scans the Docker image for OS-level vulnerabilities.
- Dependencies are pinned via
pip-tools(requirements.txtis compiled fromrequirements.in).
Run a manual audit:
make audit # lint, type, security, dependency, dead-code, and complexity checks
make docker-check # Hadolint (Dockerfile lint) + Trivy
Network Exposure¶
The application listens on port 8080 inside the container (or whatever OB_PORT is set to). It is not intended to be exposed directly to the Internet. Use a reverse proxy (nginx, Caddy, Traefik) that:
- Terminates TLS with a valid certificate
- Sets
X-Forwarded-ForandX-Forwarded-Protoheaders - Enforces HTTPS-only access
Example nginx snippet is in the Installation guide.
When running behind HTTPS, set: