Un servidor web en miniatura: de la petición HTTP al fichero o al PHP
Programa lo que hace Apache o nginx antes de llegar a tu PHP: leer la petición HTTP en bruto, normalizar la ruta y frenar los ataques con ../, proteger los ficheros ocultos, redirigir los directorios, servir el índice, el tipo MIME y el 304, y mandar lo demás al controlador frontal.
- Arquitectura web: servidor web e intérprete
- Peticiones y respuestas HTTP
- Códigos de estado y cabeceras
- Recorrido de directorios (../)
- Caché con If-Modified-Since y 304
- Controlador frontal (front controller)
Enunciado
Cuando el navegador pide una URL, la petición no llega directamente a tu código: primero la recibe el servidor web (Apache, nginx). Él decide si es un fichero estático que puede servir solo (una hoja de estilos, una imagen), si hay que redirigir, si está prohibida o si se la pasa al intérprete de PHP. En los frameworks, todo lo que no es un fichero va a un único index.php, el controlador frontal, que decide qué hacer según la ruta.
La entrada empieza con los ficheros del sitio (ruta bytes fecha_de_modificación), sigue con --- y después vienen las peticiones HTTP tal como llegan por la red: la línea de petición (GET /css/estilos.css HTTP/1.1) y las cabeceras, una por línea, con una línea en blanco entre peticiones. El código de partida ya lee todo y escribe las respuestas: tú programas normalizar y responder.
Qué tiene que hacer el programa
responderdevuelve[estado, cabeceras, cuerpo]comprobando en este orden: versión distinta deHTTP/1.0yHTTP/1.1→ 505;HTTP/1.1sin cabeceraHost→ 400; destino que no empieza por/→ 400; ruta (sin la query) quenormalizarrechaza → 400; algún segmento que empieza por punto → 403.normalizardecodifica la ruta (rawurldecode, así%2e%2etambién es..), quita los segmentos vacíos y., resuelve..y devuelvenullsi sube por encima de la raíz; conserva la barra final si la había (salvo en la raíz, que es/).- Una ruta sin barra final que no es un fichero pero sí un directorio (hay ficheros dentro) → 301 con
Locationigual a la ruta con barra y la query original. Con barra final, se sirve suindex.htmlo, si no hay, suindex.php; si es un directorio sin índice → 403 (no se listan directorios). Lo que no existe va a/index.phpsi existe (controlador frontal) o es 404. - Un
.phpdevuelve 200,Content-Type: text/html; charset=utf-8y el cuerpo[PHP: fichero con REQUEST_URI=destino y método MÉTODO](con cualquier método). Un fichero estático solo admiteGETyHEAD(si no, 405 conAllow: GET, HEAD); si la petición traeIf-Modified-Sincecon una fecha válida igual o posterior a la del fichero → 304 conLast-Modified; si no, 200 conContent-Type(según la extensión, oapplication/octet-stream),Content-LengthyLast-Modified, y el cuerpo[cuerpo: N bytes]salvo enHEAD. - Las fechas de HTTP se escriben con
fechaHttp(ya escrita). Las cabeceras de la petición llegan en un array con el nombre en minúsculas. Las peticiones mal formadas (sin tres partes, método en minúsculas, cabecera sin:) ya las responde el código de partida con 400.
Entrada
Los ficheros del sitio, uno por línea: ruta bytes fecha_ISO.
Una línea ---.
Las peticiones: la línea de petición y sus cabeceras, separadas entre sí por una línea en blanco.
Datos de referencia
| Ruta | Bytes | Modificado |
|---|---|---|
| /index.php | 900 | 2026-10-01 10:00 |
| /inicio.html | 1200 | 2026-10-01 10:00 |
| /css/estilos.css | 3400 | 2026-09-28 08:30 |
| /js/app.js | 5120 | 2026-09-30 17:45 |
| /img/logo.png | 15000 | 2026-09-01 12:00 |
| /docs/index.html | 800 | 2026-09-15 09:00 |
| /docs/manual.pdf | 250000 | 2026-09-15 09:00 |
| /descargas/plantilla.docx | 32000 | 2026-09-20 11:00 |
| /.env | 120 | 2026-09-01 12:00 |
Ejemplos de ejecución
Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.
Estáticos, caché y controlador frontal
Entrada
/index.php 900 2026-10-01T10:00:00Z /inicio.html 1200 2026-10-01T10:00:00Z /css/estilos.css 3400 2026-09-28T08:30:00Z /js/app.js 5120 2026-09-30T17:45:00Z /img/logo.png 15000 2026-09-01T12:00:00Z /docs/index.html 800 2026-09-15T09:00:00Z /docs/manual.pdf 250000 2026-09-15T09:00:00Z /descargas/plantilla.docx 32000 2026-09-20T11:00:00Z /.env 120 2026-09-01T12:00:00Z --- GET /css/estilos.css HTTP/1.1 Host: instituto.example.org GET /css/estilos.css HTTP/1.1 Host: instituto.example.org If-Modified-Since: Mon, 28 Sep 2026 08:30:00 GMT HEAD /img/logo.png HTTP/1.1 Host: instituto.example.org GET /alumnos/7?orden=nombre HTTP/1.1 Host: instituto.example.org POST /api/notas HTTP/1.1 Host: instituto.example.org Content-Type: application/json GET / HTTP/1.1 Host: instituto.example.org
Salida por consola
> GET /css/estilos.css HTTP/1.1 HTTP/1.1 200 OK Content-Type: text/css; charset=utf-8 Content-Length: 3400 Last-Modified: Mon, 28 Sep 2026 08:30:00 GMT [cuerpo: 3400 bytes] > GET /css/estilos.css HTTP/1.1 HTTP/1.1 304 Not Modified Last-Modified: Mon, 28 Sep 2026 08:30:00 GMT > HEAD /img/logo.png HTTP/1.1 HTTP/1.1 200 OK Content-Type: image/png Content-Length: 15000 Last-Modified: Tue, 01 Sep 2026 12:00:00 GMT > GET /alumnos/7?orden=nombre HTTP/1.1 HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 [PHP: /index.php con REQUEST_URI=/alumnos/7?orden=nombre y método GET] > POST /api/notas HTTP/1.1 HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 [PHP: /index.php con REQUEST_URI=/api/notas y método POST] > GET / HTTP/1.1 HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 [PHP: /index.php con REQUEST_URI=/ y método GET] Peticiones: 6 · 2xx: 5 · 3xx: 1 · 4xx: 0 · 5xx: 0
Directorios, ocultos y ataques
Entrada
/index.php 900 2026-10-01T10:00:00Z /inicio.html 1200 2026-10-01T10:00:00Z /css/estilos.css 3400 2026-09-28T08:30:00Z /js/app.js 5120 2026-09-30T17:45:00Z /img/logo.png 15000 2026-09-01T12:00:00Z /docs/index.html 800 2026-09-15T09:00:00Z /docs/manual.pdf 250000 2026-09-15T09:00:00Z /descargas/plantilla.docx 32000 2026-09-20T11:00:00Z /.env 120 2026-09-01T12:00:00Z --- GET /docs HTTP/1.1 Host: instituto.example.org GET /docs/ HTTP/1.1 Host: instituto.example.org GET /descargas/ HTTP/1.1 Host: instituto.example.org GET /docs/../.env HTTP/1.1 Host: instituto.example.org GET /%2e%2e/%2e%2e/etc/passwd HTTP/1.1 Host: instituto.example.org DELETE /docs/manual.pdf HTTP/1.1 Host: instituto.example.org GET /docs/manual.pdf HTTP/1.1 GET /docs/manual.pdf HTTP/2.0 Host: instituto.example.org get /inicio.html HTTP/1.1 Host: instituto.example.org
Salida por consola
> GET /docs HTTP/1.1 HTTP/1.1 301 Moved Permanently Location: /docs/ > GET /docs/ HTTP/1.1 HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Content-Length: 800 Last-Modified: Tue, 15 Sep 2026 09:00:00 GMT [cuerpo: 800 bytes] > GET /descargas/ HTTP/1.1 HTTP/1.1 403 Forbidden > GET /docs/../.env HTTP/1.1 HTTP/1.1 403 Forbidden > GET /%2e%2e/%2e%2e/etc/passwd HTTP/1.1 HTTP/1.1 400 Bad Request > DELETE /docs/manual.pdf HTTP/1.1 HTTP/1.1 405 Method Not Allowed Allow: GET, HEAD > GET /docs/manual.pdf HTTP/1.1 HTTP/1.1 400 Bad Request > GET /docs/manual.pdf HTTP/2.0 HTTP/1.1 505 HTTP Version Not Supported > get /inicio.html HTTP/1.1 HTTP/1.1 400 Bad Request Peticiones: 9 · 2xx: 1 · 3xx: 1 · 4xx: 6 · 5xx: 1
Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. Primero, lo que se rechaza
Ordena las comprobaciones de lo más general a lo más concreto: la versión, el Host, la forma del destino, la ruta. Cada una devuelve en cuanto falla y el resto del código ya puede fiarse de la petición.
2. Normalizar antes de buscar
Una ruta como /docs/../.env o /%2e%2e/etc/passwd tiene que resolverse antes de comprobar nada; si no, un atacante puede leer ficheros fuera del sitio. Recorre los segmentos con una pila.
foreach (explode("/", rawurldecode($ruta)) as $p) {
if ($p === "" || $p === ".") continue;
if ($p === "..") { /* sacar el último, o null si no hay */ }
else $partes[] = $p;
}3. Directorios y controlador frontal
Un directorio es una ruta con ficheros dentro (str_starts_with). Sin la barra final se redirige, para que las rutas relativas del HTML funcionen; con barra, se busca su índice. Si nada encaja, /index.php recibe la petición entera.
4. La caché del navegador
Si el navegador ya tiene el fichero y no ha cambiado desde la fecha que manda en If-Modified-Since, basta con un 304 sin cuerpo: se ahorra toda la descarga. strtotime convierte la fecha de HTTP en un número comparable.
Resuélvelo aquí
El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.
Ejemplo
/index.php 900 2026-10-01T10:00:00Z /inicio.html 1200 2026-10-01T10:00:00Z /css/estilos.css 3400 2026-09-28T08:30:00Z /js/app.js 5120 2026-09-30T17:45:00Z /img/logo.png 15000 2026-09-01T12:00:00Z /docs/index.html 800 2026-09-15T09:00:00Z /docs/manual.pdf 250000 2026-09-15T09:00:00Z /descargas/plantilla.docx 32000 2026-09-20T11:00:00Z /.env 120 2026-09-01T12:00:00Z --- GET /css/estilos.css HTTP/1.1 Host: instituto.example.org GET /css/estilos.css HTTP/1.1 Host: instituto.example.org If-Modified-Since: Mon, 28 Sep 2026 08:30:00 GMT HEAD /img/logo.png HTTP/1.1 Host: instituto.example.org GET /alumnos/7?orden=nombre HTTP/1.1 Host: instituto.example.org POST /api/notas HTTP/1.1 Host: instituto.example.org Content-Type: application/json GET / HTTP/1.1 Host: instituto.example.org
> GET /css/estilos.css HTTP/1.1 HTTP/1.1 200 OK Content-Type: text/css; charset=utf-8 Content-Length: 3400 Last-Modified: Mon, 28 Sep 2026 08:30:00 GMT [cuerpo: 3400 bytes] > GET /css/estilos.css HTTP/1.1 HTTP/1.1 304 Not Modified Last-Modified: Mon, 28 Sep 2026 08:30:00 GMT > HEAD /img/logo.png HTTP/1.1 HTTP/1.1 200 OK Content-Type: image/png Content-Length: 15000 Last-Modified: Tue, 01 Sep 2026 12:00:00 GMT > GET /alumnos/7?orden=nombre HTTP/1.1 HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 [PHP: /index.php con REQUEST_URI=/alumnos/7?orden=nombre y método GET] > POST /api/notas HTTP/1.1 HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 [PHP: /index.php con REQUEST_URI=/api/notas y método POST] > GET / HTTP/1.1 HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 [PHP: /index.php con REQUEST_URI=/ y método GET] Peticiones: 6 · 2xx: 5 · 3xx: 1 · 4xx: 0 · 5xx: 0
Solución explicada
Ver la solución completa
1<?php
2const TIPOS = [
3 "html" => "text/html; charset=utf-8", "css" => "text/css; charset=utf-8", "js" => "text/javascript; charset=utf-8",
4 "json" => "application/json", "png" => "image/png", "jpg" => "image/jpeg", "svg" => "image/svg+xml", "pdf" => "application/pdf",
5];
6const ESTADOS = [
7 200 => "OK", 301 => "Moved Permanently", 304 => "Not Modified", 400 => "Bad Request", 403 => "Forbidden",
8 404 => "Not Found", 405 => "Method Not Allowed", 505 => "HTTP Version Not Supported",
9];
10
11/** Fecha en el formato de HTTP: Mon, 28 Sep 2026 08:30:00 GMT */
12function fechaHttp(int $t): string
13{
14 return gmdate("D, d M Y H:i:s", $t) . " GMT";
15}
16
17/** Decodifica la ruta y resuelve . y ..; null si intenta salir de la raíz del sitio. */
18function normalizar(string $ruta): ?string
19{
20 $partes = [];
21 // se decodifica antes de partir: %2e%2e también es ..
22 foreach (explode("/", rawurldecode($ruta)) as $p) {
23 if ($p === "" || $p === ".") continue;
24 if ($p === "..") {
25 if (!$partes) return null;
26 array_pop($partes);
27 continue;
28 }
29 $partes[] = $p;
30 }
31 return "/" . implode("/", $partes) . ($partes && str_ends_with($ruta, "/") ? "/" : "");
32}
33
34/** [estado, cabeceras, cuerpo] para una petición ya separada en sus partes. */
35function responder(array $ficheros, string $metodo, string $objetivo, string $version, array $cab): array
36{
37 if ($version !== "HTTP/1.0" && $version !== "HTTP/1.1") return [505, [], null];
38 if ($version === "HTTP/1.1" && !isset($cab["host"])) return [400, [], null];
39 if (!str_starts_with($objetivo, "/")) return [400, [], null];
40 [$camino] = explode("?", $objetivo, 2);
41 $consulta = str_contains($objetivo, "?") ? "?" . explode("?", $objetivo, 2)[1] : "";
42 $ruta = normalizar($camino);
43 if ($ruta === null) return [400, [], null];
44 // ficheros ocultos (.env, .git…): nunca se sirven
45 foreach (explode("/", $ruta) as $p) {
46 if (str_starts_with($p, ".")) return [403, [], null];
47 }
48 $esDirectorio = fn(string $r) => (bool) array_filter(array_keys($ficheros), fn($f) => str_starts_with($f, rtrim($r, "/") . "/"));
49 $fichero = null;
50 if (!str_ends_with($ruta, "/") && $ruta !== "/" && !isset($ficheros[$ruta]) && $esDirectorio($ruta)) {
51 return [301, ["Location" => $ruta . "/" . $consulta], null];
52 }
53 if (str_ends_with($ruta, "/")) {
54 foreach (["index.html", "index.php"] as $indice) {
55 if (isset($ficheros[$ruta . $indice])) {
56 $fichero = $ruta . $indice;
57 break;
58 }
59 }
60 if ($fichero === null && $esDirectorio($ruta)) return [403, [], null]; // sin listado de directorios
61 } elseif (isset($ficheros[$ruta])) {
62 $fichero = $ruta;
63 }
64 if ($fichero === null) {
65 // controlador frontal: lo que no existe lo atiende /index.php (FallbackResource o try_files)
66 if (!isset($ficheros["/index.php"])) return [404, [], null];
67 $fichero = "/index.php";
68 }
69 if (str_ends_with($fichero, ".php")) {
70 return [200, ["Content-Type" => TIPOS["html"]], "[PHP: $fichero con REQUEST_URI=$objetivo y método $metodo]"];
71 }
72 if ($metodo !== "GET" && $metodo !== "HEAD") return [405, ["Allow" => "GET, HEAD"], null];
73 [$bytes, $modificado] = $ficheros[$fichero];
74 $ext = strtolower(pathinfo($fichero, PATHINFO_EXTENSION));
75 $desde = isset($cab["if-modified-since"]) ? strtotime($cab["if-modified-since"]) : false;
76 if ($desde !== false && $modificado <= $desde) return [304, ["Last-Modified" => fechaHttp($modificado)], null];
77 $cabeceras = ["Content-Type" => TIPOS[$ext] ?? "application/octet-stream", "Content-Length" => $bytes, "Last-Modified" => fechaHttp($modificado)];
78 return [200, $cabeceras, $metodo === "HEAD" ? null : "[cuerpo: $bytes bytes]"];
79}
80
81// ── Lectura: los ficheros del sitio, ---, y las peticiones separadas por una línea en blanco
82$lineas = explode("\n", str_replace("\r", "", stream_get_contents(STDIN)));
83$ficheros = [];
84$i = 0;
85for (; $i < count($lineas) && trim($lineas[$i]) !== "---"; $i++) {
86 if (trim($lineas[$i]) === "") continue;
87 [$ruta, $bytes, $fecha] = preg_split('/\s+/', trim($lineas[$i]));
88 $ficheros[$ruta] = [(int) $bytes, strtotime($fecha)];
89}
90$bloques = array_filter(preg_split('/\n\s*\n/', implode("\n", array_slice($lineas, $i + 1))), fn($b) => trim($b) !== "");
91$cuenta = ["2xx" => 0, "3xx" => 0, "4xx" => 0, "5xx" => 0];
92foreach ($bloques as $bloque) {
93 $filas = explode("\n", trim($bloque));
94 $primera = array_shift($filas);
95 echo "> $primera\n";
96 $partes = explode(" ", $primera);
97 $cab = [];
98 $malFormada = count($partes) !== 3 || !preg_match('/^[A-Z]+$/', $partes[0]);
99 foreach ($filas as $f) {
100 if (!preg_match('/^([\w-]+):\s*(.*)$/', $f, $m)) $malFormada = true;
101 else $cab[strtolower($m[1])] = trim($m[2]);
102 }
103 [$estado, $cabeceras, $cuerpo] = $malFormada ? [400, [], null] : responder($ficheros, ...[$partes[0], $partes[1], $partes[2], $cab]);
104 echo "HTTP/1.1 $estado " . ESTADOS[$estado] . "\n";
105 foreach ($cabeceras as $nombre => $valor) echo "$nombre: $valor\n";
106 if ($cuerpo !== null) echo "$cuerpo\n";
107 $cuenta[intdiv($estado, 100) . "xx"]++;
108}
109echo "Peticiones: " . count($bloques) . " · " . implode(" · ", array_map(fn($k, $v) => "$k: $v", array_keys($cuenta), $cuenta)) . "\n";Así se reparte el trabajo en una arquitectura web: el servidor web sirve muy rápido lo estático y aplica las reglas de seguridad y caché; el intérprete de PHP (con PHP-FPM o como módulo) solo se arranca para lo dinámico. Por eso las imágenes y las hojas de estilo nunca deberían pasar por PHP.
El ataque de recorrido de directorios (path traversal) es antiguo y sigue apareciendo: si el servidor busca el fichero sin normalizar la ruta, /../../etc/passwd sale del sitio. Normalizar después de decodificar evita el truco de escribir los puntos como %2e.
Bloquear los ficheros que empiezan por punto protege .env (con las contraseñas de la base de datos), .git o .htaccess. Es una de las primeras reglas de cualquier configuración de producción.
La regla del controlador frontal es la que escriben Laravel o Symfony en su configuración: en Apache FallbackResource /index.php y en nginx try_files $uri $uri/ /index.php?$query_string.
Para ir más allá
- Añade
ETag(por ejemplo, tamaño y fecha en hexadecimal) y responde 304 aIf-None-Match. - Responde las peticiones
Range: bytes=0-1023con 206 Partial Content, como para los vídeos. - Añade hosts virtuales: dos sitios distintos según la cabecera
Host.