Skip to content

Hardening LXC VM Wordpress

Indice


1. Ambiente Macchina

Container LXC/VM, Ubuntu 24.04 LTS.

Note

Questa guida è applicabile sia a container nuovi che a container già operativi. Nelle sezioni dove la procedura differisce tra i due scenari è presente un callout Ambiente esistente con le istruzioni specifiche. In assenza di tale callout, la procedura è identica in entrambi i casi.


1.1. Isolamento directory critiche

Separare alcune directory su volumi/partizioni dedicate con opzioni di mount restrittive offre due vantaggi di sicurezza fondamentali:

  • Limitazione dei diritti di esecuzione — impedisce che un attaccante, dopo aver caricato un file malevolo in una directory scrivibile (es. /tmp), possa eseguirlo.
  • Isolamento dello spazio — evita che una directory riempia l'intero filesystem di root, bloccando il sistema o cancellando dati importanti.

Il meccanismo per ottenere l'isolamento cambia in base alla piattaforma:

  • LXC: i volumi vengono iniettati dall'host Proxmox come mount point dedicati (pct set).
  • VM: i volumi sono partizioni/LV veri, gestiti interamente dentro il guest.

Warning

Container/VM nuovi: eseguire questa operazione prima di installare qualsiasi software (§1.2). Non ci sono dati da migrare e la procedura è immediata.

Note

Ambiente esistente: su un sistema già operativo i servizi scrivono attivamente in /var/log e usano /tmp. Prima di procedere: 1. Backup completo — LXC: pct backup <CT_ID> dal nodo Proxmox. VM: snapshot o backup a livello di hypervisor/PBS. 2. Fermare i servizi: systemctl stop apache2 mysql fail2ban 3. Copiare i dati esistenti nei nuovi volumi prima di renderli operativi (vedi procedure sotto)

Opzioni di mount

Opzione Effetto
noexec Impedisce l'esecuzione di qualsiasi binario o script
nosuid Ignora i bit setuid e setgid
nodev Impedisce la creazione di file di dispositivo (device node)
noatime Non aggiorna il timestamp di accesso (migliora le performance)

Directory da isolare

Directory Opzioni consigliate Dimensione tipica Note
/tmp noexec,nosuid,nodev 1–2 GB Alternativa: tmpfs in RAM (vedi sotto), identica su LXC e VM
/var/tmp noexec,nosuid,nodev 1 GB Usato da alcune applicazioni, spesso trascurato
/var/log nosuid,nodev 2–4 GB Deve contenere i log di Apache, PHP, WP e AIDE (§2.13)
/home nosuid,nodev 2–5 GB Home degli utenti
/root nosuid,nodev 1 GB Home di root

Note

/var/log non monta noexec perché alcuni tool di log rotation e monitoring potrebbero richiedere script nella directory. nosuid,nodev è sufficiente per questo volume.


Procedura — LXC (Proxmox)

Verificare che il container sia in modalità unprivileged, aprendo sul nodo Proxmox /etc/pve/lxc/<CT_ID>.conf e controllando la presenza di:

Text Only
unprivileged: 1
Bash
# 1. Spegnere il container
pct shutdown <CT_ID>

# 2. Aggiungere i mount point con le opzioni di sicurezza
#    Sintassi: <storage>:<size_GB>,mp=<path>,mountoptions=<opt1>;<opt2>
#    Sostituire "local-lvm" con lo storage disponibile (es. local-zfs)
pct set <CT_ID> -mp0 "local-lvm:2,mp=/tmp,mountoptions=noexec;nosuid;nodev"
pct set <CT_ID> -mp1 "local-lvm:1,mp=/var/tmp,mountoptions=noexec;nosuid;nodev"
pct set <CT_ID> -mp2 "local-lvm:4,mp=/var/log,mountoptions=nosuid;nodev"
pct set <CT_ID> -mp3 "local-lvm:3,mp=/home,mountoptions=nosuid;nodev"
pct set <CT_ID> -mp4 "local-lvm:1,mp=/root,mountoptions=nosuid;nodev"

# 3. Riavviare (vedi WARNING sotto per la migrazione dati)
pct start <CT_ID>

Warning

Ambiente esistente: copiare i dati prima del riavvio, con container ancora spento:

