← Volver al blog

Cómo detectar y solucionar un IDOR en una API .NET usando JWT

  • Backend
  • .NET
  • Security
  • dotnet
  • JWT
  • OWASP

Descubre qué es una vulnerabilidad IDOR, cómo detecté esta brecha de seguridad en una API .NET (C#) y aprende a solucionarla correctamente usando claims en JWT.

TL;DR: Validar la identidad de un usuario usando datos del body de una petición (StudentId) permite a un atacante alterar registros de otros usuarios (IDOR). La solución segura en una API .NET es almacenar la identidad en los Claims de un JWT firmado y extraerla en el backend ignorando el payload del cliente.

Un IDOR silencioso en una API .NET: el error de confiar en el body

Durante una revisión de seguridad detecté en una API una potencial brecha de seguridad. La identificación del usuario que trataba de acceder o modificar los datos se realizaba utilizando el body que el mismo usuario enviaba. El JWT era válido, se generaba con normalidad tras el login, pero la capa de seguridad recaía sobre la información que se enviaba en el body, en lugar de utilizar los claims para validar la identidad real del usuario.

Autenticación ≠ Autorización El token era válido.
El usuario estaba autenticado. Pero el sistema no verificaba que el recurso realmente perteneciera a ese usuario. Eso es exactamente lo que convierte el problema en un IDOR.

¿Qué es IDOR?

Un IDOR (Insecure Direct Object Reference u Objeto de Referencia Directa Insegura) ocurre cuando el aislamiento de los datos de cada usuario se ve comprometido. Es decir, el usuario A puede acceder a los datos del usuario B, C, D... sin que estos le hayan dado acceso, explotando una vulnerabilidad del sistema. Esto es especialmente sensible en APIs multi-tenant y multi-usuario que comparten una misma base de datos y que confían en la seguridad de una herramienta.

El Problema: Confiar en el Body de la petición

En mi caso concreto el método que generaba el JWT era el siguiente:

private string GenerateJwtToken(string username)
{
    var claims = new[]
    {
        new Claim(ClaimTypes.Name, username) // Solo contiene el nombre de usuario
    };

    var token = new JwtSecurityToken(
        claims: claims,
        expires: DateTime.UtcNow.AddHours(1),
        signingCredentials: creds
    );
    return new JwtSecurityTokenHandler().WriteToken(token);
}

¿Y cuál es el problema en este método?

Solo almacenamos el nombre de usuario. Por lo tanto, no existía una forma rápida y fiable para saber a qué alumno pertenecía el token cuando el servidor recibía una petición posterior con ese token. Usar JWT no es un problema; el problema es la decisión de qué incluir o no en el token y cómo usamos esa información.

En segundo lugar, los endpoints confiaban ciegamente en el body. Por poner un ejemplo:

[HttpPost("UpdateGameTime")]
public APIResponse UpdateGameTime([FromBody] UpdateGameTime ugt)
{
    var student = _context.Students
        .FirstOrDefault(s => s.StudentId == ugt.StudentId); 

    student.WeeklyGameTime += ugt.GameTime - student.GameTime;
    student.GameTime = ugt.GameTime;
    _context.Students.Update(student);
    _context.SaveChanges();
    return new APIResponse() { Success = true };
}

También vemos que no hacían uso de métodos async para no bloquear hilos, pero eso es otra historia.

El StudentId viene del body de la petición ugt.StudentId, sin poder comprobar que sea el mismo alumno que hizo el login de forma fiable. A partir de este punto, cualquier dato que dependiera del StudentId como FK estaba comprometido y la cadena de confianza estaba rota.

Un usuario en una petición podría poner cualquier StudentId en el body y actualizar el tiempo de juego de otro usuario.

¿Qué podía hacer un atacante?

Si un usuario disponía de unas credenciales válidas, podía loguearse y obtener un token válido. Con este token podía acceder a cualquier StudentId simplemente cambiando el StudentId del body, porque el servidor solo verificaba que el token fuera válido y luego tenía en cuenta el body para identificar al usuario.

La solución: Usar Claims en el JWT para validación de usuarios

Al crear el token debemos asignar el StudentId como claim dentro del token. Así, cada vez que el servidor recibe una petición, sabemos con certeza quién es el alumno sin depender de lo que nos pase el body.

private string GenerateJwtToken(Student student)
{
    var claims = new[]
    {
        new Claim(ClaimTypes.NameIdentifier, student.StudentId.ToString()), // NUEVO
        new Claim(ClaimTypes.Name, student.StudentUser)
    };

    var token = new JwtSecurityToken(
        claims: claims,
        expires: DateTime.UtcNow.AddHours(5),
        signingCredentials: creds
    );
    return new JwtSecurityTokenHandler().WriteToken(token);
}

El JWT no se puede modificar sin invalidarlo, por lo que asignarlo como Claim lo hace seguro.

Del mismo modo, se creó un servicio para extraer la identidad del token para evitar duplicar código en todos los endpoints o controladores que lo requieran.

public interface IAuthStudentService
{
	int? GetAuthenticatedStudentId();
 
	Task<bool> OwnsGameDataResource(int gameDataId);
}
public class AuthStudentService : IAuthStudentService
{
	private readonly IHttpContextAccessor _httpContextAccessor;
	private readonly MiDbContext _context;

	public AuthStudentService(IHttpContextAccessor httpContextAccessor, MiDbContext context)
	{
		_httpContextAccessor = httpContextAccessor;	
		_context = context;
	}

	public int? GetAuthenticatedStudentId()
	{
		var claim = _httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier);	
		return int.TryParse(claim, out var id) ? id : null;
	}

	public async Task<bool> OwnsGameDataResource(int gameDataId)
	{
		var studentId = GetAuthenticatedStudentId();
		if (studentId == null) return false;
		return await _context.GameDatas.AnyAsync(g => g.GameDataId == gameDataId && g.StudentId == studentId.Value);
	}
}

