TL;DR: En una auditoría de seguridad de una app multi-tenant en ASP.NET Core MVC encontré varios endpoints que aceptaban classroomId, teacherId o gameDataId sin validar ownership, permitiendo que un docente operase sobre datos de otro. La solución: un servicio IAuthTeacherService que centraliza la validación, queries compuestas para recursos hijos, y la regla de nunca confiar en IDs que vienen del cliente.
Los principales hallazgos se centraban en tres endpoints que aceptaban classroomId, teacherId o gameDataId sin realizar una validación cruzada... provocando una potencial brecha en el aislamiento, integridad y confidencialidad de los datos y permitiendo una escalada horizontal entre docentes.
Hablando en plata, un docente podía operar sobre las aulas de otro docente simplemente cambiando un classroomId en la petición.
Causa raíz del IDOR: Falta de validación de Ownership
Todos los hallazgos tenían la misma raíz: el servidor confiaba en el ID que le pasaba el cliente y lo usaba directamente en la consulta sin preguntar si el usuario tenía derecho a tocar ese recurso.
Por poner los ejemplos concretos:
StudentsController.CheckLicenses(int classroomId)→ permitía operar sobre un aula sin verificar que pertenecía al docente autenticado.MetricsController.GenerateCSV→ exportaba datos filtrados pormodel.SelectedClassroomIdsin, de nuevo, verificar la propiedad de los datos.HomeController.GetStudents(int teacherId)→ aceptaba unteacherIddel cliente en lugar de derivarlo del claim de las cookies.
Implementar validación de Ownership en ASP.NET Core
El principio es simple: validar el ownership del recurso que se está tratando de acceder al principio de cada acción. Antes de tocar nada.
En ASP.NET Core MVC se traduce en utilizar un helper o servicio reutilizable que aborte cuando la validación no certifique la propiedad de los datos.
public interface IAuthTeacherService
{
bool IsAdmin();
int? GetAuthenticatedTeacherId();
Task<bool> OwnsTeacherAsync(int teacherId);
Task<bool> OwnsClassroomAsync(int classroomId);
Task<bool> OwnsStudentAsync(int studentId);
Task<bool> OwnsGameDataAsync(int gameDataId);
Task<bool> OwnsBlockingCategoryStateAsync(int stateId, int expectedClassroomId);
Task<bool> OwnsBlockingMiniGameStateAsync(int stateId, int expectedClassroomId);
}
Y la implementación concreta de uno de los métodos:
public async Task<bool> OwnsClassroomAsync(int classroomId)
{
if (IsAdmin()) return true;
var authTeacherId = GetAuthenticatedTeacherId(); //recupera el TeacherId en el claim de la sesión
if (!authTeacherId.HasValue) return false;
return await _context.Classrooms.AnyAsync(c => c.ClassroomId == classroomId && c.TeacherId == authTeacherId.Value);
}
Y su uso en el controlador:
[HttpGet]
public async Task<JsonResult> CheckLicenses(int classroomId)
{
if (!await OwnsClassroomAsync(classroomId))
return Json(new { success = false, message = "No autorizado." });
//Aquí ejecutamos la lógica real
}
Verificación de FKs y queries compuestas con Entity Framework Core
Algunos endpoints reciben un stateId que no identifica al docente ni al aula, sino a un recurso hijo relacionado por FK. Validar solo que este stateId existe no es suficiente y hay que verificar también que el recurso pertenece a la clase que ya validamos.
// ❌ Sin query compuesta: cualquier stateId válido pasa aunque sea de otra clase
var state = await _context.BlockingMiniGameState.FindAsync(stateId);
// ✅ El aislamiento está en la propia query
// Si el stateId no pertenece a classroomId, devuelve null y abortamos
var owns = await OwnsBlockingMiniGameStateAsync(stateId, classroomId);
public async Task<bool> OwnsBlockingMiniGameStateAsync(int stateId, int expectedClassroomId)
{
if (IsAdmin())
{
return await _context.BlockingMiniGameState.AnyAsync(b => b.Id == stateId && b.ClassroomId == expectedClassroomId); //BlockingMiniGameState tiene como FK ClassroomId
}
var authTeacherId = GetAuthenticatedTeacherId();
if (!authTeacherId.HasValue) return false;
return await _context.BlockingMiniGameState.AnyAsync(b => b.Id == stateId
&& (b.ClassroomId == expectedClassroomId)
&& b.Classroom.TeacherId == authTeacherId.Value);
}
No confíes en IDs que provengan del cliente
Esta es la regla más importante. Si el dato está en el claim de la sesión, úsalo desde el claim. Si está en el body, haz validación cruzada para certificar la propiedad de los datos a los que se está tratando de acceder.
Cualquiera puede modificar el body de una petición HTTP y, si el servidor no valida correctamente el acceso al recurso, estarás ante un caso de IDOR.
Lo Que Aprendí (y lo que Queda Pendiente)
Resolver IDOR en multi-tenant no es complicado a nivel técnico, pero sí requiere disciplina: ownership first, siempre, sin atajos. El riesgo real no es la complejidad del ataque, que es baja, sino que el bug parece inocente hasta que alguien decide explotarlo.
Lo que dejo como deuda técnica documentada para fases siguientes:
- Tests de integración con escenario Admin / Teacher propio / Teacher ajeno para cada endpoint sensible.
- Documentar el contrato de respuestas (
NotFoundvsForbidvs JSON) para que el equipo sea consistente. - Valorar la idoneidad en la aplicación de un Global Query Filter desde el builder para evitar errores humanos y garantizar el aislamiento.
Un Global Query Filter ejecuta un filtro automático en las consultas a nivel de DbContext, en el constructor del modelo. Este filtro no se puede soslayar salvo que se incluya un IgnoreQueryFilters() o la consulta se haga utilizando SqlRaw. De este modo estamos garantizando que las consultas siempre se filtran utilizando algún parámetro - TeacherId, TenantId - y reducimos el riesgo de problemas de tipo IDOR.