Bash
pct mount <CT_ID>
cp -a /var/lib/lxc/<CT_ID>/rootfs/var/log/* /var/lib/lxc/<CT_ID>/mnt/mp2/
cp -a /var/lib/lxc/<CT_ID>/rootfs/home/*    /var/lib/lxc/<CT_ID>/mnt/mp3/
cp -a /var/lib/lxc/<CT_ID>/rootfs/root/.    /var/lib/lxc/<CT_ID>/mnt/mp4/
pct unmount <CT_ID>
/tmp e /var/tmp contengono solo file temporanei: copia non necessaria.


Procedura — VM (LVM)

Richiede spazio libero (VFree) sul VG esistente. Verificarlo prima di tutto:

Bash
sudo vgs

Se VFree è 0 — caso tipico di VM Proxmox provisionate con un disco singolo interamente allocato al VG (es. ubuntu-vg) — non è possibile creare nuovi LV senza prima liberare spazio. Non ridurre la LV di root su una macchina già in produzione (operazione rischiosa a caldo): la via sicura è aggiungere un disco virtuale dedicato ed estendere il VG con quello.

Preparazione — VG senza spazio libero (disco dedicato)
  1. Aggiungere un disco virtuale alla VM dal nodo Proxmox (Hardware → Add → Hard Disk). Dimensione consigliata: somma delle 5 directory + margine (es. 12G copre 2+1+4+3+1G). Con controller SCSI/VirtIO e hotplug abilitato non serve riavviare; altrimenti pianificare un riavvio.

  2. Verificare il nuovo disco nel guest (es. /dev/sdb, ancora senza partizioni):

    Bash
    lsblk
    

  3. Creare una partizione LVM sul nuovo disco:

    Bash
    sudo parted /dev/sdb --script mklabel gpt
    sudo parted /dev/sdb --script mkpart primary 0% 100%
    sudo parted /dev/sdb --script set 1 lvm on
    

  4. Creare il PV ed estendere il VG esistente (sostituire ubuntu-vg con il nome reale, verificabile con sudo vgs):

    Bash
    sudo pvcreate /dev/sdb1
    sudo vgextend ubuntu-vg /dev/sdb1
    sudo vgs
    # VFree ora deve mostrare lo spazio del nuovo disco
    

Note

Se invece VFree è già sufficiente (VM con VG non completamente allocato, o LVM thin-provisioned con spazio residuo), saltare questa preparazione e procedere direttamente sotto.

Creazione dei volumi

Eseguire tutto dentro il guest. Sostituire vg0 con il nome reale del VG (es. ubuntu-vg) in tutti i comandi seguenti e del resto della sezione.

Bash
# 1. Verificare i VG disponibili e lo spazio libero
lsblk
sudo vgs

# 2. Creare i volumi logici (adattare dimensioni e nome VG)
sudo lvcreate -L 2G -n lv_tmp     vg0
sudo lvcreate -L 1G -n lv_vartmp  vg0
sudo lvcreate -L 4G -n lv_varlog  vg0
sudo lvcreate -L 3G -n lv_home    vg0
sudo lvcreate -L 1G -n lv_root    vg0

# 3. Formattare
for lv in lv_tmp lv_vartmp lv_varlog lv_home lv_root; do
    sudo mkfs.ext4 /dev/vg0/$lv
done

Warning

Ambiente esistente: montare i nuovi volumi su punti temporanei e copiare i dati prima di sostituirli nel percorso definitivo:

Bash
sudo mkdir -p /mnt/new_varlog /mnt/new_home /mnt/new_root
sudo mount /dev/vg0/lv_varlog /mnt/new_varlog
sudo mount /dev/vg0/lv_home   /mnt/new_home
sudo mount /dev/vg0/lv_root   /mnt/new_root

sudo rsync -aXS /var/log/ /mnt/new_varlog/
sudo rsync -aXS /home/    /mnt/new_home/
sudo rsync -aXS /root/    /mnt/new_root/

sudo umount /mnt/new_varlog /mnt/new_home /mnt/new_root
/tmp e /var/tmp contengono solo file temporanei: copia non necessaria.

Aggiungere le righe in /etc/fstab (usare blkid per gli UUID se si preferisce evitare i path /dev/mapper):

/etc/fstab
/dev/vg0/lv_tmp     /tmp      ext4  defaults,noexec,nosuid,nodev  0 2
/dev/vg0/lv_vartmp  /var/tmp  ext4  defaults,noexec,nosuid,nodev  0 2
/dev/vg0/lv_varlog  /var/log  ext4  defaults,nosuid,nodev         0 2
/dev/vg0/lv_home    /home     ext4  defaults,nosuid,nodev         0 2
/dev/vg0/lv_root    /root     ext4  defaults,nosuid,nodev         0 2
Bash
sudo systemctl daemon-reload
sudo mount -a

Verifica (LXC e VM)

Bash
# LXC: pct enter <CT_ID>  — poi eseguire dentro il container
mount | grep -E "/tmp|/var/tmp|/var/log|/home|/root"

I flag noexec, nosuid, nodev devono comparire nell'output per le rispettive directory.


Alternativa per /tmp: tmpfs in RAM (LXC e VM, identica)

/etc/fstab
tmpfs /tmp tmpfs rw,nodev,nosuid,noexec,size=512M 0 0
Bash
sudo mount /tmp
mount | grep /tmp

Note

tmpfs non richiede la creazione di un volume dedicato — né mount point Proxmox né LV. noexec è ugualmente applicato dal kernel e non interferisce con il funzionamento di WordPress o WP-CLI.


1.2. Installazione ambiente LAMP

Bash
sudo apt update

# Apache
# Nota: con Cloudflare Tunnel (§1.3) il traffico transita via tunnel locale.
# Non aprire porte Apache su UFW — il firewall mantiene default deny incoming (§1.4).
# I moduli Apache (ssl, headers, rewrite, remoteip) vengono abilitati in §1.5.
sudo apt install apache2 -y
sudo systemctl restart apache2

# MySQL
# Nota: mysql_secure_installation viene eseguito in §1.9 con le istruzioni complete
# per la configurazione dell'utente WordPress con privilegi minimi.
sudo apt install mysql-server -y

# PHP — rilevare la versione installata e usarla come prefisso per i moduli aggiuntivi
# Nota: DOMDocument è incluso in php-xml
sudo apt install php libapache2-mod-php php-mysql -y
PHP_VERSION=$(php -r "echo PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION;")
sudo apt install \
    php${PHP_VERSION}-curl \
    php${PHP_VERSION}-xml \
    php${PHP_VERSION}-imagick \
    php${PHP_VERSION}-mbstring \
    php${PHP_VERSION}-zip \
    php${PHP_VERSION}-intl \
    php${PHP_VERSION}-gd \
    -y

# Certbot (SOLO SE NON SI USANO GLI ORIGIN CERTIFICATE DI CLOUDFLARE — vedi §1.3)
sudo apt install certbot python3-certbot-apache -y

# Utilità necessarie
sudo apt install curl gnupg mailutils unzip -y

Note

gnupg è richiesto dallo script di manutenzione per cifrare i backup SQL (§2.4). mailutils (con un MTA locale come postfix) è richiesto da tutti i comandi mail usati per gli alert nel documento. Durante l'installazione di mailutils, selezionare Internet Site e inserire il FQDN (Fully Qualified Domain Name) del server.


1.3. Cloudflare Tunnel

La macchina comunica con internet tramite un tunnel Cloudflare, con certificato SSL attivato tramite Let's Encrypt. Su Cloudflare la verifica del certificato è disabilitata (No TLS Verify), in modo da accettare qualsiasi certificato presentato dalla macchina, anche se scaduto o auto-firmato. In questo modo, anche se il certificato Let's Encrypt scade o non viene rinnovato, il tunnel continuerà a funzionare senza interruzioni.

Warning

No TLS Verify è una soluzione temporanea. Per ambienti di produzione è fortemente raccomandato utilizzare un Origin Certificate Cloudflare e abilitare la verifica TLS verso l'origine. L'opzione No TLS Verify va considerata una soluzione di compatibilità, non la configurazione finale.

1.3.1. Alternativa consigliata: Origin Certificate Cloudflare

Per ambienti di produzione è raccomandato sostituire la configurazione Let's Encrypt + No TLS Verify con un Origin Certificate emesso da Cloudflare. I vantaggi:

  • Validità di 15 anni (nessun rinnovo automatico, nessuna scadenza da monitorare).
  • Nessuna porta in ingresso richiesta per la validazione (compatibile con UFW §1.4).
  • Verifica TLS Full (strict) attivabile su Cloudflare.
  • Non richiede Certbot né altri ACME client sul server.

Procedura:

1.3.1.1. Generare l'Origin Certificate

Dalla Dashboard Cloudflare: SSL/TLS → Origin Server → Create Certificate.

  • Private key type: ECDSA (consigliato) o RSA.
  • Hostnames: inserire il dominio del sito e il suo alias (es. example.com, www.example.com). Per sicurezza, evitare wildcard * a meno che non siano strettamente necessarie.
  • Certificate Validity: 15 years.
  • Copiare immediatamente la chiave privata e il certificato (PEM).
1.3.1.2. Installare il certificato sul server
Bash
sudo mkdir -p /etc/cloudflare
sudo chmod 700 /etc/cloudflare

Creare il file della chiave privata (/etc/cloudflare/example.com.key):

Bash
sudo nano /etc/cloudflare/example.com.key
# Incollare la chiave privata
sudo chmod 600 /etc/cloudflare/example.com.key
sudo chown root:root /etc/cloudflare/example.com.key

Creare il file del certificato (/etc/cloudflare/example.com.pem):

Bash
sudo nano /etc/cloudflare/example.com.pem
# Incollare il certificato (inclusi BEGIN e END)
sudo chmod 644 /etc/cloudflare/example.com.pem
sudo chown root:root /etc/cloudflare/example.com.pem
1.3.1.3. Aggiornare il VirtualHost Apache

Nel file /etc/apache2/sites-available/example.com.conf (§1.5.2), sostituire i percorsi dei certificati:

ApacheConf
SSLCertificateFile    /etc/cloudflare/example.com.pem
SSLCertificateKeyFile /etc/cloudflare/example.com.key
1.3.1.4. Attivare la verifica TLS su Cloudflare

Nella Dashboard Cloudflare, SSL/TLS → Overview, impostare "Full (strict)".

1.3.1.5. Rimuovere Certbot (se presente)
Bash
sudo apt purge certbot python3-certbot-apache -y
sudo rm -rf /etc/letsencrypt /var/lib/letsencrypt

1.4. Firewall

LXC/VM con firewall UFW attivo: blocco di tutte le porte in entrata, consenso di tutte le porte in uscita, blocco del traffico instradato.

Warning

Ambiente esistente: ufw reset cancella tutte le regole esistenti. Prima di procedere, documentare le regole correnti:

Bash
sudo ufw status numbered
Se sono presenti regole personalizzate (es. porte SSH, VPN, servizi interni), ripristinarle manualmente dopo il reset. Con Cloudflare Tunnel non è necessaria alcuna porta in entrata, quindi il reset è solitamente sicuro — ma verificare sempre prima.

Warning

Questo blocca qualsiasi porta in entrata. Accertarsi di avere accesso tramite la console del nodo Proxmox prima di abilitare il firewall.

Bash
sudo ufw reset                    # Resetta le regole del firewall
sudo ufw default deny incoming    # Blocca tutto il traffico in entrata
sudo ufw default allow outgoing   # Consente tutto il traffico in uscita
sudo ufw default deny routed      # Blocca tutto il traffico instradato
sudo ufw enable                   # Abilita il firewall
sudo ufw status verbose           # Verifica lo stato del firewall

1.5. Apache

1.5.1. Moduli necessari

Bash
sudo a2enmod ssl headers rewrite remoteip
sudo systemctl restart apache2

1.5.2. File di configurazione VirtualHost

Note

Ambiente esistente: se il file /etc/apache2/sites-available/example.com.conf esiste già, non sovrascriverlo interamente. Integrare selettivamente le direttive mancanti (cipher suite, security headers, restrizione IP su wp-login, blocco uploads) confrontando il contenuto attuale con la configurazione sotto. Usare apache2ctl configtest dopo ogni modifica prima di ricaricare.

File: /etc/apache2/sites-available/example.com.conf

ApacheConf
# VirtualHost HTTP → redirect permanente a HTTPS
<VirtualHost *:80>
    ServerName example.com
    Redirect permanent / https://example.com/
</VirtualHost>

# VirtualHost HTTPS con sicurezza e ottimizzazione
<VirtualHost *:443>
    ServerName example.com
    ServerAlias www.example.com
    DocumentRoot /var/www/example.com

    # SSL/TLS
    SSLEngine on
    SSLCertificateFile    /etc/letsencrypt/live/example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem

    # Se si usa un Origin Certificate Cloudflare (§1.3.1), sostituire con:
    # SSLCertificateFile    /etc/cloudflare/example.com.pem
    # SSLCertificateKeyFile /etc/cloudflare/example.com.key

    # Protocolli e cipher suite
    # - Disabilita tutti i protocolli obsoleti, consente solo TLS 1.2 e 1.3.
    # - Le cipher suite seguono il profilo "Intermediate" di Mozilla SSL Config Generator
    #   (https://ssl-config.mozilla.org/) e le raccomandazioni NIST SP 800-52 Rev. 2.
    # - SSLHonorCipherOrder off: in TLS 1.3 la selezione della cipher è gestita dal client;
    #   su TLS 1.2 lascia al server la precedenza, ma con TLS 1.3 disabilitarlo è preferibile.
    SSLProtocol             -all +TLSv1.2 +TLSv1.3
    SSLCipherSuite          ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
    SSLHonorCipherOrder     off
    SSLCompression          off
    SSLSessionTickets       off

    # Directory principale WordPress
    # - AllowOverride All: consente ai file .htaccess di sovrascrivere la configurazione Apache.
    # - Options -Indexes: disabilita il directory listing. +FollowSymLinks: consente symlink.
    # - Require all granted: accesso HTTP consentito (regole specifiche sotto la restringono).
    <Directory /var/www/example.com>
        AllowOverride All
        Options -Indexes +FollowSymLinks
        Require all granted
    </Directory>

    # Blocca esecuzione PHP nella directory uploads
    <Directory /var/www/example.com/wp-content/uploads>
        <FilesMatch "\.php$">
            Require all denied
        </FilesMatch>
    </Directory>

    # Protegge file sensibili, metadata e file informativi
    <FilesMatch "^(\.htaccess|\.htpasswd|\.git|\.svn|composer\.(json|lock)|package(-lock)?\.json|yarn\.lock|phpunit\.xml|readme\.html|license\.txt)$">
        Require all denied
    </FilesMatch>

    # Impedisce l'accesso diretto a wp-config.php
    # Nota: il file dovrebbe risiedere fuori dalla webroot (vedi §2.3 / §2.7).
    # Questa regola è un secondo livello di protezione.
    <Files wp-config.php>
        Require all denied
    </Files>

    # Blocca XML-RPC se non utilizzato
    <Files xmlrpc.php>
        Require all denied
    </Files>

    # Restrizione accesso wp-login.php e /wp-admin/ per IP
    # - Sostituire 1.2.3.4 con il proprio IP statico o il range della VPN (es. Tailscale 100.64.0.0/10).
    # - Questa è la difesa più efficace contro brute-force e credential stuffing
    #   (fonte: WordPress.org Hardening Guide — https://developer.wordpress.org/advanced-administration/security/hardening/#admin-url-access).
    <Files wp-login.php>
        Require ip 1.2.3.4
        Require ip 100.64.0.0/10
    </Files>

    <LocationMatch "^/wp-admin/">
        Require ip 1.2.3.4
        Require ip 100.64.0.0/10
    </LocationMatch>

    # Security Headers
    # - X-Content-Type-Options: impedisce MIME sniffing su tipi di contenuto non dichiarati correttamente.
    # - X-Frame-Options: impedisce il framing del sito da domini esterni (clickjacking).
    # - Referrer-Policy: limita le informazioni del referrer verso altri origin.
    # - Permissions-Policy: disabilita API browser sensibili non necessarie al sito.
    # - X-Permitted-Cross-Domain-Policies: blocca policy cross-domain legacy (Adobe/Flash).
    # - Strict-Transport-Security: forza HTTPS per 1 anno su dominio e sottodomini; preload
    #   iscrive il dominio nella lista HSTS preload dei browser (https://hstspreload.org/),
    #   proteggendo anche il primo accesso da attacchi SSLStrip.
    # - Content-Security-Policy: principale difesa contro XSS e data injection (OWASP).
    #   unsafe-inline è necessario per WordPress base; rimuoverlo richiede nonce support
    #   tramite plugin dedicati.
    Header always set X-Content-Type-Options          "nosniff"
    Header always set X-Frame-Options                 "SAMEORIGIN"
    Header always set Referrer-Policy                 "strict-origin-when-cross-origin"
    Header always set Permissions-Policy              "geolocation=(), microphone=(), camera=()"
    Header always set X-Permitted-Cross-Domain-Policies "none"
    Header always set Strict-Transport-Security       "max-age=31536000; includeSubDomains; preload"
    Header always set Content-Security-Policy         "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"

    # Log del VirtualHost
    ErrorLog  ${APACHE_LOG_DIR}/example-error.log
    CustomLog ${APACHE_LOG_DIR}/example-access.log combined
</VirtualHost>

1.5.3. DocumentRoot e configurazione nome dominio

Creare, se non esiste già, la cartella DocumentRoot per il sito:

Bash
sudo mkdir -p /var/www/example.com

Configurare il nome di dominio in /etc/hosts:

Bash
# Verifica e aggiunge la riga se manca
DOMAIN="example.com"
if ! grep -q "127.0.0.1.*$DOMAIN" /etc/hosts; then
    echo "127.0.0.1 $DOMAIN www.$DOMAIN" | sudo tee -a /etc/hosts
fi

1.5.4. Abilitare il sito e ricaricare Apache

Bash
sudo a2ensite example.com
sudo apache2ctl configtest
sudo systemctl reload apache2

1.5.5. Direttive di sicurezza globali

Creare, se non esiste, il file /etc/apache2/conf-available/security.conf:

ApacheConf
# - ServerSignature Off: disabilita la firma del server nelle pagine generate da Apache.
# - ServerTokens Prod: espone solo "Apache" nell'header Server, senza versione o moduli.
# - TraceEnable Off: disabilita il metodo HTTP TRACE (information disclosure e abuse).
ServerSignature Off
ServerTokens    Prod
TraceEnable     Off
Bash
sudo a2enconf security
sudo apache2ctl configtest
sudo systemctl reload apache2

1.6. Configurazione IP reale client (Cloudflare + Apache)

Quando si utilizza un tunnel Cloudflare, Apache vede l'IP del proxy e non quello reale del client. Questo compromette logging, rate limiting e Fail2Ban.

Per ripristinare l'IP reale del client, abilitare il modulo remoteip:

Bash
sudo a2enmod remoteip

Creare o modificare il file /etc/apache2/conf-available/remoteip.conf:

ApacheConf
RemoteIPHeader       CF-Connecting-IP
RemoteIPTrustedProxy 127.0.0.1

Aggiornare il formato dei log per usare l'IP reale, modificando la seguente parte nel file /etc/apache2/apache2.conf, sostituendo %h (host remoto) con %a (IP remoto dopo remoteip).

Da:

ApacheConf
LogFormat "%v:%p %h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" vhost_combined
LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined
LogFormat "%h %l %u %t \"%r\" %>s %O" common
LogFormat "%{Referer}i -> %U" referer
LogFormat "%{User-agent}i" agent

A:

ApacheConf
LogFormat "%v:%p %a %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" vhost_combined
LogFormat "%a %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined
LogFormat "%a %l %u %t \"%r\" %>s %O" common
LogFormat "%{Referer}i -> %U" referer
LogFormat "%{User-agent}i" agent

Infine, abilitare la configurazione e riavviare Apache:

Bash
sudo a2enconf remoteip
sudo apache2ctl configtest
sudo systemctl restart apache2

Note

Questa configurazione è obbligatoria con Cloudflare (Tunnel o Proxy): garantisce log corretti, rende efficace Fail2Ban e permette rate limiting affidabile.

La direttiva RemoteIPTrustedProxy 127.0.0.1 è corretta con Cloudflare Tunnel locale. Se si usa Cloudflare Proxy diretto (senza tunnel), configurare i range IP ufficiali Cloudflare come trusted:

Bash
curl https://api.cloudflare.com/client/v4/ips


1.7. Fail2Ban

Fail2Ban blocca a livello di rete (iptables/nftables) gli IP che generano troppi errori di autenticazione, complementando il rate limiting applicativo di Wordfence e le regole di Cloudflare WAF. In questo modo, anche se un attaccante riesce a superare i limiti di Wordfence o WAF, Fail2Ban può bannare l'IP a livello di firewall, impedendo ulteriori tentativi.

1.7.1. Installazione

Bash
sudo apt install fail2ban -y
sudo systemctl enable fail2ban
sudo systemctl start fail2ban

1.7.2. Jail per WordPress

File: /etc/fail2ban/jail.d/wordpress.conf

INI
[wordpress-auth]
enabled  = true
port     = http,https
filter   = wordpress-auth
logpath  = /var/log/apache2/example-access.log
maxretry = 5
bantime  = 3600
findtime = 600

[wordpress-xmlrpc]
enabled  = true
port     = http,https
filter   = wordpress-xmlrpc
logpath  = /var/log/apache2/example-access.log
maxretry = 2
bantime  = 86400
findtime = 600

1.7.3. Filtri

File: /etc/fail2ban/filter.d/wordpress-auth.conf

INI
[Definition]
failregex = ^<HOST> .* "POST /wp-login\.php
ignoreregex =

File: /etc/fail2ban/filter.d/wordpress-xmlrpc.conf

INI
[Definition]
failregex = ^<HOST> .* "POST /xmlrpc\.php
ignoreregex =

Riavviare Fail2Ban e verificare lo stato della jail:

Bash
sudo systemctl restart fail2ban
sudo fail2ban-client status wordpress-auth

Note

Il logpath deve puntare al log Apache del sito configurato con remoteip (§1.6), altrimenti Fail2Ban banna l'IP del proxy Cloudflare invece di quello del client reale.


1.8. Cloudflare Security Rules (WAF Free)

Il piano Free di Cloudflare include Security Rules personalizzabili che permettono di filtrare il traffico in base a criteri come paese di provenienza, URI richiesta, IP, host e altro. Queste regole costituiscono la prima linea di difesa prima che il traffico raggiunga Apache, Fail2Ban e WordPress.

Vantaggi delle Security Rules - Blocco geografico e per path senza consumare risorse del server - Mitigazione di brute‑force, scansioni e traffico indesiderato già all'edge - Logging centralizzato nella dashboard Cloudflare

1.8.1. Creazione delle regole (Cloudflare Dashboard)

Accedere a Security → Rules → Create Rule e inserire i blocchi nell'ordine consigliato. L'ordine di esecuzione è importante: le regole vengono valutate dall'alto verso il basso e la prima corrispondenza determina l'azione.

Regola 1 – Blocco geografico Cina e Russia Azione: Block

Text Only
(ip.geoip.country eq "RU" or ip.geoip.country eq "CN")
Blocca completamente il traffico da questi paesi.

Regola 2 – Protezione wp-login.php e wp-admin solo dall'Italia Azione: Block

Text Only
(http.request.uri.path contains "/wp-login.php" and ip.geoip.country ne "IT")
or
(http.request.uri.path contains "/wp-admin/" and http.request.uri.path ne "/wp-admin/admin-ajax.php" and ip.geoip.country ne "IT")
- Consente l'accesso a wp-login.php e alle pagine di amministrazione solo da IP italiani. - Esclude admin-ajax.php per non interferire con chiamate AJAX pubbliche (es. plugin frontend).

Regola 3 – Blocco XML‑RPC Azione: Block

Text Only
(http.request.uri.path contains "xmlrpc.php")
Blocca qualsiasi richiesta a xmlrpc.php, a meno che non sia strettamente necessario (es. app mobile tramite WordPress.com). Se il sito non utilizza XML‑RPC, questa regola elimina un vettore di attacco comune.

1.8.2. Verifica e monitoraggio

Dopo aver attivato le regole, verificare nella dashboard Cloudflare (Security → Events) che il traffico legittimo non venga bloccato. In particolare:

  • Controllare che admin-ajax.php risponda correttamente da qualsiasi paese.
  • Assicurarsi che i propri IP (italiani) possano accedere a wp-admin.
  • Se si utilizza Jetpack o altre integrazioni che chiamano XML‑RPC, disabilitare la Regola 3 o restringerla a specifici IP di servizio.

1.8.3. Aggiunta di Bot Fight Mode

Per una protezione aggiuntiva contro bot malevoli, abilitare Security → Bots → Bot Fight Mode. È una funzionalità gratuita che blocca il traffico bot noto prima che raggiunga le Security Rules, riducendo ulteriormente il carico sul server.

Tip

Le Security Rules sono il primo filtro. A valle operano Fail2Ban (§1.7) e Wordfence (§2.14.1), creando una difesa a più livelli.


1.9. Hardening MySQL

1.9.1. Eseguire la procedura di hardening guidata

Warning

Ambiente esistente: mysql_secure_installation è idempotente e può essere eseguito più volte senza danni. Se era già stato eseguito in precedenza, le domande relative a operazioni già completate (es. rimozione utenti anonimi) non avranno effetto.

Avviare la procedura:

Bash
sudo mysql_secure_installation

Il programma è interattivo. Rispondere come indicato di seguito:

Domanda Risposta consigliata Note
Validate Password Component? y Attiva il controllo della forza delle password
Scegli livello di policy: 1 MEDIUM (lunghezza ≥ 8, maiuscole, minuscole, numeri e caratteri speciali). La password generata con openssl rand -base64 24 è conforme a questo livello.
Impostare password per root? (Invio) Con auth_socket (default su Ubuntu) non è necessario impostare una password per root. L'accesso è garantito da sudo mysql. Se si desidera comunque una password, eseguire ALTER USER dopo la procedura (vedi nota sotto).
Remove anonymous users? y Rimuove gli utenti anonimi che permettono accesso senza autenticazione.
Disallow root login remotely? y Impedisce connessioni remote come root.
Remove test database? y Rimuove il database di test accessibile a tutti.
Reload privilege tables now? y Applica tutte le modifiche.

Output atteso (al termine):

Text Only
All done!

Nota sulla password di root

Su Ubuntu, l'utente root di MySQL è configurato con il plugin auth_socket (autenticazione tramite socket Unix). Questo significa che:

  • Puoi accedere a MySQL come root senza password usando sudo mysql.
  • L'accesso remoto come root è già disabilitato.

Se per esigenze particolari (es. accesso da applicazioni esterne) volessi impostare una password per root, puoi farlo in un secondo momento con:

SQL
ALTER USER 'root'@'localhost' IDENTIFIED BY 'nuova_password_sicura';
FLUSH PRIVILEGES;

Ma nella configurazione standard di questa guida non è necessario.


1.9.2. Creazione del database e utente WordPress

Questa sezione copre sia la creazione da zero (per nuove installazioni) sia la revisione/adeguamento di un ambiente già esistente. I valori qui definiti (db_wordpress_name, db_wordpress_user, password) vengono riutilizzati in §2.2 per generare wp-config.php.

Nuova installazione

Generare una password sicura per l'utente del database (da conservare in un password manager):

Bash
openssl rand -base64 24

Prendere nota dei seguenti valori:

  • Nome database: db_wordpress_name
  • Nome utente: db_wordpress_user
  • Password: quella generata con openssl

Collegarsi a MySQL come root:

Bash
sudo mysql -u root -p

Eseguire i seguenti comandi:

SQL
-- Creare il database con charset UTF-8 (supporto completo per emoji e lingue)
CREATE DATABASE db_wordpress_name CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- Creare l'utente con la password generata in precedenza
CREATE USER 'db_wordpress_user'@'localhost' IDENTIFIED BY 'password_sicura';

-- Concedere i privilegi minimi necessari per WordPress
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, INDEX
    ON db_wordpress_name.* TO 'db_wordpress_user'@'localhost';

-- Applicare le modifiche
FLUSH PRIVILEGES;

-- Verificare che l'utente sia stato creato correttamente
SELECT User, Host FROM mysql.user WHERE User = 'db_wordpress_user';

Warning

Sostituire password_sicura con la password generata tramite openssl. Non utilizzare password deboli o riutilizzate.

Note

WordPress richiede permessi come CREATE, DROP, ALTER per eseguire aggiornamenti automatici di plugin e core tramite WP‑CLI. I privilegi elencati sono il minimo indispensabile (fonte: WordPress.org — Database Security).


Ambiente esistente

Se l'utente WordPress è già configurato con GRANT ALL (o con privilegi eccessivi), non è necessario ricrearlo. È sufficiente revocare i privilegi in eccesso e riassegnare solo quelli necessari.

SQL
-- Collegarsi come root
sudo mysql -u root -p

-- Revocare tutti i privilegi esistenti
REVOKE ALL PRIVILEGES ON db_wordpress_name.* FROM 'db_wordpress_user'@'localhost';

-- Assegnare solo i privilegi minimi
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, INDEX
    ON db_wordpress_name.* TO 'db_wordpress_user'@'localhost';

FLUSH PRIVILEGES;

Se il database o l'utente non esistono ancora, seguire la procedura per Nuova installazione sopra.

Verifica di sicurezza aggiuntiva

Dopo aver configurato l'utente, verificare che l'utente root non sia accessibile da remoto:

SQL
SELECT User, Host FROM mysql.user WHERE User = 'root';

L'output deve mostrare solo localhost (o 127.0.0.1), mai un host esterno o %.

Fonte: WordPress.org — Hardening — Database Security + CIS MySQL Benchmark


1.10. Backup

La protezione avviene su due livelli:

  • Backup infrastrutturale: backup giornaliero del LXC/VM tramite Proxmox Backup Server.
  • Backup applicativo: dump SQL cifrato generato automaticamente dallo script di manutenzione prima degli aggiornamenti (vedi §2.4).

2. Ambiente WordPress

2.0. Download di WordPress

Questa procedura scarica l'ultima versione di WordPress, la estrae e posiziona i file nella DocumentRoot configurata in Apache (§1.5.2).

Warning

Ambiente esistente: se nella DocumentRoot è già presente un'installazione WordPress, questa procedura la sovrascrive. Utilizzarla solo per nuove installazioni.

Bash
# Scaricare l'ultima versione di WordPress in /tmp
wget https://wordpress.org/latest.zip -P /tmp

# Estrarre l'archivio in /tmp
cd /tmp
unzip latest.zip

# Spostare i file nella DocumentRoot
sudo mv /tmp/wordpress/* /var/www/example.com/

# Rimuovere l'archivio e la directory temporanea
rm /tmp/latest.zip
rm -rf /tmp/wordpress/

Note

L'archivio ufficiale non contiene wp-config.php, ma solo wp-config-sample.php. Il file reale viene generato in §2.2 tramite WP-CLI, quindi installare prima WP-CLI (§2.1).


2.1. WP-CLI

WP-CLI permette di gestire WordPress da terminale in modo veloce, scriptabile e senza passare dal backend. Viene installato qui, prima della creazione di wp-config.php (§2.2), perché quest'ultima si basa su un comando WP-CLI.

Bash
sudo apt update
sudo apt install php-cli curl -y

curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar

chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp

wp --info
sudo -u www-data wp --info

2.2. Creazione di wp-config.php

Con WordPress scaricato (§2.0), WP-CLI installato (§2.1) e il database/utente già creati (§1.9.2), generare wp-config.php con i valori corretti:

Bash
sudo -u www-data wp config create \
    --path=/var/www/example.com \
    --dbname=db_wordpress_name \
    --dbuser=db_wordpress_user \
    --dbpass='password_sicura' \
    --dbhost=localhost \
    --dbcharset=utf8mb4 \
    --dbcollate=utf8mb4_unicode_ci

Warning

Sostituire db_wordpress_name, db_wordpress_user e password_sicura con i valori effettivamente creati in §1.9.2.

Verificare che il file sia stato creato correttamente:

Bash
sudo -u www-data wp config list --path=/var/www/example.com

Note

Le configurazioni di hardening aggiuntive (prefisso tabelle, WP_DEBUG, DISALLOW_FILE_EDIT, ecc.) vengono aggiunte in un secondo momento, in §2.7. Lo spostamento del file fuori dalla webroot avviene invece in §2.3, insieme al resto dei permessi.


2.3. Permessi cartelle e file

Note

Ambiente esistente: il reset massivo dei permessi con find è sicuro su una installazione WordPress standard. Se sono presenti script custom con bit di esecuzione intenzionali (es. in wp-content/mu-plugins o in tool di deploy), documentarli prima con find /var/www/example.com -perm /111 -type f e ripristinarli manualmente dopo.

Bash
# Permessi base WordPress
sudo chown -R www-data:www-data /var/www/example.com
sudo find /var/www/example.com -type d -exec chmod 755 {} \;
sudo find /var/www/example.com -type f -exec chmod 644 {} \;

# .htaccess
sudo touch /var/www/example.com/.htaccess
sudo chgrp www-data /var/www/example.com/.htaccess
sudo chmod 644 /var/www/example.com/.htaccess

# wp-content
sudo chown -R www-data:www-data /var/www/example.com/wp-content
sudo find /var/www/example.com/wp-content -type d -exec chmod 755 {} \;
sudo find /var/www/example.com/wp-content -type f -exec chmod 644 {} \;

# uploads e cache
# Nota: 755/644 è sufficiente con owner www-data. Permessi 775/664 consentono
# la scrittura al gruppo e andrebbero usati solo se strettamente necessario
# (fonte: WordPress.org — Changing File Permissions).
sudo chown -R www-data:www-data /var/www/example.com/wp-content/uploads
sudo chown -R www-data:www-data /var/www/example.com/wp-content/cache
sudo find /var/www/example.com/wp-content/uploads -type d -exec chmod 755 {} \;
sudo find /var/www/example.com/wp-content/uploads -type f -exec chmod 644 {} \;
sudo find /var/www/example.com/wp-content/cache -type d -exec chmod 755 {} \;
sudo find /var/www/example.com/wp-content/cache -type f -exec chmod 644 {} \;

# Spostare wp-config.php fuori dalla webroot per ridurre l'esposizione
# in caso di misconfiguration Apache. WordPress cerca automaticamente
# il file nella directory padre (fonte: WordPress.org — Hardening —
# Securing wp-config.php).
sudo mv /var/www/example.com/wp-config.php /var/www/wp-config.php
sudo chown www-data:www-data /var/www/wp-config.php
sudo chmod 640 /var/www/wp-config.php

Note

Ambiente esistente: lo spostamento di wp-config.php causa un'interruzione del sito di pochi secondi (il tempo di eseguire mv). Se il sito è ad alto traffico, pianificare l'operazione in una finestra di bassa frequentazione.


2.4. Aggiornamenti e manutenzione con WP-CLI

Script da eseguire periodicamente tramite cron: aggiorna WordPress, esegue un backup SQL cifrato con GPG, esegue health check HTTP con verifica dello status code, e invia alert email in caso di errore.

2.4.1. Prerequisiti — Chiave di cifratura backup

Bash
# Generare una passphrase sicura e salvarla in un file protetto
sudo bash -c 'openssl rand -base64 32 > /etc/wp-backup.key'
sudo chmod 400 /etc/wp-backup.key
sudo chown root:root /etc/wp-backup.key

Warning

Conservare una copia di /etc/wp-backup.key in un luogo sicuro e separato dal server (es. password manager). La chiave deve rimanere presente sul server per la cifratura automatica, ma senza la copia offline i backup cifrati non sono recuperabili in caso di guasto.

2.4.2. Script di manutenzione

File: /usr/local/bin/wp-maintenance.sh

Bash
#!/bin/bash
set -euo pipefail

export PATH=/usr/local/bin:/usr/bin:/bin

SITE_PATH="/var/www/example.com"
SITE_URL="https://example.com"
LOG_DIR="/var/log/wp"
LOG_FILE="${LOG_DIR}/example.com-maintenance.log"
BACKUP_DIR="/var/backups/wp"
BACKUP_RETENTION_DAYS=14
DB_BACKUP_FILE="${BACKUP_DIR}/example.com-$(date '+%Y-%m-%d-%H%M%S').sql.gz.gpg"
GPG_PASSPHRASE_FILE="/etc/wp-backup.key"
ADMIN_EMAIL="admin@example.com"

mkdir -p "${LOG_DIR}"
mkdir -p "${BACKUP_DIR}"

touch "${LOG_FILE}"
chown www-data:www-data "${LOG_FILE}"
chmod 640 "${LOG_FILE}"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "${LOG_FILE}"
}

run_wp() {
    sudo -u www-data wp "$@" --path="${SITE_PATH}" >> "${LOG_FILE}" 2>&1
}

# Invia email di alert in caso di errore e registra la riga del fallimento.
# Fonte: Bash manual — trap (https://www.gnu.org/software/bash/manual/bash.html#index-trap)
send_alert() {
    local msg="$1"
    log "ERRORE: ${msg}"
    echo "${msg}" | mail -s "[WP Maintenance] ERRORE: ${SITE_URL}" "${ADMIN_EMAIL}" 2>/dev/null || true
}

trap 'send_alert "Lo script ha terminato con errore alla riga ${LINENO}. Controllare ${LOG_FILE}."' ERR

cleanup_old_backups() {
    find "${BACKUP_DIR}" -type f -name 'example.com-*.sql.gz.gpg' -mtime +"${BACKUP_RETENTION_DAYS}" -delete
}

# Verifica che il sito risponda HTTP 200.
# curl -f fallisce su status >= 400 ma non distingue 200 da 301/503.
# La verifica esplicita del codice è necessaria per un health check affidabile.
check_http() {
    local url="$1"
    local http_code
    http_code=$(curl -o /dev/null -s -w "%{http_code}" --max-time 15 "$url")
    if [ "$http_code" != "200" ]; then
        log "ERRORE: health check ${url} ha restituito HTTP ${http_code}"
        send_alert "Health check fallito: ${url} ha restituito HTTP ${http_code}"
        exit 1
    fi
    log "OK: ${url} risponde HTTP ${http_code}"
}

log "=== INIZIO MANUTENZIONE WORDPRESS ==="

if ! sudo -u www-data wp core is-installed --path="${SITE_PATH}" >> "${LOG_FILE}" 2>&1; then
    log "ERRORE: WordPress non risulta installato in ${SITE_PATH}"
    exit 1
fi

# Backup database cifrato con GPG (AES-256).
# Il dump SQL viene generato da www-data, compresso con gzip e cifrato.
# La cifratura dei backup è richiesta per proteggere dati personali e credenziali
# (fonte: CIS Controls v8 — Control 11.3; GDPR Art. 32).
log "Esporto e cifro backup database: ${DB_BACKUP_FILE}"
sudo -u www-data wp db export - --path="${SITE_PATH}" 2>>"${LOG_FILE}" \
    | gzip \
    | gpg --batch --yes --symmetric --cipher-algo AES256 \
          --passphrase-file "${GPG_PASSPHRASE_FILE}" \
          --output "${DB_BACKUP_FILE}"

log "Pulizia backup più vecchi di ${BACKUP_RETENTION_DAYS} giorni"
cleanup_old_backups >> "${LOG_FILE}" 2>&1

log "Aggiornamento plugin"
run_wp plugin update --all

log "Aggiornamento core WordPress"
run_wp core update

log "Aggiornamento temi"
run_wp theme update --all

log "Pulizia transient"
run_wp transient delete --all

log "Ottimizzazione database"
run_wp db optimize

log "Health check homepage"
check_http "${SITE_URL}/"

log "Health check pagina login"
check_http "${SITE_URL}/wp-login.php"

log "=== FINE MANUTENZIONE WORDPRESS ==="

Rendere lo script eseguibile:

Bash
sudo chmod 750 /usr/local/bin/wp-maintenance.sh
sudo chown root:root /usr/local/bin/wp-maintenance.sh

2.4.3. Predisposizione directory

Bash
sudo mkdir -p /var/log/wp
sudo mkdir -p /var/backups/wp
sudo chown -R www-data:www-data /var/log/wp
sudo chown -R www-data:www-data /var/backups/wp
sudo chmod 750 /var/log/wp
sudo chmod 750 /var/backups/wp

2.5. Esempio di cron di sistema

Per eseguire lo script di manutenzione:

Bash
sudo crontab -e

ed inserire:

Bash
# flock -n evita esecuzioni parallele dello script.
# In caso di lock già acquisito (run in corso), il job viene saltato e viene inviata
# una notifica email. Eseguire silenziosamente senza notifica lascerebbe l'admin
# ignaro di run saltate.
0 4 * * * /usr/bin/flock -n /tmp/wp-maintenance.lock /usr/local/bin/wp-maintenance.sh || echo "wp-maintenance già in esecuzione, run saltata." | mail -s "[WP] Maintenance skip: $(date)" admin@example.com

2.6. Gestione del log rotate del file di log generato dallo script di manutenzione

File: /etc/logrotate.d/wp-maintenance

Text Only
/var/log/wp/example.com-maintenance.log {
    daily           # Ruota il log ogni giorno
    rotate 14       # Mantieni 14 file ruotati (2 settimane)
    compress        # Comprimi i log ruotati per risparmiare spazio
    delaycompress   # Ritarda la compressione alla rotazione successiva (evita di comprimere log ancora in uso)
    missingok       # Non generare errori se il file non esiste
    notifempty      # Non ruotare se il file è vuoto
    copytruncate    # Copia e tronca invece di rinominare (compatibilità con processi che tengono il file aperto)
    create 640 www-data www-data  # Ricrea il file con permessi corretti
}

2.7. Configurazioni consigliate in wp-config.php

wp-config.php è già stato creato (§2.2) e spostato fuori dalla webroot (§2.3). Aprire il file (/var/www/wp-config.php) e integrare le seguenti direttive di hardening prima della riga /* That's all, stop editing! Happy publishing. */:

PHP
<?php
# [...]

// Prefisso tabelle database — impostare solo durante l'installazione iniziale.
// Un prefisso non standard riduce la superficie in caso di SQL injection.
// (fonte: WordPress.org Hardening — Database table prefix)
$table_prefix = 'x7k_';  // generare un valore random

// Modalità debug — sempre false in produzione per evitare information disclosure
define( 'WP_DEBUG',         false );
define( 'WP_DEBUG_DISPLAY', false );
define( 'SCRIPT_DEBUG',     false );

// Forza HTTPS nel backend
define( 'FORCE_SSL_ADMIN', true );

// Disabilita l'editor file nel backend (previene modifiche via UI compromessa)
define( 'DISALLOW_FILE_EDIT', true );

// Disabilita installazione e aggiornamento plugin/temi dal backend.
// La gestione avviene esclusivamente tramite WP-CLI (vedi §2.1).
define( 'DISALLOW_FILE_MODS', true );

// Disabilita WP-Cron interno — viene gestito con cron di sistema (vedi §2.10)
define( 'DISABLE_WP_CRON', true );

/* That's all, stop editing! Happy publishing. */
# [...]

Warning

Il prefisso tabelle ($table_prefix) va impostato prima di eseguire wp core install / il setup guidato di WordPress: modificarlo su un'installazione già popolata richiede la migrazione manuale di tutte le tabelle esistenti.


