Las auditorías de seguridad son fáciles de saltear. No porque el desarrollador no le importe la seguridad — sino porque sin un sistema, siempre se revisa lo mismo y se omite lo demás. OWASP Top 10 es el estándar, pero revisarlo de memoria no escala.
Este artículo muestra cómo aplicar cada punto con código PHP real: el patrón vulnerable, el fix, y cómo Claude Code ejecuta el checklist automáticamente cuando tiene Cerebro MCP disponible.
El problema con las auditorías ad-hoc
Cuando le pedís a Claude "revisá la seguridad de este archivo" sin contexto específico, el resultado es predecible: SQL injection, XSS y validación de entrada. Los mismos tres puntos de siempre, expresados en términos genéricos que aplican a cualquier proyecto PHP del universo.
Los puntos que más se omiten en proyectos PHP vanilla en hosting compartido:
- CSRF en paneles administrativos (especialmente formularios de gestión interna)
- Validación real de uploads: extensión + MIME type + nombre único en disco
- Rate limiting en login (el más implementado en teoría, el menos en práctica)
- Headers de seguridad en
.htaccess - Exposición de información en mensajes de error en producción
- Configuración de sesiones:
httponly,samesite,secure
Los 10 puntos con código real
1. SQL Injection — prepared statements PDO
Vulnerable:
// ❌ Concatenación directa — inyectable
$user = $pdo->query("SELECT * FROM users WHERE email = '$email'")->fetch();
Fix:
// ✅ Prepared statement con parámetro nombrado
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email LIMIT 1');
$stmt->execute([':email' => $email]);
$user = $stmt->fetch();
2. CSRF — tokens de sesión en formularios POST
Vulnerable:
// ❌ Formulario sin CSRF token — cualquier sitio externo puede enviar POST
<form method="POST" action="/panel/clientes.php">
<input name="nombre" value="">
<button>Guardar</button>
</form>
Fix:
// Generar token al renderizar el form
function csrf_token(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
// Validar en el handler POST
function csrf_validate(): void {
$token = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
http_response_code(403);
exit('Token CSRF inválido');
}
}
// ✅ Form con token
<form method="POST">
<input type="hidden" name="csrf_token" value="<?= e(csrf_token()) ?>">
<input name="nombre" value="">
<button>Guardar</button>
</form>
3. Contraseñas — bcrypt obligatorio
// ❌ MD5 o SHA1 — reversibles con rainbow tables
$hash = md5($password);
// ✅ bcrypt con cost factor 12
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// Verificar
if (!password_verify($input, $hash)) {
// login fallido
}
4. Headers de seguridad — .htaccess
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
# CSP básico — ajustar según los recursos del sitio
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline';"
</IfModule>
5. Uploads — triple validación
function validar_upload(array $file): string {
$permitidos = ['image/jpeg', 'image/png', 'image/webp'];
$ext_ok = ['jpg', 'jpeg', 'png', 'webp'];
// 1. Extensión whitelist (no blacklist)
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $ext_ok, true)) {
throw new RuntimeException('Extensión no permitida');
}
// 2. MIME type real con finfo (no confiar en $_FILES['type'])
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, $permitidos, true)) {
throw new RuntimeException('Tipo de archivo no permitido');
}
// 3. Verificar que es imagen real
if (!getimagesize($file['tmp_name'])) {
throw new RuntimeException('El archivo no es una imagen válida');
}
// 4. Nombre único en disco — nunca usar el nombre original
return bin2hex(random_bytes(16)) . '.' . $ext;
}
6. Rate limiting en login
function check_rate_limit(string $email, PDO $pdo): void {
$stmt = $pdo->prepare(
'SELECT COUNT(*) FROM login_attempts
WHERE email = ? AND created_at > DATE_SUB(NOW(), INTERVAL 15 MINUTE)'
);
$stmt->execute([$email]);
if ($stmt->fetchColumn() >= 5) {
http_response_code(429);
exit('Demasiados intentos. Intentá de nuevo en 15 minutos.');
}
}
function log_attempt(string $email, PDO $pdo): void {
$stmt = $pdo->prepare('INSERT INTO login_attempts (email, ip, created_at) VALUES (?, ?, NOW())');
$stmt->execute([$email, $_SERVER['REMOTE_ADDR'] ?? '']);
}
7. Sesiones seguras
// Configurar antes de session_start()
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => true, // solo HTTPS
'httponly' => true, // no accesible desde JS
'samesite' => 'Lax', // protección CSRF adicional
]);
session_start();
// Regenerar ID tras login exitoso
session_regenerate_id(true);
8. Output escaping — todo lo que se renderiza
// Una función helper que debería estar en bootstrap.php
function e(string $str): string {
return htmlspecialchars($str, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
// Uso en las vistas — sin excepciones
<p><?= e($user['nombre']) ?></p>
<input value="<?= e($post['titulo']) ?>">
9. Errores silenciados en producción
// .env o bootstrap.php
if (getenv('APP_ENV') === 'production') {
error_reporting(0);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', '/ruta/segura/fuera/de/public_html/error.log');
}
10. Paths sensibles en robots.txt y .htaccess
# robots.txt
Disallow: /app/
Disallow: /.env
Disallow: /*.sql
Disallow: /database/
Disallow: /scripts/
# .htaccess — denegar acceso directo a app/
<FilesMatch "\.(env|sql|log|sh|json)$">
Require all denied
</FilesMatch>
Cómo Claude ejecuta esto con Cerebro MCP
Con Cerebro MCP activo, cuando escribís "auditá la seguridad de este proyecto", Claude llama automáticamente:
get_context("security-audit")— retorna el prompt especializado + chunks de conocimiento sobre el stackget_checklist("security")— retorna el checklist completo con cada ítem numerado
Claude aplica cada ítem al código real del proyecto, no a un ejemplo genérico. Si tu proyecto usa PDO con parámetros nombrados en algunos lugares y concatenación en otros, Claude lo detecta y señala las inconsistencias específicas. Si ya tenés rate limiting implementado, lo valida en lugar de sugerirte que lo implementes.
Una auditoría completa con este checklist toma entre 30 y 60 minutos dependiendo del tamaño del proyecto. Sin el checklist, podés tardar lo mismo y cubrir la mitad de los puntos.