Seguridad en PHP: prevenir SQL Injection, XSS, CSRF y validar datos correctamente


Después de implementar login, sesiones, roles y permisos, el siguiente paso es aprender a proteger correctamente una aplicación PHP.

La seguridad no consiste en una sola técnica. Una aplicación puede utilizar contraseñas seguras y, al mismo tiempo, ser vulnerable a inyección SQL, XSS o CSRF.

En esta guía vamos a repasar algunas de las vulnerabilidades más comunes y cómo prevenirlas.

1. SQL Injection

Una inyección SQL ocurre cuando datos ingresados por el usuario terminan formando parte directa de una consulta SQL.

Este código es inseguro:

<?php

$email = $_POST['email'];

$sql = "SELECT * FROM usuarios WHERE email = '$email'";

$usuario = $pdo->query($sql)->fetch();

?>

El problema es que el contenido de $email se concatena directamente dentro de la consulta.

La solución: consultas preparadas

Con PDO debemos separar los datos de la consulta:

<?php

$sql = 'SELECT * FROM usuarios WHERE email = :email';

$stmt = $pdo->prepare($sql);

$stmt->execute([
    'email' => $email
]);

$usuario = $stmt->fetch(PDO::FETCH_ASSOC);

?>

El marcador :email evita que el valor recibido sea interpretado como parte de la consulta SQL.

No confiar en addslashes()

Escapar manualmente textos no reemplaza a las consultas preparadas.

La opción recomendada es utilizar prepare() y execute().

2. XSS: Cross-Site Scripting

XSS aparece cuando mostramos contenido enviado por un usuario sin escapar correctamente el HTML.

Por ejemplo:

echo $_POST['nombre'];

Si el usuario envía código HTML o JavaScript, ese contenido podría ejecutarse en el navegador.

Escapar la salida con htmlspecialchars()

echo htmlspecialchars(
    $_POST['nombre'],
    ENT_QUOTES,
    'UTF-8'
);

Esto convierte caracteres especiales en entidades HTML y evita que el navegador los interprete como etiquetas.

Escapar al mostrar, no necesariamente al guardar

Una regla práctica importante es guardar los datos de forma adecuada y aplicar el escape según el contexto de salida.

Por ejemplo:

<h1>
    <?= htmlspecialchars($usuario['nombre'], ENT_QUOTES, 'UTF-8') ?>
</h1>

3. Validación y sanitización

Validar significa comprobar que un dato cumple las reglas esperadas.

Sanitizar significa limpiar o transformar un valor.

Validar un email

if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errores[] = 'El email no es válido';
}

Validar un número entero

$edad = filter_input(INPUT_POST, 'edad', FILTER_VALIDATE_INT);

if ($edad === false) {
    $errores[] = 'La edad debe ser un número entero';
}

No confiar solamente en validaciones HTML

Este campo:

<input type="email" name="email" required>

mejora la experiencia del usuario, pero no reemplaza la validación en PHP.

Las peticiones pueden enviarse sin utilizar el formulario del navegador.

4. CSRF: Cross-Site Request Forgery

Un ataque CSRF intenta lograr que un usuario autenticado ejecute una acción sin darse cuenta.

Por ejemplo, una petición podría intentar eliminar un usuario utilizando la sesión activa de un administrador.

Crear un token CSRF

Podemos generar un valor aleatorio y guardarlo en la sesión:

<?php

session_start();

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

?>

Agregar el token al formulario

<input
    type="hidden"
    name="csrf_token"
    value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"
>

Validar el token

if (
    !isset($_POST['csrf_token']) ||
    !hash_equals(
        $_SESSION['csrf_token'],
        $_POST['csrf_token']
    )
) {
    http_response_code(403);
    exit('Token CSRF inválido');
}

Las operaciones importantes deberían usar POST

No conviene eliminar datos utilizando enlaces como:

<a href="eliminar.php?id=10">Eliminar</a>

Para operaciones que modifican información es preferible utilizar formularios con método POST y protección CSRF.

5. Contraseñas seguras

Las contraseñas nunca deben almacenarse en texto plano.

Para guardarlas:

$hash = password_hash(
    $password,
    PASSWORD_DEFAULT
);

Para comprobarlas:

if (password_verify($password, $hash)) {
    // Contraseña correcta
}

No utilizar MD5 o SHA1 para contraseñas

Aunque son funciones hash, md5() y sha1() no están diseñadas para almacenar contraseñas de forma segura.

PHP ofrece específicamente:

  • password_hash();
  • password_verify();
  • password_needs_rehash().

6. Seguridad de las sesiones

Después de un login correcto conviene regenerar el identificador:

session_regenerate_id(true);

También podemos configurar las cookies de sesión:

session_set_cookie_params([
    'httponly' => true,
    'secure' => true,
    'samesite' => 'Lax'
]);

session_start();

secure debe utilizarse cuando el sitio funciona mediante HTTPS.

7. No mostrar errores sensibles en producción

Durante el desarrollo puede ser útil visualizar errores, pero en producción pueden revelar información interna.

Por ejemplo:

  • rutas del servidor;
  • nombres de archivos;
  • estructura de la base de datos;
  • credenciales mal configuradas;
  • detalles del código.

En producción conviene registrar errores en logs y mostrar al usuario mensajes genéricos.

8. Manejo seguro de archivos subidos

Las subidas de archivos requieren controles adicionales.

No debemos confiar solamente en:

$_FILES['archivo']['type']

Ese valor proviene del cliente.

Podemos analizar el tipo MIME real con finfo:

$finfo = new finfo(FILEINFO_MIME_TYPE);

$tipo = $finfo->file(
    $_FILES['archivo']['tmp_name']
);

Después podemos compararlo contra una lista permitida.

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

if (!in_array($tipo, $permitidos, true)) {
    exit('Tipo de archivo no permitido');
}

No utilizar directamente el nombre original

En vez de confiar en el nombre recibido, podemos generar uno aleatorio:

$nombreSeguro = bin2hex(random_bytes(16)) . '.jpg';

9. Validar identificadores

Un identificador recibido mediante GET también debe validarse.

$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);

if (!$id) {
    http_response_code(400);
    exit('ID inválido');
}

10. Autorización en cada operación

Aunque un usuario conozca una URL, no debería poder ejecutar una operación sin permiso.

Por ejemplo:

if (!tienePermiso(
    $pdo,
    $_SESSION['usuario_id'],
    'usuarios.eliminar'
)) {
    http_response_code(403);
    exit('Acceso denegado');
}

11. Variables de entorno y credenciales

No conviene publicar credenciales directamente en archivos accesibles o repositorios públicos.

Información como:

  • usuario de MySQL;
  • contraseña de la base;
  • tokens de APIs;
  • claves privadas;

debe tratarse como información sensible.

12. Aplicar el principio de mínimo privilegio

Un usuario de base de datos utilizado por una aplicación no necesariamente necesita permisos administrativos completos.

La idea es conceder solamente los privilegios necesarios para que la aplicación funcione.

Ejemplo de formulario más seguro

<?php

session_start();

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

?>

<form method="POST">

    <input
        type="hidden"
        name="csrf_token"
        value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"
    >

    <label>Email</label>

    <input
        type="email"
        name="email"
        required
    >

    <button type="submit">
        Guardar
    </button>

</form>

Procesar el formulario

<?php

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    if (
        !isset($_POST['csrf_token']) ||
        !hash_equals(
            $_SESSION['csrf_token'],
            $_POST['csrf_token']
        )
    ) {
        http_response_code(403);
        exit('Solicitud inválida');
    }

    $email = filter_input(
        INPUT_POST,
        'email',
        FILTER_VALIDATE_EMAIL
    );

    if (!$email) {
        exit('Email inválido');
    }

    $stmt = $pdo->prepare(
        'UPDATE usuarios
         SET email = :email
         WHERE id = :id'
    );

    $stmt->execute([
        'email' => $email,
        'id' => $_SESSION['usuario_id']
    ]);
}

?>

Checklist de seguridad para una aplicación PHP

  • usar PDO con consultas preparadas;
  • validar todos los datos externos;
  • escapar correctamente el contenido al mostrarlo;
  • proteger formularios sensibles contra CSRF;
  • usar password_hash() y password_verify();
  • regenerar la sesión después del login;
  • verificar roles y permisos en el servidor;
  • no confiar en cookies ni campos ocultos;
  • validar correctamente archivos subidos;
  • no mostrar errores internos en producción;
  • utilizar HTTPS;
  • mantener PHP y las dependencias actualizadas.

Errores comunes

  • creer que la validación de JavaScript es suficiente;
  • concatenar valores dentro de consultas SQL;
  • mostrar contenido de usuarios sin escapar;
  • usar GET para acciones destructivas;
  • guardar contraseñas con MD5 o SHA1;
  • confiar en el nombre o tipo MIME enviado por un archivo;
  • ocultar botones sin proteger la operación real;
  • guardar secretos directamente en repositorios públicos.

Ejercicio práctico

Tomá el sistema de usuarios de las guías anteriores y agregale:

  1. consultas preparadas en todas las operaciones;
  2. escape de nombres y datos mostrados en HTML;
  3. validación del email;
  4. token CSRF para editar y eliminar usuarios;
  5. protección por permisos;
  6. regeneración de sesión al iniciar sesión;
  7. mensajes de error seguros.

¿Qué sigue?

En la próxima guía podemos avanzar con programación orientada a objetos en PHP, un paso fundamental antes de comenzar a trabajar con arquitecturas más organizadas y frameworks como Laravel.

Veremos clases, objetos, propiedades, métodos, constructores, encapsulamiento, herencia e interfaces.

¿Te sirvió esta guía? ☕

Si este contenido te ayudó y querés apoyar a Club Programador para seguir publicando ejercicios, proyectos y guías gratuitas, podés colaborar mediante:

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal


Descubre más desde Club Programador

Suscríbete y recibe las últimas entradas en tu correo electrónico.

Deja un comentario

Descubre más desde Club Programador

Suscríbete ahora para seguir leyendo y obtener acceso al archivo completo.

Seguir leyendo