2.8. Hardening PHP

La configurazione di default di PHP espone la versione, permette funzioni pericolose e non limita inclusioni remote. Modificare /etc/php/8.x/apache2/php.ini (sostituire 8.x con la versione installata):

INI
; Sicurezza — information disclosure
expose_php = Off

; Gestione errori — non esporre mai errori in produzione
display_errors  = Off
log_errors      = On
error_log       = /var/log/php/error.log

; Prevenzione RFI (Remote File Inclusion) e SSRF
allow_url_fopen   = Off
allow_url_include = Off

; Disabilita funzioni che permettono esecuzione di comandi di sistema.
; Fonte: OWASP PHP Configuration Cheat Sheet
; (https://cheatsheetseries.owasp.org/cheatsheets/PHP_Configuration_Cheat_Sheet.html)
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,\
                    curl_exec,curl_multi_exec,parse_ini_file,show_source

; Limiti di upload — adattare alle necessità del sito
upload_max_filesize = 10M
post_max_size       = 12M
max_execution_time  = 60

; Cookie di sessione — HttpOnly e Secure prevengono accesso JS e trasmissione su HTTP
session.cookie_httponly  = 1
session.cookie_secure    = 1
session.use_strict_mode  = 1

Creare la directory di log PHP:

Bash
sudo mkdir -p /var/log/php
sudo chown www-data:www-data /var/log/php
sudo chmod 750 /var/log/php
sudo systemctl restart apache2

2.9. Cron per WP-Cron

Con DISABLE_WP_CRON true in wp-config.php (§2.7), WP-Cron va gestito tramite cron di sistema:

Bash
sudo crontab -u www-data -e

2.10. Cron di sistema per WP-Cron

Bash
# (CONSIGLIATO) Esecuzione tramite WP-CLI ogni 15 minuti.
# Evita il caricamento dell'intero stack PHP/WordPress per ogni richiesta HTTP,
# eseguendo solo gli eventi dovuti senza processare richieste inutili.
*/15 * * * * /usr/local/bin/wp cron event run --due-now --path=/var/www/example.com > /dev/null 2>&1

# Alternativa senza WP-CLI:
# */15 * * * * /usr/bin/php /var/www/example.com/wp-cron.php > /dev/null 2>&1

2.11. Cron di sistema per manutenzione e aggiornamenti con WP-CLI

Bash
# Esecuzione script di manutenzione ogni notte alle 4:00.
# flock -n evita esecuzioni parallele; in caso di lock già acquisito, invia alert email.
0 4 * * * /usr/bin/flock -n /tmp/wp-maintenance.lock /usr/local/bin/wp-maintenance.sh \
    || echo "wp-maintenance già in esecuzione, run saltata." \
       | mail -s "[WP] Maintenance skip: $(date)" admin@example.com

2.12. Hardening enumeration

MU Plugin per bloccare l'enumerazione degli utenti, che può essere sfruttata per identificare nomi utente validi per brute-force e altri attacchi.

File: /var/www/example.com/wp-content/mu-plugins/hardening-enumeration.php

PHP
<?php
/**
 * Hardening enumerazione utenti
 *
 * Mitiga:
 * - /?author=1
 * - archivi autore pubblici (se non necessari)
 * - endpoint REST /wp/v2/users per utenti anonimi
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Blocca l'enumerazione via query string ?author=ID.
 */
add_action( 'template_redirect', function () {
    if ( is_admin() ) {
        return;
    }

    if ( isset( $_GET['author'] ) && is_numeric( $_GET['author'] ) ) {
        wp_safe_redirect( home_url( '/' ), 301 );
        exit;
    }
}, 0 );

/**
 * Disabilita gli archivi autore.
 * Attivare solo se il sito NON usa pagine autore pubbliche.
 */
add_action( 'template_redirect', function () {
    if ( is_admin() ) {
        return;
    }

    if ( is_author() ) {
        global $wp_query;
        $wp_query->set_404();
        status_header( 404 );
        nocache_headers();
        exit;
    }
}, 1 );

/**
 * Rimuove gli endpoint REST utenti per i visitatori anonimi.
 * Non disabilita tutta la REST API.
 */
add_filter( 'rest_endpoints', function ( $endpoints ) {
    if ( is_user_logged_in() ) {
        return $endpoints;
    }

    unset( $endpoints['/wp/v2/users'] );
    unset( $endpoints['/wp/v2/users/(?P<id>[\d]+)'] );

    return $endpoints;
} );

2.13. File Integrity Monitoring (AIDE)

AIDE monitora l'integrità del filesystem e rileva modifiche non autorizzate ai file WordPress. Fondamentale per individuare compromissioni post-exploit.

Warning

Ambiente esistente: il database AIDE fotografa lo stato del filesystem al momento dell'inizializzazione. Su un sistema già operativo, assicurarsi che non siano presenti file non autorizzati o malware prima di eseguire aideinit — altrimenti lo stato compromesso diventa il baseline di riferimento. Eseguire una verifica preliminare:

Bash
# Cerca file PHP fuori dai percorsi attesi
find /var/www/example.com -name "*.php" -newer /var/www/example.com/wp-settings.php
# Cerca file con permessi di esecuzione inattesi
find /var/www/example.com/wp-content/uploads -type f -perm /111
Investigare qualsiasi file sospetto prima di procedere con l'inizializzazione.

Bash
sudo apt install aide -y

# Inizializzare il database di riferimento
sudo aideinit
sudo mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db

Configurazione cron per check giornaliero:

Bash
sudo crontab -e
Bash
# Check AIDE ogni notte alle 3:00 — alert via email in caso di modifiche
0 3 * * * /usr/bin/aide --check >> /var/log/aide/aide.log 2>&1 || echo "AIDE ha rilevato modifiche al filesystem. Controllare /var/log/aide/aide.log." | mail -s "[AIDE] Alert integrità filesystem: $(hostname)" admin@example.com

# Refresh baseline AIDE dopo la manutenzione notturna (che parte alle 4:00)
# Se il database AIDE esiste, lo rigenera così da includere gli aggiornamenti legittimi.
30 4 * * * [ -f /var/lib/aide/aide.db ] && /usr/bin/aideinit --force > /dev/null 2>&1 && mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db
Bash
sudo mkdir -p /var/log/aide
sudo chmod 750 /var/log/aide

Note

Il database AIDE viene rigenerato automaticamente ogni notte alle 4:30 (dopo la manutenzione WordPress delle 4:00) tramite un cron di root. Non è richiesto alcun intervento manuale dopo gli aggiornamenti.

Fonte: CIS Controls v8 — Control 10 + SANS Institute — File Integrity Monitoring


2.14. Plugins


2.14.1 Wordfence Security

Wordfence è una suite di sicurezza completa per WordPress che integra firewall WAF, brute force protection, rate limiting e two-factor authentication (2FA) in un unico plugin.


2.14.1.1 Installazione

Installare Wordfence tramite WP-CLI:

Bash
sudo -u www-data wp plugin install wordfence --activate --path=/var/www/example.com

Per ambienti esistenti che utilizzano LLAR (Limit Login Attempts Reloaded), non è necessario disinstallarlo immediatamente. Wordfence può coesistere con LLAR durante la fase di transizione. Dopo aver configurato Wordfence e verificato che tutto funzioni correttamente, si può disattivare e rimuovere LLAR:

Bash
sudo -u www-data wp plugin deactivate limit-login-attempts-reloaded --path=/var/www/example.com
sudo -u www-data wp plugin delete limit-login-attempts-reloaded --path=/var/www/example.com

2.14.1.2 Configurazione della Two-Factor Authentication (2FA)

Accedere al backend di WordPress e navigare su Wordfence → Login Security → 2FA.

Impostazioni consigliate
Parametro Valore consigliato Note
2FA Roles Amministratore: Required; Editor/Author/Contributor/Subscriber: Disabled Solo gli amministratori devono avere 2FA obbligatoria.
Grace Period 10 giorni Tempo concesso agli utenti per attivare la 2FA prima che venga loro impedito l'accesso.
Allow remembering device for 30 days Attivato Riduce la frequenza delle richieste OTP per dispositivi fidati.
Require 2FA for XML-RPC Required (se NON si usa app WordPress o Jetpack) Richiede 2FA per le richieste XML-RPC (Skipped se si usano app WordPress o Jetpack).
Disable XML-RPC authentication Attivo Disabilita l'autenticazione XML-RPC (oppure attivarla nel caso sia necessaria per app e Jetpack).
2FA per l'amministratore

Per attivare la 2FA per l'utente corrente:

  1. Andare su Wordfence → Login Security → 2FA.
  2. Cliccare su "Activate" accanto al proprio profilo.
  3. Scansionare il codice QR con un'app TOTP (Google Authenticator, FreeOTP, Authy, ecc.).
  4. Inserire il codice OTP generato dall'app per confermare l'attivazione.
  5. Salvare i recovery codes in un luogo sicuro (password manager). Questi codici permettono di recuperare l'accesso in caso di perdita del dispositivo.

Warning

Conservare i recovery codes in un luogo sicuro e separato dal server (es. password manager). Senza di essi, in caso di perdita del dispositivo che genera gli OTP, l'accesso al sito potrebbe essere irrecuperabile.


2.14.1.3 Configurazione Brute Force Protection

Navigare su Wordfence → All Options → Brute Force Protection.

Impostazioni consigliate
Parametro Valore consigliato Note
Enable brute force protection Attivato Attiva tutte le opzioni di protezione.
Lock out after how many login failures 2 Dopo 2 tentativi falliti, l'IP viene bloccato.
Lock out after how many forgot password attempts 2 Dopo 2 tentativi di reset password falliti, l'IP viene bloccato.
Count failures over what time period 4 hours Le finestra di conteggio dei tentativi.
Amount of time a user is locked out 5 days Durata del lockout.
Immediately lock out invalid usernames Attivato Blocca immediatamente gli IP che provano username inesistenti.
Immediately block the IP of users who try to sign in as these usernames admin, administrator, test Blocca IP che provano username comuni (modificare a piacere).
Prevent the use of passwords leaked in data breaches For admin only Controlla le password degli amministratori contro database di data breach.
Block IPs who send POST requests with blank User-Agent and Referer Disattivato Può bloccare servizi legittimi che non inviano questi header.
Check password strength on profile update Attivato Impone password forti agli amministratori.

2.14.1.4 Configurazione Rate Limiting

Navigare su Wordfence → All Options → Rate Limiting.

Impostazioni consigliate
Parametro Valore consigliato Note
Enable Rate Limiting and Advanced Blocking Attivato Attiva tutte le funzioni di rate limiting.
How should we treat Google's crawlers Unlimited Non limitare i crawler Google per non penalizzare il SEO.
If anyone's requests exceed 240 per minute Limite per utenti normali.
then Block for 30 minutes Blocco per IP che superano il limite.
If a crawler's page views exceed 480 per minute Limite più permissivo per crawler legittimi.
then Block for 30 minutes Blocco breve per crawler aggressivi.
If a crawler's pages not found (404s) exceed 240 per minute Limite per 404 generati da crawler.
then Block for 30 minutes Blocco per crawler che generano troppi 404.

2.14.1.5 Gestione del reCAPTCHA

Navigare su Wordfence → Login Security → reCAPTCHA.

Impostazioni consigliate
Parametro Valore consigliato Note
Enable reCAPTCHA Disabilitato (toggle off) Con Cloudflare Tunnel, Fail2Ban (§1.7) e le Security Rules (§1.8), il reCAPTCHA è ridondante. Inoltre, in assenza di chiavi API, causa il blocco dei login legittimi.

2.14.1.6 Firewall e Learning Mode

Wordfence include un Web Application Firewall (WAF) che, appena installato, entra in Learning Mode.

Cos'è il Learning Mode

Durante la prima settimana di utilizzo, Wordfence osserva il traffico del sito per apprendere quali richieste sono legittime e quali sono malevole. Questo permette al firewall di adattarsi automaticamente alle specificità del sito senza causare falsi positivi.

Configurazione consigliata
Parametro Valore consigliato Note
Learning Mode Attivo per 7 giorni Lasciare attivo per almeno una settimana per permettere al firewall di apprendere.
Protection Level Basic WordPress Protection Protezione sufficiente per la maggior parte dei siti.
Dopo il Learning Mode

Trascorsa una settimana, Wordfence uscirà automaticamente dal Learning Mode e attiverà la protezione completa. In alternativa, è possibile uscire manualmente dopo aver verificato che non ci siano falsi positivi:

  1. Navigare su Wordfence → Firewall → Manage WAF.
  2. Impostare Protection Level su Wordfence Full Protection.
  3. Verificare il funzionamento del sito.

Note

Durante il Learning Mode, il firewall non blocca attivamente il traffico sospetto ma registra solo le richieste. La protezione brute force e il rate limiting rimangono comunque attivi e funzionanti.


2.14.1.7 Verifica della configurazione

Dopo aver configurato Wordfence:

  1. Verificare il funzionamento del sito: navigare nel frontend e nel backend per assicurarsi che non ci siano falsi positivi.
  2. Testare il login con 2FA: disconnettersi e riconnettersi, verificando che il codice OTP venga richiesto e funzioni.
  3. Monitorare i log: controllare Wordfence → Tools → Live Traffic per vedere le richieste in tempo reale.
  4. Verificare i lockout: provare a inserire credenziali errate per verificare che il blocco brute force funzioni.