Four small ones found alongside the Gate 2 defects. Nothing configured the root logger, so every `log.info` in the package went nowhere: `docker logs` showed uvicorn's handful of lines and nothing else, including across a 0.9 -> 1.0 config migration — the riskiest thing the app ever does, and it left no trace of having run. Both entry points now set logging up first, with `JOULENAP_LOG_LEVEL` to turn it up. The route editor's preview chip printed the internal kind-prefixed key (`pve:pve`) instead of the device id, ever since the draft started carrying keys so a PVE and a backup server could share a name. SECURITY.md said backup-server API traffic is pinned to a stored fingerprint without saying where that fingerprint comes from. Adding a server through a Proxmox host takes it from that host's storage configuration; adding one directly reads it off the box over a connection nothing has authenticated, which is trust on first use. Pinning protects everything after setup, not setup itself, and the document now says so. The changelog now warns upgraders that history is tracked per route, so converted routes read "never run" and every guest reads "never backed up" until the first 1.0 run — with the old runs still listed underneath, which makes it look like data was lost when nothing was.
9.2 KiB
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 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 arestrictedauthorized_keysline 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
/remoteroles a sync route needs (RemoteAdminandRemoteSyncPushOperator), 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.yamlor 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 infoon 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 populateknown_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_tlsis 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), andapp.sessioncontrols its lifetime (max_age_days, default 14) and theSecureflag (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.bakbeside your config as a rollback parachute; it contains every token, password hash and secret from the old configuration. Joulenap sets it to0600, 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 for operator guidance, and
docs/CONFIG-WIZARD.md 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.