Volver al laboratorio ~/iphobiuss/casos/api-acceso

Una sesión no autoriza todos los objetos

De una petición permitida a una escritura ajena: reproducir, corregir y comprobar la frontera de acceso.

En este caso
Problema
Una sesión válida puede leer y modificar el informe de otra identidad cambiando su ID.
Causa raíz
La API comprueba que el objeto existe, pero no que pertenezca a la identidad autenticada.
Decisión
Comprobar el propietario almacenado antes de leer o escribir; conservar el acceso legítimo.
Evidencia
18 escenarios, dos modos y 48 intercambios HTTP reales. Cada respuesta se compara con su expectativa.
Resultado
El parche rechaza los accesos ajenos ensayados. La lectura posterior confirma que las escrituras rechazadas no alteran el objeto.

Alcance y criterio de éxito

Esta aplicación fue construida para demostrar y comprobar un fallo de autorización por objeto. El modo vulnerable contiene el defecto de forma deliberada; este caso no describe un incidente ni un encargo de un cliente.

Alice posee el informe 101 y Bruno, el 202. Cada uno tiene un token sintético. La API admite GET /reports/{id} y PATCH /reports/{id}. La autenticación es la misma en los dos modos; cambia la política que autoriza el objeto.

  • Una identidad válida puede leer y modificar su propio título.
  • La misma identidad no puede leer ni modificar el informe ajeno.
  • Después de un rechazo, el propietario debe recuperar el objeto sin cambios.
  • El control debe funcionar en ambos sentidos: Alice hacia Bruno y Bruno hacia Alice.

Cada escenario inicia un servidor nuevo en 127.0.0.1 y un cliente envía HTTP real. Los datos se reinician entre escenarios y se conservan entre los pasos de cada uno. No hay un destino externo ni respuestas simuladas.

Compara una identidad, un objeto y una operación

Explorador de evidencia registrada. Cada combinación muestra intercambios HTTP ya ejecutados en el laboratorio local y publicados en el JSON. Cambiar los controles no envía peticiones a la API ni ejecuta una nueva prueba. Los datos son sintéticos.

El informe 101 pertenece a Alice; el 202, a Bruno. GET lee el informe; PATCH intenta cambiar su título.

Elegir una comparación

Sin JavaScript puedes leer la comparación inicial y abrir todos los escenarios en la sección de retest.

Alice intenta leer el informe de Bruno

GET /reports/202 con el token sintético de Alice.

Vulnerable

Respuesta a la solicitud: HTTP 200

Cuerpo JSON registrado
{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Informe sintético B"}

Corregido

Respuesta a la solicitud: HTTP 404

Cuerpo JSON registrado
{"error":"not_found"}

El modo vulnerable expone el objeto ajeno; el corregido no devuelve su contenido.

Abrir peticiones, cabeceras y cuerpos completos

Hipótesis y controles que las delimitan

Las siguientes conclusiones se apoyan en los escenarios publicados. Una hipótesis descartada aquí no equivale a descartar esa clase de fallo en cualquier aplicación.

Confirmada
Una sesión válida basta para acceder a un informe ajeno. Las lecturas cruzadas devuelven el marcador del otro propietario y las escrituras cruzadas cambian su título en modo vulnerable. Lectura Alice → Bruno y escritura Bruno → Alice.
Descartada aquí
El fallo depende de entrar sin autenticación. Las solicitudes sin token y con token no reconocido reciben 401 en ambos modos. Los accesos cruzados usan una sesión válida; comprobar la sesión no resuelve la autorización del objeto.
Descartada aquí
Basta con validar los campos del JSON. El JSON inválido y el intento de cambiar owner ya se rechazan en ambos modos. Aun así, un PATCH con un título válido modifica un informe ajeno en modo vulnerable.
Control negativo
Una cabecera del cliente no debe elegir la identidad autorizada. Enviar X-Lab-User: bruno con el token de Alice no abre el informe de Bruno después del parche. Ver petición y rechazo.
Conclusión acotada
Ajeno e inexistente deben responder igual. El estado y el cuerpo del informe ajeno coinciden con los del identificador inexistente después del parche. No se midieron diferencias de tiempo ni canales laterales.

Del fallo a su impacto, paso a paso

  1. 1. Establecer dos controles legítimos. Alice lee 101 y Bruno lee 202. Ambos reciben 200 con marcadores distintos. Así se identifica a qué objeto pertenece cada respuesta.
  2. 2. Mantener la sesión y cambiar solo el identificador. Con el token de Alice, GET /reports/202 devuelve el informe de Bruno en el modo vulnerable. La misma petición recibe 404 en el corregido. Comparar los dos modos.
  3. 3. Invertir las identidades. Bruno solicita /reports/101. También obtiene el informe ajeno antes del parche y un rechazo después. Comprobar la dirección opuesta.
  4. 4. Comprobar el impacto de escritura. Alice envía un PATCH válido al informe 202. Bruno lo lee después con su propio token: en modo vulnerable el título cambió; en el corregido conserva su valor inicial. Ver PATCH y lectura posterior.
  5. 5. Conservar la operación permitida. Alice actualiza su propio informe y vuelve a leerlo. El título nuevo persiste en ambos modos: el parche no consiste en desactivar la escritura. Ver el control positivo.

El impacto demostrado es una lectura y una modificación no autorizadas entre dos propietarios del laboratorio. No se asigna una severidad de producción sin el contexto de una aplicación real.

Cuerpo observado al leer el informe ajeno en modo vulnerable
{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Informe sintético B"}

El marcador SYNTHETIC_BRUNO_202 identifica el objeto ajeno de forma inequívoca. Todos los nombres, títulos y tokens son datos públicos inventados para esta prueba.

El cambio de código que corrige la causa

Estas dos funciones están en el lab.py descargable y son las políticas que ejecuta la batería. La comparación se genera desde ese archivo al construir esta página. Muestra la diferencia entre los dos modos del laboratorio; no representa el historial de un proyecto ajeno.

Comparación de las políticas ejecutadas
--- lab.py: política vulnerable
+++ lab.py: política corregida
@@ -1,2 +1,2 @@
-def can_access_vulnerable(item, identity):
-    return item is not None
+def can_access_fixed(item, identity):
+    return item is not None and item["owner"] == identity

El handler resuelve primero la identidad desde el token del servidor y busca el objeto. Después llama a la política seleccionada. Si esta devuelve falso, responde 404 antes de devolver contenido o procesar la modificación. La identidad no procede del JSON ni de X-Lab-User.

En la lectura cruzada, el modo corregido devuelve 404 con {"error":"not_found"}, sin propietario, título ni marcador del informe.

Los controles anteriores al parche se mantienen: una única cabecera de autenticación, un JSON que solo puede cambiar el título y respuestas con Cache-Control: no-store. Ninguno sustituye la comprobación de propietario.

Retest: abrir la evidencia de cada escenario

48/48 intercambios coinciden con su expectativa. Los 18 escenarios se ejecutan contra ambos modos. En el vulnerable, varios resultados esperados reproducen el fallo; no significan que esa versión sea segura.

Abre cualquier escenario para comparar petición, cabeceras, estado y cuerpo completo. La flecha une pasos del mismo escenario; en las escrituras, el segundo paso es la lectura de comprobación. Estos controles desplegables funcionan también sin JavaScript.

Las transcripciones proceden del JSON observado. Se omiten el puerto efímero, Host y las cabeceras Date y Server para permitir comparaciones reproducibles. No son una captura de red; el JSON descargable conserva además las expectativas y la huella SHA-256 de cada cuerpo.

Alice lee su informe
Vulnerable: 200 · Corregido: 200

Escenario: alice_own_read. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: Alice lee su informe
Intercambio 1
GET /reports/101
Authorization: Bearer lab-alice-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Informe sintético A"}
Modo corregido: Alice lee su informe
Intercambio 1
GET /reports/101
Authorization: Bearer lab-alice-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Informe sintético A"}
Bruno lee su informe
Vulnerable: 200 · Corregido: 200

Escenario: bruno_own_read. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: Bruno lee su informe
Intercambio 1
GET /reports/202
Authorization: Bearer lab-bruno-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Informe sintético B"}
Modo corregido: Bruno lee su informe
Intercambio 1
GET /reports/202
Authorization: Bearer lab-bruno-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Informe sintético B"}
Alice intenta leer el informe de Bruno
Vulnerable: 200 · Corregido: 404

Escenario: alice_cross_read. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: Alice intenta leer el informe de Bruno
Intercambio 1
GET /reports/202
Authorization: Bearer lab-alice-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Informe sintético B"}
Modo corregido: Alice intenta leer el informe de Bruno
Intercambio 1
GET /reports/202
Authorization: Bearer lab-alice-only

Estado HTTP observado: 404
Content-Type: application/json; charset=utf-8
Content-Length: 21
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"not_found"}
Bruno intenta leer el informe de Alice
Vulnerable: 200 · Corregido: 404

Escenario: bruno_cross_read. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: Bruno intenta leer el informe de Alice
Intercambio 1
GET /reports/101
Authorization: Bearer lab-bruno-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Informe sintético A"}
Modo corregido: Bruno intenta leer el informe de Alice
Intercambio 1
GET /reports/101
Authorization: Bearer lab-bruno-only

Estado HTTP observado: 404
Content-Type: application/json; charset=utf-8
Content-Length: 21
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"not_found"}
Lectura sin sesión
Vulnerable: 401 · Corregido: 401

