Introduction

Welcome to the very first lesson of the broken access control course! In this lesson, we will explore the critical topic of unverified account parameters in API endpoints. This vulnerability is a common entry point for attackers seeking unauthorized access to sensitive data.

By understanding how these vulnerabilities occur and learning how to secure your code, you'll be taking a significant step toward building more secure web applications. Let's dive in! 🚀

Understanding Unauthorized Access via Parameter Manipulation

When building API endpoints that handle user data, it's crucial to implement proper access controls. Without proper verification, attackers can manipulate request parameters to access data belonging to other users. This is particularly dangerous when dealing with account information, as it can lead to unauthorized access to user data.

In this lesson, we'll focus on how unverified parameters can lead to unauthorized access vulnerabilities and the importance of securing these parameters to protect your application.

Vulnerable Code Example

Let's take a look at a code snippet that demonstrates a vulnerable API endpoint using unverified parameters. This example will help us understand the risks associated with such vulnerabilities.

@RestController
@RequestMapping("/api/account")
public class AccountController {
    private final UserRepository users;

    public AccountController(UserRepository users) {
        this.users = users;
    }
    
    @GetMapping("/accountInfo")
    public ResponseEntity<?> accountInfo(@RequestParam(name = "id", required = false) String id) {
        // Check if the id parameter is provided
        if (id == null || id.isBlank()) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(new ErrorResponse("Missing user ID parameter"));
        }
        
        // Parse the id parameter to an integer
        Integer userId;
        try {
            userId = Integer.parseInt(id);
        } catch (NumberFormatException e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(new ErrorResponse("Invalid user ID"));
        }
        
        // VULNERABILITY: No authentication or authorization check!
        // Any user can access any account by changing the id parameter
        return users.findById(userId)
            .<ResponseEntity<?>>map(ResponseEntity::ok)
            .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse("User not found")));
    }
}

In this code, the id parameter is taken directly from the query string using @RequestParam and is used to fetch user data without any verification. This lack of validation allows an attacker to manipulate the id parameter to access any user's account details, leading to unauthorized data access.

Note: This is a classic example of Insecure Direct Object Reference (IDOR). The application trusts the user-supplied id without verifying if the authenticated user has the right to access that specific resource.

Exploiting the Vulnerability

An attacker can easily exploit this vulnerability by manipulating the URL parameters. Here is an example of how this can be done using a simple curl request:

curl "http://localhost:3000/api/account/accountInfo?id=1"

By sending this request, an attacker can access the account information of the user with id=1, who could be an admin or any other user. This demonstrates how easily unverified parameters can be exploited to gain unauthorized access to sensitive data.

Authentication Checks

The first line of defense is proper authentication. We need to ensure that only authenticated users can access account information. We'll use json web tokens (JWT) for authentication. A JWT is a compact, URL-safe means of representing claims between two parties. It consists of three parts: a header, a payload, and a signature. When a user logs in, they receive a JWT that they must include in subsequent requests to prove their identity.

The JWT_SECRET_KEY is a private key used to sign and verify tokens. It should be kept secure and never exposed to the public, as anyone with access to this key could forge valid tokens. In your application.yml, this is configured as:

app:
  jwt:
    secret: jwt-secret-key

Here's how we implement JWT verification in our controller:

@RestController
@RequestMapping("/api/account")
public class AccountController {
    private final UserRepository users;
    private final JwtUtil jwt;

    public AccountController(UserRepository users, JwtUtil jwt) {
        this.users = users;
        this.jwt = jwt;
    }
    
    @GetMapping("/accountInfo")
    public ResponseEntity<?> accountInfo(
            @RequestHeader(value = "authorization", required = false) String authorization,
            @RequestParam(name = "id", required = false) String id) {
        
        // Check if the Authorization header is present
        if (authorization == null || authorization.isBlank()) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(new ErrorResponse("Authentication required"));
        }
        