Y lo utilizamos en todos los endpoints que requieran verificar la identidad del estudiante - y de paso añadimos try/catch y async.

[HttpPost("UpdateGameTime")]
public async Task<APIResponse> UpdateGameTime([FromBody] UpdateGameTime ugt)
{
    try
    {
        var authStudentId = _authService.GetAuthenticatedStudentId();
        if (authStudentId == null) 
            return new APIResponse { Success = false, Error = "Unauthorized" };

        var student = await _context.Students
            .FirstOrDefaultAsync(s => s.StudentId == authStudentId.Value);
        if (student == null) 
            return new APIResponse { Success = false, Error = "Student not found" };

        student.WeeklyGameTime += ugt.GameTime - student.GameTime;

        student.GameTime = ugt.GameTime;
        _context.Students.Update(student);
        await _context.SaveChangesAsync();
        return new APIResponse() { Success = true };
    }
    catch (Exception ex)
    {
        return new APIResponse() { Success = false, Error = "Internal error" };
    }
}

De este modo, si el token tiene el claim StudentId = X actualizamos el tiempo de juego de ese usuario, ya que los claims del token que se ha utilizado nos indican qué estudiante es el que está tratando de actualizar su información.

En esta solución estamos aplicando varios principios como el de:

  • Never Trust the client, usando el token en lugar del body,
  • Least Privilege, validando el ownership y garantizando el aislamiento de los datos de cada usuario,
  • Defense in Depth, creando una capa adicional para garantizar este aislamiento y
  • Fail Secure, ante la duda devolvemos un error.

¿Qué aprendemos de esto?

Si queremos proteger los datos de nuestros usuarios y aislarlos de la mejor manera posible, es imprescindible que no utilicemos información contenida en el body para verificar la identidad, roles o permisos. Usar JWT es muchísimo más seguro porque está firmado criptográficamente y garantiza que esos datos no han sido manipulados: si alguien modifica el payload, la firma dejará de ser válida y el middleware lo rechazará. Revisad siempre el ownership de los recursos que estáis sirviendo y garantizad que no existen IDOR en vuestras soluciones, hasta el más mínimo detalle puede convertirse en una vulnerabilidad. Evitarlas a través de un diseño consciente es parte de la seguridad de tu API.

¡Hasta más ver!