Skip to content
Sirio is in private beta: sources and installer are sent to testers by email. Ask to join.

Security

Sirio talks to the Docker socket. Whoever can use the Docker socket can become root on the host: it only takes starting a privileged container that mounts /. As a result, whoever gets into Sirio controls the machine, and every design decision starts from here.

  • The first administrator account is created by the first-start wizard. The setup endpoint closes as soon as a user exists: an already-configured instance can’t be “claimed”. Further accounts are created from Users, by an administrator.
  • Passwords with bcrypt at cost 12, minimum 10 characters, maximum 72 bytes (beyond that, bcrypt would silently truncate).
  • On a login for a nonexistent user, a hash is still computed, so response times don’t reveal which usernames exist.
  • Server-side sessions: the cookie holds a random 256-bit token, only its SHA-256 is stored in the database. A database dump doesn’t let anyone impersonate a user.
  • HttpOnly, SameSite=Lax cookies, Secure when SIRIO_SECURE_COOKIES=true or with direct TLS.
  • Changing the password invalidates every session, including an intruder’s.
  • Rate limiting on login: 10 attempts every 15 minutes per IP, with Retry-After in the response.

Every unsafe request (POST, PUT, DELETE) must carry the X-Sirio-CSRF header with the session’s token, which the SPA receives from /api/status. A third-party site can trigger a request carrying the cookie, but it can’t read the token.

WebSockets are protected by the Origin check, which rejects cross-site connections.

Every route under /api other than status, setup or login goes through the authentication middleware. There’s no Docker or pipeline endpoint reachable without a session.

The only exception is /webhooks/pipelines/{id}, which authenticates via HMAC signature.

An administrator can do everything, and only an administrator manages users and permissions: that’s not an assignable permission, or a user could grant themselves any other access.

Everyone else gets what an administrator assigns them, on two levels:

  • Panel sections — sites, proxy, packages, docker, settings — each granting access to a whole section, across all sites.
  • Individual sites: giving access to a site grants, by default, every action on it.

Actions come in areas (repositories, pipelines, domains, containers) and, inside each one, individual operations: creating, editing, changing site, deleting, running a pipeline. Each can be removed on its own — per site, from the site’s row, or globally, which removes it from every site reached by the sites permission and from the containers that belong to no site. Removing a whole area removes its operations too. What’s never removable this way is reading: whoever sees a site sees what’s in it.

The server decides every request on this basis, including the ones the UI never offers: hiding a button is a convenience, not the check.

The signature is verified before reading the payload and before any execution:

  • GitHub and Gitea: X-Hub-Signature-256 (HMAC-SHA256), compared in constant time;
  • GitLab: X-Gitlab-Token, compared in constant time;
  • X-Hub-Signature (SHA-1) accepted only for compatibility with old installations.

The secret is generated by the server (32 random bytes), shown once and stored encrypted. The body is limited to 1 MiB and there’s a rate limit of 60 calls per minute per IP.

Even with a valid signature, the pipeline only starts if the ref matches the configured trigger: a push to a different branch is logged and ignored.

Git tokens, SSH deploy keys and webhook secrets are encrypted with AES-256-GCM. The key lives in secret.key (permissions 0600) or in the SIRIO_SECRET_KEY variable, never in the database. The API never returns a secret: it only exposes a has_secret flag. Secrets never appear in logs.

  • Commands come from the configuration written by the administrator, not from the webhook payload. Untrusted data (ref, commit) reaches steps only as environment variables: it’s never concatenated into a command string.
  • Non-shell steps (build, compose, restart) are executed by passing arguments as an array, with no shell in between. Compose subcommands are whitelisted; a compose step’s additional arguments are split on spaces and come after the subcommand, so they can’t replace it.
  • workdir, context and compose_file must be relative to the checkout: absolute paths or ones containing .. are rejected at validation.
  • Repository URLs are limited to https://, ssh:// and the git@host:path form. Schemes like ext:: would allow arbitrary command execution through git.
  • Child processes don’t inherit the service’s environment: they only receive PATH, LANG and the pipeline’s variables. An Environment= with secrets in the systemd unit doesn’t leak into steps.
  • The Git token never goes through the command line (it would be visible in ps) and never ends up in the checkout’s .git/config: it’s provided to git via GIT_ASKPASS.
  • Every step has a timeout (30 minutes by default), and on cancellation the whole process group is terminated, not just the parent process.
  • The Caddyfile is generated by concatenating values written by the administrator. An FQDN containing a newline or a brace would inject arbitrary directives into the proxy’s configuration. That’s why every field that ends up in the file — domain, path, destination, port, ACME address — goes through whitelist validation in internal/proxy/validate.go before generation. Generation does no escaping, because there’s no form of quoting in a Caddyfile that makes arbitrary input safe: the defense happens upstream.
  • caddy validate runs on every configuration before activating it, as a second line of defense. If it fails, the previous configuration stays in effect.
  • Commands to the proxy container are passed as an array of arguments, with no shell.
  • A route of type host allows reaching any address visible from the proxy, including internal services. That’s acceptable — whoever administers Sirio already has access to the Docker socket, and therefore root on the host — but every route creation and change ends up in the activity log with its destination.
  • The proxy runs as a container, not as a host service: Sirio only writes configuration inside SIRIO_DATA_DIR and doesn’t need to widen ReadWritePaths in the systemd unit or reload privileges on system services.
  • With SIRIO_PANEL_DOMAIN the proxy reaches the panel through a Unix socket in SIRIO_DATA_DIR/proxy (0700, mounted only into the proxy container), not a TCP port. On that listener alone Sirio takes the client address from X-Forwarded-For, because there the header can only be Caddy’s: Caddy discards the one a client sends and sets the peer it sees. Everywhere else the header is still ignored, so it can’t be used to dodge the login rate limit. The panel domain goes through the same validation as a site’s, and can’t be a wildcard.
  • A domain’s DNS check is a warning, not a block, and that’s deliberate: the machine’s public IP can’t be reliably determined from the inside, and an unreliable comparison would use security as a pretext to block legitimate configurations.

