Guía completa de seguridad en PHP: validación, uploads, SQL y sesiones
Esta guía práctica cubre técnicas y patrones para proteger aplicaciones PHP: validación de entrada, manejo seguro de uploads, consultas con PDO/prepared statements, hardening de sesiones y CSRF. Incluye estructura de proyecto, código completo y explicaciones del por qué detrás de cada decisión.
Prerequisitos
- PHP 7.4+ (idealmente 8.x)
- Servidor web (Apache/Nginx)
- Extensiones: PDO, fileinfo
Estructura de carpetas
project/
├─ public/ # Document root
│ ├─ index.php
│ └─ uploads/ # Carpeta de archivos subidos (bloquear ejecución)
├─ src/
│ ├─ config.php
│ ├─ db.php
│ ├─ auth.php
│ ├─ upload.php
│ └─ helpers.php
├─ logs/
├─ .env
└─ .htaccess # reglas de seguridad para public/
1) Configuración y conexión segura a BD (PDO)
Usa PDO con opciones que obliguen a excepciones y deshabiliten emulación de prepared statements.
<?php
// src/db.php
declare(strict_types=1);
require_once __DIR__ . '/config.php';
function getPDO(): PDO
{
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME);
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$pdo = new PDO($dsn, DB_USER, DB_PASS, $options);
return $pdo;
}
Por qué: deshabilitar emulación evita inyección when drivers try to interpolate values; las excepciones facilitan manejo centralizado de errores.
2) Manejo de contraseñas
<?php
// src/auth.php (fragmento)
function registerUser(string $email, string $password): bool
{
$pdo = getPDO();
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (:email, :hash)');
return $stmt->execute([':email' => $email, ':hash' => $hash]);
}
function verifyLogin(string $email, string $password): ?array
{
$pdo = getPDO();
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE email = :email LIMIT 1');
$stmt->execute([':email' => $email]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
// opcional: password_needs_rehash
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
$newHash = password_hash($password, PASSWORD_DEFAULT);
$update = $pdo->prepare('UPDATE users SET password_hash = :h WHERE id = :id');
$update->execute([':h' => $newHash, ':id' => $user['id']]);
}
return $user;
}
return null;
}
Por qué: password_hash + password_verify implementan salado y funciones de stretching correctamente; evita implementar tu propio esquema.
3) Validación y saneamiento de entrada
Separa validación (si el dato es válido) de saneamiento (transformación para uso). Nunca confíes en datos del cliente.
<?php
// src/helpers.php
function isValidEmail(string $email): bool
{
return (bool) filter_var($email, FILTER_VALIDATE_EMAIL);
}
function sanitizeString(string $s): string
{
return trim(filter_var($s, FILTER_SANITIZE_STRING)); // para HTML usar escape al imprimir
}
Al imprimir en HTML, usa htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') para prevenir XSS.
4) Manejo seguro de uploads
Pasos clave: validar tamaño y tipo MIME usando fileinfo, usar nombres aleatorios, almacenar fuera del document root o bloquear ejecución con .htaccess, restringir permisos.
<?php
// src/upload.php
function handleUpload(array $file, string $destDir): string
{
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('Upload error code: ' . $file['error']);
}
// tamaño máximo
$max = 5 * 1024 * 1024; // 5MB
if ($file['size'] > $max) {
throw new RuntimeException('File too large');
}
// comprobar tipo real con finfo
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
];
if (!isset($allowed[$mime])) {
throw new RuntimeException('Tipo de archivo no permitido');
}
// nombre seguro
$ext = $allowed[$mime];
$basename = bin2hex(random_bytes(16));
$filename = $basename . '.' . $ext;
// mover a destino fuera del docroot si es posible
$target = rtrim($destDir, '/') . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $target)) {
throw new RuntimeException('No se pudo mover el archivo');
}
// establecer permisos restrictivos
chmod($target, 0640);
return $filename;
}
Complementa con validación adicional: getimagesize para comprobar imágenes y procesar/reescalar para prevenir imágenes maliciosas.
.htaccess para uploads (Apache)
# public/uploads/.htaccess
# Previene ejecución de scripts
Options -ExecCGI -Indexes
AddType text/plain .php .php5 .phtml .pl .py .jsp
php_flag engine off
Si no usas Apache, configura Nginx para no interpretar scripts en la carpeta de uploads.
5) CSRF y seguridad de sesiones
Genera tokens por sesión y valida en formularios; fortalece cookies de sesión.
<?php
// src/session.php
function secureSessionStart(): void
{
$cookieParams = session_get_cookie_params();
session_set_cookie_params([
'lifetime' => 0,
'path' => $cookieParams['path'],
'domain' => $cookieParams['domain'],
'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'httponly' => true,
'samesite' => 'Lax',
]);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
function csrfToken(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function validateCsrf(string $token): bool
{
return hash_equals($_SESSION['csrf_token'] ?? '', $token);
}
Además, regenera el ID de sesión al iniciar sesión (session_regenerate_id(true)) y limita la duración de la sesión con control de actividad.
6) Manejo de errores y logging
No muestres errores de PHP en producción. Registra errores con un sistema central (Monolog recomendado) y muestra mensajes genéricos al usuario.
// src/config.php (fragmento)
const ENV = 'production'; // o 'development'
if (ENV === 'production') {
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', __DIR__ . '/../logs/php-errors.log');
} else {
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
}
Por qué: evitar fugas de información sensible (stack traces, consultas SQL) al usuario final.
7) Configuración y secretos
Guarda credenciales fuera del repositorio en variables de entorno (.env) y no en el código. Usa libs como vlucas/phpdotenv para cargar en desarrollo y el gestor de entorno del hosting en producción.
# .env ejemplo
DB_HOST=127.0.0.1
DB_NAME=myapp
DB_USER=myuser
DB_PASS=s3cret
APP_ENV=production
8) Reglas adicionales y hardening
- Usa Content Security Policy (CSP) para reducir XSS.
- Valida el tamaño y tipo de entrada en servidor (nunca solo JS).
- Implementa rate limiting y bloqueo por IP para endpoints sensibles.
- Escanea dependencias y aplica actualizaciones de seguridad.
- Realiza pruebas de penetración y revisiones de código (SAST).
9) Ejemplo completo: registro con upload de avatar
Flujo resumido:
- secureSessionStart()
- Comprobar CSRF
- Validar email y password
- Procesar upload con handleUpload()
- Guardar user y nombre de archivo en BD con prepared statement
<?php
// public/index.php (simplificado)
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/session.php';
require_once __DIR__ . '/../src/helpers.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/upload.php';
require_once __DIR__ . '/../src/auth.php';
secureSessionStart();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!validateCsrf($_POST['csrf'] ?? '')) {
http_response_code(400);
echo 'Invalid CSRF token';
exit;
}
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
if (!isValidEmail($email) || strlen($password) < 8) {
echo 'Datos inválidos';
exit;
}
try {
$avatarFilename = null;
if (!empty($_FILES['avatar']['name'])) {
$avatarFilename = handleUpload($_FILES['avatar'], __DIR__ . '/../public/uploads');
}
$pdo = getPDO();
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash, avatar) VALUES (:email, :hash, :avatar)');
$stmt->execute([
':email' => $email,
':hash' => password_hash($password, PASSWORD_DEFAULT),
':avatar' => $avatarFilename,
]);
echo 'Registrado';
} catch (Throwable $e) {
error_log($e->getMessage());
echo 'Ocurrió un error';
}
}
// Formulario mínimo
$csrf = csrfToken();
?>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf" value="<?php echo htmlspecialchars($csrf, ENT_QUOTES, 'UTF-8'); ?>" />
<input type="email" name="email" required />
<input type="password" name="password" minlength="8" required />
<input type="file" name="avatar" accept="image/*" />
<button type="submit">Registrar</button>
</form>
Recap rápido de por qué estas prácticas importan
- Prepared statements: evitan inyección SQL.
- Validación + fileinfo: previenen uploads maliciosos.
- password_hash: almacenamiento seguro de credenciales.
- Session cookie flags + CSRF token: reducen secuestro de sesión y ataques CSRF.
- No mostrar errores en producción: previene fuga de información sensible.
Consejo avanzado: integra una suite de pruebas de seguridad (SAST + DAST) en tu CI/CD y automatiza escaneos de dependencias. Una regla práctica: cualquier entrada que llegue de fuera pasa por validación estricta y, si es un archivo, por comprobaciones de tipo y re-procesado (resizing/normalización) antes de cualquier uso.
Advertencia: la seguridad es una capa continua. Aplica parches, revisa logs y mantén principios de mínimo privilegio en base de datos y sistema de archivos. Siguiente paso: instrumenta rate limiting y logging estructurado (JSON) para correlacionar intentos maliciosos.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación