Seguridad¶
De un vistazo
Materia: Transversal (aplica en todas las capas) · Tiempo de lectura: ~35 min · Requisitos previos: Bases de datos, APIs, Networking.
En una frase: vas a cambiar el chip de "hago que funcione" a "supongo que alguien va a intentar abusar de esto" —y vas a entender las vulnerabilidades más comunes no como una lista que memorizar, sino como consecuencias predecibles de confiar en datos que no controlas, junto con las defensas que las matan de raíz.
Por qué esto importa¶
Escribes un formulario de login, guarda la contraseña, comprueba el usuario, redirige. Funciona. Lo pruebas tú, entra. Fin. Y ahí está el problema: tú lo has probado como quien lo va a usar bien. La seguridad empieza cuando dejas de ser el único que toca tu código.
Porque tarde o temprano aparecen situaciones que no puedes entender desde "funciona", solo desde "¿cómo se abusa de esto?":
- Tu buscador va perfecto hasta que alguien escribe
'; DROP TABLE usuarios; --en la caja y te quedas sin base de datos. - Un usuario cualquiera cambia el número
?id=42de la URL por?id=43y ve la factura de otro cliente. - Alguien roba tu base de datos y, aunque "las contraseñas estaban cifradas", en una semana tiene las contraseñas en claro de todos tus usuarios.
- Un comentario en tu blog con un
<script>acaba robando la sesión de cada persona que lo lee. - Subiste sin querer un fichero
.envcon la clave de la pasarela de pago al repositorio, y un bot lo encontró en 40 segundos.
Todas tienen la misma raíz: en algún punto confiaste en algo que no controlabas —una entrada del usuario, una URL, un dato de la base, un sistema externo— y alguien lo aprovechó. Sin una mentalidad de seguridad, cada una de estas es una sorpresa desagradable que arreglas a posteriori (a veces con la prensa mirando). Con ella, son riesgos que ves venir mientras escribes el código. Ese es el salto de "programador que hace features" a "ingeniero responsable de lo que despliega".
Y hay algo que conviene decir ya: la seguridad no es una feature que marcas y ya está. No hay una casilla "poner segura la app". Es una propiedad del sistema entero que se degrada con cada línea nueva. Volveremos a esto al final, pero tenlo presente desde el principio.
Intuición primero: el portero paranoico¶
Imagina que tu aplicación es un edificio, y tú eres el portero. Por la puerta entra gente todo el día: usuarios legítimos, curiosos, y de vez en cuando alguien que quiere robar. El problema es que todos llegan vestidos igual y todos te dicen que vienen a algo legítimo.
Un portero ingenuo cree lo que le dicen: "vengo a ver al cliente 43" → "adelante". Un portero paranoico —el que queremos ser— parte de otra premisa:
Toda persona que llega es sospechosa hasta que demuestre lo contrario, y aun así solo la dejo llegar a donde le corresponde.
No es que odie a la gente. Es que no puede distinguir al bueno del malo por la cara, así que aplica la misma regla a todos: verificar quién dice ser, comprobar a dónde tiene permiso de ir, y no fiarse de nada que traiga en las manos sin mirarlo.
graph LR
U["Entrada externa<br/>(usuario, URL, API,<br/>fichero, otro sistema)"] -->|"¿de quién me fío?"| P["Portero paranoico<br/>(tu código)"]
P -->|"NADIE. Verifico todo."| A["Recurso protegido<br/>(BD, ficheros,<br/>dinero, datos)"]
La frase que resume toda la mentalidad de seguridad es una sola, y quiero que se te quede grabada:
No confíes en ninguna entrada externa. Nunca. Jamás.
Todo lo demás en esta lección —inyección SQL, XSS, control de acceso, contraseñas— son casos concretos de esta misma regla. Cada vulnerabilidad es un sitio donde alguien confió en algo que venía de fuera. Guarda la imagen del portero paranoico. Vamos a colgar el resto de aquí.
El detalle¶
1. La mentalidad: pensar como quien va a abusar de esto¶
Programar normal y programar con seguridad usan dos músculos mentales opuestos. Cuando construyes una feature, piensas en el camino feliz: qué pasa cuando el usuario hace lo que esperas. Cuando piensas en seguridad, haces lo contrario: buscas el camino retorcido, el input que no esperabas, el orden de pasos que no previste.
La pregunta que hay que hacerse ante cada trozo de código que toca datos externos es:
"Si yo fuera malintencionado y quisiera romper esto, ¿qué metería aquí?"
No es paranoia gratuita; es modelado de amenazas informal, y es lo que distingue a quien escribe código que aguanta el mundo real. Un par de reglas mentales que hacen operativa esta actitud:
- Toda entrada es hostil hasta que la validas. El campo de un formulario, un parámetro de la URL, una cabecera HTTP, el cuerpo de una petición a tu API, el nombre de un fichero subido, un valor que te devuelve otro servicio… todo. Si viene de fuera de tu proceso, no te fías.
- Lo que no controlas, lo controla otro. Si tu seguridad depende de que el usuario "no toque" el campo oculto de un formulario, o "no cambie" el precio que va en la petición, no tienes seguridad: tienes una petición. Todo lo que llega al servidor pudo ser fabricado a mano.
- La ausencia de ataque no es prueba de seguridad. Que nadie te haya reventado aún no significa que sea seguro; significa que nadie lo ha intentado todavía, o que no te has enterado.
El error de mentalidad número uno
"Esto es una app interna, no hace falta asegurarla" / "nadie va a adivinar esta URL" / "el campo va oculto en el formulario". Todos son la misma falacia: seguridad por oscuridad. Confías en que el atacante no sepa algo, en vez de en que no pueda hacer algo. La oscuridad se evapora en cuanto alguien mira. Los datos de tu app interna acaban en un portátil robado; la URL "secreta" aparece en los logs de un proxy; el campo oculto se ve con F12. La seguridad real no depende de secretos que se descubren mirando, sino de controles que aguantan aunque el atacante lo sepa todo.
2. Superficie de ataque: todo lo que da a la calle¶
La superficie de ataque es el conjunto de todos los puntos por los que alguien de fuera puede interactuar con tu sistema. Es, literalmente, cada puerta y ventana del edificio. Cuanto más grande, más sitios que vigilar y más probable que uno esté mal cerrado.
Piensa en una app web típica y cuenta las puertas:
graph TB
subgraph EXT["Mundo exterior (no confiable)"]
ATT["Cualquiera con<br/>una conexión"]
end
subgraph SUP["Superficie de ataque"]
F["Formularios y<br/>campos de entrada"]
API["Endpoints de la API<br/>(cada ruta)"]
URL["Parámetros de URL<br/>y query strings"]
UP["Subida de ficheros"]
H["Cabeceras y cookies"]
DEP["Dependencias<br/>(librerías de terceros)"]
ADM["Paneles de admin,<br/>puertos abiertos"]
end
ATT --> F & API & URL & UP & H & DEP & ADM
La lección práctica es doble. Primero, reducir la superficie es una defensa en sí misma: cada endpoint que no expones, cada puerto que cierras, cada dependencia que no instalas, es una puerta menos que puede tener un fallo. Segundo, la superficie incluye lo que no escribiste tú: las librerías de terceros de tu composer.json son parte de tu superficie de ataque. Una vulnerabilidad en un paquete que usas es una vulnerabilidad tuya. Por eso existe composer audit y por eso mantener dependencias al día no es manía, es higiene.
3. Inyección SQL: el caso que lo enseña todo¶
Esta es la vulnerabilidad para entender la mentalidad, porque muestra el mecanismo con una claridad brutal. Conecta directo con lo que viste en Bases de datos: tu código construye una consulta SQL en forma de texto y se la manda al motor. El problema aparece cuando metes datos del usuario dentro de ese texto.
Mira este login, escrito como lo escribiría alguien que solo piensa en el camino feliz:
// ❌ VULNERABLE — nunca escribas esto
$email = $_POST['email'];
$sql = "SELECT * FROM usuarios WHERE email = '$email'";
$resultado = $conexion->query($sql);
Con un usuario normal (ana@correo.es) la consulta queda:
Perfecto. Pero recuerda: el usuario controla $email. ¿Qué pasa si en vez de un email escribe ' OR '1'='1? Tu texto se convierte en:
'1'='1' es siempre verdadero, así que la consulta devuelve todos los usuarios y el atacante entra como el primero (a menudo un admin). El motor de base de datos hizo su trabajo perfectamente; el problema es que no puede distinguir tu SQL de los datos del usuario, porque los mezclaste en la misma cadena de texto. Para el motor, ' OR '1'='1 no son datos: es estructura de la consulta. Ahí está la raíz: has dejado que un dato externo cambie la estructura de tu código.
La cura: consultas preparadas¶
La solución no es "escapar comillas a mano" (frágil, se te olvida un caso y estás muerto). Es un cambio estructural: separar la consulta de los datos para que el motor sepa, sin ninguna duda, qué es estructura y qué es dato. Eso son las consultas preparadas (prepared statements):
// ✅ SEGURO — consulta preparada con PDO
$sql = "SELECT * FROM usuarios WHERE email = :email";
$stmt = $pdo->prepare($sql);
$stmt->execute(['email' => $_POST['email']]);
$usuario = $stmt->fetch();
Aquí pasan dos cosas en dos fases separadas. Primero mandas al motor la consulta con un hueco (:email): el motor la analiza y fija su estructura sin conocer aún el dato. Después mandas el dato por un canal aparte. Cuando el motor rellena el hueco, ya ha decidido que la consulta busca "un email igual a X"; el dato solo puede ser un valor, jamás estructura. Si el atacante manda ' OR '1'='1, el motor busca literalmente un usuario cuyo email sea la cadena ' OR '1'='1 —no lo encuentra— y ya está. La inyección es imposible por construcción, no por vigilancia.
graph TB
subgraph MAL["❌ Concatenar (mezcla dato y estructura)"]
direction TB
C1["Texto SQL + dato usuario<br/>= una sola cadena"] --> C2["El motor no puede<br/>separar qué es qué"] --> C3["El dato puede volverse<br/>estructura → INYECCIÓN"]
end
subgraph BIEN["✅ Consulta preparada (separa las fases)"]
direction TB
P1["1· Consulta con huecos<br/>→ el motor fija la estructura"] --> P2["2· Datos por canal aparte<br/>→ solo pueden ser valores"] --> P3["El dato NUNCA es<br/>estructura → a salvo"]
end
En Symfony casi nunca escribes SQL a mano: Doctrine (el ORM) usa consultas preparadas por debajo siempre que le pasas parámetros bien. Lo correcto:
// ✅ Doctrine con parámetro — preparado por debajo
$qb->where('u.email = :email')
->setParameter('email', $emailUsuario);
// ❌ Doctrine concatenando — vuelves a estar vendido
$qb->where("u.email = '" . $emailUsuario . "'");
La regla general que te llevas, y que aplica más allá de SQL: nunca construyas comandos concatenando datos externos como texto. Vale para SQL, para comandos del sistema (shell_exec), para LDAP, para plantillas. Siempre que un dato externo pueda convertirse en estructura de algo que se ejecuta, tienes una inyección esperando. La defensa es siempre la misma idea: separar el dato del comando.
4. XSS: inyección, pero en el navegador de otro¶
La inyección SQL mete código en tu base de datos. El XSS (Cross-Site Scripting) es la misma idea un piso más arriba: meter código —JavaScript— que se ejecuta en el navegador de otros usuarios. Y el mecanismo es idéntico: confías en un dato externo y lo dejas convertirse en estructura, esta vez estructura HTML.
Imagina un campo de comentarios. Un usuario escribe:
Si tu plantilla pinta ese comentario tal cual en la página, cada visitante que lo lea ejecutará ese script en su navegador, con su sesión. El atacante acaba de robar cookies de sesión de todos tus usuarios sin tocar tu servidor. Otra vez la raíz: un dato del usuario (el comentario) se convirtió en estructura (etiquetas HTML/JS) en vez de quedarse como texto inofensivo.
La cura, otra vez, es no dejar que el dato sea estructura: escapar la salida según el contexto donde la pintas. En HTML eso significa convertir < en <, > en >, etc., para que el navegador lo muestre como texto y no lo interprete como etiquetas.
// ❌ VULNERABLE — pinta el comentario crudo
echo "<p>" . $comentario . "</p>";
// ✅ SEGURO — escapa antes de pintar
echo "<p>" . htmlspecialchars($comentario, ENT_QUOTES, 'UTF-8') . "</p>";
Symfony y Twig te cubren por defecto (pero no bajes la guardia)
Twig autoescapa todas las variables por defecto: {{ comentario }} sale escapado sin que hagas nada. Ese es el buen diseño: seguro por defecto, inseguro solo si lo pides explícitamente. El peligro es cuando tú desactivas la red de seguridad con {{ comentario|raw }} porque "necesitabas pintar HTML". Ahí vuelves a ser responsable de sanear ese HTML tú mismo (con una librería como HTML Purifier), y casi nunca lo haces bien. Regla: usa |raw solo sobre contenido que tú generas, jamás sobre datos de usuario.
5. CSRF: te hacen firmar sin que lo sepas¶
El CSRF (Cross-Site Request Forgery) es más sutil porque no inyecta nada: abusa de tu propia confianza en el navegador del usuario. Funciona así: si estás logueado en tu banco, tu navegador guarda una cookie de sesión y la manda automáticamente en cada petición al banco. El atacante no puede leer esa cookie, pero puede provocar que tu navegador haga una petición que la incluya.
Te llega un email con una imagen invisible: <img src="https://tubanco.es/transferir?a=atacante&importe=5000">. Tu navegador, al cargar la "imagen", hace esa petición GET a tu banco con tu cookie de sesión adjunta. Si el banco solo comprueba "¿tiene cookie válida? → hago la transferencia", acabas de mandar 5000 € sin enterarte.
graph LR
V["Víctima logueada<br/>en el banco"] -->|"visita web trampa"| T["Web del atacante"]
T -->|"fuerza una petición<br/>al banco"| N["Navegador de la víctima"]
N -->|"petición + cookie<br/>de sesión (automática)"| B["Banco: '¿cookie válida?<br/>Sí' → ejecuta 😱"]
La defensa clave es el token CSRF: un valor secreto, único por sesión e impredecible, que tu servidor incrusta en cada formulario legítimo y que exige de vuelta en la petición. El atacante puede forzar la petición desde fuera, pero no puede saber el token (está en tu página, y la política del navegador le impide leerlo). Sin token válido, la petición se rechaza.
// Symfony: en la plantilla del formulario
<input type="hidden" name="_token" value="{{ csrf_token('borrar_cuenta') }}">
// En el controlador, antes de actuar
if (!$this->isCsrfTokenValid('borrar_cuenta', $request->request->get('_token'))) {
throw new AccessDeniedException('Token CSRF inválido');
}
En Symfony, el componente Form añade y valida el token CSRF automáticamente; solo tienes que no desactivarlo. Complementos modernos: cookies con atributo SameSite=Lax/Strict (que le dicen al navegador que no mande la cookie en peticiones venidas de otros sitios) y, para acciones sensibles, no usar GET para nada que cambie estado (un GET nunca debería transferir dinero).
6. Control de acceso roto: la más común y la más silenciosa¶
Las anteriores meten algo malicioso. Esta es distinta y más traicionera: el atacante no inyecta nada, simplemente pide algo a lo que no debería tener derecho, y se lo das. Es, año tras año, la vulnerabilidad número uno en las clasificaciones (OWASP), y la más fácil de introducir sin darte cuenta.
El caso clásico se llama IDOR (referencia directa a objeto insegura). Tu app muestra la factura así:
// ❌ VULNERABLE — comprueba que estás logueado, pero NO que la factura sea tuya
public function verFactura(int $id): Response
{
$factura = $this->facturaRepo->find($id); // trae la factura $id
return $this->render('factura.html.twig', ['factura' => $factura]);
}
Estás logueado como cliente 42 y ves /factura/42. Todo bien… hasta que cambias la URL a /factura/43 y ves la factura de otro cliente. El código comprobó autenticación (estás logueado) pero se olvidó de autorización (¿esta factura es tuya?). El fallo es de omisión: no hay ataque, hay una comprobación que falta.
// ✅ SEGURO — verifica la propiedad del recurso
public function verFactura(int $id): Response
{
$factura = $this->facturaRepo->find($id);
if (!$factura || $factura->getCliente() !== $this->getUser()) {
throw $this->createAccessDeniedException();
}
return $this->render('factura.html.twig', ['factura' => $factura]);
}
En Symfony esto se hace limpio con Voters, que centralizan la lógica de "¿puede este usuario hacer X sobre este objeto?":
// ✅ Con Voter: la lógica de permiso vive en un solo sitio
$this->denyAccessUnlessGranted('VER', $factura);
La regla general: cada acceso a un recurso debe verificarse en el servidor, en el momento, contra el usuario actual. No basta con ocultar el enlace en la interfaz (el atacante teclea la URL directamente). No basta con comprobar el login. Hay que preguntarse, para cada recurso: ¿tiene este usuario derecho a esto, ahora? Y esa comprobación va siempre en el servidor, nunca en el navegador —que es territorio del atacante.
7. Autenticación vs autorización: quién eres vs qué puedes hacer¶
Los dos conceptos que acabas de ver merecen quedar clavados, porque confundirlos causa la mitad de los agujeros de control de acceso:
- Autenticación (authentication, "authN"): ¿quién eres? Verificar identidad. Es el momento del login: demuestras que eres quien dices (contraseña, huella, código del móvil).
- Autorización (authorization, "authZ"): ¿qué puedes hacer? Verificar permisos. Ya sé quién eres; ahora decido si tienes derecho a esta acción concreta sobre este recurso concreto.
graph LR
P["Petición entra"] --> A["AUTENTICACIÓN<br/>¿Quién eres?<br/>(login, token)"]
A -->|"identidad verificada"| Z["AUTORIZACIÓN<br/>¿Puedes hacer ESTO?<br/>(roles, propiedad)"]
Z -->|"permiso verificado"| OK["Acción permitida"]
A -.->|"no sé quién eres"| E1["401 No autenticado"]
Z -.->|"sé quién eres,<br/>pero no puedes"| E2["403 Prohibido"]
La distinción tiene incluso códigos HTTP propios: 401 ("no sé quién eres, autentícate") vs 403 ("sé quién eres, pero esto no es para ti"). El error mortal, el de la factura, es hacer authN y creer que ya hiciste authZ: comprobar que alguien está logueado y asumir que por eso puede hacer lo que pide. Son dos preguntas distintas y las dos hay que hacerlas, siempre en este orden.
8. Contraseñas: por qué se hashean y NO se cifran¶
Aquí hay una decisión de diseño que separa a quien entiende seguridad de quien no. La pregunta: ¿cómo guardas las contraseñas de tus usuarios? La respuesta ingenua es "cifrándolas, para que estén protegidas". Es incorrecta, y entender por qué es entender un principio profundo.
Primero, la distinción:
- Cifrar es reversible: con la clave, recuperas el texto original. Cifras algo que necesitas volver a leer (un mensaje, un dato que enviarás). Pero cifrar implica que existe una clave que descifra, y esa clave vive en algún sitio de tu sistema. Si un atacante roba tu base de datos, es muy probable que también pueda robar la clave —y entonces tiene todas las contraseñas en claro.
- Hashear es irreversible por diseño: una función que convierte la contraseña en una huella de la que no se puede volver atrás. La misma entrada da siempre la misma huella, pero de la huella no sacas la entrada.
Y aquí está la clave que casi nadie interioriza: tú no necesitas saber la contraseña de tu usuario. Nunca. Solo necesitas comprobar, cuando hace login, que la que teclea coincide con la que registró. Y eso lo puedes hacer con hashes: guardas el hash, y en el login hasheas lo que teclea y comparas hashes. Jamás guardas ni recuperas la contraseña real.
Si tu sistema puede mostrarte la contraseña de un usuario, está mal diseñado. La prueba del algodón: cuando un usuario olvida su contraseña, un sistema bien hecho le deja crear una nueva, no le envía la que tenía (porque no la sabe).
Por qué "lento" es bueno: bcrypt y argon2¶
Pero no vale cualquier hash. Los hashes rápidos clásicos (MD5, SHA-256) tienen un problema para esto: son rapidísimos, y eso, contra un atacante que ha robado tu base de datos, es malo. Con una GPU se pueden probar miles de millones de hashes SHA-256 por segundo, así que el atacante prueba diccionarios enteros de contraseñas comunes contra tus hashes y las va rompiendo a velocidad industrial.
La solución es contraintuitiva: usar funciones de hash deliberadamente lentas y costosas, diseñadas para contraseñas: bcrypt y argon2. Que tarden, pongamos, 0,2 segundos por hash. A ti eso te da igual: hasheas una vez por login, ni lo notas. Pero al atacante le cambia la vida: en vez de mil millones por segundo, prueba cinco por segundo. Un ataque que tardaría horas ahora tardaría siglos. La lentitud es la defensa.
// ✅ Symfony hashea con bcrypt/argon2 según tu configuración
$hash = $passwordHasher->hashPassword($usuario, $plainPassword);
// ... se guarda $hash en la BD, NUNCA $plainPassword
// En el login:
if ($passwordHasher->isPasswordValid($usuario, $intentoDeLogin)) {
// coincide
}
La sal (salt): que dos iguales no se vean iguales¶
Falta una pieza. Si dos usuarios tienen la misma contraseña (123456), su hash saldría idéntico, y un atacante con una tabla precalculada de hashes comunes (rainbow table) los rompe de golpe. La sal (salt) es un valor aleatorio y único por usuario que se añade a la contraseña antes de hashear. Así, dos usuarios con la misma contraseña tienen hashes distintos, las tablas precalculadas no sirven, y el atacante tiene que atacar cada contraseña por separado.
La sal ya va incluida (no la gestiones a mano)
bcrypt y argon2 generan la sal automáticamente y la guardan dentro del propio hash resultante (el string que almacenas ya contiene sal + parámetros + huella). Por eso en Symfony no manejas la sal tú: el PasswordHasher la mete y la lee sola. Que no te veas nunca escribiendo lógica de sal a mano: si lo estás haciendo, casi seguro lo estás haciendo mal.
9. Principios que atan todo: mínimo privilegio y defensa en profundidad¶
Dos principios que no son vulnerabilidades concretas sino formas de pensar que reducen el daño de todo lo demás.
Principio de mínimo privilegio: cada parte del sistema (un usuario, un proceso, una cuenta de base de datos, un token de API) debe tener exactamente los permisos que necesita para su tarea, y ni uno más. ¿Por qué? Porque cuando algo se compromete —y algo se comprometerá—, el daño queda acotado a lo que ese algo podía hacer.
Ejemplo concreto que ves en Bases de datos: la cuenta que usa tu app web para leer el catálogo no necesita permiso de DROP TABLE ni de DELETE. Si le das solo SELECT sobre las tablas que consulta, una inyección SQL que se te cuele no puede borrarte nada: el atacante hereda los permisos de esa cuenta, y esa cuenta no puede hacer daño. Lo mismo aplica a las claves de API (dale a un servicio de solo-lectura una clave de solo-lectura), a los permisos de ficheros, a los roles.
Defensa en profundidad: no confíes en una sola barrera. Pon varias capas independientes, de modo que si una falla, la siguiente aún protege. El castillo medieval no tenía solo un muro: tenía foso, muralla, puerta, patio y torre del homenaje.
graph TB
A["Ataque"] --> L1["Capa 1: WAF / rate limiting"]
L1 --> L2["Capa 2: Validación de entrada"]
L2 --> L3["Capa 3: Consultas preparadas"]
L3 --> L4["Capa 4: Mínimo privilegio en BD"]
L4 --> L5["Capa 5: Datos sensibles hasheados/cifrados"]
L5 --> D["Daño real"]
L1 -.->|"si falla"| L2
L2 -.->|"si falla"| L3
La idea es que ninguna capa es infalible, así que apuestas a que no fallen todas a la vez. Una validación de entrada y consultas preparadas y una cuenta de BD con mínimo privilegio: para que un atacante te borre la base de datos tendrían que fallar las tres. Esto es lo contrario de "ya tengo consultas preparadas, no hace falta lo demás". Cada capa asume que las otras pueden caer.
10. Validación, saneamiento, secretos y HTTPS¶
Un bloque de defensas transversales que apuntalan todo lo anterior:
Validar vs sanear la entrada. No son lo mismo:
- Validar es rechazar lo que no cumple las reglas: "¿el email tiene formato de email? ¿la edad es un número entre 0 y 120? ¿el importe es positivo?". Si no cumple, no pasa. Se hace siempre en el servidor —la validación de JavaScript en el navegador es comodidad para el usuario, no seguridad, porque el atacante se salta el navegador.
- Sanear es limpiar/transformar la entrada para neutralizarla en el contexto donde va (escapar HTML antes de pintarlo, por ejemplo).
La regla de oro es la lista blanca sobre la lista negra: define lo que sí permites (más seguro) en vez de intentar enumerar todo lo malo que prohíbes (imposible, siempre se te escapa un caso). "Solo letras, números y guiones" es robusto; "todo menos <script>" lo esquivan de mil formas.
// ✅ Symfony: validación declarativa con constraints
#[Assert\Email]
#[Assert\NotBlank]
private string $email;
#[Assert\Range(min: 0, max: 120)]
private int $edad;
Gestión de secretos: fuera del repositorio, siempre. Claves de API, contraseñas de base de datos, tokens de pasarelas de pago… nunca van en el código ni se suben a git. ¿Por qué tan tajante? Porque el historial de git es para siempre: aunque borres el secreto en un commit posterior, sigue ahí en la historia, y los repos se clonan, se filtran, se hacen públicos por error. Hay bots que escanean GitHub buscando claves subidas y las explotan en segundos.
// ❌ Secreto incrustado en el código — desastre
$stripeKey = 'sk_live_51H8xY2...';
// ✅ Desde variable de entorno / secrets de Symfony
$stripeKey = $_ENV['STRIPE_SECRET_KEY'];
La práctica: variables de entorno (.env.local, fuera de git vía .gitignore), el sistema de secrets de Symfony (cifrados) o un gestor dedicado (Vault, secretos del proveedor cloud). Y si un secreto se te escapó al repo alguna vez: no basta con borrarlo, hay que rotarlo (invalidarlo y generar uno nuevo), porque el viejo ya se considera comprometido.
HTTPS/TLS: por qué no es opcional. Sin HTTPS, los datos viajan en texto plano entre el navegador y tu servidor, y cualquiera en el camino (el wifi de la cafetería, el ISP, un router comprometido) puede leerlos y modificarlos. TLS te da tres cosas: confidencialidad (nadie en medio lee el tráfico), integridad (nadie lo altera sin que se note) y autenticidad (el certificado prueba que hablas con quien crees, no con un impostor). Sin HTTPS, la contraseña que tan bien hasheas en la base de datos viaja en claro por la red hasta llegar ahí, y todo tu esfuerzo no sirve de nada. Conecta con lo que viste en Networking: TLS se asienta sobre TCP y cifra todo lo que va por encima. Hoy no hay excusa —los certificados son gratis (Let's Encrypt)— y HTTPS es el suelo mínimo, no un extra.
Cómo se ve en la práctica¶
Juntemos varias capas en un caso real: el endpoint que actualiza el email de un usuario. Un ejemplo condensa la mentalidad entera.
// ❌ Versión ingenua — cuenta los agujeros
public function cambiarEmail(Request $request): Response
{
$id = $request->query->get('id');
$email = $request->request->get('email');
$sql = "UPDATE usuarios SET email = '$email' WHERE id = $id";
$this->conexion->query($sql);
return new Response('Email actualizado');
}
Agujeros: inyección SQL (concatena $email y $id), control de acceso roto (cambia el email de cualquier id, no del usuario logueado), sin validación ($email puede no ser un email), sin CSRF (petición forzable), y probablemente sin HTTPS el email viaja en claro. Cinco fallos en seis líneas.
// ✅ Versión con defensa en profundidad
public function cambiarEmail(Request $request): Response
{
// Capa 1 · Autorización: solo sobre el usuario logueado, no un id de la URL
$usuario = $this->getUser();
// Capa 2 · CSRF
if (!$this->isCsrfTokenValid('cambiar_email', $request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}
// Capa 3 · Validación (lista blanca: tiene que ser un email de verdad)
$email = $request->request->get('email');
$errores = $this->validator->validate($email, [new Assert\Email(), new Assert\NotBlank()]);
if (count($errores) > 0) {
return new Response('Email inválido', 422);
}
// Capa 4 · Consulta preparada vía Doctrine (nada de concatenar)
$usuario->setEmail($email);
$this->em->flush();
return new Response('Email actualizado');
}
Ninguna de estas capas es redundante: cada una tapa un vector distinto. Y fíjate en el patrón —no hay una línea "hacer seguro"; la seguridad es el conjunto de decisiones correctas repartidas por todo el método. Esa es la naturaleza real de esto.
Lo que sacrificas / errores comunes¶
- Seguridad por oscuridad como única defensa: "nadie adivinará esta URL / este id". Ya lo vimos: el secreto que se descubre mirando no es una defensa. Puede ser una capa más (defensa en profundidad), nunca la única.
- Confiar en la validación del cliente: el JavaScript que valida el formulario es UX, no seguridad. El atacante manda la petición con
curlsaltándose tu navegador entero. Toda validación de seguridad va en el servidor. - Cifrar contraseñas en vez de hashearlas: el error de diseño clásico. Si tu sistema puede recuperar la contraseña, puede filtrarla. Hashea con bcrypt/argon2.
- Rodar tu propia criptografía: "me hago mi función de hash / mi cifrado". No. La criptografía casera está rota siempre; usa librerías probadas y las primitivas del framework. Tu trabajo es usarlas bien, no inventarlas.
- Tratar la seguridad como una fase final: "lo aseguramos antes de salir a producción". No se puede espolvorear seguridad encima de un diseño inseguro; hay que pensarla desde el principio. Es transversal, no un paso.
- Actualizar dependencias "cuando dé tiempo": las vulnerabilidades conocidas de tus librerías son puertas abiertas documentadas públicamente.
composer audity actualizar es de las defensas más baratas y de mayor impacto.
Resumen¶
- La mentalidad lo es todo: no confíes en ninguna entrada externa y pregúntate siempre "¿cómo abusaría alguien de esto?". Cada vulnerabilidad es un sitio donde confiaste en algo de fuera.
- Inyección (SQL, XSS) = un dato externo se convierte en estructura (SQL, HTML/JS). La cura estructural es separar dato de comando: consultas preparadas y escapar la salida. Vigilar no basta; hay que hacerlo imposible por construcción.
- Autenticación (quién eres) y autorización (qué puedes hacer) son dos preguntas distintas y hay que hacer las dos, siempre en el servidor. Olvidar la segunda es el fallo de control de acceso más común.
- Las contraseñas se hashean (irreversible), no se cifran (reversible), con funciones lentas (bcrypt/argon2) y sal. Lento es bueno: frena al atacante. Nunca necesitas saber la contraseña real.
- Mínimo privilegio + defensa en profundidad + secretos fuera del repo + HTTPS. Varias capas independientes, porque ninguna es infalible. La seguridad es un proceso continuo, no una feature que se marca.
Ejercicios socráticos¶
No busques la respuesta fuera. Razónala con el modelo de esta lección. Si te atascas, la pista está en el texto.
- Un compañero dice: "escapo las comillas del input con
str_replaceantes de meterlo en el SQL, así evito la inyección sin usar consultas preparadas". Nombra al menos un problema de este enfoque y explica por qué las consultas preparadas atacan la raíz y su parche solo el síntoma. - Tu app guarda contraseñas con SHA-256 (rápido) y sin sal. Roban la base de datos. Explica dos cosas distintas que hacen que ese atacante lo tenga muchísimo más fácil que si hubieras usado bcrypt con sal, y por qué cada una importa.
- Tienes una inyección SQL en un endpoint. En un escenario la cuenta de BD de la app tiene todos los permisos; en otro solo tiene
SELECTsobre tres tablas. Describe cómo cambia el daño máximo en cada caso. ¿Qué principio ilustra esto? - Un endpoint comprueba que el usuario está logueado antes de mostrar
/pedido/{id}. ¿Es suficiente? Si no, ¿qué comprobación falta, cómo se llama el fallo, y por qué esconder el enlace en la interfaz no lo arregla? - "Tenemos HTTPS y hasheamos las contraseñas, estamos seguros." Da al menos tres razones por las que esta afirmación revela una comprensión incompleta de qué es la seguridad.
Repaso espaciado¶
Pasa estas a Anki (
repasos.md). Formato: pregunta que obligue a razonar, no a reconocer.
- [ ] ¿Cuál es la frase que resume la mentalidad de seguridad, y por qué la inyección SQL y el XSS son la misma idea aplicada en sitios distintos?
- [ ] ¿Por qué una consulta preparada hace imposible la inyección SQL en vez de solo dificultarla? (Responde en términos de estructura vs dato y las dos fases.)
- [ ] Distingue autenticación de autorización con un ejemplo, y di qué código HTTP corresponde a fallar cada una.
- [ ] ¿Por qué las contraseñas se hashean y no se cifran, y por qué una función de hash lenta es mejor que una rápida para esto?
- [ ] Explica mínimo privilegio y defensa en profundidad, y cómo la primera limita el daño de una inyección SQL que se cuele.
Para seguir tirando del hilo¶
- OWASP Top Ten — la lista de referencia de las diez vulnerabilidades web más críticas, actualizada periódicamente. Empieza por aquí; cada entrada tiene ejemplos y defensas.
- OWASP Cheat Sheet Series — fichas prácticas y concretas por tema (SQL Injection, Password Storage, CSRF…). Cuando tengas que implementar una defensa, la ficha te dice cómo hacerlo bien.
- Documentación de seguridad de Symfony (
symfony.com/doc/current/security.html) — authN, authZ, Voters, hashers de contraseña y CSRF, con el código real del framework. - Experimento: monta un login vulnerable a inyección SQL en local (una VM de tu propiedad, nunca un sistema ajeno), rómpelo con
' OR '1'='1, y luego arréglalo con una consulta preparada. Verlo fallar y dejar de fallar con tus ojos asienta esto más que releerlo.