Applying updates requires root, which Sirio doesn’t have and doesn’t take. The service drops a request in SIRIO_DATA_DIR/packages, a dedicated systemd unit picks it up and runs it: neither sudo (blocked by NoNewPrivileges) nor a loosening of ProtectSystem=strict, which would make half the filesystem writable for the whole service instead of for a single operation.

The set of runnable commands is closed and checked twice, in Sirio and in the root helper, because whoever writes the request is less trusted than whoever executes it. Package names go through a format check, are accepted only if already present among the upgradable ones, and reach apt as a list of arguments, never concatenated into a shell string. Outside the set stay dist-upgrade, and installing or removing arbitrary packages. Details in packages.md.

Every privileged helper (packages, Docker install, container logs, fail2ban, updates, power) talks to Sirio through files in a directory under SIRIO_DATA_DIR. That directory, and SIRIO_DATA_DIR itself, belong to the service user, which can replace any file or folder in there with a symlink. So the helpers never touch those files as root: reading and removing the request, and writing the log and the result, run as the service user (setpriv, or runuser without it). A symlink then leads only where that user could already write. install.sh creates those directories the same way. Root writes only its own places (/etc, /usr/local, a private /tmp): for instance sirio-fail2ban-ctl keeps the copy of its file in jail.d, which it puts back if fail2ban refuses the new one, in its unit’s private /tmp, never in the mailbox.

An update replaces the binary the root helpers trust, so it’s an admin-only action, and what makes a release installable is its ed25519 signature, checked by Sirio and again as root with the installed binary’s keys, not the server it came from. Details in updates.md.

Administrators can restart or shut down the machine from the user menu; like updates, it isn’t a permission that can be handed out, since it takes every site down and a VPS that’s off only comes back from the provider’s console. Sirio drops a request in SIRIO_DATA_DIR/power carrying only reboot or poweroff and a token for the result; the sirio-power unit (root, ProtectSystem=strict, writing nothing but its mailbox) picks the systemctl verb from its own closed list and schedules it five seconds later through a transient systemd timer, so the answer reaches the panel first. A second request while one is pending is refused. Pipeline and backup runs in progress would be lost, so the panel warns and asks to confirm; a privileged operation in progress (packages, Docker install, fail2ban setup, an update of Sirio) can’t be overridden at all, since cutting it halfway can leave the system broken. Both actions are written to the audit log (host.reboot, host.poweroff), with whether runs were interrupted.

Docker has no API to delete a container’s logs, and the files are root’s, under Docker’s data root. Same scheme as packages: Sirio drops a request in SIRIO_DATA_DIR/container-logs naming only a full container ID, and the sirio-logs unit (root) revalidates it, asks Docker itself which files belong to that container and empties them — never a path taken from the request, never a symlink, only the json-file and local drivers. A running container is stopped first and started again afterwards, and the panel says so before asking: dockerd keeps its own write offset into the active file, and emptying it underneath a running container leaves docker logs -f reading past the real end until the next rotation. The helper refuses a running container even if asked. Clearing needs the containers.edit permission and is written to the audit log with the space freed.

The Ports page (Host → Ports, settings permission) lists every TCP and UDP port listening on the host and flags the risky ones: SSH on the default port 22 or accepting passwords or root logins (or still asking for one through PAM’s keyboard-interactive after PasswordAuthentication no) or empty passwords, no fail2ban watching a public sshd, databases and caches on a public interface, plaintext protocols (Telnet, FTP, rsh), the Docker API, file sharing, remote desktop, admin panels, and Sirio itself served over plain HTTP on every interface. The rules and the catalog of well-known ports are in internal/ports.