Escenario: anonymous. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: Lectura sin sesión
Intercambio 1
GET /reports/101

Estado HTTP observado: 401
Content-Type: application/json; charset=utf-8
Content-Length: 24
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"unauthorized"}
Modo corregido: Lectura sin sesión
Intercambio 1
GET /reports/101

Estado HTTP observado: 401
Content-Type: application/json; charset=utf-8
Content-Length: 24
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"unauthorized"}
Token sintético no reconocido
Vulnerable: 401 · Corregido: 401

Escenario: unknown_token. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: Token sintético no reconocido
Intercambio 1
GET /reports/101
Authorization: Bearer lab-unknown

Estado HTTP observado: 401
Content-Type: application/json; charset=utf-8
Content-Length: 24
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"unauthorized"}
Modo corregido: Token sintético no reconocido
Intercambio 1
GET /reports/101
Authorization: Bearer lab-unknown

Estado HTTP observado: 401
Content-Type: application/json; charset=utf-8
Content-Length: 24
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"unauthorized"}
Identificador inexistente
Vulnerable: 404 · Corregido: 404

Escenario: unknown_object. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: Identificador inexistente
Intercambio 1
GET /reports/999
Authorization: Bearer lab-alice-only

Estado HTTP observado: 404
Content-Type: application/json; charset=utf-8
Content-Length: 21
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"not_found"}
Modo corregido: Identificador inexistente
Intercambio 1
GET /reports/999
Authorization: Bearer lab-alice-only

Estado HTTP observado: 404
Content-Type: application/json; charset=utf-8
Content-Length: 21
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"not_found"}
Identidad propuesta en la URL
Vulnerable: 400 · Corregido: 400

Escenario: client_owner_query. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: Identidad propuesta en la URL
Intercambio 1
GET /reports/202?owner=bruno
Authorization: Bearer lab-alice-only

Estado HTTP observado: 400
Content-Type: application/json; charset=utf-8
Content-Length: 31
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"query_not_supported"}
Modo corregido: Identidad propuesta en la URL
Intercambio 1
GET /reports/202?owner=bruno
Authorization: Bearer lab-alice-only

Estado HTTP observado: 400
Content-Type: application/json; charset=utf-8
Content-Length: 31
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"query_not_supported"}
Identidad propuesta en una cabecera
Vulnerable: 200 · Corregido: 404

Escenario: client_owner_header. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: Identidad propuesta en una cabecera
Intercambio 1
GET /reports/202
Authorization: Bearer lab-alice-only
X-Lab-User: bruno

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Informe sintético B"}
Modo corregido: Identidad propuesta en una cabecera
Intercambio 1
GET /reports/202
Authorization: Bearer lab-alice-only
X-Lab-User: bruno

Estado HTTP observado: 404
Content-Type: application/json; charset=utf-8
Content-Length: 21
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"not_found"}
Alice modifica su título y lo vuelve a leer
Vulnerable: 200 → 200 · Corregido: 200 → 200

Escenario: alice_own_write. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: Alice modifica su título y lo vuelve a leer
Intercambio 1
PATCH /reports/101
Authorization: Bearer lab-alice-only
Content-Type: application/json
Content-Length: 41

{"title":"Título actualizado por Alice"}

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 99
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Título actualizado por Alice"}

Intercambio 2
GET /reports/101
Authorization: Bearer lab-alice-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 99
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Título actualizado por Alice"}
Modo corregido: Alice modifica su título y lo vuelve a leer
Intercambio 1
PATCH /reports/101
Authorization: Bearer lab-alice-only
Content-Type: application/json
Content-Length: 41

{"title":"Título actualizado por Alice"}

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 99
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Título actualizado por Alice"}

Intercambio 2
GET /reports/101
Authorization: Bearer lab-alice-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 99
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Título actualizado por Alice"}
Bruno modifica su título y lo vuelve a leer
Vulnerable: 200 → 200 · Corregido: 200 → 200

Escenario: bruno_own_write. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: Bruno modifica su título y lo vuelve a leer
Intercambio 1
PATCH /reports/202
Authorization: Bearer lab-bruno-only
Content-Type: application/json
Content-Length: 41

