Best Practice di Sicurezza in Java
Errori di sicurezza comuni in Java e relative difese: validazione input, deserializzazione, dipendenze e segreti.
La maggior parte dei bug di sicurezza in Java non è esotica. Si tratta di un controllo di input mancante, una query SQL costruita tramite concatenazione di stringhe, una password memorizzata con un hash semplice o un segreto committato su Git. Questo capitolo illustra le difese che bloccano la maggior parte degli attacchi reali: validare tutto ciò che attraversa un confine di fiducia, non costruire mai query tramite concatenazione, eseguire l'hashing delle password con una funzione lenta di derivazione delle chiavi, tenere i segreti fuori dal codice ed eseguire con il minimo privilegio necessario per il compito.
Validare l'input con una allowlist
La prima regola è trattare tutto l'input esterno come ostile finché non si dimostra il contrario: parametri di richiesta, nomi di file, intestazioni, payload di messaggi, qualsiasi cosa attraversi un confine di fiducia. Preferire una allowlist (accettare solo forme note e valide) rispetto a una denylist (cercare di bloccare quelle cattive) — una denylist manca sempre qualcosa.
// Allowlist: only lowercase letters, digits and underscore, 3–16 chars.
static boolean isValidUsername(String s) {
return s != null && s.matches("[a-z0-9_]{3,16}");
}
// Constrain numbers to a sane range instead of trusting the caller.
int page = Math.clamp(requested, 1, 1000);Validare al bordo del sistema e di nuovo a qualsiasi confine più profondo che non si controlla. Rifiutare subito, fallire in modo chiuso e restituire un errore generico per non rivelare la regola di validazione a un attaccante che sonda il proprio endpoint. Quando si fa un match contro un pattern, ancorarlo e mantenerlo semplice — vedere l'introduzione alle espressioni regolari per capire come matches controlla l'intera stringa, non solo un frammento.
Usare prepared statement, mai la concatenazione di stringhe
L'SQL injection è ancora una delle vulnerabilità web più comuni e più dannose, e in Java è banale da prevenire. Costruire le query con parametri bind attraverso PreparedStatement; il driver invia il template della query e i valori separatamente, quindi i dati utente non possono mai essere interpretati come SQL.
// NEVER do this — user input becomes part of the query text.
String bad = "SELECT * FROM users WHERE name = '" + name + "'";
// Do this — the value is bound, not concatenated.
String sql = "SELECT id FROM users WHERE name = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, name);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) process(rs.getLong("id"));
}
}La stessa idea si applica oltre SQL: usare API parametrizzate per LDAP, comandi OS (ProcessBuilder con una lista di argomenti, non una stringa shell) e qualsiasi template che mescola codice con dati. Per i dettagli JDBC, vedere PreparedStatement e l'introduzione a JDBC.
Eseguire l'hashing delle password con un KDF lento
Le password non devono mai essere memorizzate in testo semplice o con un hash veloce come un singolo round di SHA-256 — le GPU moderne ne provano miliardi al secondo. Usare una funzione di derivazione delle chiavi deliberatamente lenta e con salt. Il JDK include PBKDF2; Argon2 e bcrypt sono ottime opzioni di terze parti.
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.SecureRandom;
byte[] salt = new byte[16];
SecureRandom.getInstanceStrong().nextBytes(salt); // unique per user
var spec = new PBEKeySpec(password, salt, 600_000, 256); // iterations, key bits
var skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
byte[] hash = skf.generateSecret(spec).getEncoded();
spec.clearPassword(); // wipe the secret| Approccio | Verdetto |
|---|---|
| Testo semplice / reversibile | Mai |
| MD5, SHA-1, singolo SHA-256 | Troppo veloce — inadatto per le password |
| PBKDF2 / bcrypt / Argon2 con salt per utente | Corretto |
| Stesso salt per ogni utente | Vanifica lo scopo del salt |
Confrontare sempre gli hash con un controllo a tempo costante (MessageDigest.isEqual) in modo che il timing delle risposte non riveli quanto fosse corretta una tentativo.
Tenere i segreti fuori dal codice
Le chiavi API, le password del database e le chiavi di firma non appartengono ai file sorgente — una volta committate vivono per sempre nella storia di Git. Leggerle dall'ambiente o da un gestore di segreti a runtime e tenere le credenziali fuori dai log e dai messaggi di eccezione.
String dbPassword = System.getenv("DB_PASSWORD");
if (dbPassword == null || dbPassword.isBlank()) {
throw new IllegalStateException("DB_PASSWORD is not configured");
}
// Hold short-lived secrets in char[]/byte[] and wipe them, not String,
// because String is immutable and lingers in the heap until GC.Usare SecureRandom (non java.util.Random) per qualsiasi cosa sensibile alla sicurezza — token, salt, nonce, ID di sessione. Random è prevedibile e con seed, il che rende il suo output indovinabile.
Applicare il minimo privilegio e i default sicuri
Dare a ogni componente solo l'accesso di cui ha bisogno e nient'altro: un utente database di sola lettura per i percorsi di lettura, un account di servizio limitato a un bucket, permessi sui file che escludono gruppo e altri. Validare i certificati TLS (non disabilitare mai la verifica dell'hostname "per farlo funzionare"), impostare timeout su ogni chiamata di rete e limitare la dimensione di qualsiasi cosa si analizzi per evitare attacchi denial-of-service tramite input di grandi dimensioni o deserializzazione.
// Never deserialize untrusted bytes with Java's native serialization.
// Prefer a data format you can validate (JSON/Protobuf) and bound its size.
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)) // fail fast, don't hang
.build();Mantenere le dipendenze aggiornate — la maggior parte delle violazioni sfrutta una CVE nota in una libreria vecchia, quindi eseguire uno scanner (OWASP Dependency-Check, mvn versions:display-dependency-updates) in CI.
Il programma seguente mette insieme le idee fondamentali: validazione con allowlist, stretching della password con salt, verifica a tempo costante e dimostrazione che due utenti con la stessa password ottengono hash diversi.
Cosa ricavare dall'esecuzione:
- La allowlist accetta
alice_99ma rifiuta siaRobert'); DROP TABLEche il troppo cortoab, quindi input malevoli o malformati non raggiungono mai il livello successivo. - Lo stretching di una password produce un digest fisso di 32 byte in 120.000 iterazioni — il costo è ciò che rende impraticabile il brute-forcing dell'hash memorizzato.
verifyrestituiscetrueper la password corretta efalseper quella sbagliata, perché l'hash candidato corrisponde solo quando l'input è identico.- Due utenti diversi che registrano la stessa password ottengono hash disuguali (
same input, equal hash? false), dimostrando che il salt casuale per utente fa il suo lavoro. MessageDigest.isEqualriportatrueper byte identici efalseper un cambio di un carattere, fornendo un confronto a tempo costante che non perde informazioni tramite il timing.