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.\
\
+
+
+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.
+
+ 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.
+
+ 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.
+
+ 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.
+