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.
Sin JavaScript puedes leer la comparación inicial y abrir todos los escenarios en la sección de retest.
Alice lee su informe
GET /reports/101 con el token sintético de Alice.
Vulnerable
Respuesta a la solicitud: HTTP 200
{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Informe sintético A"}Corregido
Respuesta a la solicitud: HTTP 200
{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Informe sintético A"}El propietario conserva el acceso legítimo en ambos modos.
Abrir peticiones, cabeceras y cuerpos completosAlice modifica su título y lo vuelve a leer
PATCH /reports/101 con el token sintético de Alice.
Cuerpo enviado: {"title":"Título actualizado por Alice"}
Vulnerable
Respuesta a la solicitud: HTTP 200
{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Título actualizado por Alice"}Lectura posterior del propietario: HTTP 200
{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Título actualizado por Alice"}Corregido
Respuesta a la solicitud: HTTP 200
{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Título actualizado por Alice"}Lectura posterior del propietario: HTTP 200
{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Título actualizado por Alice"}El propietario conserva el acceso legítimo en ambos modos.
Abrir peticiones, cabeceras y cuerpos completosAlice intenta leer el informe de Bruno
GET /reports/202 con el token sintético de Alice.
Vulnerable
Respuesta a la solicitud: HTTP 200
{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Informe sintético B"}Corregido
Respuesta a la solicitud: HTTP 404
{"error":"not_found"}El modo vulnerable expone el objeto ajeno; el corregido no devuelve su contenido.
Abrir peticiones, cabeceras y cuerpos completosAlice intenta modificar el informe de Bruno
PATCH /reports/202 con el token sintético de Alice.
Cuerpo enviado: {"title":"Cambio no autorizado"}
Vulnerable
Respuesta a la solicitud: HTTP 200
{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Cambio no autorizado"}Lectura posterior del propietario: HTTP 200
{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Cambio no autorizado"}Corregido
Respuesta a la solicitud: HTTP 404
{"error":"not_found"}Lectura posterior del propietario: HTTP 200
{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Informe sintético B"}La lectura posterior confirma el cambio ajeno en el vulnerable y el título original en el corregido.
Abrir peticiones, cabeceras y cuerpos completosBruno intenta leer el informe de Alice
GET /reports/101 con el token sintético de Bruno.
Vulnerable
Respuesta a la solicitud: HTTP 200
{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Informe sintético A"}Corregido
Respuesta a la solicitud: HTTP 404
{"error":"not_found"}El modo vulnerable expone el objeto ajeno; el corregido no devuelve su contenido.
Abrir peticiones, cabeceras y cuerpos completosBruno intenta modificar el informe de Alice
PATCH /reports/101 con el token sintético de Bruno.
Cuerpo enviado: {"title":"Cambio no autorizado"}
Vulnerable
Respuesta a la solicitud: HTTP 200
{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Cambio no autorizado"}Lectura posterior del propietario: HTTP 200
{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Cambio no autorizado"}Corregido
Respuesta a la solicitud: HTTP 404
{"error":"not_found"}Lectura posterior del propietario: HTTP 200
{"id":"101","marker":"SYNTHETIC_ALICE_101","owner":"alice","title":"Informe sintético A"}La lectura posterior confirma el cambio ajeno en el vulnerable y el título original en el corregido.
Abrir peticiones, cabeceras y cuerpos completosBruno lee su informe
GET /reports/202 con el token sintético de Bruno.
Vulnerable
Respuesta a la solicitud: HTTP 200
{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Informe sintético B"}Corregido
Respuesta a la solicitud: HTTP 200
{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Informe sintético B"}El propietario conserva el acceso legítimo en ambos modos.
Abrir peticiones, cabeceras y cuerpos completosBruno modifica su título y lo vuelve a leer
PATCH /reports/202 con el token sintético de Bruno.
Cuerpo enviado: {"title":"Título actualizado por Bruno"}
Vulnerable
Respuesta a la solicitud: HTTP 200
{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Título actualizado por Bruno"}Lectura posterior del propietario: HTTP 200
{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Título actualizado por Bruno"}Corregido
Respuesta a la solicitud: HTTP 200
{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Título actualizado por Bruno"}Lectura posterior del propietario: HTTP 200
{"id":"202","marker":"SYNTHETIC_BRUNO_202","owner":"bruno","title":"Título actualizado por Bruno"}El propietario conserva el acceso legítimo en ambos modos.
Abrir peticiones, cabeceras y cuerpos completosHipó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: brunocon 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. 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. Mantener la sesión y cambiar solo el identificador. Con el token de Alice,
GET /reports/202devuelve el informe de Bruno en el modo vulnerable. La misma petición recibe 404 en el corregido. Comparar los dos modos. - 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. 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. 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.
{"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.
--- 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.
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"}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.
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"}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.
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"}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.
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"}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.
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"}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.
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"}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.
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"}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.
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"}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.
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"}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.
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"}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.
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"}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.
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"}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.
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"}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.
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"}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.
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"}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.
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"}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.
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"}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.
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"}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:
python3 lab.py --verify resultados.jsonSalida 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.
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.0El 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.