{"title":"Título actualizado por Bruno"}

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 99
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Título actualizado por Bruno"}

Intercambio 2
GET /reports/202
Authorization: Bearer lab-bruno-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 99
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Título actualizado por Bruno"}
Modo corregido: Bruno modifica su título y lo vuelve a leer
Intercambio 1
PATCH /reports/202
Authorization: Bearer lab-bruno-only
Content-Type: application/json
Content-Length: 41

{"title":"Título actualizado por Bruno"}

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 99
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Título actualizado por Bruno"}

Intercambio 2
GET /reports/202
Authorization: Bearer lab-bruno-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 99
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Título actualizado por Bruno"}
Alice intenta modificar el informe de Bruno
Vulnerable: 200 → 200 · Corregido: 404 → 200

Escenario: alice_cross_write. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: Alice intenta modificar el informe de Bruno
Intercambio 1
PATCH /reports/202
Authorization: Bearer lab-alice-only
Content-Type: application/json
Content-Length: 32

{"title":"Cambio no autorizado"}

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Cambio no autorizado"}

Intercambio 2
GET /reports/202
Authorization: Bearer lab-bruno-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Cambio no autorizado"}
Modo corregido: Alice intenta modificar el informe de Bruno
Intercambio 1
PATCH /reports/202
Authorization: Bearer lab-alice-only
Content-Type: application/json
Content-Length: 32

{"title":"Cambio no autorizado"}

Estado HTTP observado: 404
Content-Type: application/json; charset=utf-8
Content-Length: 21
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"not_found"}

Intercambio 2
GET /reports/202
Authorization: Bearer lab-bruno-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Informe sintético B"}
Bruno intenta modificar el informe de Alice
Vulnerable: 200 → 200 · Corregido: 404 → 200

Escenario: bruno_cross_write. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: Bruno intenta modificar el informe de Alice
Intercambio 1
PATCH /reports/101
Authorization: Bearer lab-bruno-only
Content-Type: application/json
Content-Length: 32

{"title":"Cambio no autorizado"}

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Cambio no autorizado"}

Intercambio 2
GET /reports/101
Authorization: Bearer lab-alice-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Cambio no autorizado"}
Modo corregido: Bruno intenta modificar el informe de Alice
Intercambio 1
PATCH /reports/101
Authorization: Bearer lab-bruno-only
Content-Type: application/json
Content-Length: 32

{"title":"Cambio no autorizado"}

Estado HTTP observado: 404
Content-Type: application/json; charset=utf-8
Content-Length: 21
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"not_found"}

Intercambio 2
GET /reports/101
Authorization: Bearer lab-alice-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Informe sintético A"}
JSON inválido no cambia el informe
Vulnerable: 400 → 200 · Corregido: 400 → 200

Escenario: invalid_json. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: JSON inválido no cambia el informe
Intercambio 1
PATCH /reports/101
Authorization: Bearer lab-alice-only
Content-Type: application/json
Content-Length: 1

{

Estado HTTP observado: 400
Content-Type: application/json; charset=utf-8
Content-Length: 24
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"invalid_body"}

Intercambio 2
GET /reports/101
Authorization: Bearer lab-alice-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Informe sintético A"}
Modo corregido: JSON inválido no cambia el informe
Intercambio 1
PATCH /reports/101
Authorization: Bearer lab-alice-only
Content-Type: application/json
Content-Length: 1

{

Estado HTTP observado: 400
Content-Type: application/json; charset=utf-8
Content-Length: 24
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"invalid_body"}

Intercambio 2
GET /reports/101
Authorization: Bearer lab-alice-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Informe sintético A"}
El cliente no puede cambiar al propietario
Vulnerable: 400 → 200 · Corregido: 400 → 200

Escenario: mass_assignment. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: El cliente no puede cambiar al propietario
Intercambio 1
PATCH /reports/101
Authorization: Bearer lab-alice-only
Content-Type: application/json
Content-Length: 35

{"title":"Intento","owner":"bruno"}

Estado HTTP observado: 400
Content-Type: application/json; charset=utf-8
Content-Length: 24
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"invalid_body"}

Intercambio 2
GET /reports/101
Authorization: Bearer lab-alice-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Informe sintético A"}
Modo corregido: El cliente no puede cambiar al propietario
Intercambio 1
PATCH /reports/101
Authorization: Bearer lab-alice-only
Content-Type: application/json
Content-Length: 35

{"title":"Intento","owner":"bruno"}

Estado HTTP observado: 400
Content-Type: application/json; charset=utf-8
Content-Length: 24
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"invalid_body"}

Intercambio 2
GET /reports/101
Authorization: Bearer lab-alice-only

