Mejores Prácticas de Seguridad en Java
Errores de seguridad comunes en Java y sus defensas: validación de entrada, deserialización, dependencias y secretos.
La mayoría de los errores de seguridad en Java no son exóticos. Son una verificación de entrada omitida, una consulta SQL construida con concatenación de cadenas, una contraseña almacenada como un hash simple, o un secreto confirmado en Git. Este capítulo recorre las defensas que detienen la mayoría de los ataques del mundo real: valida todo lo que cruza un límite de confianza, nunca construyas consultas por concatenación, hashea contraseñas con una función de derivación de claves lenta, mantén los secretos fuera del código y ejecuta con el mínimo privilegio que la tarea necesita.
Valida la entrada con una lista de permitidos
La primera regla es tratar toda entrada externa como hostil hasta que se demuestre lo contrario: parámetros de solicitud, nombres de archivos, cabeceras, cargas de mensajes, cualquier cosa que cruce un límite de confianza. Prefiere una lista de permitidos (acepta solo formas conocidas y válidas) sobre una lista de bloqueados (intenta bloquear las malas) — una lista de bloqueados siempre omite un caso.
// 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);Valida en el borde del sistema y de nuevo en cualquier límite más profundo que no controles. Rechaza temprano, falla de forma cerrada y devuelve un error genérico para no filtrar la regla de validación a un atacante que sondea tu endpoint. Cuando hagas coincidir con un patrón, anclalo y mantenlo simple — consulta la introducción a las expresiones regulares para ver cómo matches verifica la cadena completa, no solo un fragmento.
Usa sentencias preparadas, nunca concatenación de cadenas
La inyección SQL sigue siendo una de las vulnerabilidades web más comunes y dañinas, y en Java es trivial de prevenir. Construye consultas con parámetros de enlace a través de PreparedStatement; el controlador envía la plantilla de consulta y los valores por separado, por lo que los datos del usuario nunca pueden interpretarse como 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 misma idea se aplica más allá de SQL: usa API parametrizadas para LDAP, comandos del sistema operativo (ProcessBuilder con una lista de argumentos, no una cadena de shell) y cualquier plantilla que mezcle código con datos. Para los detalles de JDBC, consulta PreparedStatement y la introducción a JDBC.
Hashea contraseñas con una KDF lenta
Las contraseñas nunca deben almacenarse en texto plano ni detrás de un hash rápido como una sola ronda de SHA-256 — las GPU modernas prueban miles de millones de ellas por segundo. Usa una función de derivación de claves deliberadamente lenta y con sal. El JDK incluye PBKDF2; Argon2 y bcrypt son excelentes opciones de terceros.
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| Enfoque | Veredicto |
|---|---|
| Texto plano / reversible | Nunca |
| MD5, SHA-1, SHA-256 simple | Demasiado rápido — roto para contraseñas |
| PBKDF2 / bcrypt / Argon2 con sal por usuario | Correcto |
| Misma sal para todos los usuarios | Anula el propósito de la sal |
Siempre compara hashes con una verificación en tiempo constante (MessageDigest.isEqual) para que el tiempo de respuesta no revele cuánto de una suposición era correcta.
Mantén los secretos fuera del código
Las claves API, las contraseñas de base de datos y las claves de firma no pertenecen a los archivos fuente — una vez confirmados, viven en el historial de Git para siempre. Léelos del entorno o de un gestor de secretos en tiempo de ejecución, y mantén las credenciales fuera de los registros y los mensajes de excepción.
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.Usa SecureRandom (no java.util.Random) para cualquier cosa sensible a la seguridad — tokens, sales, nonces, IDs de sesión. Random es predecible y con semilla, lo que hace que su salida sea adivinable.
Aplica el mínimo privilegio y los valores predeterminados seguros
Dale a cada componente solo el acceso que necesita y nada más: un usuario de base de datos de solo lectura para rutas de lectura, una cuenta de servicio con alcance a un solo bucket, permisos de archivo que excluyan grupo y mundo. Valida los certificados TLS (nunca deshabilites la verificación de nombre de host "para que funcione"), establece tiempos de espera en cada llamada de red y limita el tamaño de todo lo que analices para evitar la denegación de servicio mediante entrada excesivamente grande o deserialización.
// 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();Mantén las dependencias parcheadas — la mayoría de las brechas explotan un CVE conocido en una biblioteca antigua, así que ejecuta un escáner (OWASP Dependency-Check, mvn versions:display-dependency-updates) en CI.
El programa a continuación reúne las ideas principales: validación con lista de permitidos, estiramiento de contraseñas con sal, verificación en tiempo constante y prueba de que dos usuarios con la misma contraseña obtienen hashes diferentes.
Lo que se puede extraer de la ejecución:
- La lista de permitidos acepta
alice_99pero rechaza tantoRobert'); DROP TABLEcomo el demasiado cortoab, por lo que la entrada maliciosa o malformada nunca llega a la siguiente capa. - Estirar una contraseña produce un resumen fijo de 32 bytes en 120,000 iteraciones — el costo es lo que hace que la fuerza bruta del hash almacenado sea impráctica.
verifydevuelvetruepara la contraseña correcta yfalsepara la incorrecta, porque el hash candidato solo coincide cuando la entrada es idéntica.- Dos usuarios diferentes que registran exactamente la misma contraseña obtienen hashes desiguales (
same input, equal hash? false), lo que demuestra que la sal aleatoria por usuario cumple su función. MessageDigest.isEqualinformatruepara bytes idénticos yfalsepara un cambio de un carácter, proporcionando una comparación en tiempo constante que no se filtra a través del tiempo de respuesta.