Salta ai contenuti
Sirio è in beta privata: sorgenti e installer vengono inviati via email a chi la prova. Chiedi di partecipare.

Sicurezza

Sirio parla con il socket Docker. Chi può usare il socket Docker può diventare root sull’host: basta avviare un container privilegiato che monta /. Di conseguenza chi entra in Sirio controlla la macchina, e ogni scelta di progetto parte da qui.

  • Il primo account amministratore è creato dal wizard di primo avvio. L’endpoint di setup si chiude appena esiste un utente: non è possibile “reclamare” un’istanza già configurata. Gli account successivi si creano da Utenti, da parte di un amministratore.
  • Password con bcrypt a costo 12, minimo 10 caratteri, massimo 72 byte (oltre, bcrypt troncherebbe silenziosamente).
  • Al login inesistente viene comunque calcolato un hash, per non rivelare dai tempi di risposta quali username esistono.
  • Sessioni server-side: il cookie contiene un token casuale da 256 bit, nel database ne è salvato solo lo SHA-256. Un dump del database non permette di impersonare nessuno.
  • Cookie HttpOnly, SameSite=Lax, Secure quando SIRIO_SECURE_COOKIES=true o con TLS diretto.
  • Il cambio password invalida tutte le sessioni, compresa quella di un eventuale intruso.
  • Rate limiting sul login: 10 tentativi ogni 15 minuti per IP, con Retry-After nella risposta.

Ogni richiesta non sicura (POST, PUT, DELETE) deve portare l’header X-Sirio-CSRF con il token della sessione, che la SPA riceve da /api/status. Un sito terzo può far partire una richiesta con il cookie, ma non può leggere il token.

I WebSocket sono protetti dal controllo dell’Origin, che rifiuta le connessioni cross-site.

Ogni rotta sotto /api che non sia status, setup o login passa dal middleware di autenticazione. Non esiste alcun endpoint Docker o pipeline raggiungibile senza sessione.

L’unica eccezione è /webhooks/pipelines/{id}, che si autentica con la firma HMAC.

Un amministratore può fare tutto, e solo un amministratore gestisce utenti e permessi: non è un permesso assegnabile, altrimenti un utente potrebbe concedersi qualsiasi altro accesso.

Tutti gli altri hanno quello che un amministratore assegna loro, su due livelli:

  • Sezioni del pannello — sites, proxy, packages, docker, settings — ognuna dà accesso a un’intera sezione, su tutti i siti.
  • Singoli siti: dare accesso a un sito concede di base tutte le azioni su quel sito.

Le azioni sono raggruppate per area (repository, pipeline, domini, container) e, dentro ognuna, per singola operazione: creare, modificare, cambiare sito, eliminare, avviare una pipeline. Ciascuna si può togliere per conto suo — sul singolo sito, dalla riga del sito, oppure globalmente, e in quel caso viene tolta su tutti i siti raggiunti dal permesso sites e sui container che non appartengono a nessun sito. Togliere un’intera area toglie anche le sue operazioni. Quello che non si toglie in questo modo è la lettura: chi vede un sito vede cosa c’è dentro.

Il server decide ogni richiesta su questa base, comprese quelle che l’interfaccia non propone mai: nascondere un pulsante è una comodità, non il controllo.

La firma viene verificata prima di leggere il payload e prima di qualunque esecuzione:

  • GitHub e Gitea: X-Hub-Signature-256 (HMAC-SHA256), confrontata a tempo costante;
  • GitLab: X-Gitlab-Token, confrontato a tempo costante;
  • X-Hub-Signature (SHA-1) accettata solo per compatibilità con installazioni vecchie.

Il secret è generato dal server (32 byte casuali), mostrato una sola volta e conservato cifrato. Il corpo è limitato a 1 MiB e c’è un rate limit di 60 chiamate al minuto per IP.

Anche con firma valida, la pipeline parte solo se il ref corrisponde al trigger configurato: un push su un branch diverso viene registrato e ignorato.

Token Git, chiavi SSH di deploy e secret dei webhook sono cifrati con AES-256-GCM. La chiave sta in secret.key (permessi 0600) o nella variabile SIRIO_SECRET_KEY, mai nel database. L’API non restituisce mai un segreto: espone solo un flag has_secret. I segreti non compaiono nei log.

  • I comandi provengono dalla configurazione scritta dall’amministratore, non dal payload dei webhook. I dati non fidati (ref, commit) arrivano agli step solo come variabili d’ambiente: non vengono mai concatenati dentro una stringa di comando.
  • Gli step non-shell (build, compose, restart) sono eseguiti passando gli argomenti come array, senza shell di mezzo. I sottocomandi compose sono su whitelist; gli argomenti aggiuntivi di uno step compose sono divisi sugli spazi e vengono dopo il sottocomando, quindi non possono sostituirlo.
  • workdir, context e compose_file devono essere relativi al checkout: percorsi assoluti o con .. vengono rifiutati alla validazione.
  • Gli URL dei repository sono limitati a https://, ssh:// e alla forma git@host:path. Schemi come ext:: permetterebbero l’esecuzione di comandi arbitrari tramite git.
  • I processi figli non ereditano l’ambiente del servizio: ricevono solo PATH, LANG e le variabili della pipeline. Un Environment= con segreti nell’unit systemd non trapela negli step.
  • Il token Git non passa mai dalla riga di comando (sarebbe visibile in ps) né finisce nel .git/config del checkout: viene fornito a git tramite GIT_ASKPASS.
  • Ogni step ha un timeout (30 minuti di default) e alla cancellazione viene terminato l’intero process group, non solo il processo padre.
  • Il Caddyfile è generato concatenando valori scritti dall’amministratore. Un FQDN che contenga un a capo o una graffa inietterebbe direttive arbitrarie nella configurazione del proxy. Per questo ogni campo che finisce nel file — dominio, path, destinazione, porta, indirizzo ACME — passa da una validazione per whitelist in internal/proxy/validate.go prima della generazione. La generazione non fa escaping, perché in un Caddyfile non esiste una forma di quoting che renda sicuro un input arbitrario: la difesa è a monte.
  • caddy validate gira su ogni configurazione prima di attivarla, come seconda linea. Se fallisce, la configurazione precedente resta in vigore.
  • I comandi verso il container del proxy sono passati come array di argomenti, senza shell.
  • Una rotta di tipo host permette di raggiungere qualsiasi indirizzo visibile dal proxy, inclusi servizi interni. È accettabile — chi amministra Sirio ha già accesso al socket Docker, quindi root sull’host — ma ogni creazione e modifica di rotta finisce nel registro attività con la destinazione.
  • Il proxy gira come container, non come servizio dell’host: Sirio scrive la configurazione solo dentro SIRIO_DATA_DIR e non richiede di allargare ReadWritePaths nell’unit systemd né privilegi di reload su servizi di sistema.
  • Con SIRIO_PANEL_DOMAIN il proxy raggiunge il pannello tramite un socket Unix in SIRIO_DATA_DIR/proxy (0700, montata solo nel container del proxy), non una porta TCP. Solo su quel listener Sirio prende l’indirizzo del client da X-Forwarded-For, perché lì l’header può essere solo di Caddy: Caddy scarta quello inviato dal client e imposta il peer che vede. Ovunque altrove l’header resta ignorato, quindi non serve ad aggirare il rate limit dei login. Il dominio del pannello passa dalla stessa validazione di quello di un sito, e non può essere un wildcard.
  • La verifica DNS di un dominio è un avviso e non un blocco, ed è deliberato: l’IP pubblico della macchina non è determinabile in modo affidabile dall’interno, e un confronto inaffidabile userebbe la sicurezza come pretesto per bloccare configurazioni legittime.

Applicare aggiornamenti richiede root, che Sirio non ha e non prende. Il servizio deposita una richiesta in SIRIO_DATA_DIR/packages, una unit systemd dedicata la raccoglie e la esegue: né sudo (bloccato da NoNewPrivileges) né un allentamento di ProtectSystem=strict, che renderebbe scrivibile mezzo filesystem per l’intero servizio invece che per una singola operazione.