Estado HTTP observado: 200
Content-Type: application/json; charset=utf-8
Content-Length: 90
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Informe sintético A"}
Ruta con identificador inválido
Vulnerable: 404 · Corregido: 404

Escenario: malformed_identifier. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: Ruta con identificador inválido
Intercambio 1
GET /reports/../202
Authorization: Bearer lab-alice-only

Estado HTTP observado: 404
Content-Type: application/json; charset=utf-8
Content-Length: 21
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"not_found"}
Modo corregido: Ruta con identificador inválido
Intercambio 1
GET /reports/../202
Authorization: Bearer lab-alice-only

Estado HTTP observado: 404
Content-Type: application/json; charset=utf-8
Content-Length: 21
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"not_found"}
DELETE no está implementado
Vulnerable: 405 · Corregido: 405

Escenario: unsupported_method. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: DELETE no está implementado
Intercambio 1
DELETE /reports/101
Authorization: Bearer lab-alice-only

Estado HTTP observado: 405
Content-Type: application/json; charset=utf-8
Content-Length: 30
Cache-Control: no-store
X-Content-Type-Options: nosniff
Allow: GET, PATCH

{"error":"method_not_allowed"}
Modo corregido: DELETE no está implementado
Intercambio 1
DELETE /reports/101
Authorization: Bearer lab-alice-only

Estado HTTP observado: 405
Content-Type: application/json; charset=utf-8
Content-Length: 30
Cache-Control: no-store
X-Content-Type-Options: nosniff
Allow: GET, PATCH

{"error":"method_not_allowed"}
Dos cabeceras de sesión se rechazan
Vulnerable: 401 · Corregido: 401

Escenario: ambiguous_identity. Los estados y cuerpos completos coinciden con las expectativas declaradas.

Modo vulnerable: Dos cabeceras de sesión se rechazan
Intercambio 1
GET /reports/101
Authorization: Bearer lab-alice-only
Authorization: Bearer lab-bruno-only

Estado HTTP observado: 401
Content-Type: application/json; charset=utf-8
Content-Length: 24
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"unauthorized"}
Modo corregido: Dos cabeceras de sesión se rechazan
Intercambio 1
GET /reports/101
Authorization: Bearer lab-alice-only
Authorization: Bearer lab-bruno-only

Estado HTTP observado: 401
Content-Type: application/json; charset=utf-8
Content-Length: 24
Cache-Control: no-store
X-Content-Type-Options: nosniff

{"error":"unauthorized"}

Reproducir y descargar

Python 3.10 o posterior, sin dependencias. Descarga y descomprime el ZIP; desde su carpeta:

Ejecutar el retest HTTP
python3 lab.py --verify resultados.json

Salida esperada: PASS: 48/48 HTTP checks; complete evidence identical.

Ejecutar la misma batería en Docker

El contenedor incluye cliente y servidor; no publica puertos, corre como usuario no root y no necesita red durante la prueba. La imagen base se descarga durante la construcción.

Construir y ejecutar el contenedor
docker build -t iphobiuss-http-access:1.0.0 .
docker run --rm --network none --read-only --cap-drop ALL \
  --security-opt no-new-privileges --pids-limit 64 \
  --memory 128m --cpus 1 iphobiuss-http-access:1.0.0

El ZIP contiene API, escenarios, evidencia, instrucciones de exploración manual, Dockerfile y un manifiesto SHA-256. --verify repite las solicitudes HTTP y compara todo el JSON con la evidencia incluida; una diferencia hace fallar la comprobación.

Qué permite concluir y qué queda fuera

Los controles reproducidos respaldan una conclusión concreta: comprobar el propietario antes de leer o escribir bloquea los accesos cruzados ensayados y conserva las operaciones legítimas. Una respuesta de rechazo no basta por sí sola; el retest comprueba también el estado del objeto.

El servidor y las sesiones son una demostración local. No es una API lista para producción: no hay TLS, login, expiración o revocación de tokens, base de datos, roles complejos ni control de concurrencia de escrituras. Tampoco se evaluaron tiempos de respuesta como canal lateral.

La ejecución publicada es automática. Las instrucciones manuales permiten repetirla y explorarla, pero no acreditan una sesión de evaluación humana. Estos escenarios no demuestran ausencia de cualquier otra vulnerabilidad. Docker ejecuta la misma batería y no amplía por sí solo su cobertura.

El siguiente paso técnico sería añadir persistencia y probar carreras entre autorización y escritura, revocación de sesiones y reglas de acceso compartido. Cada ampliación necesitaría nuevas expectativas y evidencia.