PHP en la web: formularios, sesiones y bases de datos

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:

VariableQué contiene
$_GETlos parámetros de la dirección, lo que va tras el ?
$_POSTlos campos de un formulario enviado con method="post"
$_COOKIElas cookies que envía el navegador
$_FILESlos archivos subidos
$_SERVERdatos de la petición y del servidor, como REQUEST_METHOD
$_SESSIONlos 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:

Una petición trae datos en GET y POST; primero se validan con filter_var y límites, luego se procesan con PDO usando marcadores y, al escribirlos en la página, se escapan con htmlspecialcharsValidar a la entrada, escapar a la salidapetición$_GET, $_POSTvalidarfilter_var, límitesprocesarPDO con marcadoresescaparhtmlspecialcharsLo que llega de fuera no es de fiar: se comprueba al entrar,nunca se mezcla con el código SQL y se escapa al escribirlo en la página.

Las tres costumbres de esta lección: comprobar los datos al recibirlos, tratarlos con consultas preparadas y escaparlos al mostrarlos. Esquema: decodigo.com.

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 &lt;b&gt;. 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_var valida el correo y el entero con su rango en una sola llamada.
  • La función e() escapa con ENT_QUOTES (también las comillas simples, necesario dentro de atributos como value) 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ónQué hacePor qué
cookie_httponlyel JavaScript de la página no puede leer la cookiesi se cuela un script (XSS), no se lleva la sesión
cookie_securela cookie solo viaja por HTTPSnadie puede leerla en una red wifi abierta
cookie_samesiteno se envía en peticiones que empiezan en otra webLax frena la mayoría de los CSRF
use_strict_modePHP no acepta identificadores de sesión que no creó élimpide 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";

Probar en el editor →

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: prepare recibe la consulta con marcadores (:nombre o ?) en vez de los datos, y execute le 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 PDOException cuando algo falla, en vez de devolver false en silencio.
  • Transacciones: entre beginTransaction y commit, o se hace todo o no se hace nada. La segunda orden falló (el precio es obligatorio), rollBack deshizo 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.

Más 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.

Lo que tu servidor cuenta de sí mismo. Las respuestas de nuestro servidor de prueba incluían además esta cabecera, que hemos quitado de las salidas de arriba: 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]));

Probar en el editor →

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.

Más en Los fallos de seguridad típicos de PHP.

Resumen

ParaEscribeRecuerda
Leer un campotrim((string) ($_POST['x'] ?? ''))puede faltar o venir como array
Validarfilter_var($x, FILTER_VALIDATE_INT, ...)en el servidor, siempre
Escribir en la páginahtmlspecialchars($x, ENT_QUOTES)todo, sin excepciones
Redirigirheader('Location: ...'); exit;antes de escribir nada
Sesión segurasession_start([... httponly, secure, samesite ...])session_regenerate_id al entrar
Consultar la base$pdo->prepare(...)->execute([...])nunca datos dentro del texto SQL
Guardar contraseñaspassword_hash y password_verifynunca md5 ni sha1
Formularios que cambian algotoken CSRF con hash_equalsy 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');
}

Probar en el editor →

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, '%'));

Probar en el editor →

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.