It works without privileges, and that bounds what it can tell:

  • Sockets come from /proc/net/{tcp,udp}[6], which any user can read. Sirio judges a port by the address it’s bound to — 0.0.0.0, :: or a public address is public, a private or VPN address is private network, localhost is local only — but it can’t read the firewall (that needs CAP_NET_ADMIN): a public port may still be filtered by ufw, nftables or the provider. Nor can it tell which process owns a socket of another user; it shows the owner’s user name instead.
  • Ports published by containers come from Docker and are flagged on their own, because Docker writes its own iptables rules that bypass ufw and firewalld: the classic case of a database “closed” by the firewall and open to the world. Publish them as 127.0.0.1:PORT when only the host needs them; sites need no published port at all, since the proxy reaches containers over its own network. That’s why the New container form publishes on 127.0.0.1 unless you pick every interface (or write an address in the line), and warns when a port would end up public.
  • The SSH checks read sshd_config the way sshd does (Include in place, first value wins, Match blocks excluded). Drop-ins that aren’t readable by Sirio — some distros install them as 0600 — make the affected values unknown rather than assumed.
  • Moving SSH off port 22 is flagged as a warning, not as a fix: it cuts the automated scans and the log noise, but key-only login, no root login and fail2ban or an IP allowlist are what actually protect the server.