L’insieme dei comandi eseguibili è chiuso e verificato due volte, in Sirio e nell’helper di root, perché chi scrive la richiesta è meno fidato di chi la esegue. I nomi dei pacchetti passano una convalida sulla forma, sono accettati solo se già presenti fra gli aggiornabili e arrivano ad apt come lista di argomenti, mai concatenati in una stringa di shell. Fuori dall’insieme restano dist-upgrade, l’installazione e la rimozione di pacchetti arbitrari. Dettagli in pacchetti.md.

Ogni helper privilegiato (pacchetti, installazione di Docker, log dei container, fail2ban, aggiornamenti, alimentazione) parla con Sirio attraverso file in una cartella sotto SIRIO_DATA_DIR. Quella cartella, e SIRIO_DATA_DIR stessa, appartengono all’utente del servizio, che può sostituire con un symlink qualunque file o cartella lì dentro. Per questo gli helper non toccano mai quei file come root: leggere e rimuovere la richiesta, scrivere il log e l’esito avvengono come utente del servizio (setpriv, o runuser se manca). Un symlink porta così solo dove quell’utente poteva già scrivere. install.sh crea quelle cartelle nello stesso modo. Root scrive solo nei posti suoi (/etc, /usr/local, una /tmp privata): per esempio sirio-fail2ban-ctl tiene la copia del suo file in jail.d, da rimettere se fail2ban rifiuta quello nuovo, nella /tmp privata della sua unit, mai nella casella.

Un aggiornamento sostituisce il binario di cui si fidano gli helper di root, quindi è un’azione riservata agli amministratori, e una release diventa installabile per la sua firma ed25519, verificata da Sirio e di nuovo come root con le chiavi del binario installato, non per il server da cui arriva. Dettagli in aggiornamenti.md.

Gli amministratori possono riavviare o spegnere la macchina dal menù utente; come gli aggiornamenti, non è un permesso assegnabile, perché porta giù tutti i siti e una VPS spenta torna solo dalla console del provider. Sirio deposita in SIRIO_DATA_DIR/power una richiesta che contiene solo reboot o poweroff e un token per l’esito; l’unit sirio-power (root, ProtectSystem=strict, che scrive solo nella sua casella) sceglie il verbo di systemctl dalla propria lista chiusa e lo programma cinque secondi dopo con un timer transitorio di systemd, così la risposta arriva prima al pannello. Una seconda richiesta mentre una è in attesa viene rifiutata. Le esecuzioni di pipeline e di backup in corso andrebbero perse, quindi il pannello avvisa e chiede conferma; un’operazione privilegiata in corso (pacchetti, installazione di Docker, configurazione di fail2ban, un aggiornamento di Sirio) non si può scavalcare affatto, perché interromperla a metà può lasciare il sistema danneggiato. Entrambe le azioni finiscono nel registro attività (host.reboot, host.poweroff), con l’indicazione delle esecuzioni interrotte.

Docker non ha un’API per eliminare i log di un container, e i file sono di root, sotto la data root di Docker. Stesso schema dei pacchetti: Sirio deposita in SIRIO_DATA_DIR/container-logs una richiesta che nomina solo l’ID completo di un container, e l’unit sirio-logs (root) la riconvalida, chiede a Docker stesso quali file appartengono a quel container e li svuota — mai un percorso preso dalla richiesta, mai un link simbolico, solo i driver json-file e local. Un container in esecuzione viene prima fermato e poi riavviato, e il pannello lo dice prima di chiedere conferma: dockerd tiene un proprio offset di scrittura nel file attivo, e svuotarlo sotto un container in esecuzione lascia docker logs -f a leggere oltre la fine reale fino alla rotazione successiva. L’helper rifiuta un container in esecuzione anche se glielo si chiede. Serve il permesso containers.edit, e l’operazione finisce nel registro attività con lo spazio liberato.

La pagina Porte (Host → Porte, permesso settings) elenca tutte le porte TCP e UDP in ascolto sull’host e segnala quelle rischiose: SSH sulla porta predefinita 22 o che accetta password o il login di root (o che chiede ancora la password tramite il keyboard-interactive di PAM dopo PasswordAuthentication no) o password vuote, nessun fail2ban a sorvegliare un sshd pubblico, database e cache su un’interfaccia pubblica, protocolli in chiaro (Telnet, FTP, rsh), l’API Docker, la condivisione file, il desktop remoto, i pannelli di amministrazione e Sirio stesso servito in HTTP in chiaro su tutte le interfacce. Le regole e il catalogo delle porte note stanno in internal/ports.

Funziona senza privilegi, e questo delimita ciò che può dire:

  • I socket arrivano da /proc/net/{tcp,udp}[6], leggibili da qualunque utente. Sirio giudica una porta dall’indirizzo a cui è legata — 0.0.0.0, :: o un indirizzo pubblico è pubblica, un indirizzo privato o di VPN è rete privata, localhost è solo locale — ma non può leggere il firewall (servirebbe CAP_NET_ADMIN): una porta pubblica può comunque essere filtrata da ufw, nftables o dal provider. Né può sapere quale processo possiede il socket di un altro utente; mostra invece il nome dell’utente proprietario.
  • Le porte pubblicate dai container arrivano da Docker e sono segnalate a parte, perché Docker scrive regole iptables proprie che scavalcano ufw e firewalld: il caso classico del database “chiuso” dal firewall e aperto al mondo. Pubblicale come 127.0.0.1:PORTA quando servono solo all’host; ai siti non serve nessuna porta pubblicata, perché il proxy raggiunge i container sulla sua rete. Per questo il form Nuovo container pubblica su 127.0.0.1 a meno che tu non scelga tutte le interfacce (o scriva un indirizzo nella riga), e avvisa quando una porta finirebbe pubblica.
  • I controlli su SSH leggono sshd_config come fa sshd (Include sul posto, vince il primo valore, blocchi Match esclusi). I drop-in che Sirio non può leggere — alcune distro li installano 0600 — rendono sconosciuti i valori interessati invece di presumerli.
  • Spostare SSH dalla porta 22 è segnalato come avviso, non come soluzione: riduce le scansioni automatiche e il rumore nei log, ma a proteggere davvero il server sono l’accesso solo con chiave, niente login di root e fail2ban o un elenco di IP ammessi.