        // Extract the JWT token from the Authorization header
        // Remove "Bearer " prefix if present
        String token = authorization.startsWith("Bearer ") 
            ? authorization.substring(7) 
            : authorization;
        
        try {
            // Verify the JWT token using the secret key
            DecodedJWT decoded = jwt.verify(token);
            
            // Extract the user ID from the token claims
            Integer tokenUserId = decoded.getClaim("userId").asInt();
            
            // User is authenticated, continue with parameter validation
            
        } catch (Exception e) {
            // Token is invalid, expired, or tampered with
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(new ErrorResponse("Invalid token"));
        }
        
        // Rest of the code...
    }
}

Here, we extract the JWT token from the Authorization header and verify it using our JwtUtil component. The code first checks whether the token exists and has the proper Bearer prefix; if it does not, it returns a 401 error. If the token is present, we verify it using jwt.verify() from the com.auth0.jwt library.

Upon successful verification, we extract the user's ID from the token claims using decoded.getClaim("userId").asInt(). If the token is invalid or expired, a 401 error is returned. This authentication layer ensures that only users with valid tokens can proceed to access protected endpoints.

Parameter Validation

After ensuring authentication, we must validate the parameter format and verify access rights. This step ensures that users can only access their own account information.

@GetMapping("/accountInfo")
public ResponseEntity<?> accountInfo(
        @RequestHeader(value = "authorization", required = false) String authorization,
        @RequestParam(name = "id", required = false) String id) {
    
    // Step 1: Check if the Authorization header is present
    if (authorization == null || authorization.isBlank()) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
            .body(new ErrorResponse("Authentication required"));
    }
    
    // Step 2: Extract the JWT token from the Authorization header
    String token = authorization.startsWith("Bearer ") 
        ? authorization.substring(7) 
        : authorization;
    
    try {
        // Step 3: Verify the JWT token and extract user information
        DecodedJWT decoded = jwt.verify(token);
        Integer tokenUserId = decoded.getClaim("userId").asInt();
        
        // Step 4: Validate that the id parameter is provided
        if (id == null || id.isBlank()) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(new ErrorResponse("Missing user ID parameter"));
        }
        
        // Step 5: Parse and validate the id parameter format
        Integer requestedUserId;
        try {
            requestedUserId = Integer.parseInt(id);
        } catch (NumberFormatException e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(new ErrorResponse("Invalid user ID"));
        }
        
        // Step 6: CRITICAL SECURITY CHECK - Authorization
        // Verify that the authenticated user is requesting their own data
        if (!tokenUserId.equals(requestedUserId)) {
            return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(new ErrorResponse("Access denied: account mismatch"));
        }
        
        // Step 7: User is authenticated and authorized - fetch the data
        return users.findById(requestedUserId)
            .<ResponseEntity<?>>map(ResponseEntity::ok)
            .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse("User not found")));
        
    } catch (Exception e) {
        // Token verification failed
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
            .body(new ErrorResponse("Invalid token"));
    }
}

This code performs several crucial checks:

  1. Verifies that the JWT token is present and valid
  2. Ensures the id parameter is present and is a valid integer
  3. Compares the requested id with the authenticated user's ID from the token to ensure users can only access their own data

Once we've verified authentication and authorization, we can safely access the database using the repository!

Conclusion and Next Steps

In this lesson, we've explored the risks associated with unverified account parameters in API endpoints and learned how to secure our code using authentication, input validation, and proper authorization checks.

Important Note: In a real-world secure API, if a user is requesting their own info, you typically don't even ask for the id in the parameter. You simply fetch the ID from the JWT and use that. Including it in the parameter is redundant and creates a surface for attackers to test.

As you move on to the practice exercises, focus on applying these secure coding practices to reinforce your understanding. In the next lesson, we'll continue to build on this foundation by exploring other common vulnerabilities and their mitigations. Keep up the great work! 🌟

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal