Curso de PHP

Seguridad en aplicaciones PHP: las 8 reglas esenciales

Por Víctor Peña · Publicado el

Hola, ¿cómo están? Tercer tema complementario del curso de PHP, y probablemente el más importante de todos.

A lo largo del curso fueron apareciendo advertencias de seguridad sueltas. Aquí las reunimos y añadimos las que faltaban, porque un sistema que funciona pero es inseguro no está terminado.

¡Empecemos!

1. Inyección SQL

El riesgo: un atacante escribe SQL dentro de un campo y toma el control de la consulta.

<?php
// Vulnerable
$id = $_GET['id'];
$pdo->query("SELECT * FROM usuarios WHERE id = $id");

Con ?id=1 OR 1=1 devuelve todos los usuarios. Con un poco más, borra tablas.

La solución: consultas preparadas, siempre.

<?php
$sentencia = $pdo->prepare("SELECT * FROM usuarios WHERE id = :id");
$sentencia->execute(['id' => $_GET['id']]);

Un detalle: los nombres de tabla y columna no se pueden parametrizar. Si necesitas ordenar por una columna que llega del usuario, valídala contra una lista blanca:

<?php
$permitidas = ['nombre', 'precio', 'fecha'];
$orden = in_array($_GET['orden'] ?? '', $permitidas, true) ? $_GET['orden'] : 'nombre';

2. XSS: código inyectado en tus páginas

El riesgo: guardas lo que escribió un usuario y lo imprimes tal cual. Si escribió un <script>, se ejecuta en el navegador de quien vea la página.

La solución: escapar siempre al mostrar.

<?php
echo htmlspecialchars($comentario, ENT_QUOTES, 'UTF-8');

Como vimos en formularios: escapa al mostrar, no al guardar.

Si necesitas permitir algo de HTML —un editor de texto enriquecido—, no lo filtres a mano: usa una librería especializada como HTML Purifier. Escribir tu propio filtro de HTML es garantía de dejar un agujero.

3. CSRF: peticiones desde otro sitio

El riesgo: este es menos conocido y muy real. Un usuario con sesión abierta en tu sistema visita otra web, y esa web envía una petición a la tuya. El navegador adjunta la cookie de sesión automáticamente, así que la petición llega autenticada.

La solución: un token único por sesión que acompañe cada formulario.

<?php
// Al generar el formulario
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
?>
<form method="post">
    <input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
    <!-- ... -->
</form>
<?php
// Al procesarlo
$recibido = $_POST['csrf_token'] ?? '';

if (!hash_equals($_SESSION['csrf_token'] ?? '', $recibido)) {
    http_response_code(403);
    exit('Petición no válida');
}

Usa hash_equals() y no ===: compara en tiempo constante y evita ataques por medición de tiempos.

Todo formulario que modifique datos necesita token CSRF. Los frameworks lo ponen automáticamente; a mano hay que acordarse.

4. Contraseñas

Nunca en texto plano. Nunca con md5() o sha1(). Esos algoritmos son rapidísimos, y esa velocidad es justo lo que permite probar millones de combinaciones por segundo.

<?php
// Al registrar
$hash = password_hash($clave, PASSWORD_DEFAULT);

// Al verificar
if (password_verify($claveIngresada, $hashGuardado)) {
    // correcto
}

password_hash() genera una sal aleatoria automáticamente y usa un algoritmo diseñado para ser lento. Si mañana PHP cambia el algoritmo por defecto, PASSWORD_DEFAULT se actualiza solo.

Y en el login, mensajes genéricos: «correo o contraseña incorrectos», nunca «ese correo no existe».

5. Subida de archivos

Es el punto más peligroso de cualquier aplicación, porque un archivo subido puede ser código ejecutable.

<?php
$archivo = $_FILES['imagen'];

// 1. Comprobar que no hubo error
if ($archivo['error'] !== UPLOAD_ERR_OK) {
    throw new RuntimeException('Error al subir el archivo');
}

// 2. Validar el tipo REAL, no la extensión ni el tipo declarado
$finfo = new finfo(FILEINFO_MIME_TYPE);
$tipo = $finfo->file($archivo['tmp_name']);

$permitidos = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];

if (!isset($permitidos[$tipo])) {
    throw new RuntimeException('Tipo de archivo no permitido');
}

// 3. Validar el tamaño
if ($archivo['size'] > 2 * 1024 * 1024) {
    throw new RuntimeException('El archivo supera los 2 MB');
}

// 4. Generar un nombre nuevo: nunca usar el original
$nombre = bin2hex(random_bytes(16)) . '.' . $permitidos[$tipo];

// 5. Guardar fuera de la carpeta pública si es posible
move_uploaded_file($archivo['tmp_name'], __DIR__ . '/../almacen/' . $nombre);

Los cinco pasos importan, pero dos son críticos:

  • No confíes en $_FILES['imagen']['type']. Lo envía el navegador y se puede falsificar. Comprueba el tipo real con finfo.
  • Nunca uses el nombre original. Un archivo llamado foto.php.jpg en un servidor mal configurado puede acabar ejecutándose.

6. Credenciales fuera del código

Como advertimos en constantes: las contraseñas de base de datos y las claves de API no van en archivos que llegan al repositorio.

Un archivo de configuración aparte, incluido en el .gitignore, y leído desde ahí. Y recuerda: si una credencial llegó alguna vez a un repositorio, cámbiala. Sigue en el historial aunque borres el archivo.

7. Configuración del servidor

En producción:

<?php
ini_set('display_errors', '0');
ini_set('log_errors', '1');
error_reporting(E_ALL);

Como vimos en gestión de errores, un mensaje de error revela rutas, nombres de tablas y versiones de software.

Además:

  • Usa HTTPS. Sin él, las cookies y contraseñas viajan en texto plano.
  • Mantén PHP actualizado. Una versión sin soporte es una versión sin parches de seguridad.
  • Quita del servidor cualquier phpinfo.php, test.php o copia de seguridad que hayas dejado.

8. Valida siempre en el servidor

Todo lo que llega del cliente es sospechoso: formularios, URL, cabeceras y cookies. Los atributos required del HTML son comodidad para el usuario, no seguridad.

Y aplica el principio del mínimo privilegio: el usuario de base de datos de tu aplicación no necesita permisos para borrar tablas. Dale solo lo que usa.

Una lista para revisar antes de publicar

  • Todas las consultas usan parámetros
  • Toda salida pasa por htmlspecialchars()
  • Los formularios que modifican datos llevan token CSRF
  • Las contraseñas usan password_hash()
  • Las subidas validan tipo real, tamaño y renombran el archivo
  • Las credenciales están fuera del repositorio
  • display_errors está desactivado
  • El sitio usa HTTPS
  • session_regenerate_id(true) tras el login
  • Las cookies usan httponly, secure y samesite

Para cerrar

La seguridad no es un módulo que se agrega al final: es una forma de escribir cada línea. Y la buena noticia es que en PHP moderno casi todo está resuelto —password_hash, consultas preparadas, htmlspecialchars—; solo hay que usarlo.

Si tienes que quedarte con una sola idea: nunca confíes en un dato que no generaste tú.

En el siguiente artículo veremos el manejo de fechas.

Saludos y éxitos.

Norvic Software

Desarrollamos el software que tu empresa necesita

Somos una fábrica de software en Bolivia. Construimos sistemas a medida y aplicaciones móviles, y llevamos Inteligencia Artificial a las empresas que ya tienen un sistema funcionando.

Solicitar cotizaciónVer todos los servicios

Cotización sin costo · Respuesta directa por WhatsApp