-
-
Notifications
You must be signed in to change notification settings - Fork 774
Add CSRF vulnerability lab with 5 challenge levels #758
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mmustafasenoglu
wants to merge
12
commits into
SasanLabs:master
Choose a base branch
from
mmustafasenoglu:feat/csrf-vulnerability-lab
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
affa3b9
feat: add CSRF vulnerability lab with 5 levels
mmustafasenoglu 34c7c73
Address CodeRabbit review: fix XSS, session leak, token validation
mmustafasenoglu f9fb795
Fix spotless formatting violations
mmustafasenoglu 8bd5301
Fix remaining spotless formatting violations
mmustafasenoglu a6a85a4
Fix prettier/spotless line wrapping in JS files
mmustafasenoglu d569554
Run prettier on all CSRF JS files to fix formatting
mmustafasenoglu 9fc1746
Fix prettier trailing comma config to match spotless
mmustafasenoglu 6add90a
feat(csrf): add comprehensive CSRF unit tests
mmustafasenoglu 234c97f
fix(csrf): apply spotless formatting fixes to CSRF tests
mmustafasenoglu 150ac96
fix(csrf): fix spotless formatting for CSRF tests
mmustafasenoglu c901317
fix(csrf): fix ambiguous JdbcTemplate.query() matcher in CSRF tests
mmustafasenoglu 7f1bc29
fix(csrf): address CodeRabbit review findings
mmustafasenoglu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
51 changes: 51 additions & 0 deletions
51
src/main/java/org/sasanlabs/service/vulnerability/csrf/CSRFAccount.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
231 changes: 231 additions & 0 deletions
231
src/main/java/org/sasanlabs/service/vulnerability/csrf/CSRFSessionService.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| * | ||
| * <p>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<String, CSRFLoginSession> 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<CSRFAccount> 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<CSRFAccount> 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; | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.