Con sshd raggiungibile dall’esterno, la pagina Porte controlla anche che fail2ban sia installato, attivo e con la jail sshd accesa — un avviso dove SSH accetta ancora password, una nota dove è solo a chiavi, perché lì fail2ban riduce soprattutto il rumore nei log. Se la jail è accesa lo legge dalla configurazione, combinata come fa fail2ban (jail.conf, jail.d/*.conf, jail.local, jail.d/*.local, vince l’ultimo valore); lo stato effettivo richiederebbe il socket, che è solo di root.

Dalla stessa pagina, con i permessi settings e packages, Sirio può configurarlo: stesso schema dei pacchetti, una richiesta in SIRIO_DATA_DIR/fail2ban raccolta dalla unit sirio-fail2ban (root). La richiesta non porta altro che l’intenzione — l’helper riconosce apt, dnf o yum, abilita EPEL su Rocky/Alma/CentOS, legge le porte SSH da sshd -T, legge il log di sshd dal journal quando c’è il modulo systemd di Python, scrive /etc/fail2ban/jail.d/sirio-sshd.conf (che il tuo jail.local continua a sovrascrivere), riavvia fail2ban e verifica che la jail sia davvero su. Blocca nella catena INPUT, quindi protegge i servizi dell’host come SSH, non le porte pubblicate dai container. Non disponibile senza systemd (Alpine).

La pagina fail2ban, con il permesso settings, mostra il server in esecuzione: ogni jail, i suoi contatori e gli indirizzi che banna, con la scadenza di ciascun ban. Quello stato sta solo nel socket di fail2ban, che è di root, quindi passa da una seconda unit, sirio-fail2ban-ctl (root), con una casella tutta sua in SIRIO_DATA_DIR/fail2ban/ctl — un’installazione tiene occupata sirio-fail2ban per minuti, e una lettura non può aspettarla. La richiesta indica un’azione da un insieme chiuso e un token casuale per il nome del file di risposta, nient’altro; l’helper interroga fail2ban con la sua stessa libreria client in un solo processo e risponde in JSON. La sua unit gira con ProtectSystem=strict e può scrivere solo in quella casella. Sirio riusa una lettura per qualche secondo prima di chiedere di nuovo, così una pagina che si aggiorna da più schede non avvia un processo root ogni volta.

Dalla stessa pagina si può bannare un indirizzo in una jail o sbloccarlo, sempre con il solo settings, e ogni modifica andata a buon fine finisce nel registro di audit (fail2ban.ban, fail2ban.unban). La jail e l’indirizzo vengono controllati due volte, in Go e di nuovo nell’helper, e arrivano a fail2ban come argomenti del suo protocollo, mai attraverso una shell. Un ban chiesto a mano fail2ban lo applica qualunque cosa dica la sua configurazione — banna senza obiezioni un indirizzo presente in ignoreip — quindi Sirio rifiuta quelli che riconosce come errori: indirizzi di loopback, non specificati e multicast, tutto ciò che ignoreip della jail copre (i nomi host lì dentro non vengono risolti) e l’indirizzo da cui arriva la richiesta. Quest’ultimo controllo vede l’indirizzo che vede Sirio: dietro al proxy gestito è quello vero del client, ma dietro a un tuo reverse proxy è quello del proxy, e il controllo non serve.

La stessa pagina imposta i limiti della jail sshd (fallimenti prima del ban, la finestra in cui vengono contati, la durata del ban) e una whitelist di indirizzi e reti che nessuna jail banna mai. Entrambi stanno in un unico file di Sirio, /etc/fail2ban/jail.d/sirio-tuning.conf — [DEFAULT] ignoreip e [sshd] — che scrive solo sirio-fail2ban-ctl, per cui la sua unit può scrivere in quella directory oltre che nella sua casella. La richiesta porta l’intero file sotto forma di valori (interi, indirizzi e reti CIDR, controllati in Go e di nuovo nell’helper): l’helper non tiene stato. Scrive il file, esegue fail2ban-client -t sull’intera configurazione e poi ricarica fail2ban, che mantiene i suoi ban; se uno dei due passi fallisce rimette il file precedente, così una modifica fatta dal pannello non lascia mai fail2ban impossibilitato a partire. Essendo un .conf, il file perde contro jail.local e jail.d/*.local: la pagina legge la configurazione come la combina fail2ban e dice quale dei tuoi file sovrascrive un valore impostato da Sirio. Il loopback è sempre nella whitelist, le reti più ampie di /8 (IPv4) o /16 (IPv6) vengono rifiutate e i nomi host non sono accettati.

Lo stesso file può attivare la jail recidive, che legge il log di fail2ban stesso e banna, su tutte le porte, gli indirizzi che le altre jail continuano a bannare (con i valori predefiniti di fail2ban, cinque ban in un giorno valgono una settimana). La richiesta dice solo “attiva”: l’helper legge logtarget dalla configurazione di fail2ban e scrive da sé il backend — auto con quel file come logpath, oppure systemd per SYSTEMD-JOURNAL — e rifiuta tutto il resto, perché alcune distribuzioni impostano backend = systemd per ogni jail e una recidive che guarda il journal mentre fail2ban scrive su un file non bannerebbe mai nessuno. Dato che blocca tutte le porte, pannello compreso, la pagina chiede di tenere i tuoi indirizzi nella whitelist. Una recidive attivata da un tuo file viene mostrata come tale, e lasciata stare.

I ban recenti vengono dal database dei ban di fail2ban, che sirio-fail2ban-ctl apre in sola lettura (la sua unit lì non può comunque scrivere), trovato attraverso la configurazione così funziona anche con fail2ban fermo. Ogni ban mostra le ultime righe di log che lo hanno causato; sono ciò che un client ha inviato, quindi la pagina le mostra come testo semplice. È una memoria, non un registro: fail2ban dimentica un ban dbpurgeage dopo la sua fine (un giorno di default), e un indirizzo sbloccato a mano subito. La pagina lo legge solo quando richiesto.

Opzionali (SIRIO_SFTP_ENABLED, spenti di default). Dettagli in sftp.md.

  • Docker monta qualsiasi cosa gli venga chiesta con i diritti di root, quindi ciò che il server di un sito può esporre lo decide Sirio prima che il container esista, da ciò che i container del sito montano in quel momento, e lo decide di nuovo a ogni modifica e ogni minuto. Le directory di sistema, il socket e la directory dati di Docker, la directory dati di Sirio, le home degli utenti e i file nascosti al loro interno non vengono mai esposti, né le directory che ne contengono una; e neppure i dati di un database, i mount condivisi con un altro sito (o con nessun sito) e i percorsi che Sirio non riesce a risolvere. I percorsi vengono controllati così come sono scritti e dopo averne seguito i symlink, e il container monta quello risolto.
  • Chi può creare container su un sito può già montarci qualsiasi percorso dell’host: è root sull’host, come ovunque ci sia di mezzo Docker. I controlli dell’SFTP impediscono che quel potere arrivi agli account SFTP del sito, che non sono utenti di Sirio.
  • Il server è il binario di Sirio, da solo in un’immagine che Sirio costruisce a partire da esso: nessuna immagine di terzi di cui fidarsi. Serve solo il sottosistema SFTP, apre ogni mount come os.Root, rifiuta symlink, hard link e rinomine tra mount diversi, e non permette mai a un client di cambiare il proprietario o i bit speciali dei permessi.
  • Il suo container rinuncia a ogni capability tranne CHOWN, DAC_OVERRIDE e FOWNER, gira con no-new-privileges, il filesystem radice in sola lettura, limiti su processi e memoria, su una rete senza traffico tra container, e monta la sua configurazione in sola lettura.
  • Le password sono salvate come hash bcrypt, le chiavi host sigillate con la secret box. Il file di configurazione letto dal server contiene solo hash e chiavi pubbliche, e Sirio lo valida con le stesse regole del server prima di scriverlo.
  • I limiti per IP su password sbagliate e connessioni presuppongono che l’indirizzo del client arrivi al container: dietro il proxy userland di Docker tutti i client condividono quello del gateway.

X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer e una CSP che consente solo risorse dell’origine — la SPA è interamente servita dal binario, non carica nulla da CDN.

  1. Metti HTTPS davanti. Senza TLS il cookie di sessione è leggibile in rete. Poi attiva SIRIO_SECURE_COOKIES=true.
  2. Richiudi la porta di Sirio quando hai HTTPS. Il default SIRIO_LISTEN=0.0.0.0:22722 serve a raggiungere il pannello su http://IP:22722 prima di avere un dominio: è in chiaro, quindi è una condizione di partenza, non di esercizio. Con un reverse proxy sull’host, torna a 127.0.0.1:22722 e raggiungi il pannello solo attraverso di lui. Con il proxy gestito da Sirio usa SIRIO_PANEL_DOMAIN e sposta SIRIO_LISTEN su un’altra porta di loopback: una rotta host verso host.docker.internal smette di funzionare se Sirio ascolta su 127.0.0.1 (vedi domini.md).
  3. Restringi l’accesso. Se puoi, tieni il pannello dietro VPN o limitalo per IP nel proxy. È amministrazione di sistema, non un sito pubblico.
  4. Custodisci secret.key. Con il database e la chiave si ricavano tutti i token di deploy. Backup separati.
  5. Usa credenziali Git minime. Deploy key in sola lettura, token con lo scope più stretto possibile.
  6. Leggi il registro attività. In Impostazioni: login falliti, webhook rifiutati, azioni sui container.
  • Un solo ruolo: chi accede può fare tutto. RBAC granulare è rimandato.
  • L’helper dei pacchetti gira come root: restringe cosa può essere eseguito, non chi può chiederlo. Chi entra nel pannello può aggiornare la macchina.
  • Eliminare i log di un container li distrugge per tutti, docker logs compreso: chi deve conservarli li mandi altrove (un log driver, un collector) prima di affidarsi a questo pannello.
  • Nessun secondo fattore.
  • Dietro reverse proxy il rate limiting vede l’IP del proxy, non quello del client.
  • Uno step shell è, per sua natura, esecuzione di codice arbitrario sull’host: chi può modificare una pipeline può fare qualsiasi cosa. Non è una falla, è la funzione del prodotto.

Aprire una issue pubblica per una vulnerabilità la rende sfruttabile prima che ci sia una correzione. Scrivi in privato al manutentore descrivendo il problema e come riprodurlo.