Apuntes DAM
Volver al inicio

Un servidor web en miniatura: de la petición HTTP al fichero o al PHP

Ejercicio de PHPDifícilUnos 80 minutos

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

  1. responder devuelve [estado, cabeceras, cuerpo] comprobando en este orden: versión distinta de HTTP/1.0 y HTTP/1.1 → 505; HTTP/1.1 sin cabecera Host → 400; destino que no empieza por / → 400; ruta (sin la query) que normalizar rechaza → 400; algún segmento que empieza por punto → 403.
  2. normalizar decodifica la ruta (rawurldecode, así %2e%2e también es ..), quita los segmentos vacíos y ., resuelve .. y devuelve null si sube por encima de la raíz; conserva la barra final si la había (salvo en la raíz, que es /).
  3. Una ruta sin barra final que no es un fichero pero sí un directorio (hay ficheros dentro) → 301 con Location igual a la ruta con barra y la query original. Con barra final, se sirve su index.html o, si no hay, su index.php; si es un directorio sin índice → 403 (no se listan directorios). Lo que no existe va a /index.php si existe (controlador frontal) o es 404.
  4. Un .php devuelve 200, Content-Type: text/html; charset=utf-8 y el cuerpo [PHP: fichero con REQUEST_URI=destino y método MÉTODO] (con cualquier método). Un fichero estático solo admite GET y HEAD (si no, 405 con Allow: GET, HEAD); si la petición trae If-Modified-Since con una fecha válida igual o posterior a la del fichero → 304 con Last-Modified; si no, 200 con Content-Type (según la extensión, o application/octet-stream), Content-Length y Last-Modified, y el cuerpo [cuerpo: N bytes] salvo en HEAD.
  5. 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

Ficheros del sitio de los ejemplos
RutaBytesModificado
/index.php9002026-10-01 10:00
/inicio.html12002026-10-01 10:00
/css/estilos.css34002026-09-28 08:30
/js/app.js51202026-09-30 17:45
/img/logo.png150002026-09-01 12:00
/docs/index.html8002026-09-15 09:00
/docs/manual.pdf2500002026-09-15 09:00
/descargas/plantilla.docx320002026-09-20 11:00
/.env1202026-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.

php
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.

🐘PHPUn servidor web en miniatura: de la petición HTTP al fichero o al PHPDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
/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 esperada
> 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
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
php
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 a If-None-Match.
  • Responde las peticiones Range: bytes=0-1023 con 206 Partial Content, como para los vídeos.
  • Añade hosts virtuales: dos sitios distintos según la cabecera Host.

Dónde se explica