Lección 7 de 8. Aquí PHP hace aquello para lo que nació: atender peticiones web. Formularios, sesiones, bases de datos y respuestas JSON, y con ellos las costumbres de seguridad que no son opcionales: validar lo que llega, escapar lo que se muestra, consultar con marcadores, guardar bien las contraseñas y proteger los formularios.
Lo que llega en cada petición
Cada vez que un navegador pide una página, PHP rellena unas variables especiales, las superglobales, con lo que llega en la petición:
| Variable | Qué contiene |
|---|---|
$_GET | los parámetros de la dirección, lo que va tras el ? |
$_POST | los campos de un formulario enviado con method="post" |
$_COOKIE | las cookies que envía el navegador |
$_FILES | los archivos subidos |
$_SERVER | datos de la petición y del servidor, como REQUEST_METHOD |
$_SESSION | los datos de la sesión del visitante, guardados en el servidor |
Todo lo que viene de las cinco primeras lo controla quien hace la petición, no tu formulario: puede faltar, traer otro tipo de dato o cualquier texto. Por eso toda aplicación web sigue el mismo esquema:
Un formulario con validación
Este archivo muestra un formulario y, cuando se envía, comprueba cada campo. Si hay errores, vuelve a mostrarlo con lo que escribió el usuario, para que no tenga que repetirlo:
<?php
declare(strict_types=1);
$errores = [];
$datos = ['nombre' => '', 'email' => '', 'edad' => ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$datos['nombre'] = trim((string) ($_POST['nombre'] ?? ''));
$datos['email'] = trim((string) ($_POST['email'] ?? ''));
$datos['edad'] = trim((string) ($_POST['edad'] ?? ''));
if ($datos['nombre'] === '' || mb_strlen($datos['nombre']) > 50) {
$errores[] = 'El nombre es obligatorio y como mucho de 50 letras.';
}
if (filter_var($datos['email'], FILTER_VALIDATE_EMAIL) === false) {
$errores[] = 'El correo no es válido.';
}
$edad = filter_var($datos['edad'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 130]]);
if ($edad === false) {
$errores[] = 'La edad debe ser un número entre 0 y 130.';
}
if ($errores === []) {
echo '<p>Gracias, ', htmlspecialchars($datos['nombre']), '. Te escribiremos a ', htmlspecialchars($datos['email']), ".</p>\n";
exit;
}
}
function e(string $s): string
{
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<?php foreach ($errores as $error): ?>
<p class="error"><?= e($error) ?></p>
<?php endforeach; ?>
<form method="post">
<input name="nombre" value="<?= e($datos['nombre']) ?>">
<input name="email" value="<?= e($datos['email']) ?>">
<input name="edad" value="<?= e($datos['edad']) ?>">
<button>Enviar</button>
</form>Así responde si se envía con el nombre vacío, un correo incompleto y una edad imposible:
respuesta<p class="error">El nombre es obligatorio y como mucho de 50 letras.</p> <p class="error">El correo no es válido.</p> <p class="error">La edad debe ser un número entre 0 y 130.</p> <form method="post"> <input name="nombre" value=""> <input name="email" value="ana@"> <input name="edad" value="200"> <button>Enviar</button> </form>
Y con datos correctos. El nombre lleva una etiqueta HTML, que llega escapada y se muestra como texto:
respuesta<p>Gracias, Ana <b>. Te escribiremos a ana@ejemplo.com.</p>
Detalles que importan:
$_SERVER['REQUEST_METHOD']distingue si se está mostrando el formulario (GET) o recibiéndolo (POST).- Cada campo se lee con
?? '', por si no llega, y con(string), porque un campo puede llegar como array si alguien lo envía así. filter_varvalida el correo y el entero con su rango en una sola llamada.- La función
e()escapa conENT_QUOTES(también las comillas simples, necesario dentro de atributos comovalue) y fija UTF-8. Úsala para todo lo que escribas en el HTML. - La validación del navegador (
required,type="email") mejora la experiencia, pero no protege nada: cualquiera puede enviar la petición sin pasar por tu formulario. La que cuenta es la del servidor.
El error que te ahorra: enviar una cabecera después de escribir
<?php
declare(strict_types=1);
echo "Guardando...\n";
header('Location: /gracias.php');respuestaHTTP/1.1 200 OK Content-type: text/html; charset=UTF-8 Guardando... <br /> <b>Warning</b>: Cannot modify header information - headers already sent by (output started at /tmp/web/redirigir_mal.php:4) in <b>/tmp/web/redirigir_mal.php</b> on line <b>5</b><br />
Las cabeceras HTTP, como la redirección Location o una cookie, viajan antes que el contenido. En cuanto PHP escribe el primer byte de la página, las cabeceras ya se han enviado, y header() ya no puede añadir nada: la redirección no ocurre y PHP avisa de en qué línea empezó la salida. Pasa también con un espacio o una línea en blanco antes de <?php o después de un ?> final, y por eso en la lección 1 recomendamos no cerrar los archivos que solo tienen PHP. La regla: primero decide y envía las cabeceras, después escribe.
Sesiones y cookies
Como vimos en la lección 1, PHP olvida todo entre una petición y otra. Las sesiones lo resuelven: PHP guarda los datos en el servidor y le da al navegador una cookie con un identificador aleatorio para reconocerlo en la siguiente visita.
<?php
declare(strict_types=1);
session_start([
'cookie_httponly' => true,
'cookie_secure' => true,
'cookie_samesite' => 'Lax',
'use_strict_mode' => true,
]);
$_SESSION['visitas'] = ($_SESSION['visitas'] ?? 0) + 1;
echo "Visitas en esta sesión: {$_SESSION['visitas']}\n";La primera respuesta incluye la cookie de sesión, con sus opciones de seguridad:
respuestaHTTP/1.1 200 OK Set-Cookie: PHPSESSID=…; path=/; secure; HttpOnly; SameSite=Lax Visitas en esta sesión: 1
Y en las dos siguientes visitas, con esa cookie, el contador sigue:
respuestaVisitas en esta sesión: 2 Visitas en esta sesión: 3
| Opción | Qué hace | Por qué |
|---|---|---|
cookie_httponly | el JavaScript de la página no puede leer la cookie | si se cuela un script (XSS), no se lleva la sesión |
cookie_secure | la cookie solo viaja por HTTPS | nadie puede leerla en una red wifi abierta |
cookie_samesite | no se envía en peticiones que empiezan en otra web | Lax frena la mayoría de los CSRF |
use_strict_mode | PHP no acepta identificadores de sesión que no creó él | impide que alguien te imponga uno que conoce |
Una costumbre más: al iniciar sesión un usuario, llama a session_regenerate_id(true), que cambia el identificador y borra el anterior. Así, un identificador que alguien hubiera conocido antes del inicio de sesión deja de servir. Lo haremos en el proyecto.
Bases de datos con PDO
PDO es la forma estándar de hablar con bases de datos en PHP: la misma interfaz sirve para SQLite, MySQL, PostgreSQL y otras. Usaremos SQLite, que guarda la base entera en un archivo y viene incluida en PHP. Este programa se ejecuta en la terminal con una base en memoria:
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite::memory:', options: [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('CREATE TABLE productos (
id INTEGER PRIMARY KEY,
nombre TEXT NOT NULL,
precio_centimos INTEGER NOT NULL
)');
$alta = $pdo->prepare('INSERT INTO productos (nombre, precio_centimos) VALUES (:nombre, :precio)');
foreach ([['Café', 1250], ['Taza', 800], ['Té verde', 950]] as [$nombre, $precio]) {
$alta->execute(['nombre' => $nombre, 'precio' => $precio]);
}
echo "Último id: ", $pdo->lastInsertId(), "\n";
$baratos = $pdo->prepare('SELECT nombre, precio_centimos FROM productos WHERE precio_centimos < :maximo ORDER BY nombre');
$baratos->execute(['maximo' => 1000]);
foreach ($baratos as $fila) {
printf("%s %6.2f\n", mb_str_pad($fila['nombre'], 10), $fila['precio_centimos'] / 100);
}
try {
$pdo->beginTransaction();
$pdo->prepare('UPDATE productos SET precio_centimos = precio_centimos - 100')->execute();
$pdo->prepare('INSERT INTO productos (nombre) VALUES (?)')->execute(['Sin precio']);
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
echo "Deshecho: ", $e->getMessage(), "\n";
}
echo "El café sigue costando ", $pdo->query("SELECT precio_centimos FROM productos WHERE nombre = 'Café'")->fetchColumn(), "\n";salidaÚltimo id: 3 Taza 8.00 Té verde 9.50 Deshecho: SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: productos.precio_centimos El café sigue costando 1250
Tres cosas que conviene llevarse:
- Consultas preparadas:
preparerecibe la consulta con marcadores (:nombreo?) en vez de los datos, yexecutele pasa los datos aparte. La base de datos recibe por un lado el código SQL y por otro los valores, y nunca los confunde. - Errores como excepciones: desde PHP 8, PDO lanza una
PDOExceptioncuando algo falla, en vez de devolverfalseen silencio. - Transacciones: entre
beginTransactionycommit, o se hace todo o no se hace nada. La segunda orden falló (el precio es obligatorio),rollBackdeshizo también la rebaja, y el café sigue costando lo mismo.
Ojo con la seguridad: meter datos en el texto de una consulta
La inyección SQL ocurre cuando un dato que viene de fuera se pega dentro del texto de la consulta, por ejemplo con comillas y un punto. Entonces lo que escribe el visitante deja de ser un dato y pasa a formar parte de la orden SQL, y con ello puede cambiar lo que hace: leer tablas que no debería, saltarse una comprobación de contraseña o borrar datos. Lleva más de veinte años entre los fallos más graves de la web.
La defensa es la que acabas de ver: consultas preparadas siempre, sin excepciones «porque este dato es seguro». Esta es la búsqueda de usuarios de nuestra web de prueba:
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite:' . __DIR__ . '/tienda.sqlite');
$nombre = $_GET['nombre'] ?? '';
$consulta = $pdo->prepare('SELECT nombre, email FROM usuarios WHERE nombre = :nombre');
$consulta->execute(['nombre' => $nombre]);
foreach ($consulta as $fila) {
echo $fila['nombre'], ' ', $fila['email'], "\n";
}
echo "(fin de los resultados)\n";respuesta a buscar.php?nombre=luisluis luis@ejemplo.com (fin de los resultados)
Escriba lo que escriba el visitante, la base de datos solo lo usará como el nombre que hay que buscar. Los marcadores sirven para valores, no para nombres de tablas o columnas: si necesitas ordenar por una columna que elige el usuario, compárala con una lista de columnas permitidas. Cómo se aprovecha este fallo y cómo encontrarlo en código existente lo contamos en Los fallos de seguridad típicos de PHP.
Responder con JSON
Muchas páginas PHP no devuelven HTML sino datos, para una aplicación móvil o para el JavaScript de otra página. La diferencia está en la cabecera Content-Type y en el código de estado HTTP:
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
$pdo = new PDO('sqlite:' . __DIR__ . '/tienda.sqlite');
$id = filter_var($_GET['id'] ?? '', FILTER_VALIDATE_INT);
if ($id === false) {
http_response_code(400);
echo json_encode(['error' => 'El id debe ser un número entero']);
exit;
}
$consulta = $pdo->prepare('SELECT id, nombre, email FROM usuarios WHERE id = ?');
$consulta->execute([$id]);
$usuario = $consulta->fetch(PDO::FETCH_ASSOC);
if ($usuario === false) {
http_response_code(404);
echo json_encode(['error' => 'No existe ese usuario']);
exit;
}
echo json_encode($usuario, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);GET api.php?id=2HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"id":2,"nombre":"luis","email":"luis@ejemplo.com"}GET api.php?id=99HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
{"error":"No existe ese usuario"}GET api.php?id=dosHTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
{"error":"El id debe ser un n\u00famero entero"}http_response_code cambia el código de estado: 404 si no existe, 400 si la petición está mal formada. json_encode convierte el array en JSON; sin JSON_UNESCAPED_UNICODE, las letras con tilde se escriben como \u00fa, que es válido pero menos legible, como se ve en el último mensaje.
X-Powered-By: PHP/8.5.12. Anunciar la versión exacta de PHP facilita que alguien busque fallos conocidos de esa versión. En producción, pon expose_php = Off en php.ini.Contraseñas: password_hash
Las contraseñas nunca se guardan tal cual, ni con md5 o sha1, que son rapidísimos de probar por fuerza bruta. PHP trae dos funciones que hacen todo bien por ti:
<?php
declare(strict_types=1);
$hash = password_hash('caballo-correcto-bateria-grapa', PASSWORD_DEFAULT);
echo $hash, "\n";
echo strlen($hash), " caracteres\n";
var_dump(password_verify('caballo-correcto-bateria-grapa', $hash));
var_dump(password_verify('Caballo-correcto-bateria-grapa', $hash));
$otro = password_hash('caballo-correcto-bateria-grapa', PASSWORD_DEFAULT);
var_dump($hash === $otro);
var_dump(password_needs_rehash($hash, PASSWORD_DEFAULT));
var_dump(password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => 13]));salida$2y$12$Hrjfu0M6hb36BwYiiLMOAepc0MEOPIlhzGzZuJVrCecoKvP2HHlr6 60 caracteres bool(true) bool(false) bool(false) bool(false) bool(true)
password_hash genera un resumen lento a propósito (con bcrypt, el algoritmo por omisión), con una sal aleatoria incluida: por eso dos resúmenes de la misma contraseña son distintos, y el texto guarda dentro el algoritmo y el coste ($2y$12$). password_verify lo comprueba en un tiempo constante. Y password_needs_rehash te dice, al iniciar sesión, si conviene recalcular el resumen porque has subido el coste o PHP ha cambiado de algoritmo. Guarda el resultado en una columna de al menos 255 caracteres, por si el algoritmo cambia en el futuro.
Ojo con la seguridad: peticiones que el usuario no quiso hacer
Mientras tienes tu sesión abierta en una web, otra página que visites puede intentar que tu navegador envíe un formulario a la primera, y el navegador adjuntaría tu cookie de sesión. Se llama CSRF (cross-site request forgery). SameSite=Lax frena la mayoría de los casos, pero la defensa completa es un token: un valor aleatorio que se guarda en la sesión, va escondido en cada formulario y se comprueba al recibirlo. Otra web no puede leerlo, así que no puede incluirlo:
<?php
declare(strict_types=1);
session_start(['cookie_httponly' => true, 'cookie_samesite' => 'Lax', 'use_strict_mode' => true]);
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!hash_equals($_SESSION['csrf'], (string) ($_POST['csrf'] ?? ''))) {
http_response_code(403);
exit("Petición rechazada: falta el token o no coincide\n");
}
exit('Correo cambiado a ' . htmlspecialchars((string) ($_POST['email'] ?? '')) . "\n");
}
?>
<form method="post">
<input type="hidden" name="csrf" value="<?= $_SESSION['csrf'] ?>">
<input name="email">
<button>Cambiar correo</button>
</form>el formulario que recibe el navegador (token acortado)<form method="post"> <input type="hidden" name="csrf" value="…"> <input name="email"> <button>Cambiar correo</button> </form>
envío sin tokenPetición rechazada: falta el token o no coincide código HTTP: 403
envío con un token inventadoPetición rechazada: falta el token o no coincide código HTTP: 403
envío desde el formulario, con el token correctoCorreo cambiado a ana@ejemplo.com código HTTP: 200
El token sale de random_bytes, que es aleatorio de verdad, y se compara con hash_equals, como vimos en la lección 2. Los frameworks lo hacen solo en todos los formularios; si escribes PHP sin uno, tienes que hacerlo tú en cada POST que cambie algo.
Resumen
| Para | Escribe | Recuerda |
|---|---|---|
| Leer un campo | trim((string) ($_POST['x'] ?? '')) | puede faltar o venir como array |
| Validar | filter_var($x, FILTER_VALIDATE_INT, ...) | en el servidor, siempre |
| Escribir en la página | htmlspecialchars($x, ENT_QUOTES) | todo, sin excepciones |
| Redirigir | header('Location: ...'); exit; | antes de escribir nada |
| Sesión segura | session_start([... httponly, secure, samesite ...]) | session_regenerate_id al entrar |
| Consultar la base | $pdo->prepare(...)->execute([...]) | nunca datos dentro del texto SQL |
| Guardar contraseñas | password_hash y password_verify | nunca md5 ni sha1 |
| Formularios que cambian algo | token CSRF con hash_equals | y SameSite en la cookie |
Retos
Reto 1. Añade al formulario de esta lección un campo «web» opcional: si llega vacío se acepta, y si no, tiene que ser una URL que empiece por https://.
Ver la solución
<?php
declare(strict_types=1);
function webValida(string $web): bool
{
if ($web === '') {
return true;
}
return filter_var($web, FILTER_VALIDATE_URL) !== false && str_starts_with($web, 'https://');
}
foreach (['', 'https://decodigo.com', 'http://ejemplo.com', 'javascript://x%0Aalert(1)', 'no es una web'] as $w) {
printf("%s filter_var: %s webValida: %s\n",
mb_str_pad("«{$w}»", 28),
mb_str_pad(filter_var($w, FILTER_VALIDATE_URL) === false ? 'no' : 'sí', 4),
webValida($w) ? 'aceptada' : 'rechazada');
}salida«» filter_var: no webValida: aceptada «https://decodigo.com» filter_var: sí webValida: aceptada «http://ejemplo.com» filter_var: sí webValida: rechazada «javascript://x%0Aalert(1)» filter_var: sí webValida: rechazada «no es una web» filter_var: no webValida: rechazada
FILTER_VALIDATE_URL solo comprueba que el texto tenga forma de URL, con cualquier esquema: da por buena una dirección javascript:// que, puesta en un enlace, ejecutaría código al pulsarlo. Por eso hay que comprobar además el esquema. Las columnas se alinean con mb_str_pad (PHP 8.3), porque printf cuenta bytes y descuadra las palabras con tilde.
Reto 2. Escribe con PDO y SQLite en memoria una función buscarProductos(PDO $pdo, string $texto): array que devuelva los productos cuyo nombre contiene el texto, sin concatenar nada en el SQL.
Ver la solución
<?php
declare(strict_types=1);
function buscarProductos(PDO $pdo, string $texto): array
{
$consulta = $pdo->prepare("SELECT nombre FROM productos WHERE nombre LIKE :patron ESCAPE '\' ORDER BY nombre");
$patron = '%' . addcslashes($texto, '%_\\') . '%';
$consulta->execute(['patron' => $patron]);
return $consulta->fetchAll(PDO::FETCH_COLUMN);
}
$pdo = new PDO('sqlite::memory:');
$pdo->exec('CREATE TABLE productos (nombre TEXT)');
$alta = $pdo->prepare('INSERT INTO productos VALUES (?)');
foreach (['Café', 'Café molido', 'Té', 'Descuento 50%'] as $n) {
$alta->execute([$n]);
}
print_r(buscarProductos($pdo, 'Caf'));
print_r(buscarProductos($pdo, '%'));salidaArray
(
[0] => Café
[1] => Café molido
)
Array
(
[0] => Descuento 50%
)El patrón con los % se construye en PHP y viaja como dato. addcslashes escapa los comodines de LIKE (% y _) que escriba el usuario, para que buscar «%» encuentre el símbolo y no todos los productos.
Reto 3. Un compañero propone guardar las contraseñas con hash('sha256', $clave) «porque SHA-256 es seguro». ¿Qué le contestas?
Ver la solución
Que SHA-256 es un buen resumen para comprobar archivos, pero demasiado rápido para contraseñas: una tarjeta gráfica prueba miles de millones por segundo, y sin sal, dos usuarios con la misma contraseña tienen el mismo resumen. password_hash es lento a propósito, añade una sal distinta para cada contraseña y permite subir el coste con los años. No hay motivo para hacerlo a mano.
Todo el código de esta lección se ejecutó con PHP 8.5.12 en un contenedor limpio; las páginas web, con el servidor integrado de PHP dentro de ese contenedor, sin acceso a la red, y peticiones hechas con curl. Las respuestas están copiadas de esa ejecución.
