MySQL esposto su un VPS Hetzner con root senza password: il CIS benchmark che applico nelle prime due ore di hardening

MySQL esposto su un VPS Hetzner con root senza password: il CIS benchmark che applico nelle prime due ore di hardening

A luglio 2025 ho eseguito un audit di sicurezza su un VPS Hetzner AX41 (Ryzen 5 3600, 64 GB RAM, 2×512 GB NVMe) che ospitava un e-commerce B2B Laravel 10 nel settore dell'elettronica industriale - circa 1.200 clienti attivi e un fatturato e-commerce di 800.000 euro. Il titolare mi aveva chiamato per un problema di performance, ma quando ho aperto il terminale la prima cosa che ho verificato è stata la sicurezza del database. Quello che ho trovato era lo scenario che incontro in almeno il 60% dei VPS che prendo in carico per PMI italiane: MySQL era raggiungibile da internet sulla porta 3306, l'utente root non aveva password, local_infile era abilitato, e l'applicazione Laravel si connetteva al database come root per ogni operazione - dal checkout del cliente alla migrazione dello schema.

Il CIS Benchmark per MySQL - lo standard di riferimento per la configurazione sicura dei database pubblicato dal Center for Internet Security - definisce circa 80 controlli organizzati in categorie. Sull'installazione del cliente, 14 di questi controlli fallivano. Questo articolo descrive i 14 fix nell'ordine in cui li applico, e il perché di ciascuno.

Perché un MySQL non hardenizzato è il bersaglio più pericoloso su un VPS?

Un MySQL esposto su internet senza autenticazione è peggio di un SSH con password debole. SSH almeno richiede un'autenticazione; MySQL con root senza password e bind su 0.0.0.0 dà accesso completo a tutti i dati dell'applicazione a chiunque conosca l'IP del server. E gli IP dei VPS Hetzner, OVH, Contabo e Digital Ocean sono in range noti - i bot di scansione li testano sistematicamente, porta per porta. La guida al secure deployment di MySQL 8.0 pubblicata da Oracle è esplicita: un'installazione di default non è pensata per la produzione.

Il primo comando diagnostico:

# MySQL è raggiungibile dall'esterno?
ss -tlnp | grep 3306
# Se mostra 0.0.0.0:3306 o :::3306 → è esposto

# Root ha password?
mysql -u root -e "SELECT User, Host, authentication_string FROM mysql.user WHERE User='root';"
# Se authentication_string è vuoto → nessuna password

# Quali utenti esistono?
mysql -e "SELECT User, Host, plugin FROM mysql.user ORDER BY User;"

Sul VPS del cliente, ss mostrava 0.0.0.0:3306 - MySQL ascoltava su tutte le interfacce di rete, inclusa quella pubblica. Root non aveva password. Esistevano 4 utenti: root@localhost, root@% (accesso da qualunque host), mysql.sys@localhost e un utente generico debian-sys-maint. L'applicazione Laravel si connetteva come root@localhost con password vuota.

Stai cercando un Consulente Informatico esperto per un hardening MySQL sulla tua infrastruttura Laravel? Nel mio profilo professionale trovi l'esperienza concreta su sicurezza database, segregazione utenti e conformità CIS Benchmark per PMI.

Il protocollo di hardening: 14 fix in due ore

Fase 1: accesso e autenticazione (30 minuti)

Il primo passo è mysql_secure_installation - uno script incluso in ogni installazione MySQL che automatizza i fix più basilari. Ma sulla maggior parte dei VPS che prendo in carico, non è mai stato eseguito:

mysql_secure_installation
# Cosa fa:
# - Imposta password per root
# - Rimuove utenti anonimi
# - Disabilita login remoto per root
# - Rimuove il database 'test'
# - Ricarica i privilegi

Dopo mysql_secure_installation, creo gli utenti segregati - uno per l'applicazione (solo DML), uno per le migration (DDL completo), e rimuovo root@%:

-- Rimuovere root remoto
DROP USER IF EXISTS 'root'@'%';

-- Utente applicativo: solo lettura/scrittura dati
CREATE USER 'app_ecommerce'@'127.0.0.1'
    IDENTIFIED WITH caching_sha2_password BY 'password_generata_con_openssl_rand'
    REQUIRE SSL;
GRANT SELECT, INSERT, UPDATE, DELETE ON ecommerce_db.* TO 'app_ecommerce'@'127.0.0.1';

-- Utente migration: DDL per artisan migrate (usato solo da CLI, mai dal web)
CREATE USER 'deploy_ecommerce'@'127.0.0.1'
    IDENTIFIED WITH caching_sha2_password BY 'altra_password_generata'
    REQUIRE SSL;
GRANT ALL PRIVILEGES ON ecommerce_db.* TO 'deploy_ecommerce'@'127.0.0.1';

FLUSH PRIVILEGES;

La separazione tra utente applicativo e utente deploy è il singolo cambiamento con il maggiore impatto sulla sicurezza. Se un attaccante ottiene accesso all'applicazione (tramite SQL injection, compromissione dell'APP_KEY, o qualunque altro vettore), può leggere e scrivere dati - ma non può DROP TABLE, non può ALTER TABLE, non può creare utenti, non può esportare il database intero con mysqldump. Il danno è contenuto al livello DML. Ho descritto in dettaglio come un APP_KEY compromessa può dare accesso completo al server nel mio articolo sulla compromissione di un'applicazione Laravel via APP_KEY su GitHub - in quel caso, l'utente MySQL era root e l'attaccante ha fatto un mysqldump completo.

Fase 2: rete e cifratura (30 minuti)

Il binding su localhost impedisce connessioni dall'esterno. Il TLS obbligatorio cifra il traffico anche su localhost - ridondante? No, perché protegge contro sniffing da processi locali compromessi:

# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
bind-address = 127.0.0.1
require_secure_transport = ON

# Disabilitare local_infile (previene lettura di file del server via SQL)
local_infile = OFF

# Disabilitare symbolic-links (previene escape dalla data directory)
symbolic-links = 0

local_infile = OFF è un controllo CIS specifico che la guida di hardening MySQL 2025 segnala come prioritario: quando è abilitato, una query SQL può leggere qualunque file sul filesystem a cui l'utente mysql ha accesso - incluso /etc/passwd, i file di configurazione dell'applicazione, e potenzialmente l'.env di Laravel con tutte le credenziali.

Fase 3: hardening dei permessi e logging (30 minuti)

# /etc/mysql/mysql.conf.d/mysqld.cnf (continuazione)
[mysqld]
# Log di tutte le connessioni e le query DDL per audit
general_log = OFF              # troppo verboso per produzione continua
log_error = /var/log/mysql/error.log
log_error_verbosity = 3        # include warning, non solo errori

# Permessi filesystem
# (da eseguire dopo ogni modifica a my.cnf)
# Permessi sulla data directory (controllo CIS 1.1)
chmod 700 /var/lib/mysql
chown -R mysql:mysql /var/lib/mysql

# Permessi sulla configurazione (controllo CIS 1.2)
chmod 640 /etc/mysql/mysql.conf.d/mysqld.cnf
chown root:mysql /etc/mysql/mysql.conf.d/mysqld.cnf

Fase 4: verifica e restart (30 minuti)

# Restart MySQL con la nuova configurazione
systemctl restart mysql

# Verificare che il bind sia solo su localhost
ss -tlnp | grep 3306
# Deve mostrare solo 127.0.0.1:3306

# Verificare che root remoto non esista più
mysql -u root -p -e "SELECT User, Host FROM mysql.user WHERE Host='%';"
# Deve restituire empty set

# Verificare che local_infile sia OFF
mysql -u root -p -e "SHOW VARIABLES LIKE 'local_infile';"

# Verificare che require_secure_transport sia ON
mysql -u root -p -e "SHOW VARIABLES LIKE 'require_secure_transport';"

# Test connessione applicativa con nuovo utente
mysql -u app_ecommerce -p --ssl-mode=REQUIRED -e "SELECT 1;"

Sul VPS del cliente, dopo il restart, il test di connessione dall'esterno ha confermato che MySQL non rispondeva più sulla porta 3306 da IP remoti. Il test di SQL injection simulato - una query con LOAD DATA LOCAL INFILE '/etc/passwd' - ha fallito con l'errore The used command is not allowed with this MySQL version perché local_infile era disabilitato. Il test di escalation - tentare un DROP TABLE con l'utente applicativo - ha restituito Access denied for user 'app_ecommerce'. Tre scenari di attacco chiusi in due ore.

Un punto che i titolari di PMI non capiscono fino a quando non glielo mostri con un esempio concreto: la segregazione degli utenti MySQL non protegge solo contro attaccanti esterni. Protegge anche contro errori interni. Se un operatore di backoffice con accesso phpMyAdmin cancella per errore una tabella - cosa che ho visto succedere tre volte in carriera - con l'utente segregato non può farlo. Il DELETE funziona (è DML), il DROP TABLE no (è DDL). La differenza tra un errore recuperabile (record cancellati, ripristinabili da backup) e un errore catastrofico (tabella eliminata, schema perso) è tutta nella configurazione dei permessi.

Un altro scenario che vale la pena menzionare: la guida CIS Benchmark Configuration del 2026 pubblicata da OneUpTime raccomanda di verificare anche che il plugin validate_password sia attivo con policy MEDIUM o STRONG. Su MySQL 8, l'attivazione è semplice:

INSTALL COMPONENT 'file://component_validate_password';
SET GLOBAL validate_password.policy = MEDIUM;
SET GLOBAL validate_password.length = 12;

Questo impedisce la creazione di utenti con password deboli - un controllo che sembra superfluo finché non scopri che il precedente sviluppatore aveva creato un utente test con password test per il debugging e non l'aveva mai rimosso.

Dopo la verifica, aggiornare il .env di Laravel con le nuove credenziali:

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_USERNAME=app_ecommerce
DB_PASSWORD=password_generata

E testare che l'applicazione funzioni: php artisan migrate:status (con l'utente deploy nel .env temporaneo o via parametro) e una navigazione completa del sito (con l'utente applicativo nel .env di produzione).

Un errore che trovo frequentemente dopo la segregazione utenti: Laravel lancia un PDOException: SQLSTATE[42000]: Access denied la prima volta che esegui php artisan migrate perché il .env usa l'utente applicativo (che non ha permessi DDL). La soluzione è avere due configurazioni database in config/database.php - una per il runtime web (mysql con l'utente applicativo), una per le migration (mysql_deploy con l'utente deploy) - e specificare la connessione nei comandi Artisan:

# Migration con utente deploy (ha privilegi DDL)
DB_USERNAME=deploy_ecommerce DB_PASSWORD=... php artisan migrate --force

Questo pattern evita di tenere credenziali DDL nel .env di runtime - le credenziali dell'utente deploy vivono solo nell'ambiente CI/CD o nella sessione SSH dell'operatore che esegue il deploy.

Backup cifrati e monitoring: i due pezzi che completano l'hardening

L'hardening della configurazione MySQL è il primo livello. Ma un database protetto da password forte, TLS e permessi segregati resta vulnerabile se i backup non sono cifrati e se nessuno monitora gli accessi anomali.

Sul VPS del cliente, il backup girava con mysqldump -u root -pPASSWORD in un cron job - password in chiaro nella crontab, dump non cifrato scritto in una directory senza permessi restrittivi. Chiunque con accesso al filesystem poteva leggere il dump e ottenere tutti i dati del database, bypassando completamente i permessi MySQL. Il fix è su tre livelli:

# 1. Backup con credential file (password non in chiaro nella crontab)
# Creare /root/.my.cnf con permessi 600:
cat > /root/.my.cnf << 'EOF'
[mysqldump]
user=deploy_ecommerce
password=la_password_generata
EOF
chmod 600 /root/.my.cnf

# 2. Dump cifrato con openssl (chiave simmetrica, AES-256)
mysqldump --single-transaction ecommerce_db \
    | gzip \
    | openssl enc -aes-256-cbc -salt -pbkdf2 -pass file:/root/.backup-key \
    > /backup/ecommerce-$(date +%F).sql.gz.enc

# 3. Permessi sulla directory di backup
chmod 700 /backup
chown root:root /backup

Per il monitoring degli accessi, il log delle connessioni fallite è il segnale più utile. Su MySQL 8, il log_error_verbosity = 3 cattura i tentativi di connessione con credenziali errate. Un alert Prometheus che conta gli Access denied nel log errori e scatta dopo 10 tentativi in 5 minuti è sufficiente per rilevare un brute force sull'autenticazione MySQL - e si integra con lo stack di monitoring che ho descritto nel mio articolo sul monitoring proattivo per Laravel su VPS unmanaged.

Un ultimo controllo che raccomando: verificare periodicamente (ogni trimestre) che non siano stati creati utenti MySQL non autorizzati. Su un VPS con più sviluppatori che si alternano nel tempo, è comune trovare utenti "temporanei" creati per debugging e mai rimossi - ognuno dei quali è un potenziale punto di ingresso.

-- Audit trimestrale: utenti esistenti e loro privilegi
SELECT User, Host, plugin, account_locked
FROM mysql.user
ORDER BY User;

-- Privilegi globali per utente
SELECT * FROM mysql.global_priv WHERE User NOT IN ('mysql.sys', 'mysql.infoschema', 'mysql.session', 'debian-sys-maint');

Per il quadro completo dell'hardening a livello di server - non solo MySQL ma anche SSH, PHP-FPM, Nginx e firewall - ho scritto una guida dedicata all'hardening di server Debian e Ubuntu per applicativi PHP delle PMI. E per la gestione specifica delle credenziali Laravel, inclusa la rotazione dell'APP_KEY e il rehashing delle password, c'è il mio articolo sulla sicurezza credenziali Laravel da L9/L10 a L12.

Se il tuo MySQL è in produzione da più di un anno e nessuno ha mai eseguito mysql_secure_installation, il tuo database è quasi certamente esposto. La verifica richiede 5 minuti: ss -tlnp | grep 3306 per il bind, mysql -u root -e "SELECT 1" per la password root. Se il bind è su 0.0.0.0 o root entra senza password, hai un problema che va risolto oggi - non la prossima settimana, oggi. Il rischio non è teorico: i bot che scansionano la porta 3306 sono attivi 24 ore su 24 sugli IP di Hetzner, OVH, Contabo e Digital Ocean. Un MySQL esposto senza password viene trovato in media entro 48 ore dalla messa online - e il danno non è un cryptominer come per Redis, è l'esfiltrazione completa di tutti i dati del tuo database, clienti inclusi. Con le implicazioni GDPR che ne derivano: notifica al Garante entro 72 ore, potenziale sanzione fino al 4% del fatturato annuo, e un danno reputazionale che nessun hardening retroattivo può riparare. Contattami se hai bisogno di un hardening MySQL: in due ore applico il protocollo CIS completo, segrego gli utenti, abilito TLS, configuro i backup cifrati e ti consegno un server con 14 controlli di sicurezza in meno da preoccuparti.

Ultima modifica: