# Security Policy Joulenap can trigger backups and power machines on and off, and it holds Proxmox API tokens and an SSH key. We take security reports seriously and appreciate responsible disclosure. ## Supported versions Only the latest release line receives security fixes. | Version | Supported | | ------- | --------- | | 1.0.x | ✅ | | < 1.0 | ❌ | ## Reporting a vulnerability **Please do not open a public issue for security problems.** Report privately through GitHub's **[Report a vulnerability](https://github.com/Joulenap/joulenap/security/advisories/new)** button (Security tab → *Report a vulnerability*). This opens a private advisory visible only to the maintainers. Please include: - affected version and how Joulenap is deployed (Docker / LXC / source), - a description of the issue and its impact, - steps to reproduce or a proof of concept, - any relevant logs or config — **with secrets redacted** (tokens, SSH keys, bot tokens). You can expect an acknowledgement within a few days. We'll work with you on a fix and coordinate disclosure; we're happy to credit you unless you'd prefer to stay anonymous. ## Security model Joulenap is designed to run on a trusted LAN/VPN, not on the public internet. ### Credentials - **Scoped API tokens** over root passwords, one per Proxmox host and one per backup server. If root credentials are supplied during setup, they're used transiently to provision a scoped token and install the SSH key, then discarded — never written to `config.yaml`. - **One SSH key, shared by every backup server Joulenap powers off.** It's generated by the app at `data/id_ed25519`, and the wizard offers a `restricted` `authorized_keys` line with a forced command so the key can only trigger the power-off. Because every managed server trusts the same key, treat it as protecting all of them: the wizard deliberately reuses an existing key rather than regenerating one, since a new key would silently lock Joulenap out of the servers already configured. - **A backup-server token provisioned by the wizard also carries the two `/remote` roles a sync route needs** (`RemoteAdmin` and `RemoteSyncPushOperator`), whether or not you ever create one. They are granted at provisioning time because PBS refuses ACL writes from a token, so they cannot be added later without another root login — Settings → Devices → *Grant sync permissions* is that login, for a server set up before 1.0. The practical effect is that a leaked token can also enumerate and modify remotes on that server. If you want a narrower token, create it yourself and paste it in rather than using root-mode provisioning. ### What a sync route puts on your other server A PBS→PBS sync is executed by one of the two boxes, and PBS requires a *remote* entry for the other — which stores that other server's **API token id and secret** in `/etc/proxmox-backup/remote.cfg` on the executing box. That is how PBS sync works and Joulenap cannot avoid it, but it is worth knowing: root on either server, or a backup of its `/etc`, exposes the credential Joulenap uses for the other one. Joulenap creates the remote at the start of each sync run and **deletes it again when the run ends**, so it is not left sitting there between runs. ### Transport - **A backup server's API is TLS-pinned to its stored certificate fingerprint**, so a swapped or MITM certificate is rejected rather than trusted. A legitimately renewed certificate is accepted once you re-run that device's connect step. The wizard always captures a fingerprint, but the field can be left empty in a hand-written `config.yaml` or cleared in the device editor — and a device with **no fingerprint is not pinned and its certificate is not validated at all**. Joulenap refuses to send root credentials over such a connection, but ordinary API traffic (carrying the device's API token) still goes over it. Keep the fingerprint set. - **Where that fingerprint comes from decides what pinning is worth.** Adding a backup server through a Proxmox host takes it from that host's storage configuration — a channel you have already authenticated. Adding one directly reads it off the box over a connection nothing has authenticated yet, which is trust on first use: an attacker already in the middle at that moment would have their certificate pinned instead, and every later connection would faithfully verify against it. Pinning protects everything after setup, not setup itself. On an untrusted network, compare the fingerprint the wizard shows against `proxmox-backup-manager cert info` on the box. - **A backup server's SSH host key is verified against `data/known_hosts`.** The wizard's automatic key-install path shows it to you for confirmation first, and refuses to send a root password before you have confirmed it. Two paths do not populate `known_hosts`: installing the public key yourself instead of letting the wizard do it, and adding a server directly from Settings → Devices. For those, the first power-off **trusts the host key on first use** and records it, with a warning in the activity log; every connection after that is verified. Nothing authenticates with a password on that path — only the restricted key. - A Proxmox host's `verify_tls` is off by default, because a stock PVE serves a self-signed certificate. Where a PVE has a valid certificate, turn it on — it is the only protection for a root password used during provisioning, which is otherwise exposed to anyone who can intercept that connection. ### Access to the interface - The UI is **login-protected** with a single admin account. The session cookie is signed with a per-install random key (`app.secret_key`), and `app.session` controls its lifetime (`max_age_days`, default 14) and the `Secure` flag (`https_only` — turn it on when serving over HTTPS or behind a TLS-terminating proxy). - **Failed logins are rate-limited**: 5 failures from one IP address locks that address out for 5 minutes. Admin passwords must be at least 8 characters. - **Changing the password revokes every other session immediately** — sessions carry a version derived from the password hash, so only the session that made the change stays signed in. Changing the username or password also requires re-entering the current password, so a hijacked session alone can't silently take the account over. - The **first-run setup endpoint stays open until an account exists** (and is rate-limited). Complete the initial registration promptly. ### The two endpoints that aren't behind the login `GET /api/dashboard` (dashboard widgets) and `GET /metrics` (Prometheus) are polled by machines that can't hold a login session, so they authenticate with a **shared read-only API key** instead — sent as an `X-API-Key` header or a `?key=` query parameter. Worth knowing: - **Both are disabled until you generate a key** under Settings → Integrations, and deleting the key disables both again. A request with no key configured is refused with `403`. - The key is **read-only**: these endpoints expose backup status, schedules and datastore usage. They cannot start, stop or change anything. - A `?key=` in a URL can end up in proxy and browser logs. Prefer the header where your client supports it. ### Data at rest, and outbound traffic - **Secrets live in `config.yaml`** (git-ignored), and are redacted in every API response — including the Advanced tab's export, which is therefore a readable copy and **not** a restorable backup. - **Upgrading from 0.9 leaves a second file holding the same secrets.** The migration writes `config.yaml.pre-overhaul.bak` beside your config as a rollback parachute; it contains every token, password hash and secret from the old configuration. Joulenap sets it to `0600`, but it is a real second copy — delete it once you're satisfied with the upgrade. - **Joulenap makes no outbound internet connection unless you ask it to.** The update check is off by default; notifications only reach the services you configure. ## What Joulenap does not do So you know what to expect, and what isn't worth reporting as a vulnerability: - **One admin account, no roles.** There is no multi-user support, no RBAC and no per-user permissions. Everyone with the password can do everything. - **No audit trail of who did what.** The run history records what happened, not which person triggered it — with a single account there is nobody to distinguish. - **Not hardened for the public internet.** There's no built-in TLS, no MFA and no brute-force protection beyond the per-IP login lockout. Put it on your LAN or behind a VPN. If you must expose it, terminate TLS in front of it, set `session.https_only: true`, and restrict who can reach it. - **It is as privileged as you make it.** Joulenap can start backups and power machines off; a compromise of the host it runs on is a compromise of those capabilities. Scope its tokens as tightly as your setup allows. See the [Security section of the README](README.md#security) for operator guidance, and [`docs/CONFIG-WIZARD.md`](docs/CONFIG-WIZARD.md#security) for how pinning and host-key verification are established during setup. If you find a gap between this model and the code, that's exactly the kind of report we want.