Security
The starting point
Section titled “The starting point”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.
What Sirio does
Section titled “What Sirio does”Authentication
Section titled “Authentication”- 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=Laxcookies,SecurewhenSIRIO_SECURE_COOKIES=trueor 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-Afterin 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.
Authorization
Section titled “Authorization”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.
Webhooks
Section titled “Webhooks”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.
Secrets
Section titled “Secrets”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.
Pipeline execution
Section titled “Pipeline execution”- 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,contextandcompose_filemust be relative to the checkout: absolute paths or ones containing..are rejected at validation.- Repository URLs are limited to
https://,ssh://and thegit@host:pathform. Schemes likeext::would allow arbitrary command execution through git. - Child processes don’t inherit the service’s environment: they only receive
PATH,LANGand the pipeline’s variables. AnEnvironment=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 viaGIT_ASKPASS. - Every step has a timeout (30 minutes by default), and on cancellation the whole process group is terminated, not just the parent process.
Domains and reverse proxy
Section titled “Domains and reverse proxy”- 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.gobefore 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 validateruns 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
hostallows 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_DIRand doesn’t need to widenReadWritePathsin the systemd unit or reload privileges on system services. - With
SIRIO_PANEL_DOMAINthe proxy reaches the panel through a Unix socket inSIRIO_DATA_DIR/proxy(0700, mounted only into the proxy container), not a TCP port. On that listener alone Sirio takes the client address fromX-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.
System packages
Section titled “System packages”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.
The helpers’ mailboxes
Section titled “The helpers’ mailboxes”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.
Updates of Sirio itself
Section titled “Updates of Sirio itself”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.
Restarting and shutting down the server
Section titled “Restarting and shutting down the server”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.
Clearing container logs
Section titled “Clearing container logs”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.
Listening ports
Section titled “Listening ports”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 needsCAP_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:PORTwhen 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 on127.0.0.1unless 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_configthe way sshd does (Include in place, first value wins, Match blocks excluded). Drop-ins that aren’t readable by Sirio — some distros install them as0600— 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.
fail2ban
Section titled “fail2ban”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.
SFTP servers
Section titled “SFTP servers”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_OVERRIDEandFOWNER, runs withno-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.
Transport and headers
Section titled “Transport and headers”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.
What you need to do
Section titled “What you need to do”- Put HTTPS in front. Without TLS the session cookie is readable over the network. Then
enable
SIRIO_SECURE_COOKIES=true. - Close Sirio’s port once you have HTTPS. The default
SIRIO_LISTEN=0.0.0.0:22722is meant to reach the panel athttp://IP:22722before 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 to127.0.0.1:22722and reach the panel only through it. With the Sirio-managed proxy useSIRIO_PANEL_DOMAINand moveSIRIO_LISTENto another loopback port: a host route tohost.docker.internalstops working if Sirio listens on127.0.0.1(see domains.md). - 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.
- Guard
secret.key. With the database and the key, every deploy token can be recovered. Keep separate backups. - Use minimal Git credentials. Read-only deploy keys, tokens with the narrowest scope possible.
- Read the activity log. In Settings: failed logins, rejected webhooks, container actions.
Known limitations
Section titled “Known limitations”- 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 logsincluded: 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.
Reporting a vulnerability
Section titled “Reporting a vulnerability”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.