diff --git a/scanner/sast/expectedIssues.csv b/scanner/sast/expectedIssues.csv index 66e7f8625..94acaf83d 100644 --- a/scanner/sast/expectedIssues.csv +++ b/scanner/sast/expectedIssues.csv @@ -153,3 +153,7 @@ CWE-79,Reflected XSS,src/main/java/org/sasanlabs/service/vulnerability/xss/refle CWE-79,Reflected XSS,src/main/java/org/sasanlabs/service/vulnerability/xss/reflected/XSSWithHtmlTagInjection.java,73,1 CWE-611,Xxe,src/main/java/org/sasanlabs/service/vulnerability/xxe/XXEVulnerability.java,64,1 CWE-611,Xxe,src/main/java/org/sasanlabs/service/vulnerability/xxe/XXEVulnerability.java,137,1 +CWE-352,Cross-Site Request Forgery,src/main/java/org/sasanlabs/service/vulnerability/csrf/CSRFVulnerability.java,85,1 +CWE-352,Cross-Site Request Forgery,src/main/java/org/sasanlabs/service/vulnerability/csrf/CSRFVulnerability.java,137,1 +CWE-352,Cross-Site Request Forgery,src/main/java/org/sasanlabs/service/vulnerability/csrf/CSRFVulnerability.java,194,1 +CWE-352,Cross-Site Request Forgery,src/main/java/org/sasanlabs/service/vulnerability/csrf/CSRFVulnerability.java,266,1 diff --git a/src/main/java/org/sasanlabs/configuration/VulnerableAppConfiguration.java b/src/main/java/org/sasanlabs/configuration/VulnerableAppConfiguration.java index 222785ad1..71d9d00f4 100755 --- a/src/main/java/org/sasanlabs/configuration/VulnerableAppConfiguration.java +++ b/src/main/java/org/sasanlabs/configuration/VulnerableAppConfiguration.java @@ -145,6 +145,8 @@ public DataSourceInitializer adminDataSourceInitializer( populator.addScript(new ClassPathResource("scripts/CryptographicFailures/db/schema.sql")); populator.addScript(new ClassPathResource("scripts/SessionManagement/db/schema.sql")); populator.addScript(new ClassPathResource("scripts/SessionManagement/db/data.sql")); + populator.addScript(new ClassPathResource("scripts/CSRF/db/schema.sql")); + populator.addScript(new ClassPathResource("scripts/CSRF/db/data.sql")); populator.setSeparator(";"); DataSourceInitializer initializer = new DataSourceInitializer(); diff --git a/src/main/java/org/sasanlabs/service/vulnerability/csrf/CSRFAccount.java b/src/main/java/org/sasanlabs/service/vulnerability/csrf/CSRFAccount.java new file mode 100644 index 000000000..cadccb81e --- /dev/null +++ b/src/main/java/org/sasanlabs/service/vulnerability/csrf/CSRFAccount.java @@ -0,0 +1,51 @@ +package org.sasanlabs.service.vulnerability.csrf; + +/** Represents a CSRF lab account. */ +public class CSRFAccount { + + private int id; + private String username; + private String email; + private String storedPassword; + + public CSRFAccount(int id, String username, String email) { + this.id = id; + this.username = username; + this.email = email; + } + + public CSRFAccount(int id, String username, String storedPassword, String email) { + this.id = id; + this.username = username; + this.storedPassword = storedPassword; + this.email = email; + } + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getStoredPassword() { + return storedPassword; + } +} diff --git a/src/main/java/org/sasanlabs/service/vulnerability/csrf/CSRFSessionService.java b/src/main/java/org/sasanlabs/service/vulnerability/csrf/CSRFSessionService.java new file mode 100644 index 000000000..03adc5f21 --- /dev/null +++ b/src/main/java/org/sasanlabs/service/vulnerability/csrf/CSRFSessionService.java @@ -0,0 +1,231 @@ +package org.sasanlabs.service.vulnerability.csrf; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.sasanlabs.internal.utility.PasswordHashingUtils; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Manages CSRF lab sessions and CSRF tokens. + * + *

Each level uses its own table, cookie and session namespace so that a state change in one + * level never affects the others. + */ +@Service +public class CSRFSessionService { + + private static final String INVALID_CREDENTIALS = "Invalid credentials"; + private static final String NOT_LOGGED_IN = "Please login first"; + private static final String INVALID_TOKEN = "Invalid or missing CSRF token"; + private static final String INVALID_SESSION = "Invalid session"; + + private static final long SESSION_TTL_MILLIS = 3600_000L; + + private final JdbcTemplate jdbcTemplate; + private final SecureRandom secureRandom = new SecureRandom(); + + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); + + public CSRFSessionService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + /** Returns the account if credentials are valid, null otherwise. */ + public CSRFAccount authenticate(int level, String username, String password) { + List accounts = + jdbcTemplate.query( + "SELECT id, username, password, email FROM csrf_accounts_l" + + level + + " WHERE username=?", + new Object[] {username}, + (rs, rowNum) -> + new CSRFAccount( + rs.getInt("id"), + rs.getString("username"), + rs.getString("password"), + rs.getString("email"))); + + return accounts.stream() + .filter( + account -> + account.getStoredPassword() != null + && account.getStoredPassword().contains(":") + && PasswordHashingUtils.isValidSaltedSha256( + password, account.getStoredPassword())) + .map( + account -> + new CSRFAccount( + account.getId(), account.getUsername(), account.getEmail())) + .findFirst() + .orElse(null); + } + + /** Creates a new session for the account and returns the opaque session token. */ + public String createSession(int level, CSRFAccount account) { + String sessionToken = randomHex(32); + evictExpiredSessions(); + sessions.put( + sessionKey(level, sessionToken), + new CSRFLoginSession(account.getUsername(), account.getEmail())); + return sessionToken; + } + + private void evictExpiredSessions() { + long now = System.currentTimeMillis(); + sessions.entrySet() + .removeIf(entry -> now - entry.getValue().getCreatedAt() > SESSION_TTL_MILLIS); + } + + /** Returns a securely generated CSRF token stored with the session. */ + public String createSecureCsrfToken(int level, String sessionToken) { + String token = randomHex(32); + CSRFLoginSession session = getSession(level, sessionToken); + if (session == null) { + throw new IllegalArgumentException(INVALID_SESSION); + } + session.setCsrfToken(token); + return token; + } + + /** Returns a predictably generated CSRF token (timestamp based, weak by design). */ + public String createWeakCsrfToken(CSRFAccount account) { + return account.getUsername() + ":" + (System.currentTimeMillis() / 1000); + } + + public CSRFLoginSession getSession(int level, String sessionToken) { + String key = sessionKey(level, sessionToken); + CSRFLoginSession session = sessions.get(key); + if (session != null) { + long now = System.currentTimeMillis(); + if (now - session.getCreatedAt() > SESSION_TTL_MILLIS) { + sessions.remove(key); + return null; + } + } + return session; + } + + /** + * Validates a weak (predictable) token of the form {@code username:epochSeconds}. The token + * remains valid for the entire session lifetime because the format itself is the weakness -- + * any attacker who knows the username can forge a token with the current timestamp. + */ + public boolean isValidWeakToken(CSRFLoginSession session, String tokenHeaderValue) { + if (session == null || tokenHeaderValue == null) { + return false; + } + String[] parts = tokenHeaderValue.split(":", 2); + if (parts.length != 2 || !parts[0].equals(session.getUsername())) { + return false; + } + try { + Long.parseLong(parts[1]); + return true; + } catch (NumberFormatException e) { + return false; + } + } + + /** Constant-time comparison against the token stored with the session. */ + public boolean isValidSecureToken(CSRFLoginSession session, String tokenHeaderValue) { + if (session == null || session.getCsrfToken() == null || tokenHeaderValue == null) { + return false; + } + return MessageDigest.isEqual( + session.getCsrfToken().getBytes(StandardCharsets.UTF_8), + tokenHeaderValue.getBytes(StandardCharsets.UTF_8)); + } + + /** Updates the email for the given account id in the level's table. */ + public void updateEmail(int level, int accountId, String newEmail) { + jdbcTemplate.update( + "UPDATE csrf_accounts_l" + level + " SET email=? WHERE id=?", newEmail, accountId); + } + + public String emailOf(int level, String sessionToken) { + CSRFLoginSession session = getSession(level, sessionToken); + return session == null ? null : session.getEmail(); + } + + public CSRFAccount accountByUsername(int level, String username) { + List accounts = + jdbcTemplate.query( + "SELECT id, username, email FROM csrf_accounts_l" + + level + + " WHERE username=?", + new Object[] {username}, + (rs, rowNum) -> + new CSRFAccount( + rs.getInt("id"), + rs.getString("username"), + rs.getString("email"))); + return accounts.isEmpty() ? null : accounts.get(0); + } + + public void refreshSessionEmail(int level, String sessionToken) { + CSRFLoginSession session = getSession(level, sessionToken); + if (session != null) { + CSRFAccount account = accountByUsername(level, session.getUsername()); + if (account != null) { + session.setEmail(account.getEmail()); + } + } + } + + private String randomHex(int numBytes) { + byte[] bytes = new byte[numBytes]; + secureRandom.nextBytes(bytes); + StringBuilder sb = new StringBuilder(numBytes * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private String sessionKey(int level, String sessionToken) { + return "L" + level + ":" + sessionToken; + } + + /** Simple in-memory session holding the logged in account and optional CSRF token. */ + public static class CSRFLoginSession { + + private final String username; + private volatile String email; + private volatile String csrfToken; + private final long createdAt = System.currentTimeMillis(); + + public CSRFLoginSession(String username, String email) { + this.username = username; + this.email = email; + } + + public String getUsername() { + return username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getCsrfToken() { + return csrfToken; + } + + public void setCsrfToken(String csrfToken) { + this.csrfToken = csrfToken; + } + + public long getCreatedAt() { + return createdAt; + } + } +} diff --git a/src/main/java/org/sasanlabs/service/vulnerability/csrf/CSRFVulnerability.java b/src/main/java/org/sasanlabs/service/vulnerability/csrf/CSRFVulnerability.java new file mode 100644 index 000000000..17908895e --- /dev/null +++ b/src/main/java/org/sasanlabs/service/vulnerability/csrf/CSRFVulnerability.java @@ -0,0 +1,448 @@ +package org.sasanlabs.service.vulnerability.csrf; + +import java.util.function.BiFunction; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.sasanlabs.internal.utility.LevelConstants; +import org.sasanlabs.internal.utility.Variant; +import org.sasanlabs.internal.utility.annotations.AttackVector; +import org.sasanlabs.internal.utility.annotations.ChallengeCard; +import org.sasanlabs.internal.utility.annotations.VulnerableAppRequestMapping; +import org.sasanlabs.internal.utility.annotations.VulnerableAppRestController; +import org.sasanlabs.service.vulnerability.bean.GenericVulnerabilityResponseBean; +import org.sasanlabs.vulnerability.types.VulnerabilityType; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * CSRF (Cross-Site Request Forgery) vulnerability lab. + * + *

Demonstrates how CSRF works against a modern REST API where authenticated users perform + * state-changing actions. The victim's session is carried in a cookie; depending on the level the + * backend either performs no CSRF protection at all, accepts a token without validating it, + * validates a predictable token, exposes the state change over GET, or enforces proper protection. + * + * @author mmustafasenoglu + */ +@Profile("public") +@VulnerableAppRestController(descriptionLabel = "CSRF_VULNERABILITY", value = "CSRFVulnerability") +public class CSRFVulnerability { + + private static final String CSRF_TOKEN_HEADER = "X-CSRF-Token"; + private static final String NOT_LOGGED_IN = "Please login first"; + private static final String INVALID_CREDENTIALS = "Invalid credentials"; + private static final String INVALID_TOKEN = "Invalid or missing CSRF token"; + private static final String EMAIL_UPDATED = "Email updated successfully"; + + private final CSRFSessionService csrfSessionService; + + public CSRFVulnerability(CSRFSessionService csrfSessionService) { + this.csrfSessionService = csrfSessionService; + } + + // ------------------------------------------------------------------ + // LEVEL 1 - No CSRF protection: session cookie alone is accepted + // ------------------------------------------------------------------ + + @ChallengeCard( + challengeText = "CSRF_LEVEL_1_CHALLENGE", + hints = { + @ChallengeCard.Hint(order = 1, text = "CSRF_LEVEL_1_HINT_1"), + @ChallengeCard.Hint(order = 2, text = "CSRF_LEVEL_1_HINT_2"), + @ChallengeCard.Hint(order = 3, text = "CSRF_LEVEL_1_HINT_3") + }, + payload = + @ChallengeCard.Payload( + description = "CSRF_LEVEL_1_PAYLOAD_DESCRIPTION", + value = "CSRF_PAYLOAD_LEVEL_1")) + @AttackVector( + vulnerabilityExposed = VulnerabilityType.CROSS_SITE_REQUEST_FORGERY, + description = "CSRF_LEVEL_1_NO_CSRF_PROTECTION") + @VulnerableAppRequestMapping( + value = LevelConstants.LEVEL_1, + requestMethod = RequestMethod.POST, + htmlTemplate = "LEVEL_1/CSRF") + public ResponseEntity> level1Login( + @RequestParam String username, + @RequestParam String password, + HttpServletRequest request, + HttpServletResponse response) { + return login(1, username, password, "csrf_session_l1", request, response); + } + + @VulnerableAppRequestMapping( + value = "LEVEL_1/changeEmail", + requestMethod = RequestMethod.POST, + htmlTemplate = "LEVEL_1/CSRF") + public ResponseEntity> level1ChangeEmail( + @CookieValue(value = "csrf_session_l1", required = false) String sessionToken, + @RequestParam String email) { + return changeEmail(1, sessionToken, email, null, null); + } + + @VulnerableAppRequestMapping( + value = "LEVEL_1/account", + requestMethod = RequestMethod.GET, + htmlTemplate = "LEVEL_1/CSRF") + public ResponseEntity> level1Account( + @CookieValue(value = "csrf_session_l1", required = false) String sessionToken) { + return accountResponse(1, sessionToken); + } + + // ------------------------------------------------------------------ + // LEVEL 2 - Token is sent but never validated + // ------------------------------------------------------------------ + + @ChallengeCard( + challengeText = "CSRF_LEVEL_2_CHALLENGE", + hints = { + @ChallengeCard.Hint(order = 1, text = "CSRF_LEVEL_2_HINT_1"), + @ChallengeCard.Hint(order = 2, text = "CSRF_LEVEL_2_HINT_2"), + @ChallengeCard.Hint(order = 3, text = "CSRF_LEVEL_2_HINT_3") + }, + payload = + @ChallengeCard.Payload( + description = "CSRF_LEVEL_2_PAYLOAD_DESCRIPTION", + value = "CSRF_PAYLOAD_LEVEL_2")) + @AttackVector( + vulnerabilityExposed = VulnerabilityType.CROSS_SITE_REQUEST_FORGERY, + description = "CSRF_LEVEL_2_TOKEN_NOT_VALIDATED") + @VulnerableAppRequestMapping( + value = LevelConstants.LEVEL_2, + requestMethod = RequestMethod.POST, + htmlTemplate = "LEVEL_2/CSRF") + public ResponseEntity> level2Login( + @RequestParam String username, + @RequestParam String password, + HttpServletRequest request, + HttpServletResponse response) { + return login(2, username, password, "csrf_session_l2", request, response); + } + + @VulnerableAppRequestMapping( + value = "LEVEL_2/changeEmail", + requestMethod = RequestMethod.POST, + htmlTemplate = "LEVEL_2/CSRF") + public ResponseEntity> level2ChangeEmail( + @CookieValue(value = "csrf_session_l2", required = false) String sessionToken, + @RequestHeader(value = CSRF_TOKEN_HEADER, required = false) String csrfToken, + @RequestParam String email) { + // The token is read from the request but its value is never compared + // against anything - the state change succeeds with any (or no) token. + return changeEmail(2, sessionToken, email, csrfToken, null); + } + + @VulnerableAppRequestMapping( + value = "LEVEL_2/account", + requestMethod = RequestMethod.GET, + htmlTemplate = "LEVEL_2/CSRF") + public ResponseEntity> level2Account( + @CookieValue(value = "csrf_session_l2", required = false) String sessionToken) { + return accountResponse(2, sessionToken); + } + + // ------------------------------------------------------------------ + // LEVEL 3 - Token is validated but generated predictably + // ------------------------------------------------------------------ + + @ChallengeCard( + challengeText = "CSRF_LEVEL_3_CHALLENGE", + hints = { + @ChallengeCard.Hint(order = 1, text = "CSRF_LEVEL_3_HINT_1"), + @ChallengeCard.Hint(order = 2, text = "CSRF_LEVEL_3_HINT_2"), + @ChallengeCard.Hint(order = 3, text = "CSRF_LEVEL_3_HINT_3") + }, + payload = + @ChallengeCard.Payload( + description = "CSRF_LEVEL_3_PAYLOAD_DESCRIPTION", + value = "CSRF_PAYLOAD_LEVEL_3")) + @AttackVector( + vulnerabilityExposed = VulnerabilityType.CROSS_SITE_REQUEST_FORGERY, + description = "CSRF_LEVEL_3_WEAK_TOKEN_GENERATION") + @VulnerableAppRequestMapping( + value = LevelConstants.LEVEL_3, + requestMethod = RequestMethod.POST, + htmlTemplate = "LEVEL_3/CSRF") + public ResponseEntity> level3Login( + @RequestParam String username, + @RequestParam String password, + HttpServletRequest request, + HttpServletResponse response) { + return login(3, username, password, "csrf_session_l3", request, response); + } + + @VulnerableAppRequestMapping( + value = "LEVEL_3/changeEmail", + requestMethod = RequestMethod.POST, + htmlTemplate = "LEVEL_3/CSRF") + public ResponseEntity> level3ChangeEmail( + @CookieValue(value = "csrf_session_l3", required = false) String sessionToken, + @RequestHeader(value = CSRF_TOKEN_HEADER, required = false) String csrfTokenHeader, + @RequestParam(value = "csrfToken", required = false) String csrfTokenParam, + @RequestParam String email) { + String csrfToken = csrfTokenHeader != null ? csrfTokenHeader : csrfTokenParam; + return changeEmail( + 3, + sessionToken, + email, + csrfToken, + (session, token) -> csrfSessionService.isValidWeakToken(session, token)); + } + + @VulnerableAppRequestMapping( + value = "LEVEL_3/account", + requestMethod = RequestMethod.GET, + htmlTemplate = "LEVEL_3/CSRF") + public ResponseEntity> level3Account( + @CookieValue(value = "csrf_session_l3", required = false) String sessionToken) { + return accountResponse(3, sessionToken); + } + + // ------------------------------------------------------------------ + // LEVEL 4 - POST is protected, but GET performs the state change + // ------------------------------------------------------------------ + + @ChallengeCard( + challengeText = "CSRF_LEVEL_4_CHALLENGE", + hints = { + @ChallengeCard.Hint(order = 1, text = "CSRF_LEVEL_4_HINT_1"), + @ChallengeCard.Hint(order = 2, text = "CSRF_LEVEL_4_HINT_2"), + @ChallengeCard.Hint(order = 3, text = "CSRF_LEVEL_4_HINT_3") + }, + payload = + @ChallengeCard.Payload( + description = "CSRF_LEVEL_4_PAYLOAD_DESCRIPTION", + value = "CSRF_PAYLOAD_LEVEL_4")) + @AttackVector( + vulnerabilityExposed = VulnerabilityType.CROSS_SITE_REQUEST_FORGERY, + description = "CSRF_LEVEL_4_STATE_CHANGING_GET") + @VulnerableAppRequestMapping( + value = LevelConstants.LEVEL_4, + requestMethod = RequestMethod.POST, + htmlTemplate = "LEVEL_4/CSRF") + public ResponseEntity> level4Login( + @RequestParam String username, + @RequestParam String password, + HttpServletRequest request, + HttpServletResponse response) { + return login(4, username, password, "csrf_session_l4", request, response); + } + + @VulnerableAppRequestMapping( + value = "LEVEL_4/changeEmail", + requestMethod = RequestMethod.POST, + htmlTemplate = "LEVEL_4/CSRF") + public ResponseEntity> level4ChangeEmail( + @CookieValue(value = "csrf_session_l4", required = false) String sessionToken, + @RequestHeader(value = CSRF_TOKEN_HEADER, required = false) String csrfToken, + @RequestParam String email) { + return changeEmail( + 4, sessionToken, email, csrfToken, csrfSessionService::isValidSecureToken); + } + + // The state-changing operation is also exposed over GET without any CSRF + // check - a top-level navigation (e.g. a link in a phishing email) carries + // the victim's session cookie and triggers the change. + @VulnerableAppRequestMapping( + value = "LEVEL_4/changeEmail", + requestMethod = RequestMethod.GET, + htmlTemplate = "LEVEL_4/CSRF") + public ResponseEntity> level4ChangeEmailViaGet( + @CookieValue(value = "csrf_session_l4", required = false) String sessionToken, + @RequestParam String email) { + return changeEmail(4, sessionToken, email, null, null); + } + + @VulnerableAppRequestMapping( + value = "LEVEL_4/account", + requestMethod = RequestMethod.GET, + htmlTemplate = "LEVEL_4/CSRF") + public ResponseEntity> level4Account( + @CookieValue(value = "csrf_session_l4", required = false) String sessionToken) { + return accountResponse(4, sessionToken); + } + + // ------------------------------------------------------------------ + // LEVEL 5 - Secure CSRF protection + // ------------------------------------------------------------------ + + @ChallengeCard( + challengeText = "CSRF_LEVEL_5_CHALLENGE", + hints = { + @ChallengeCard.Hint(order = 1, text = "CSRF_LEVEL_5_HINT_1"), + @ChallengeCard.Hint(order = 2, text = "CSRF_LEVEL_5_HINT_2"), + @ChallengeCard.Hint(order = 3, text = "CSRF_LEVEL_5_HINT_3") + }, + payload = + @ChallengeCard.Payload( + description = "CSRF_LEVEL_5_PAYLOAD_DESCRIPTION", + value = "CSRF_PAYLOAD_LEVEL_5")) + @AttackVector( + vulnerabilityExposed = VulnerabilityType.CROSS_SITE_REQUEST_FORGERY, + description = "CSRF_LEVEL_5_SECURE_PROTECTION") + @VulnerableAppRequestMapping( + value = LevelConstants.LEVEL_5, + requestMethod = RequestMethod.POST, + htmlTemplate = "LEVEL_5/CSRF", + variant = Variant.SECURE) + public ResponseEntity> level5Login( + @RequestParam String username, + @RequestParam String password, + HttpServletRequest request, + HttpServletResponse response) { + return login(5, username, password, "csrf_session_l5", request, response); + } + + @VulnerableAppRequestMapping( + value = "LEVEL_5/changeEmail", + requestMethod = RequestMethod.POST, + htmlTemplate = "LEVEL_5/CSRF", + variant = Variant.SECURE) + public ResponseEntity> level5ChangeEmail( + @CookieValue(value = "csrf_session_l5", required = false) String sessionToken, + @RequestHeader(value = CSRF_TOKEN_HEADER, required = false) String csrfToken, + @RequestParam String email) { + return changeEmail( + 5, sessionToken, email, csrfToken, csrfSessionService::isValidSecureToken); + } + + @VulnerableAppRequestMapping( + value = "LEVEL_5/account", + requestMethod = RequestMethod.GET, + htmlTemplate = "LEVEL_5/CSRF", + variant = Variant.SECURE) + public ResponseEntity> level5Account( + @CookieValue(value = "csrf_session_l5", required = false) String sessionToken) { + return accountResponse(5, sessionToken); + } + + // ------------------------------------------------------------------ + // Shared helpers + // ------------------------------------------------------------------ + + private ResponseEntity> login( + int level, + String username, + String password, + String cookieName, + HttpServletRequest request, + HttpServletResponse response) { + CSRFAccount account = csrfSessionService.authenticate(level, username, password); + if (account == null) { + return response(INVALID_CREDENTIALS, false); + } + + String sessionToken = csrfSessionService.createSession(level, account); + if (level == 5) { + ResponseCookie.ResponseCookieBuilder cookieBuilder = + ResponseCookie.from(cookieName, sessionToken) + .httpOnly(true) + .path("/") + .maxAge(3600) + .sameSite("Lax"); + if (request.isSecure()) { + cookieBuilder.secure(true); + } + response.addHeader(HttpHeaders.SET_COOKIE, cookieBuilder.build().toString()); + } else { + Cookie sessionCookie = new Cookie(cookieName, sessionToken); + sessionCookie.setHttpOnly(true); + sessionCookie.setPath("/"); + sessionCookie.setMaxAge(3600); + sessionCookie.setSecure(request.isSecure()); + response.addCookie(sessionCookie); + } + + // The CSRF token is delivered during login and kept in the page + // (React-like runtime memory) rather than in a cookie. + if (level == 2 || level == 4 || level == 5) { + String csrfToken = csrfSessionService.createSecureCsrfToken(level, sessionToken); + return response(new CSRFLoginResponse(account, csrfToken), true); + } + if (level == 3) { + return response( + new CSRFLoginResponse(account, csrfSessionService.createWeakCsrfToken(account)), + true); + } + return response(account, true); + } + + private ResponseEntity> changeEmail( + int level, + String sessionToken, + String email, + String csrfToken, + BiFunction tokenValidator) { + CSRFSessionService.CSRFLoginSession session = + csrfSessionService.getSession(level, sessionToken); + if (session == null) { + return response(NOT_LOGGED_IN, false, HttpStatus.UNAUTHORIZED); + } + + if (tokenValidator != null && !tokenValidator.apply(session, csrfToken)) { + return response(INVALID_TOKEN, false, HttpStatus.FORBIDDEN); + } + + CSRFAccount account = csrfSessionService.accountByUsername(level, session.getUsername()); + if (account == null) { + return response(NOT_LOGGED_IN, false, HttpStatus.UNAUTHORIZED); + } + csrfSessionService.updateEmail(level, account.getId(), email); + csrfSessionService.refreshSessionEmail(level, sessionToken); + return response(EMAIL_UPDATED, true); + } + + private ResponseEntity> accountResponse( + int level, String sessionToken) { + CSRFSessionService.CSRFLoginSession session = + csrfSessionService.getSession(level, sessionToken); + if (session == null) { + return response(NOT_LOGGED_IN, false, HttpStatus.UNAUTHORIZED); + } + CSRFAccount account = csrfSessionService.accountByUsername(level, session.getUsername()); + if (account == null) { + return response(NOT_LOGGED_IN, false, HttpStatus.UNAUTHORIZED); + } + return response(account, true); + } + + private ResponseEntity> response( + Object content, boolean isValid) { + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean<>(content, isValid), HttpStatus.OK); + } + + private ResponseEntity> response( + Object content, boolean isValid, HttpStatus status) { + return new ResponseEntity<>( + new GenericVulnerabilityResponseBean<>(content, isValid), status); + } + + /** Response bean carrying the logged in account and (when applicable) the CSRF token. */ + public static class CSRFLoginResponse { + + private final CSRFAccount account; + private final String csrfToken; + + public CSRFLoginResponse(CSRFAccount account, String csrfToken) { + this.account = account; + this.csrfToken = csrfToken; + } + + public CSRFAccount getAccount() { + return account; + } + + public String getCsrfToken() { + return csrfToken; + } + } +} diff --git a/src/main/java/org/sasanlabs/vulnerability/types/VulnerabilityType.java b/src/main/java/org/sasanlabs/vulnerability/types/VulnerabilityType.java index 668b4d05e..de4065441 100644 --- a/src/main/java/org/sasanlabs/vulnerability/types/VulnerabilityType.java +++ b/src/main/java/org/sasanlabs/vulnerability/types/VulnerabilityType.java @@ -49,6 +49,9 @@ public enum VulnerabilityType { // IDOR - Broken Access Control INSECURE_DIRECT_OBJECT_REFERENCE(639, 13), + // CSRF - Cross-Site Request Forgery + CROSS_SITE_REQUEST_FORGERY(352, 9), + // Clickjacking CLICKJACKING(1021, null), diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index aff4b3b06..3e72d3ac3 100755 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -1064,3 +1064,61 @@ PERSISTENT_XSS_LEVEL7_HINT2=Even the null-byte-prefix trick that worked on level PERSISTENT_XSS_LEVEL7_PAYLOAD_DESCRIPTION=The same null-byte-prefixed script payload that broke level 6 is now fully escaped and rendered as inert text. PERSISTENT_XSS_LEVEL7_PAYLOAD_VALUE=%00 BLIND_SQL_INJECTION_VULNERABILITY_LEVEL1_CHALLENGE2_PAYLOAD_VALUE=1 AND (SELECT SUBSTRING(password,1,1) FROM auth_users WHERE username='admin_sqli')='n' + +# ------------------------------------------- +# CSRF Vulnerability Lab +# ------------------------------------------- +CSRF_VULNERABILITY=Cross-Site Request Forgery (CSRF) is an attack that forces an authenticated user to execute unwanted state-changing actions in a web application. \ +The browser automatically attaches the victim's authentication cookies to requests, so a malicious site can submit a forged request that the server cannot distinguish from a legitimate one. \ +CSRF is commonly discussed in the Broken Access Control category in the OWASP security framework and the OWASP Top 10.\

\ +This lab demonstrates the classic CSRF progression against a REST API: no protection, a token that is not validated, a predictable token, a state-changing GET request, and finally a properly protected implementation.\

\ +Important Links:
\ +

    \ +
  1. OWASP CSRF
  2. \ +
  3. CWE-352: Cross-Site Request Forgery
  4. \ +
+ +CSRF_LEVEL_1_NO_CSRF_PROTECTION=The change email endpoint relies only on the session cookie. No CSRF token is generated or validated. + +CSRF_LEVEL_1_CHALLENGE=You are the attacker. The victim (victim / Victim@123) is already logged in. Change the victim's account email to attacker@example.com without knowing the victim's password. +CSRF_LEVEL_1_HINT_1=Login as the victim in this page to obtain the session cookie. +CSRF_LEVEL_1_HINT_2=The change email endpoint only checks the session cookie. Create a request that includes the victim's session cookie but is sent from an external origin. +CSRF_LEVEL_1_HINT_3=For example with curl: curl -X POST -b "csrf_session_l1=" -d "email=attacker@example.com" http://localhost:9090/VulnerableApp/CSRFVulnerability/LEVEL_1/changeEmail +CSRF_LEVEL_1_PAYLOAD_DESCRIPTION=Cross-site request that changes the victim's email using only the session cookie +CSRF_PAYLOAD_LEVEL_1=curl -X POST -b "csrf_session_l1=VICTIM_SESSION" -d "email=attacker@example.com" http://localhost:9090/VulnerableApp/CSRFVulnerability/LEVEL_1/changeEmail + +CSRF_LEVEL_2_TOKEN_NOT_VALIDATED=The application generates a CSRF token and returns it after login, but the change email endpoint reads the X-CSRF-Token header and never validates it. + +CSRF_LEVEL_2_CHALLENGE=The victim's email change endpoint requires an X-CSRF-Token header. Send any value (or none) in that header and still change the victim's email. +CSRF_LEVEL_2_HINT_1=Login as the victim and note that the page receives a CSRF token after login. +CSRF_LEVEL_2_HINT_2=The server reads the X-CSRF-Token header but ignores its value - there is no comparison anywhere. +CSRF_LEVEL_2_HINT_3=Send the change request with a random header value: curl -X POST -H "X-CSRF-Token: anything" -b "csrf_session_l2=" -d "email=attacker@example.com" http://localhost:9090/VulnerableApp/CSRFVulnerability/LEVEL_2/changeEmail +CSRF_LEVEL_2_PAYLOAD_DESCRIPTION=Cross-site request that changes the victim's email with an unvalidated token header +CSRF_PAYLOAD_LEVEL_2=curl -X POST -H "X-CSRF-Token: 1234" -b "csrf_session_l2=VICTIM_SESSION" -d "email=attacker@example.com" http://localhost:9090/VulnerableApp/CSRFVulnerability/LEVEL_2/changeEmail + +CSRF_LEVEL_3_WEAK_TOKEN_GENERATION=The token is validated server side, but it is generated from the username and the current timestamp, so an attacker can forge a valid token without knowing the victim's password. + +CSRF_LEVEL_3_CHALLENGE=The server validates the CSRF token, but the token is generated from the username and the current timestamp. Forge a valid token for the victim and change the victim's email via a cross-site form submission. +CSRF_LEVEL_3_HINT_1=Login as the attacker (attacker / Attacker@123) and inspect the token returned after login. +CSRF_LEVEL_3_HINT_2=The token format is username:epochSeconds. Any numeric timestamp is accepted. +CSRF_LEVEL_3_HINT_3=Build a token as victim: and submit a cross-site form with the token in a hidden field:
+CSRF_LEVEL_3_PAYLOAD_DESCRIPTION=Cross-site form submission that changes the victim's email using a forged predictable token +CSRF_PAYLOAD_LEVEL_3=
+ +CSRF_LEVEL_4_STATE_CHANGING_GET=The POST endpoint is properly protected with a validated token, but the same state-changing operation is also exposed over GET without any CSRF check. + +CSRF_LEVEL_4_CHALLENGE=The POST change email request is protected by a valid CSRF token. Find another way to trigger the email change without knowing the token. +CSRF_LEVEL_4_HINT_1=Login as the victim and use the page normally to change the email - the POST endpoint validates the token. +CSRF_LEVEL_4_HINT_2=Check whether the same operation is also available over GET. GET requests cannot carry custom headers. +CSRF_LEVEL_4_HINT_3=A top-level navigation carries the session cookie. Open this link while logged in: /VulnerableApp/CSRFVulnerability/LEVEL_4/changeEmail?email=attacker@example.com +CSRF_LEVEL_4_PAYLOAD_DESCRIPTION=Cross-site request that changes the victim's email via a state-changing GET request +CSRF_PAYLOAD_LEVEL_4=Click to change victim's email + +CSRF_LEVEL_5_SECURE_PROTECTION=The application uses an unpredictable server-side token, validates the X-CSRF-Token header on every state change, marks the session cookie with SameSite=Lax and never exposes the operation over GET. + +CSRF_LEVEL_5_CHALLENGE=All previous attacks now fail. Understand why the request is rejected without a valid token, and confirm that the legitimate flow still works. +CSRF_LEVEL_5_HINT_1=Login as the victim and change the email through the page - the flow carries the token received at login. +CSRF_LEVEL_5_HINT_2=Now repeat the same request without the X-CSRF-Token header (or with the wrong value) and observe the rejection. +CSRF_LEVEL_5_HINT_3=The session cookie is also SameSite=Lax, so browsers do not even attach it to cross-site POST requests. +CSRF_LEVEL_5_PAYLOAD_DESCRIPTION=Legitimate request that includes the server-issued CSRF token +CSRF_PAYLOAD_LEVEL_5=curl -X POST -H "X-CSRF-Token: " -b "csrf_session_l5=" -d "email=victim@example.com" http://localhost:9090/VulnerableApp/CSRFVulnerability/LEVEL_5/changeEmail diff --git a/src/main/resources/scripts/CSRF/db/data.sql b/src/main/resources/scripts/CSRF/db/data.sql new file mode 100644 index 000000000..8fe919430 --- /dev/null +++ b/src/main/resources/scripts/CSRF/db/data.sql @@ -0,0 +1,10 @@ +INSERT INTO csrf_accounts_l1 VALUES (1, 'victim', 'CSRF_SALT_L1:f5e2a35c9dac8a96fbe2ddf38a7694cf5177ed293bc6e600236ae8aefb677407', 'victim@example.com'); +INSERT INTO csrf_accounts_l1 VALUES (2, 'attacker', 'CSRF_SALT_L1:d6f84008e9cbadae9ed05181db30fd185ab79266574e388ccda0aaa30f6f525f', 'attacker@example.com'); +INSERT INTO csrf_accounts_l2 VALUES (1, 'victim', 'CSRF_SALT_L2:d49608978e2ed53cf73f57fdba1fc49c77149a237bb52b5db45be28267d4d3d8', 'victim@example.com'); +INSERT INTO csrf_accounts_l2 VALUES (2, 'attacker', 'CSRF_SALT_L2:5aee8e1fc08a785ba8c506ec08afbced0689f20a4b5f6c4c41d4bdd0a08215e5', 'attacker@example.com'); +INSERT INTO csrf_accounts_l3 VALUES (1, 'victim', 'CSRF_SALT_L3:ecad93df0a7647c1ec08c295f48b3a6e7805b936616571b86c67a36c4aa2dd41', 'victim@example.com'); +INSERT INTO csrf_accounts_l3 VALUES (2, 'attacker', 'CSRF_SALT_L3:4e46dc613de300bb4d38a2b291f5e9fa4e4791be1cdacb87876889b7dd24f062', 'attacker@example.com'); +INSERT INTO csrf_accounts_l4 VALUES (1, 'victim', 'CSRF_SALT_L4:188f5110e871f562a71c5ac61415560d2f445a1e2cab733a761703bc155e7a73', 'victim@example.com'); +INSERT INTO csrf_accounts_l4 VALUES (2, 'attacker', 'CSRF_SALT_L4:1d452cb5d992f0481aa9d24b1c5c108fe0a8857132088b3c755a8e9ce2d5e2ee', 'attacker@example.com'); +INSERT INTO csrf_accounts_l5 VALUES (1, 'victim', 'CSRF_SALT_L5:c5896a95c341d90a254c6ce4cbf6b897ef8e94c832035159e2e4ec9e705ce0ed', 'victim@example.com'); +INSERT INTO csrf_accounts_l5 VALUES (2, 'attacker', 'CSRF_SALT_L5:ac33445e397fe2a17338816998d9835da6c85480b96f4e98dba87ca2d6f8b34b', 'attacker@example.com'); \ No newline at end of file diff --git a/src/main/resources/scripts/CSRF/db/schema.sql b/src/main/resources/scripts/CSRF/db/schema.sql new file mode 100644 index 000000000..7f4cf521e --- /dev/null +++ b/src/main/resources/scripts/CSRF/db/schema.sql @@ -0,0 +1,46 @@ +DROP TABLE IF EXISTS csrf_accounts_l1; +DROP TABLE IF EXISTS csrf_accounts_l2; +DROP TABLE IF EXISTS csrf_accounts_l3; +DROP TABLE IF EXISTS csrf_accounts_l4; +DROP TABLE IF EXISTS csrf_accounts_l5; + +CREATE TABLE csrf_accounts_l1 ( + id INT PRIMARY KEY, + username VARCHAR(50), + password VARCHAR(500), + email VARCHAR(100) +); + +CREATE TABLE csrf_accounts_l2 ( + id INT PRIMARY KEY, + username VARCHAR(50), + password VARCHAR(500), + email VARCHAR(100) +); + +CREATE TABLE csrf_accounts_l3 ( + id INT PRIMARY KEY, + username VARCHAR(50), + password VARCHAR(500), + email VARCHAR(100) +); + +CREATE TABLE csrf_accounts_l4 ( + id INT PRIMARY KEY, + username VARCHAR(50), + password VARCHAR(500), + email VARCHAR(100) +); + +CREATE TABLE csrf_accounts_l5 ( + id INT PRIMARY KEY, + username VARCHAR(50), + password VARCHAR(500), + email VARCHAR(100) +); + +GRANT SELECT, UPDATE ON csrf_accounts_l1 TO application; +GRANT SELECT, UPDATE ON csrf_accounts_l2 TO application; +GRANT SELECT, UPDATE ON csrf_accounts_l3 TO application; +GRANT SELECT, UPDATE ON csrf_accounts_l4 TO application; +GRANT SELECT, UPDATE ON csrf_accounts_l5 TO application; \ No newline at end of file diff --git a/src/main/resources/static/templates/CSRFVulnerability/LEVEL_1/CSRF.css b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_1/CSRF.css new file mode 100644 index 000000000..9100a5928 --- /dev/null +++ b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_1/CSRF.css @@ -0,0 +1,123 @@ +.csrf-shell { + width: 100%; + max-width: 600px; + margin: 12px auto; + padding: 6px; + box-sizing: border-box; + text-align: center; + color: black; +} + +.csrf-title { + margin: 0 0 6px; + font-size: 22px; +} + +.csrf-subtitle { + margin: 0 0 12px; + font-size: 14px; +} + +.csrf-card { + border: 1px solid black; + border-radius: 4px; + border-left: 6px solid burlywood; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.55); +} + +.csrf-card + .csrf-card { + margin-top: 16px; +} + +.csrf-card h5 { + margin: 0 0 8px; + font-size: 16px; + font-weight: bold; +} + +.csrf-row { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.csrf-field { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} + +.csrf-field label { + font-size: 12px; + font-weight: bold; +} + +.csrf-field input, +.csrf-field select { + padding: 4px 6px; + border: 1px solid black; + border-radius: 3px; + font-size: 13px; + min-width: 150px; +} + +.csrf-card button { + padding: 5px 14px; + border: 1px solid black; + border-radius: 3px; + background: burlywood; + color: black; + font-weight: bold; + cursor: pointer; +} + +.csrf-card button:hover { + background: #d2a55c; +} + +.status-message { + margin-top: 6px; + font-size: 13px; + font-weight: bold; +} + +.login-pill { + display: inline-block; + margin-top: 6px; + padding: 4px 10px; + border-radius: 12px; + background: #d4edda; + border: 1px solid #28a745; + font-size: 13px; +} + +.token-box { + margin-top: 6px; + padding: 6px 8px; + background: #f8f9fa; + border: 1px dashed #888; + border-radius: 3px; + font-family: monospace; + font-size: 12px; + word-break: break-all; + text-align: left; +} + +.csrf-attack-note { + margin-top: 8px; + padding: 8px; + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 4px; + font-size: 13px; + text-align: left; +} + +.hidden { + display: none; +} diff --git a/src/main/resources/static/templates/CSRFVulnerability/LEVEL_1/CSRF.html b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_1/CSRF.html new file mode 100644 index 000000000..1519b2d11 --- /dev/null +++ b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_1/CSRF.html @@ -0,0 +1,52 @@ +
+

CSRF Vulnerability - Level 1

+

+ No CSRF protection: the email change endpoint only checks the session + cookie. +

+ +
+
Login
+
+
+ + +
+
+ + + +
+ +
+ +
+
+ +
+
Change Email
+
+
+ + +
+ +
+
+
+ +
+
Account
+
+ +
+
+ diff --git a/src/main/resources/static/templates/CSRFVulnerability/LEVEL_1/CSRF.js b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_1/CSRF.js new file mode 100644 index 000000000..56086a4e0 --- /dev/null +++ b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_1/CSRF.js @@ -0,0 +1,96 @@ +(function () { + const username = document.getElementById("csrf_l1_username"); + const password = document.getElementById("csrf_l1_password"); + const newEmail = document.getElementById("csrf_l1_new_email"); + const loginState = document.getElementById("csrf_l1_login_state"); + const loginMsg = document.getElementById("csrf_l1_login_msg"); + const changeMsg = document.getElementById("csrf_l1_change_msg"); + const accountMsg = document.getElementById("csrf_l1_account_msg"); + + function showLoginState(account) { + loginState.classList.remove("hidden"); + loginState.textContent = ""; + var pill = document.createElement("div"); + pill.className = "login-pill"; + pill.appendChild(document.createTextNode("Logged in as ")); + var b = document.createElement("b"); + b.textContent = account.username; + pill.appendChild(b); + pill.appendChild( + document.createTextNode(" (email: " + account.email + ")") + ); + loginState.appendChild(pill); + } + + document + .getElementById("csrf_l1_login_btn") + .addEventListener("click", function () { + loginMsg.innerHTML = ""; + const data = + "username=" + + encodeURIComponent(username.value) + + "&password=" + + encodeURIComponent(password.value); + doPostAjaxCall( + function (response, xhr) { + if (response.isValid) { + showLoginState(response.content); + loginMsg.innerHTML = + 'Login successful'; + } else { + loginMsg.innerHTML = + '' + response.content + ""; + } + }, + getUrlForVulnerabilityLevel(), + true, + data + ); + }); + + document + .getElementById("csrf_l1_change_btn") + .addEventListener("click", function () { + changeMsg.innerHTML = ""; + const data = "email=" + encodeURIComponent(newEmail.value); + doPostAjaxCall( + function (response, xhr) { + if (response.isValid) { + changeMsg.innerHTML = + '' + response.content + ""; + } else { + changeMsg.innerHTML = + '' + response.content + ""; + } + }, + getUrlForVulnerabilityLevel() + "/changeEmail", + true, + data + ); + }); + + document + .getElementById("csrf_l1_account_btn") + .addEventListener("click", function () { + accountMsg.innerHTML = ""; + doGetAjaxCall( + function (response, xhr) { + if (response.isValid) { + accountMsg.textContent = ""; + var s = document.createElement("span"); + s.style.color = "green"; + s.textContent = "Current email: " + response.content.email; + accountMsg.appendChild(s); + } else { + accountMsg.textContent = ""; + var s = document.createElement("span"); + s.style.color = "red"; + s.textContent = response.content; + accountMsg.appendChild(s); + } + }, + getUrlForVulnerabilityLevel() + "/account", + true + ); + }); +})(); diff --git a/src/main/resources/static/templates/CSRFVulnerability/LEVEL_2/CSRF.css b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_2/CSRF.css new file mode 100644 index 000000000..9100a5928 --- /dev/null +++ b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_2/CSRF.css @@ -0,0 +1,123 @@ +.csrf-shell { + width: 100%; + max-width: 600px; + margin: 12px auto; + padding: 6px; + box-sizing: border-box; + text-align: center; + color: black; +} + +.csrf-title { + margin: 0 0 6px; + font-size: 22px; +} + +.csrf-subtitle { + margin: 0 0 12px; + font-size: 14px; +} + +.csrf-card { + border: 1px solid black; + border-radius: 4px; + border-left: 6px solid burlywood; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.55); +} + +.csrf-card + .csrf-card { + margin-top: 16px; +} + +.csrf-card h5 { + margin: 0 0 8px; + font-size: 16px; + font-weight: bold; +} + +.csrf-row { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.csrf-field { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} + +.csrf-field label { + font-size: 12px; + font-weight: bold; +} + +.csrf-field input, +.csrf-field select { + padding: 4px 6px; + border: 1px solid black; + border-radius: 3px; + font-size: 13px; + min-width: 150px; +} + +.csrf-card button { + padding: 5px 14px; + border: 1px solid black; + border-radius: 3px; + background: burlywood; + color: black; + font-weight: bold; + cursor: pointer; +} + +.csrf-card button:hover { + background: #d2a55c; +} + +.status-message { + margin-top: 6px; + font-size: 13px; + font-weight: bold; +} + +.login-pill { + display: inline-block; + margin-top: 6px; + padding: 4px 10px; + border-radius: 12px; + background: #d4edda; + border: 1px solid #28a745; + font-size: 13px; +} + +.token-box { + margin-top: 6px; + padding: 6px 8px; + background: #f8f9fa; + border: 1px dashed #888; + border-radius: 3px; + font-family: monospace; + font-size: 12px; + word-break: break-all; + text-align: left; +} + +.csrf-attack-note { + margin-top: 8px; + padding: 8px; + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 4px; + font-size: 13px; + text-align: left; +} + +.hidden { + display: none; +} diff --git a/src/main/resources/static/templates/CSRFVulnerability/LEVEL_2/CSRF.html b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_2/CSRF.html new file mode 100644 index 000000000..a60edb709 --- /dev/null +++ b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_2/CSRF.html @@ -0,0 +1,56 @@ +
+

CSRF Vulnerability - Level 2

+

+ The server issues a CSRF token after login, but the email change + endpoint reads the X-CSRF-Token header and never validates it. +

+ +
+
Login
+
+
+ + +
+
+ + + +
+ +
+ +
+
+ +
+
Change Email
+
+
+ + +
+
+ + +
+ +
+
+
+ +
+
Account
+
+ +
+
+ diff --git a/src/main/resources/static/templates/CSRFVulnerability/LEVEL_2/CSRF.js b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_2/CSRF.js new file mode 100644 index 000000000..6fbb2dfd3 --- /dev/null +++ b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_2/CSRF.js @@ -0,0 +1,130 @@ +(function () { + const username = document.getElementById("csrf_l2_username"); + const password = document.getElementById("csrf_l2_password"); + const newEmail = document.getElementById("csrf_l2_new_email"); + const tokenInput = document.getElementById("csrf_l2_token"); + const loginState = document.getElementById("csrf_l2_login_state"); + const loginMsg = document.getElementById("csrf_l2_login_msg"); + const changeMsg = document.getElementById("csrf_l2_change_msg"); + const accountMsg = document.getElementById("csrf_l2_account_msg"); + + function showLoginState(account) { + loginState.classList.remove("hidden"); + loginState.textContent = ""; + var pill = document.createElement("div"); + pill.className = "login-pill"; + pill.appendChild(document.createTextNode("Logged in as ")); + var bUser = document.createElement("b"); + bUser.textContent = account.account.username; + pill.appendChild(bUser); + pill.appendChild( + document.createTextNode(" (email: " + account.account.email + ")") + ); + loginState.appendChild(pill); + + var tokenBox = document.createElement("div"); + tokenBox.className = "token-box"; + tokenBox.appendChild(document.createTextNode("Server issued token: ")); + var bToken = document.createElement("b"); + bToken.textContent = account.csrfToken; + tokenBox.appendChild(bToken); + tokenBox.appendChild( + document.createTextNode( + "\nThe page is expected to echo it back in the X-CSRF-Token header." + ) + ); + loginState.appendChild(tokenBox); + } + + document + .getElementById("csrf_l2_login_btn") + .addEventListener("click", function () { + loginMsg.innerHTML = ""; + const data = + "username=" + + encodeURIComponent(username.value) + + "&password=" + + encodeURIComponent(password.value); + doPostAjaxCall( + function (response, xhr) { + if (response.isValid) { + showLoginState(response.content); + loginMsg.innerHTML = + 'Login successful'; + } else { + loginMsg.innerHTML = + '' + response.content + ""; + } + }, + getUrlForVulnerabilityLevel(), + true, + data + ); + }); + + document + .getElementById("csrf_l2_change_btn") + .addEventListener("click", function () { + changeMsg.innerHTML = ""; + const data = "email=" + encodeURIComponent(newEmail.value); + const headers = { "X-CSRF-Token": tokenInput.value }; + doPostAjaxCall( + function (response, xhr) { + if (response.isValid) { + changeMsg.textContent = ""; + var s = document.createElement("span"); + s.style.color = "green"; + s.textContent = response.content; + changeMsg.appendChild(s); + var note = document.createElement("span"); + note.style.color = "black"; + note.style.fontSize = "12px"; + note.appendChild(document.createTextNode("Note: the token value ")); + var bTok = document.createElement("b"); + bTok.textContent = tokenInput.value; + note.appendChild(bTok); + note.appendChild( + document.createTextNode(" was accepted - it is never validated.") + ); + changeMsg.appendChild(document.createElement("br")); + changeMsg.appendChild(note); + } else { + changeMsg.textContent = ""; + var s = document.createElement("span"); + s.style.color = "red"; + s.textContent = response.content; + changeMsg.appendChild(s); + } + }, + getUrlForVulnerabilityLevel() + "/changeEmail", + true, + data, + headers + ); + }); + + document + .getElementById("csrf_l2_account_btn") + .addEventListener("click", function () { + accountMsg.innerHTML = ""; + doGetAjaxCall( + function (response, xhr) { + if (response.isValid) { + accountMsg.textContent = ""; + var s = document.createElement("span"); + s.style.color = "green"; + s.textContent = "Current email: " + response.content.email; + accountMsg.appendChild(s); + } else { + accountMsg.textContent = ""; + var s = document.createElement("span"); + s.style.color = "red"; + s.textContent = response.content; + accountMsg.appendChild(s); + } + }, + getUrlForVulnerabilityLevel() + "/account", + true + ); + }); +})(); diff --git a/src/main/resources/static/templates/CSRFVulnerability/LEVEL_3/CSRF.css b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_3/CSRF.css new file mode 100644 index 000000000..9100a5928 --- /dev/null +++ b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_3/CSRF.css @@ -0,0 +1,123 @@ +.csrf-shell { + width: 100%; + max-width: 600px; + margin: 12px auto; + padding: 6px; + box-sizing: border-box; + text-align: center; + color: black; +} + +.csrf-title { + margin: 0 0 6px; + font-size: 22px; +} + +.csrf-subtitle { + margin: 0 0 12px; + font-size: 14px; +} + +.csrf-card { + border: 1px solid black; + border-radius: 4px; + border-left: 6px solid burlywood; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.55); +} + +.csrf-card + .csrf-card { + margin-top: 16px; +} + +.csrf-card h5 { + margin: 0 0 8px; + font-size: 16px; + font-weight: bold; +} + +.csrf-row { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.csrf-field { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} + +.csrf-field label { + font-size: 12px; + font-weight: bold; +} + +.csrf-field input, +.csrf-field select { + padding: 4px 6px; + border: 1px solid black; + border-radius: 3px; + font-size: 13px; + min-width: 150px; +} + +.csrf-card button { + padding: 5px 14px; + border: 1px solid black; + border-radius: 3px; + background: burlywood; + color: black; + font-weight: bold; + cursor: pointer; +} + +.csrf-card button:hover { + background: #d2a55c; +} + +.status-message { + margin-top: 6px; + font-size: 13px; + font-weight: bold; +} + +.login-pill { + display: inline-block; + margin-top: 6px; + padding: 4px 10px; + border-radius: 12px; + background: #d4edda; + border: 1px solid #28a745; + font-size: 13px; +} + +.token-box { + margin-top: 6px; + padding: 6px 8px; + background: #f8f9fa; + border: 1px dashed #888; + border-radius: 3px; + font-family: monospace; + font-size: 12px; + word-break: break-all; + text-align: left; +} + +.csrf-attack-note { + margin-top: 8px; + padding: 8px; + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 4px; + font-size: 13px; + text-align: left; +} + +.hidden { + display: none; +} diff --git a/src/main/resources/static/templates/CSRFVulnerability/LEVEL_3/CSRF.html b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_3/CSRF.html new file mode 100644 index 000000000..475c34cc4 --- /dev/null +++ b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_3/CSRF.html @@ -0,0 +1,71 @@ +
+

CSRF Vulnerability - Level 3

+

+ The server validates the CSRF token, but the token is generated as + username:epochSeconds, so an attacker can forge a valid token. + The token is accepted via header or form field, enabling cross-site form attacks. +

+ +
+
Login
+
+
+ + +
+
+ + + +
+ +
+ +
+
+ +
+
Change Email (normal flow)
+
+
+ + +
+
+ + +
+ +
+
+
+ +
+
Cross-site attack demo (form submission)
+

+ An attacker can host this form on a different origin. The token is sent as a form field, + so no custom header is needed - the browser will include the victim's session cookie. +

+
+ + + +
+
+
+ +
+
Account
+
+ +
+
+ diff --git a/src/main/resources/static/templates/CSRFVulnerability/LEVEL_3/CSRF.js b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_3/CSRF.js new file mode 100644 index 000000000..735f35437 --- /dev/null +++ b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_3/CSRF.js @@ -0,0 +1,135 @@ +(function () { + const username = document.getElementById("csrf_l3_username"); + const password = document.getElementById("csrf_l3_password"); + const newEmail = document.getElementById("csrf_l3_new_email"); + const tokenInput = document.getElementById("csrf_l3_token"); + const attackTokenInput = document.getElementById("csrf_l3_attack_token"); + const loginState = document.getElementById("csrf_l3_login_state"); + const loginMsg = document.getElementById("csrf_l3_login_msg"); + const changeMsg = document.getElementById("csrf_l3_change_msg"); + const attackMsg = document.getElementById("csrf_l3_attack_msg"); + const accountMsg = document.getElementById("csrf_l3_account_msg"); + + function showLoginState(account) { + loginState.classList.remove("hidden"); + loginState.textContent = ""; + var pill = document.createElement("div"); + pill.className = "login-pill"; + pill.appendChild(document.createTextNode("Logged in as ")); + var bUser = document.createElement("b"); + bUser.textContent = account.account.username; + pill.appendChild(bUser); + pill.appendChild( + document.createTextNode(" (email: " + account.account.email + ")") + ); + loginState.appendChild(pill); + + var tokenBox = document.createElement("div"); + tokenBox.className = "token-box"; + tokenBox.appendChild(document.createTextNode("Token for ")); + var bFor = document.createElement("b"); + bFor.textContent = account.account.username; + tokenBox.appendChild(bFor); + tokenBox.appendChild(document.createTextNode(": ")); + var bTok = document.createElement("b"); + bTok.textContent = account.csrfToken; + tokenBox.appendChild(bTok); + tokenBox.appendChild(document.createElement("br")); + tokenBox.appendChild( + document.createTextNode( + "The token is simply username:epochSeconds - forge one as " + ) + ); + var i = document.createElement("i"); + i.textContent = "victim:"; + tokenBox.appendChild(i); + tokenBox.appendChild(document.createTextNode(".")); + loginState.appendChild(tokenBox); + } + + document + .getElementById("csrf_l3_login_btn") + .addEventListener("click", function () { + loginMsg.innerHTML = ""; + const data = + "username=" + + encodeURIComponent(username.value) + + "&password=" + + encodeURIComponent(password.value); + doPostAjaxCall( + function (response, xhr) { + if (response.isValid) { + showLoginState(response.content); + loginMsg.innerHTML = + 'Login successful'; + } else { + loginMsg.innerHTML = + '' + response.content + ""; + } + }, + getUrlForVulnerabilityLevel(), + true, + data + ); + }); + + document + .getElementById("csrf_l3_change_btn") + .addEventListener("click", function () { + changeMsg.innerHTML = ""; + const data = "email=" + encodeURIComponent(newEmail.value) + "&csrfToken=" + encodeURIComponent(tokenInput.value); + doPostAjaxCall( + function (response, xhr) { + if (response.isValid) { + changeMsg.innerHTML = + '' + response.content + ""; + } else { + changeMsg.innerHTML = + '' + + response.content + + '
Hint: use victim: as the token value.'; + } + }, + getUrlForVulnerabilityLevel() + "/changeEmail", + true, + data + ); + }); + + document + .getElementById("csrf_l3_attack_btn") + .addEventListener("click", function (e) { + e.preventDefault(); + attackMsg.innerHTML = ""; + var epoch = Math.floor(Date.now() / 1000); + attackTokenInput.value = "victim:" + epoch; + var form = document.getElementById("csrf_l3_attack_form"); + form.submit(); + attackMsg.innerHTML = 'Attack form submitted with token: victim:' + epoch + ''; + }); + + document + .getElementById("csrf_l3_account_btn") + .addEventListener("click", function () { + accountMsg.innerHTML = ""; + doGetAjaxCall( + function (response, xhr) { + if (response.isValid) { + accountMsg.textContent = ""; + var s = document.createElement("span"); + s.style.color = "green"; + s.textContent = "Current email: " + response.content.email; + accountMsg.appendChild(s); + } else { + accountMsg.textContent = ""; + var s = document.createElement("span"); + s.style.color = "red"; + s.textContent = response.content; + accountMsg.appendChild(s); + } + }, + getUrlForVulnerabilityLevel() + "/account", + true + ); + }); +})(); diff --git a/src/main/resources/static/templates/CSRFVulnerability/LEVEL_4/CSRF.css b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_4/CSRF.css new file mode 100644 index 000000000..9100a5928 --- /dev/null +++ b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_4/CSRF.css @@ -0,0 +1,123 @@ +.csrf-shell { + width: 100%; + max-width: 600px; + margin: 12px auto; + padding: 6px; + box-sizing: border-box; + text-align: center; + color: black; +} + +.csrf-title { + margin: 0 0 6px; + font-size: 22px; +} + +.csrf-subtitle { + margin: 0 0 12px; + font-size: 14px; +} + +.csrf-card { + border: 1px solid black; + border-radius: 4px; + border-left: 6px solid burlywood; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.55); +} + +.csrf-card + .csrf-card { + margin-top: 16px; +} + +.csrf-card h5 { + margin: 0 0 8px; + font-size: 16px; + font-weight: bold; +} + +.csrf-row { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.csrf-field { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} + +.csrf-field label { + font-size: 12px; + font-weight: bold; +} + +.csrf-field input, +.csrf-field select { + padding: 4px 6px; + border: 1px solid black; + border-radius: 3px; + font-size: 13px; + min-width: 150px; +} + +.csrf-card button { + padding: 5px 14px; + border: 1px solid black; + border-radius: 3px; + background: burlywood; + color: black; + font-weight: bold; + cursor: pointer; +} + +.csrf-card button:hover { + background: #d2a55c; +} + +.status-message { + margin-top: 6px; + font-size: 13px; + font-weight: bold; +} + +.login-pill { + display: inline-block; + margin-top: 6px; + padding: 4px 10px; + border-radius: 12px; + background: #d4edda; + border: 1px solid #28a745; + font-size: 13px; +} + +.token-box { + margin-top: 6px; + padding: 6px 8px; + background: #f8f9fa; + border: 1px dashed #888; + border-radius: 3px; + font-family: monospace; + font-size: 12px; + word-break: break-all; + text-align: left; +} + +.csrf-attack-note { + margin-top: 8px; + padding: 8px; + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 4px; + font-size: 13px; + text-align: left; +} + +.hidden { + display: none; +} diff --git a/src/main/resources/static/templates/CSRFVulnerability/LEVEL_4/CSRF.html b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_4/CSRF.html new file mode 100644 index 000000000..8572fc89a --- /dev/null +++ b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_4/CSRF.html @@ -0,0 +1,66 @@ +
+

CSRF Vulnerability - Level 4

+

+ The POST endpoint properly validates the CSRF token. But the same + operation is also exposed over GET - and a top-level navigation carries + the session cookie even with the default SameSite=Lax policy. +

+ +
+
Login
+
+
+ + +
+
+ + + +
+ +
+ +
+
+ +
+
Change Email (POST - protected)
+
+
+ + +
+ +
+
+
+ +
+
Change Email (GET - state changing)
+

+ This link simulates a cross-site attack (e.g., from a phishing email or malicious site). + The request targets the vulnerable app but originates from a different origin. +

+
+ + Click here to trigger state-changing GET (opens in new tab - simulates cross-site nav) + +
+
+ +
+
Account
+
+ +
+
+ diff --git a/src/main/resources/static/templates/CSRFVulnerability/LEVEL_4/CSRF.js b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_4/CSRF.js new file mode 100644 index 000000000..fb8cd8641 --- /dev/null +++ b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_4/CSRF.js @@ -0,0 +1,124 @@ +(function () { + const username = document.getElementById("csrf_l4_username"); + const password = document.getElementById("csrf_l4_password"); + const newEmail = document.getElementById("csrf_l4_new_email"); + const loginState = document.getElementById("csrf_l4_login_state"); + const loginMsg = document.getElementById("csrf_l4_login_msg"); + const changeMsg = document.getElementById("csrf_l4_change_msg"); + const getMsg = document.getElementById("csrf_l4_get_msg"); + const accountMsg = document.getElementById("csrf_l4_account_msg"); + const attackNote = document.getElementById("csrf_l4_attack_note"); + + let currentToken = null; + + attackNote.innerHTML = + "POST without the token is rejected with 403. However the operation is also reachable over GET. " + + "A GET request has no custom headers, so no CSRF token can be attached - yet the session cookie is " + + "still sent on a top-level navigation. Try the GET variant on this URL:
" + + '' + + "/VulnerableApp/CSRFVulnerability/LEVEL_4/changeEmail?email=attacker@example.com" + + "
(open it while logged in - same tab or new tab, it is a top-level navigation)"; + + function showLoginState(account) { + currentToken = account.csrfToken; + loginState.classList.remove("hidden"); + loginState.innerHTML = ""; + var pill = document.createElement("div"); + pill.className = "login-pill"; + pill.appendChild(document.createTextNode("Logged in as ")); + var bUser = document.createElement("b"); + bUser.textContent = account.account.username; + pill.appendChild(bUser); + pill.appendChild(document.createTextNode(" (email: " + account.account.email + ")")); + loginState.appendChild(pill); + + var tokenBox = document.createElement("div"); + tokenBox.className = "token-box"; + tokenBox.appendChild(document.createTextNode("CSRF token kept in page memory: ")); + var bToken = document.createElement("b"); + bToken.textContent = account.csrfToken; + tokenBox.appendChild(bToken); + loginState.appendChild(tokenBox); + } + + document + .getElementById("csrf_l4_login_btn") + .addEventListener("click", function () { + loginMsg.innerHTML = ""; + const data = + "username=" + + encodeURIComponent(username.value) + + "&password=" + + encodeURIComponent(password.value); + doPostAjaxCall( + function (response, xhr) { + if (response.isValid) { + showLoginState(response.content); + loginMsg.innerHTML = + 'Login successful'; + } else { + loginMsg.innerHTML = + '' + response.content + ""; + } + }, + getUrlForVulnerabilityLevel(), + true, + data + ); + }); + + document + .getElementById("csrf_l4_change_btn") + .addEventListener("click", function () { + changeMsg.innerHTML = ""; + const data = "email=" + encodeURIComponent(newEmail.value); + const headers = { "X-CSRF-Token": currentToken }; + doPostAjaxCall( + function (response, xhr) { + if (response.isValid) { + changeMsg.innerHTML = + '' + response.content + ""; + } else if (xhr.status === 403) { + changeMsg.innerHTML = + '' + + response.content + + '
Note: without a valid token the POST is rejected - the header was: "' + + (headers["X-CSRF-Token"] || "missing") + + '".'; + } else { + changeMsg.innerHTML = + '' + response.content + ""; + } + }, + getUrlForVulnerabilityLevel() + "/changeEmail", + true, + data, + headers + ); + }); + + document + .getElementById("csrf_l4_account_btn") + .addEventListener("click", function () { + accountMsg.innerHTML = ""; + doGetAjaxCall( + function (response, xhr) { + if (response.isValid) { + accountMsg.textContent = ""; + var s = document.createElement("span"); + s.style.color = "green"; + s.textContent = "Current email: " + response.content.email; + accountMsg.appendChild(s); + } else { + accountMsg.textContent = ""; + var s = document.createElement("span"); + s.style.color = "red"; + s.textContent = response.content; + accountMsg.appendChild(s); + } + }, + getUrlForVulnerabilityLevel() + "/account", + true + ); + }); +})(); diff --git a/src/main/resources/static/templates/CSRFVulnerability/LEVEL_5/CSRF.css b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_5/CSRF.css new file mode 100644 index 000000000..9100a5928 --- /dev/null +++ b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_5/CSRF.css @@ -0,0 +1,123 @@ +.csrf-shell { + width: 100%; + max-width: 600px; + margin: 12px auto; + padding: 6px; + box-sizing: border-box; + text-align: center; + color: black; +} + +.csrf-title { + margin: 0 0 6px; + font-size: 22px; +} + +.csrf-subtitle { + margin: 0 0 12px; + font-size: 14px; +} + +.csrf-card { + border: 1px solid black; + border-radius: 4px; + border-left: 6px solid burlywood; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.55); +} + +.csrf-card + .csrf-card { + margin-top: 16px; +} + +.csrf-card h5 { + margin: 0 0 8px; + font-size: 16px; + font-weight: bold; +} + +.csrf-row { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.csrf-field { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} + +.csrf-field label { + font-size: 12px; + font-weight: bold; +} + +.csrf-field input, +.csrf-field select { + padding: 4px 6px; + border: 1px solid black; + border-radius: 3px; + font-size: 13px; + min-width: 150px; +} + +.csrf-card button { + padding: 5px 14px; + border: 1px solid black; + border-radius: 3px; + background: burlywood; + color: black; + font-weight: bold; + cursor: pointer; +} + +.csrf-card button:hover { + background: #d2a55c; +} + +.status-message { + margin-top: 6px; + font-size: 13px; + font-weight: bold; +} + +.login-pill { + display: inline-block; + margin-top: 6px; + padding: 4px 10px; + border-radius: 12px; + background: #d4edda; + border: 1px solid #28a745; + font-size: 13px; +} + +.token-box { + margin-top: 6px; + padding: 6px 8px; + background: #f8f9fa; + border: 1px dashed #888; + border-radius: 3px; + font-family: monospace; + font-size: 12px; + word-break: break-all; + text-align: left; +} + +.csrf-attack-note { + margin-top: 8px; + padding: 8px; + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 4px; + font-size: 13px; + text-align: left; +} + +.hidden { + display: none; +} diff --git a/src/main/resources/static/templates/CSRFVulnerability/LEVEL_5/CSRF.html b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_5/CSRF.html new file mode 100644 index 000000000..a54007d18 --- /dev/null +++ b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_5/CSRF.html @@ -0,0 +1,47 @@ +
+

CSRF Vulnerability - Level 5

+

+ Secure implementation: an unpredictable token is issued at login, + validated on every state change, the operation is never exposed over + GET, and the session cookie is marked SameSite=Lax. +

+ +
+
Login
+
+
+ + +
+
+ + +
+ +
+ +
+
+ +
+
Change Email
+
+
+ + +
+ +
+
+
+ +
+
Account
+
+ +
+
+ diff --git a/src/main/resources/static/templates/CSRFVulnerability/LEVEL_5/CSRF.js b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_5/CSRF.js new file mode 100644 index 000000000..b0c173257 --- /dev/null +++ b/src/main/resources/static/templates/CSRFVulnerability/LEVEL_5/CSRF.js @@ -0,0 +1,103 @@ +(function () { + const username = document.getElementById("csrf_l5_username"); + const password = document.getElementById("csrf_l5_password"); + const newEmail = document.getElementById("csrf_l5_new_email"); + const loginState = document.getElementById("csrf_l5_login_state"); + const loginMsg = document.getElementById("csrf_l5_login_msg"); + const changeMsg = document.getElementById("csrf_l5_change_msg"); + const accountMsg = document.getElementById("csrf_l5_account_msg"); + + let currentToken = null; + + function showLoginState(account) { + currentToken = account.csrfToken; + loginState.classList.remove("hidden"); + loginState.innerHTML = + '
CSRF token kept in page memory: ' + + account.csrfToken + + "
SameSite=Lax on the session cookie blocks cross-site POSTs.
"; + } + + document + .getElementById("csrf_l5_login_btn") + .addEventListener("click", function () { + loginMsg.innerHTML = ""; + const data = + "username=" + + encodeURIComponent(username.value) + + "&password=" + + encodeURIComponent(password.value); + doPostAjaxCall( + function (response, xhr) { + if (response.isValid) { + showLoginState(response.content); + loginMsg.innerHTML = + 'Login successful'; + } else { + loginMsg.innerHTML = + '' + response.content + ""; + } + }, + getUrlForVulnerabilityLevel(), + true, + data + ); + }); + + document + .getElementById("csrf_l5_change_btn") + .addEventListener("click", function () { + changeMsg.innerHTML = ""; + const data = "email=" + encodeURIComponent(newEmail.value); + const headers = { "X-CSRF-Token": currentToken }; + doPostAjaxCall( + function (response, xhr) { + if (response.isValid) { + changeMsg.innerHTML = + '' + response.content + ""; + } else if (xhr.status === 403) { + changeMsg.innerHTML = + '' + + response.content + + '
Note: the request was rejected because the X-CSRF-Token header was missing or wrong.'; + } else { + changeMsg.innerHTML = + '' + response.content + ""; + } + }, + getUrlForVulnerabilityLevel() + "/changeEmail", + true, + data, + headers + ); + }); + + document + .getElementById("csrf_l5_account_btn") + .addEventListener("click", function () { + accountMsg.innerHTML = ""; + doGetAjaxCall( + function (response, xhr) { + if (response.isValid) { + accountMsg.textContent = ""; + var s = document.createElement("span"); + s.style.color = "green"; + s.textContent = "Current email: " + response.content.email; + accountMsg.appendChild(s); + } else { + accountMsg.textContent = ""; + var s = document.createElement("span"); + s.style.color = "red"; + s.textContent = response.content; + accountMsg.appendChild(s); + } + }, + getUrlForVulnerabilityLevel() + "/account", + true + ); + }); +})(); diff --git a/src/test/java/org/sasanlabs/service/vulnerability/csrf/CSRFAccountTest.java b/src/test/java/org/sasanlabs/service/vulnerability/csrf/CSRFAccountTest.java new file mode 100644 index 000000000..72580fc7d --- /dev/null +++ b/src/test/java/org/sasanlabs/service/vulnerability/csrf/CSRFAccountTest.java @@ -0,0 +1,58 @@ +package org.sasanlabs.service.vulnerability.csrf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +class CSRFAccountTest { + + @Test + void constructorWithoutPassword_ShouldSetFieldsCorrectly() { + CSRFAccount account = new CSRFAccount(1, "victim", "victim@example.com"); + + assertEquals(1, account.getId()); + assertEquals("victim", account.getUsername()); + assertEquals("victim@example.com", account.getEmail()); + assertNull(account.getStoredPassword()); + } + + @Test + void constructorWithPassword_ShouldSetAllFields() { + CSRFAccount account = + new CSRFAccount( + 2, + "attacker", + "CSRF_SALT_L1:d6f84008e9cbadae9ed05181db30fd185ab79266574e388ccda0aaa30f6f525f", + "attacker@example.com"); + + assertEquals(2, account.getId()); + assertEquals("attacker", account.getUsername()); + assertEquals("attacker@example.com", account.getEmail()); + assertEquals( + "CSRF_SALT_L1:d6f84008e9cbadae9ed05181db30fd185ab79266574e388ccda0aaa30f6f525f", + account.getStoredPassword()); + } + + @Test + void setters_ShouldUpdateFields() { + CSRFAccount account = new CSRFAccount(1, "user1", "old@example.com"); + + account.setId(99); + account.setUsername("user2"); + account.setEmail("new@example.com"); + + assertEquals(99, account.getId()); + assertEquals("user2", account.getUsername()); + assertEquals("new@example.com", account.getEmail()); + } + + @Test + void emailCanBeUpdated() { + CSRFAccount account = new CSRFAccount(1, "victim", "victim@example.com"); + + account.setEmail("attacker@evil.com"); + + assertEquals("attacker@evil.com", account.getEmail()); + } +} diff --git a/src/test/java/org/sasanlabs/service/vulnerability/csrf/CSRFSessionServiceTest.java b/src/test/java/org/sasanlabs/service/vulnerability/csrf/CSRFSessionServiceTest.java new file mode 100644 index 000000000..bbca5437c --- /dev/null +++ b/src/test/java/org/sasanlabs/service/vulnerability/csrf/CSRFSessionServiceTest.java @@ -0,0 +1,394 @@ +package org.sasanlabs.service.vulnerability.csrf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.ResultSet; +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +class CSRFSessionServiceTest { + + private JdbcTemplate jdbcTemplate; + private CSRFSessionService service; + + private static final CSRFAccount VICTIM = new CSRFAccount(1, "victim", "victim@example.com"); + private static final CSRFAccount ATTACKER = + new CSRFAccount(2, "attacker", "attacker@example.com"); + + @BeforeEach + void setup() { + jdbcTemplate = mock(JdbcTemplate.class); + service = new CSRFSessionService(jdbcTemplate); + } + + // ----- authenticate ----- + + @SuppressWarnings("unchecked") + @Test + void authenticate_ShouldReturnAccount_WhenCredentialsValid() { + when(jdbcTemplate.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenAnswer( + inv -> { + RowMapper mapper = inv.getArgument(2); + ResultSet rs = mock(ResultSet.class); + when(rs.getInt("id")).thenReturn(1); + when(rs.getString("username")).thenReturn("victim"); + when(rs.getString("password")) + .thenReturn( + "CSRF_SALT_L1:f5e2a35c9dac8a96fbe2ddf38a7694cf5177ed293bc6e600236ae8aefb677407"); + when(rs.getString("email")).thenReturn("victim@example.com"); + return Arrays.asList(mapper.mapRow(rs, 0)); + }); + + CSRFAccount result = service.authenticate(1, "victim", "Victim@123"); + + assertNotNull(result); + assertEquals("victim", result.getUsername()); + assertEquals("victim@example.com", result.getEmail()); + assertNull(result.getStoredPassword()); + } + + @SuppressWarnings("unchecked") + @Test + void authenticate_ShouldReturnNull_WhenPasswordInvalid() { + when(jdbcTemplate.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenAnswer( + inv -> { + RowMapper mapper = inv.getArgument(2); + ResultSet rs = mock(ResultSet.class); + when(rs.getInt("id")).thenReturn(1); + when(rs.getString("username")).thenReturn("victim"); + when(rs.getString("password")) + .thenReturn( + "CSRF_SALT_L1:f5e2a35c9dac8a96fbe2ddf38a7694cf5177ed293bc6e600236ae8aefb677407"); + when(rs.getString("email")).thenReturn("victim@example.com"); + return Arrays.asList(mapper.mapRow(rs, 0)); + }); + + CSRFAccount result = service.authenticate(1, "victim", "WrongPassword"); + + assertNull(result); + } + + @SuppressWarnings("unchecked") + @Test + void authenticate_ShouldReturnNull_WhenUserNotFound() { + when(jdbcTemplate.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenReturn(Collections.emptyList()); + + CSRFAccount result = service.authenticate(1, "nonexistent", "password"); + + assertNull(result); + } + + @SuppressWarnings("unchecked") + @Test + void authenticate_ShouldReturnNull_WhenPasswordHasNoSeparator() { + when(jdbcTemplate.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenAnswer( + inv -> { + RowMapper mapper = inv.getArgument(2); + ResultSet rs = mock(ResultSet.class); + when(rs.getInt("id")).thenReturn(1); + when(rs.getString("username")).thenReturn("user"); + when(rs.getString("password")).thenReturn("plaintextNoColon"); + when(rs.getString("email")).thenReturn("u@e.com"); + return Arrays.asList(mapper.mapRow(rs, 0)); + }); + + CSRFAccount result = service.authenticate(1, "user", "plaintextNoColon"); + + assertNull(result); + } + + // ----- createSession ----- + + @Test + void createSession_ShouldReturnTokenAndStoreSession() { + String token = service.createSession(1, VICTIM); + + assertNotNull(token); + assertTrue(token.length() == 64); + assertNotNull(service.getSession(1, token)); + assertEquals("victim", service.getSession(1, token).getUsername()); + assertEquals("victim@example.com", service.getSession(1, token).getEmail()); + } + + @Test + void createSession_DifferentLevels_ShouldBeIsolated() { + String token1 = service.createSession(1, VICTIM); + String token2 = service.createSession(2, ATTACKER); + + assertNotNull(service.getSession(1, token1)); + assertNotNull(service.getSession(2, token2)); + assertNull(service.getSession(1, token2)); + assertNull(service.getSession(2, token1)); + } + + // ----- createSecureCsrfToken ----- + + @Test + void createSecureCsrfToken_ShouldReturnRandomToken() { + String sessionToken = service.createSession(5, VICTIM); + String csrfToken = service.createSecureCsrfToken(5, sessionToken); + + assertNotNull(csrfToken); + assertTrue(csrfToken.length() == 64); + assertEquals(csrfToken, service.getSession(5, sessionToken).getCsrfToken()); + } + + @Test + void createSecureCsrfToken_ShouldThrow_WhenSessionInvalid() { + assertThrows( + IllegalArgumentException.class, + () -> service.createSecureCsrfToken(1, "nonexistent-token")); + } + + // ----- createWeakCsrfToken ----- + + @Test + void createWeakCsrfToken_ShouldHaveUsernameEpochFormat() { + String weakToken = service.createWeakCsrfToken(VICTIM); + + assertNotNull(weakToken); + assertTrue(weakToken.startsWith("victim:")); + String[] parts = weakToken.split(":", 2); + assertEquals(2, parts.length); + Long.parseLong(parts[1]); + } + + // ----- isValidWeakToken ----- + + @Test + void isValidWeakToken_ShouldAcceptValidFormat() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "v@example.com"); + String token = "victim:" + (System.currentTimeMillis() / 1000); + + assertTrue(service.isValidWeakToken(session, token)); + } + + @Test + void isValidWeakToken_ShouldRejectNullSession() { + assertFalse(service.isValidWeakToken(null, "victim:12345")); + } + + @Test + void isValidWeakToken_ShouldRejectNullToken() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "v@example.com"); + assertFalse(service.isValidWeakToken(session, null)); + } + + @Test + void isValidWeakToken_ShouldRejectWrongUsername() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "v@example.com"); + assertFalse(service.isValidWeakToken(session, "attacker:12345")); + } + + @Test + void isValidWeakToken_ShouldRejectNonNumericTimestamp() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "v@example.com"); + assertFalse(service.isValidWeakToken(session, "victim:notanumber")); + } + + @Test + void isValidWeakToken_ShouldRejectNoColon() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "v@example.com"); + assertFalse(service.isValidWeakToken(session, "victim12345")); + } + + @Test + void isValidWeakToken_ShouldAcceptAnyTimestampWithinSession() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "v@example.com"); + assertTrue(service.isValidWeakToken(session, "victim:0")); + assertTrue(service.isValidWeakToken(session, "victim:9999999999")); + } + + // ----- isValidSecureToken ----- + + @Test + void isValidSecureToken_ShouldAcceptMatchingToken() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "v@example.com"); + session.setCsrfToken("abc123"); + + assertTrue(service.isValidSecureToken(session, "abc123")); + } + + @Test + void isValidSecureToken_ShouldRejectNullSession() { + assertFalse(service.isValidSecureToken(null, "abc")); + } + + @Test + void isValidSecureToken_ShouldRejectNullTokenInSession() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "v@example.com"); + assertFalse(service.isValidSecureToken(session, "abc")); + } + + @Test + void isValidSecureToken_ShouldRejectNullHeaderValue() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "v@example.com"); + session.setCsrfToken("abc"); + assertFalse(service.isValidSecureToken(session, null)); + } + + @Test + void isValidSecureToken_ShouldRejectDifferentToken() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "v@example.com"); + session.setCsrfToken("abc123"); + assertFalse(service.isValidSecureToken(session, "abc124")); + } + + @Test + void isValidSecureToken_ShouldRejectShorterToken() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "v@example.com"); + session.setCsrfToken("abc123"); + assertFalse(service.isValidSecureToken(session, "abc")); + } + + // ----- updateEmail ----- + + @Test + void updateEmail_ShouldCallJdbcUpdate() { + when(jdbcTemplate.update(anyString(), any(), any())).thenReturn(1); + + service.updateEmail(1, 1, "new@example.com"); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor argsCaptor = ArgumentCaptor.forClass(Object.class); + verify(jdbcTemplate) + .update(sqlCaptor.capture(), argsCaptor.capture(), argsCaptor.capture()); + assertEquals("UPDATE csrf_accounts_l1 SET email=? WHERE id=?", sqlCaptor.getValue()); + } + + // ----- emailOf ----- + + @Test + void emailOf_ShouldReturnEmail_WhenSessionExists() { + String sessionToken = service.createSession(1, VICTIM); + assertEquals("victim@example.com", service.emailOf(1, sessionToken)); + } + + @Test + void emailOf_ShouldReturnNull_WhenSessionMissing() { + assertNull(service.emailOf(1, "nonexistent")); + } + + // ----- accountByUsername ----- + + @SuppressWarnings("unchecked") + @Test + void accountByUsername_ShouldReturnAccount_WhenFound() { + when(jdbcTemplate.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenAnswer( + inv -> { + RowMapper mapper = inv.getArgument(2); + ResultSet rs = mock(ResultSet.class); + when(rs.getInt("id")).thenReturn(1); + when(rs.getString("username")).thenReturn("victim"); + when(rs.getString("email")).thenReturn("victim@example.com"); + return Arrays.asList(mapper.mapRow(rs, 0)); + }); + + CSRFAccount result = service.accountByUsername(1, "victim"); + + assertNotNull(result); + assertEquals("victim", result.getUsername()); + } + + @SuppressWarnings("unchecked") + @Test + void accountByUsername_ShouldReturnNull_WhenNotFound() { + when(jdbcTemplate.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenReturn(Collections.emptyList()); + + assertNull(service.accountByUsername(1, "nonexistent")); + } + + // ----- refreshSessionEmail ----- + + @SuppressWarnings("unchecked") + @Test + void refreshSessionEmail_ShouldUpdateSessionEmail() { + String sessionToken = service.createSession(1, VICTIM); + assertEquals("victim@example.com", service.emailOf(1, sessionToken)); + + when(jdbcTemplate.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenAnswer( + inv -> { + RowMapper mapper = inv.getArgument(2); + ResultSet rs = mock(ResultSet.class); + when(rs.getInt("id")).thenReturn(1); + when(rs.getString("username")).thenReturn("victim"); + when(rs.getString("email")).thenReturn("updated@example.com"); + return Arrays.asList(mapper.mapRow(rs, 0)); + }); + + service.refreshSessionEmail(1, sessionToken); + + assertEquals("updated@example.com", service.emailOf(1, sessionToken)); + } + + @Test + void refreshSessionEmail_ShouldHandleNullSession() { + service.refreshSessionEmail(1, "nonexistent"); + } + + // ----- CSRFLoginSession ----- + + @Test + void loginSession_ShouldHaveCreatedAt() { + long before = System.currentTimeMillis(); + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("user", "u@e.com"); + long after = System.currentTimeMillis(); + + assertTrue(session.getCreatedAt() >= before); + assertTrue(session.getCreatedAt() <= after); + } + + @Test + void loginSession_SetEmail_ShouldUpdateEmail() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("user", "old@e.com"); + + session.setEmail("new@e.com"); + + assertEquals("new@e.com", session.getEmail()); + } + + @Test + void loginSession_SetCsrfToken_ShouldUpdateToken() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("user", "u@e.com"); + + assertNull(session.getCsrfToken()); + session.setCsrfToken("tok123"); + assertEquals("tok123", session.getCsrfToken()); + } +} diff --git a/src/test/java/org/sasanlabs/service/vulnerability/csrf/CSRFVulnerabilityTest.java b/src/test/java/org/sasanlabs/service/vulnerability/csrf/CSRFVulnerabilityTest.java new file mode 100644 index 000000000..067050f9f --- /dev/null +++ b/src/test/java/org/sasanlabs/service/vulnerability/csrf/CSRFVulnerabilityTest.java @@ -0,0 +1,331 @@ +package org.sasanlabs.service.vulnerability.csrf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.sasanlabs.service.vulnerability.bean.GenericVulnerabilityResponseBean; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +class CSRFVulnerabilityTest { + + private CSRFSessionService csrfSessionService; + private CSRFVulnerability controller; + private HttpServletRequest request; + private HttpServletResponse response; + + private static final CSRFAccount VICTIM = new CSRFAccount(1, "victim", "victim@example.com"); + private static final CSRFAccount ATTACKER = new CSRFAccount(2, "attacker", "attacker@evil.com"); + private static final String SESSION_TOKEN = "abc123def456"; + private static final String CSRF_TOKEN = "tok123"; + + @BeforeEach + void setup() { + csrfSessionService = mock(CSRFSessionService.class); + controller = new CSRFVulnerability(csrfSessionService); + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + } + + // ===== LEVEL 1 ===== + + @Test + void level1Login_ShouldReturnAccount_WhenAuthenticated() { + when(csrfSessionService.authenticate(1, "victim", "Victim@123")).thenReturn(VICTIM); + when(csrfSessionService.createSession(eq(1), any())).thenReturn(SESSION_TOKEN); + + ResponseEntity> result = + controller.level1Login("victim", "Victim@123", request, response); + + assertTrue(result.getBody().getIsValid()); + verify(response).addCookie(any(Cookie.class)); + } + + @Test + void level1Login_ShouldReturnInvalidCredentials_WhenAuthFails() { + when(csrfSessionService.authenticate(1, "victim", "wrong")).thenReturn(null); + + ResponseEntity> result = + controller.level1Login("victim", "wrong", request, response); + + assertFalse(result.getBody().getIsValid()); + assertEquals("Invalid credentials", result.getBody().getContent()); + } + + @Test + void level1ChangeEmail_ShouldSucceed_WhenSessionValid() { + when(csrfSessionService.getSession(1, SESSION_TOKEN)) + .thenReturn( + new CSRFSessionService.CSRFLoginSession("victim", "victim@example.com")); + when(csrfSessionService.accountByUsername(1, "victim")).thenReturn(VICTIM); + + ResponseEntity> result = + controller.level1ChangeEmail(SESSION_TOKEN, "new@example.com"); + + assertTrue(result.getBody().getIsValid()); + assertEquals("Email updated successfully", result.getBody().getContent()); + verify(csrfSessionService).updateEmail(1, 1, "new@example.com"); + } + + @Test + void level1ChangeEmail_ShouldReturn401_WhenSessionMissing() { + when(csrfSessionService.getSession(1, null)).thenReturn(null); + + ResponseEntity> result = + controller.level1ChangeEmail(null, "new@example.com"); + + assertFalse(result.getBody().getIsValid()); + assertEquals(HttpStatus.UNAUTHORIZED, result.getStatusCode()); + } + + @Test + void level1Account_ShouldReturnAccount_WhenSessionValid() { + when(csrfSessionService.getSession(1, SESSION_TOKEN)) + .thenReturn( + new CSRFSessionService.CSRFLoginSession("victim", "victim@example.com")); + when(csrfSessionService.accountByUsername(1, "victim")).thenReturn(VICTIM); + + ResponseEntity> result = + controller.level1Account(SESSION_TOKEN); + + assertTrue(result.getBody().getIsValid()); + } + + @Test + void level1Account_ShouldReturn401_WhenSessionMissing() { + when(csrfSessionService.getSession(1, null)).thenReturn(null); + + ResponseEntity> result = + controller.level1Account(null); + + assertFalse(result.getBody().getIsValid()); + assertEquals(HttpStatus.UNAUTHORIZED, result.getStatusCode()); + } + + // ===== LEVEL 2 ===== + + @Test + void level2Login_ShouldReturnLoginResponseWithCsrfToken() { + when(csrfSessionService.authenticate(2, "victim", "Victim@123")).thenReturn(VICTIM); + when(csrfSessionService.createSession(eq(2), any())).thenReturn(SESSION_TOKEN); + when(csrfSessionService.createSecureCsrfToken(2, SESSION_TOKEN)).thenReturn(CSRF_TOKEN); + + ResponseEntity> result = + controller.level2Login("victim", "Victim@123", request, response); + + assertTrue(result.getBody().getIsValid()); + Object content = result.getBody().getContent(); + assertTrue(content instanceof CSRFVulnerability.CSRFLoginResponse); + CSRFVulnerability.CSRFLoginResponse loginResponse = + (CSRFVulnerability.CSRFLoginResponse) content; + assertEquals(CSRF_TOKEN, loginResponse.getCsrfToken()); + assertEquals("victim", loginResponse.getAccount().getUsername()); + } + + @Test + void level2ChangeEmail_ShouldSucceed_WithAnyToken() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "victim@example.com"); + when(csrfSessionService.getSession(2, SESSION_TOKEN)).thenReturn(session); + when(csrfSessionService.accountByUsername(2, "victim")).thenReturn(VICTIM); + + ResponseEntity> result = + controller.level2ChangeEmail(SESSION_TOKEN, "any-token", "evil@hacker.com"); + + assertTrue(result.getBody().getIsValid()); + verify(csrfSessionService).updateEmail(2, 1, "evil@hacker.com"); + } + + // ===== LEVEL 3 ===== + + @Test + void level3Login_ShouldReturnWeakToken() { + when(csrfSessionService.authenticate(3, "victim", "Victim@123")).thenReturn(VICTIM); + when(csrfSessionService.createSession(eq(3), any())).thenReturn(SESSION_TOKEN); + when(csrfSessionService.createWeakCsrfToken(VICTIM)).thenReturn("victim:1234567890"); + + ResponseEntity> result = + controller.level3Login("victim", "Victim@123", request, response); + + assertTrue(result.getBody().getIsValid()); + CSRFVulnerability.CSRFLoginResponse loginResponse = + (CSRFVulnerability.CSRFLoginResponse) result.getBody().getContent(); + assertEquals("victim:1234567890", loginResponse.getCsrfToken()); + } + + @Test + void level3ChangeEmail_ShouldSucceed_WithValidWeakToken() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "victim@example.com"); + when(csrfSessionService.getSession(3, SESSION_TOKEN)).thenReturn(session); + when(csrfSessionService.isValidWeakToken(session, "victim:9999999")).thenReturn(true); + when(csrfSessionService.accountByUsername(3, "victim")).thenReturn(VICTIM); + + ResponseEntity> result = + controller.level3ChangeEmail(SESSION_TOKEN, "victim:9999999", "evil@hacker.com"); + + assertTrue(result.getBody().getIsValid()); + } + + @Test + void level3ChangeEmail_ShouldReturn403_WithInvalidWeakToken() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "victim@example.com"); + when(csrfSessionService.getSession(3, SESSION_TOKEN)).thenReturn(session); + when(csrfSessionService.isValidWeakToken(session, "wrong:token")).thenReturn(false); + + ResponseEntity> result = + controller.level3ChangeEmail(SESSION_TOKEN, "wrong:token", "evil@hacker.com"); + + assertFalse(result.getBody().getIsValid()); + assertEquals(HttpStatus.FORBIDDEN, result.getStatusCode()); + } + + // ===== LEVEL 4 ===== + + @Test + void level4Login_ShouldReturnSecureToken() { + when(csrfSessionService.authenticate(4, "victim", "Victim@123")).thenReturn(VICTIM); + when(csrfSessionService.createSession(eq(4), any())).thenReturn(SESSION_TOKEN); + when(csrfSessionService.createSecureCsrfToken(4, SESSION_TOKEN)).thenReturn(CSRF_TOKEN); + + ResponseEntity> result = + controller.level4Login("victim", "Victim@123", request, response); + + assertTrue(result.getBody().getIsValid()); + } + + @Test + void level4ChangeEmail_ShouldSucceed_WithValidSecureToken() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "victim@example.com"); + when(csrfSessionService.getSession(4, SESSION_TOKEN)).thenReturn(session); + when(csrfSessionService.isValidSecureToken(session, CSRF_TOKEN)).thenReturn(true); + when(csrfSessionService.accountByUsername(4, "victim")).thenReturn(VICTIM); + + ResponseEntity> result = + controller.level4ChangeEmail(SESSION_TOKEN, CSRF_TOKEN, "new@email.com"); + + assertTrue(result.getBody().getIsValid()); + } + + @Test + void level4ChangeEmail_ShouldReturn403_WithInvalidToken() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "victim@example.com"); + when(csrfSessionService.getSession(4, SESSION_TOKEN)).thenReturn(session); + when(csrfSessionService.isValidSecureToken(session, "wrong")).thenReturn(false); + + ResponseEntity> result = + controller.level4ChangeEmail(SESSION_TOKEN, "wrong", "evil@hacker.com"); + + assertFalse(result.getBody().getIsValid()); + assertEquals(HttpStatus.FORBIDDEN, result.getStatusCode()); + } + + @Test + void level4ChangeEmailViaGet_ShouldSucceed_WithoutTokenCheck() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "victim@example.com"); + when(csrfSessionService.getSession(4, SESSION_TOKEN)).thenReturn(session); + when(csrfSessionService.accountByUsername(4, "victim")).thenReturn(VICTIM); + + ResponseEntity> result = + controller.level4ChangeEmailViaGet(SESSION_TOKEN, "evil@hacker.com"); + + assertTrue(result.getBody().getIsValid()); + verify(csrfSessionService).updateEmail(4, 1, "evil@hacker.com"); + } + + // ===== LEVEL 5 ===== + + @Test + void level5Login_ShouldSetSameSiteLaxCookie() { + when(csrfSessionService.authenticate(5, "victim", "Victim@123")).thenReturn(VICTIM); + when(csrfSessionService.createSession(eq(5), any())).thenReturn(SESSION_TOKEN); + when(csrfSessionService.createSecureCsrfToken(5, SESSION_TOKEN)).thenReturn(CSRF_TOKEN); + when(request.isSecure()).thenReturn(true); + + ResponseEntity> result = + controller.level5Login("victim", "Victim@123", request, response); + + assertTrue(result.getBody().getIsValid()); + verify(response).addHeader(eq("Set-Cookie"), anyString()); + } + + @Test + void level5Login_ShouldAddSecureFlag_WhenSecureRequest() { + when(csrfSessionService.authenticate(5, "victim", "Victim@123")).thenReturn(VICTIM); + when(csrfSessionService.createSession(eq(5), any())).thenReturn(SESSION_TOKEN); + when(csrfSessionService.createSecureCsrfToken(5, SESSION_TOKEN)).thenReturn(CSRF_TOKEN); + when(request.isSecure()).thenReturn(true); + + controller.level5Login("victim", "Victim@123", request, response); + + verify(response).addHeader(eq("Set-Cookie"), anyString()); + } + + @Test + void level5ChangeEmail_ShouldSucceed_WithValidToken() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "victim@example.com"); + when(csrfSessionService.getSession(5, SESSION_TOKEN)).thenReturn(session); + when(csrfSessionService.isValidSecureToken(session, CSRF_TOKEN)).thenReturn(true); + when(csrfSessionService.accountByUsername(5, "victim")).thenReturn(VICTIM); + + ResponseEntity> result = + controller.level5ChangeEmail(SESSION_TOKEN, CSRF_TOKEN, "new@email.com"); + + assertTrue(result.getBody().getIsValid()); + } + + @Test + void level5ChangeEmail_ShouldReturn403_WithInvalidToken() { + CSRFSessionService.CSRFLoginSession session = + new CSRFSessionService.CSRFLoginSession("victim", "victim@example.com"); + when(csrfSessionService.getSession(5, SESSION_TOKEN)).thenReturn(session); + when(csrfSessionService.isValidSecureToken(session, "wrong")).thenReturn(false); + + ResponseEntity> result = + controller.level5ChangeEmail(SESSION_TOKEN, "wrong", "evil@hacker.com"); + + assertFalse(result.getBody().getIsValid()); + assertEquals(HttpStatus.FORBIDDEN, result.getStatusCode()); + } + + @Test + void level5Account_ShouldReturnAccount() { + when(csrfSessionService.getSession(5, SESSION_TOKEN)) + .thenReturn( + new CSRFSessionService.CSRFLoginSession("victim", "victim@example.com")); + when(csrfSessionService.accountByUsername(5, "victim")).thenReturn(VICTIM); + + ResponseEntity> result = + controller.level5Account(SESSION_TOKEN); + + assertTrue(result.getBody().getIsValid()); + } + + // ===== CSRFLoginResponse ===== + + @Test + void csrfLoginResponse_Getters_ShouldWork() { + CSRFVulnerability.CSRFLoginResponse loginResponse = + new CSRFVulnerability.CSRFLoginResponse(VICTIM, CSRF_TOKEN); + + assertEquals(VICTIM, loginResponse.getAccount()); + assertEquals(CSRF_TOKEN, loginResponse.getCsrfToken()); + } +}