Skip to content

Security: arkeep-io/arkeep

SECURITY.md

Security Policy

Reporting a Vulnerability

Do not open a public GitHub issue for security vulnerabilities.

Please email hello@arkeep.io with the following information:

  • A clear description of the vulnerability
  • Steps to reproduce the issue
  • The potential impact (what an attacker could achieve)
  • Any suggested mitigations, if you have them

You will receive a response within 48 hours acknowledging your report. We will keep you informed of progress and ask that you give us reasonable time to develop and release a patch before any public disclosure.

Scope

The following are considered in scope:

  • arkeep-server — authentication, authorization, credential storage, API endpoints
  • arkeep-agent — gRPC authentication, binary extraction, restic invocation
  • Docker images published to ghcr.io/arkeep-io and Docker Hub

The following are out of scope:

  • Vulnerabilities in Restic or Rclone themselves — report those upstream
  • Issues requiring physical access to the host machine
  • Social engineering attacks
  • Denial of service via resource exhaustion

Security Considerations

Hook commands

Pre/post backup hooks are shell commands executed by the agent process on the target machine. They run with the full privileges of the agent process (typically a dedicated unprivileged user, but potentially root or docker-group depending on your deployment).

Risk: A malicious or compromised hook command has the same capabilities as direct shell access to the machine. An admin who can create or edit policies can therefore execute arbitrary code on any machine running a connected agent.

Mitigations in place:

  • Only users with the admin role can set or modify hook commands.
  • The server rejects commands containing command substitution ($(...), backticks), path traversal (..), and references to internal credential environment variables ($RESTIC_*, $RCLONE_*, $ARKEEP_*).
  • Hook commands are limited to 1024 characters.

Deployment recommendation: Run the agent as a dedicated low-privilege user with access only to the directories it needs to back up. Avoid running the agent as root unless strictly required (e.g. bare-metal full-system backup).

Credential storage

All sensitive values (repository passwords, S3 keys, SFTP passwords, OIDC client secrets) are encrypted at rest using AES-256-GCM with a server-side encryption key. Credentials are never returned in API responses — they are write-only after creation.

What the encryption protects against: someone who obtains the database alone — a database dump, a copy of the SQLite file, a backup of it, or access to a PostgreSQL server running on another host. It does not protect against someone who can read both the database and ARKEEP_SECRET_KEY, which is any user with access to the server host. The server has to decrypt credentials to send them to agents, so it always holds the key; this is true of any service that uses stored secrets unattended.

Deployment recommendations:

  • Restrict the file that holds ARKEEP_SECRET_KEY (e.g. .env) to the user running the server (chmod 600).
  • Do not keep a copy of the key next to copies of the database: a backup that contains both gives away every stored credential.
  • Treat database backups as sensitive. They contain every credential that existed when they were taken, including those of destinations and policies deleted since.

Deleted destinations and policies: deleting a destination wipes its credentials and repository password, and deleting a policy wipes its repository password. The row is kept, without secrets, so snapshot and job history still resolve its name. Restoring a snapshot of a deleted destination asks for the credentials again.

On SQLite the database is opened with secure_delete, so wiped values are overwritten on disk instead of lingering in the file's free space. Databases created by older releases are rebuilt once with VACUUM on the first start after upgrading, which erases what earlier deletes left behind. This needs free disk space about as large as the database.

On PostgreSQL old row versions stay on disk until the space is reused, and in WAL files and backups until those are rotated. Arkeep cannot erase them: if this matters for your threat model, run VACUUM FULL destinations, policies; after deleting and rotate WAL archives and backups according to your own policy.

If a destination's credentials may have been exposed, rotate them at the storage provider; deleting the destination in Arkeep does not invalidate them.

Session and token revocation

Arkeep uses two tokens per session:

Token TTL Storage Revocation
Access token (JWT) 15 minutes Browser memory only Immediate via in-memory denylist
Refresh token 7 days, rotated on use httpOnly cookie Immediate via database deletion

OIDC sessions also have an absolute lifetime, set per provider (session_max_hours, 1 to 168 hours, 12 by default). It is stored on the refresh token, kept on every rotation and carried through the two-factor step, so a session in use still ends and the user must sign in at the identity provider again, where its allowed groups are checked again. A login refused because the user is in none of the provider's allowed groups (or the groups claim is missing) also revokes every refresh token of the account already linked to that identity. A user removed from a group at the provider therefore keeps access until their current session ends; to cut it at once, deactivate the user in Arkeep as well. See ADR 0012.

On logout, both tokens are revoked simultaneously: the refresh token is deleted from the database and the access token's JTI is added to an in-memory denylist. Any subsequent request using either token is rejected immediately.

Known limitation — LogoutAllSessions: when an admin revokes all sessions for a user (e.g. after a password change or account suspension), all refresh tokens are deleted from the database. However, access tokens already issued cannot be enumerated retroactively because JWTs are stateless — their JTIs are not stored server-side. Active access tokens will remain valid until they expire naturally (≤ 15 minutes).

If this window is unacceptable for your threat model, mitigations include:

  • Reducing accessTokenDuration in server/internal/auth/jwt.go (e.g. to 5 minutes).
  • Treating admin-initiated session revocation as requiring a short propagation delay rather than an instant cut-off.
  • Running the server behind a reverse proxy that can enforce an emergency block at the network layer.

Server restart: access tokens are signed with keys the server generates in memory at every start, so a restart invalidates every access token issued before it. The in-memory denylist is lost on restart, but the tokens it held are invalid anyway. The GUI renews its access token from the refresh cookie, which is persisted in the database, so users stay signed in. If jwt_private.pem and jwt_public.pem are placed in the data directory, the server uses them instead: access tokens then survive a restart, and a token revoked shortly before one may be accepted for the rest of its 15 minutes.

Two-factor authentication

TOTP secrets and recovery codes are never stored in plaintext: the secret is encrypted at rest the same way other credentials are (AES-256-GCM), and recovery codes are stored as an unsalted SHA-256 hash of the normalised code — safe because each code carries 80 bits of entropy, unlike a user-chosen password.

A login challenge (the interim state between a correct password and a valid second factor) expires after 5 minutes and tolerates at most 5 wrong codes before it is burned, forcing the password step to be repeated. Enabling two-factor authentication revokes every other active session for the account except the one used to enroll; an administrator's reset revokes all of them, since the person completing the reset is not the account owner.

Passwords set by an administrator

An administrator can set another local user's password (POST /api/v1/users/{id}/password), never their own and never an OIDC account's. It revokes every session of the user and is recorded in the audit log. With "require a new password", the login stops after the password and the second factor and issues no session: it returns a single-use challenge, valid for 10 minutes and stored as a SHA-256 hash, which only POST /api/v1/auth/login/password accepts, together with a new password different from the one the administrator set. The challenge is consumed atomically before the password is written, so it cannot be used twice.

Health endpoints

GET /health/live and GET /health/ready are unauthenticated and accessible on the HTTP port (default :8080). They do not expose credentials or user data.

/health/ready returns a JSON body indicating the status of the database and scheduler. In environments where the server is exposed to untrusted networks, consider restricting these endpoints at the reverse-proxy level if you do not want to leak operational information.

Metrics endpoint

The /metrics endpoint (Prometheus text format) is served on the same port as the HTTP API (default :8080). By default it is unauthenticated. It does not expose credentials, backup contents, or user data — only operational counters and gauges (job counts, agent connections, HTTP request rates).

However, the endpoint can reveal information about your deployment (number of agents, job frequency, error rates) that you may not want public.

Recommendation: set ARKEEP_METRICS_TOKEN (or --metrics-token). /metrics then answers 401 unless the request carries Authorization: Bearer <token>; the token is compared in constant time. Configure the same token in your Prometheus scrape job (authorization.credentials).

You can also, or instead, restrict access at the reverse-proxy or firewall level so only your Prometheus scraper can reach it:

location /metrics {
    allow 10.0.0.0/8;   # your internal network / Prometheus scraper IP
    deny all;
}

If Arkeep is not behind a reverse proxy, use firewall rules to block port 8080 from untrusted sources, or run Prometheus on the same host and bind it to localhost.

Agent authentication

Agents authenticate to the server via mutual TLS (mTLS) using certificates issued by an auto-generated private CA on first startup. The CA and server certificates are stored in the server's data directory. Keep this directory secure.

Supported Versions

Security fixes are applied to the latest released version only. We do not backport fixes to older versions.

Version Supported
Latest ✓
Older ✗
Learn more about advisories related to arkeep-io/arkeep in the GitHub Advisory Database