With sshd reachable from outside, the Ports page also checks that fail2ban is installed, running and has its sshd jail on — a warning where SSH still takes passwords, a note where it’s key-only, since there fail2ban mostly cuts the log noise. Whether the jail is on is read from the configuration the way fail2ban merges it (jail.conf, jail.d/*.conf, jail.local, jail.d/*.local, last value wins); its running state would need the root-only socket.

From the same page, with the settings and packages permissions, Sirio can set it up: same scheme as packages, a request in SIRIO_DATA_DIR/fail2ban picked up by the sirio-fail2ban unit (root). The request carries nothing but the wish — the helper detects apt, dnf or yum, enables EPEL on Rocky/Alma/CentOS, reads the SSH ports from sshd -T, reads sshd’s log from the journal when Python’s systemd module is there, writes /etc/fail2ban/jail.d/sirio-sshd.conf (which your jail.local still overrides), restarts fail2ban and checks that the jail is really up. It bans in the INPUT chain, so it protects host services like SSH, not ports published by containers. Not available without systemd (Alpine).

The fail2ban page, with the settings permission, shows the running server: every jail, its counters and the addresses it bans, with when each ban expires. That state is only on fail2ban’s root-only socket, so it goes through a second unit, sirio-fail2ban-ctl (root), with a mailbox of its own in SIRIO_DATA_DIR/fail2ban/ctl — an installation keeps sirio-fail2ban busy for minutes, and a read can’t wait for it. The request names an action from a closed set and a random token for the answer’s file name, nothing else; the helper asks fail2ban through its own client library in a single process and answers in JSON. Its unit runs with ProtectSystem=strict and can write only to that mailbox. Sirio serves a read for a few seconds before asking again, so a page polling from several tabs doesn’t start a root process each time.

From the same page an address can be banned in a jail or unbanned, again with settings only, and each change that took effect goes to the audit log (fail2ban.ban, fail2ban.unban). The jail and the address are checked twice, in Go and again in the helper, and reach fail2ban as arguments of its own protocol, never through a shell. A ban asked by hand is one fail2ban applies whatever its configuration says — it bans an address on ignoreip without objecting — so Sirio refuses the ones it can tell are mistakes: loopback, unspecified and multicast addresses, anything the jail’s ignoreip covers (host names in it aren’t resolved), and the address the request comes from. That last check sees the address Sirio sees: behind the managed proxy that’s the real client, but behind a reverse proxy of your own it’s the proxy’s, and the check can’t help.

The same page sets the sshd jail’s limits (failures before a ban, the window they’re counted in, the ban time) and a whitelist of addresses and networks that no jail ever bans. Both live in one file Sirio owns, /etc/fail2ban/jail.d/sirio-tuning.conf — [DEFAULT] ignoreip and [sshd] — which only sirio-fail2ban-ctl writes, so its unit can write that directory as well as its mailbox. The request carries the whole file as values (integers, addresses and CIDR networks, checked in Go and again in the helper): the helper keeps no state. It writes the file, runs fail2ban-client -t on the whole configuration and then reloads fail2ban, which keeps its bans; if either step fails it puts the previous file back, so a change the panel makes never leaves fail2ban unable to start. Being a .conf, the file loses to jail.local and jail.d/*.local: the page reads the configuration the way fail2ban merges it and says which of your files overrides a value Sirio set. Loopback is always on the whitelist, networks broader than /8 (IPv4) or /16 (IPv6) are refused, and host names aren’t accepted.

The same file can turn on the recidive jail, which reads fail2ban’s own log and bans, on every port, the addresses the other jails keep banning (by fail2ban’s defaults, five bans in a day mean a week). The request only says “on”: the helper reads logtarget from fail2ban’s configuration and writes the backend itself — auto with that file as logpath, or systemd for SYSTEMD-JOURNAL — and refuses anything else, since some distros set backend = systemd for every jail and a recidive watching the journal while fail2ban logs to a file would never ban anyone. Because it blocks every port, the panel’s included, the page asks you to keep your own addresses on the whitelist. A recidive turned on by one of your files is shown as such, and left alone.

Recent bans come from fail2ban’s ban database, which sirio-fail2ban-ctl opens read-only (its unit can’t write there anyway), found through the configuration so it works with fail2ban stopped. Each ban shows the last log lines that caused it; they’re whatever a client sent, so the page shows them as plain text. It’s a memory, not a log: fail2ban forgets a ban dbpurgeage after it ends (a day by default), and an address unbanned by hand at once. The page reads it only when asked.

Optional (SIRIO_SFTP_ENABLED, off by default). Details in sftp.md.

  • Docker mounts whatever it’s asked with root’s rights, so what a site’s server may expose is decided by Sirio before the container exists, from what the site’s containers mount at that moment, and decided again on every change and every minute. System directories, the Docker socket and data root, Sirio’s data directory, home directories and the hidden files in them are never exposed, nor any directory containing one of them; neither are a database’s data, mounts another site (or no site) shares, and paths Sirio can’t resolve. Paths are checked as written and after following their symlinks, and the container mounts the resolved one.
  • A user who can create containers on a site can already bind any host path into them: that’s root on the host, as everywhere Docker is involved. The SFTP checks keep that power from reaching the site’s SFTP accounts, who aren’t Sirio users.
  • The server is Sirio’s own binary, alone in an image Sirio builds from it: no third-party image to trust. It serves only the SFTP subsystem, opens each mount as an os.Root, refuses symlinks, hard links and cross-mount renames, and never lets a client change ownership or special mode bits.
  • Its container drops every capability but CHOWN, DAC_OVERRIDE and FOWNER, runs with no-new-privileges, a read-only root filesystem, process and memory limits, on a network without inter-container traffic, and mounts its configuration read-only.
  • Passwords are stored as bcrypt hashes, host keys sealed with the secret box. The configuration file the server reads holds hashes and public keys only, and is validated by Sirio with the server’s own rules before it’s written.
  • The per-IP limits on failed passwords and connections rely on the client’s address reaching the container: behind Docker’s userland proxy every client shares the gateway’s.

X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer and a CSP that only allows same-origin resources — the SPA is served entirely by the binary, it loads nothing from a CDN.

  1. Put HTTPS in front. Without TLS the session cookie is readable over the network. Then enable SIRIO_SECURE_COOKIES=true.
  2. Close Sirio’s port once you have HTTPS. The default SIRIO_LISTEN=0.0.0.0:22722 is meant to reach the panel at http://IP:22722 before you have a domain: it’s plaintext, so it’s a starting condition, not an operating one. With a reverse proxy on the host, go back to 127.0.0.1:22722 and reach the panel only through it. With the Sirio-managed proxy use SIRIO_PANEL_DOMAIN and move SIRIO_LISTEN to another loopback port: a host route to host.docker.internal stops working if Sirio listens on 127.0.0.1 (see domains.md).
  3. Restrict access. If you can, keep the panel behind a VPN or limit it by IP in the proxy. It’s system administration, not a public site.
  4. Guard secret.key. With the database and the key, every deploy token can be recovered. Keep separate backups.
  5. Use minimal Git credentials. Read-only deploy keys, tokens with the narrowest scope possible.
  6. Read the activity log. In Settings: failed logins, rejected webhooks, container actions.
  • A single role: whoever has access can do everything. Granular RBAC is deferred.
  • The packages helper runs as root: it restricts what can be run, not who can request it. Whoever gets into the panel can update the machine.
  • Clearing a container’s logs destroys them for everyone, docker logs included: whoever needs them kept should ship them elsewhere (a log driver, a collector) before relying on this panel.
  • No second factor.
  • Behind a reverse proxy, rate limiting sees the proxy’s IP, not the client’s.
  • A shell step is, by its nature, arbitrary code execution on the host: whoever can edit a pipeline can do anything. That’s not a flaw, it’s the product’s purpose.

Opening a public issue for a vulnerability makes it exploitable before a fix exists. Write privately to the maintainer describing the problem and how to reproduce it.