From a919213ed5974624889edd47766505787a66ea01 Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:32:34 -0400 Subject: [PATCH 01/32] fix: authentication, CSRF, access control, secrets and XSS across the remaining lesson families Complements #112 and #143, which covered the injection-style bugs. This commit addresses the families they did not touch: - clear text password storage in WebGoat and WebWolf (BCrypt instead of NoOpPasswordEncoder) - CSRF protection enabled in both applications, plus login CSRF and per-session review tokens - horizontal and function level access control (IDOR profiles, admin only endpoints) - credentials, signing keys and salts that were literals in the repository - JWT verification (signature, algorithm, jku/kid handling, refresh token binding) - output encoding for stored and reflected XSS, and for the WebWolf views - information disclosure (stack traces, actuator, .git archive, salary data) - client side only validation now enforced on the server --- .../framework/VulnerableTaskHolder.java | 26 +----- .../container/PasswordEncoderConfig.java | 24 +++++ .../container/WebGoatCsrfTokenController.java | 25 +++++ .../webgoat/container/WebSecurityConfig.java | 26 ++++-- .../webgoat/container/users/UserService.java | 40 +++++++- .../owasp/webgoat/csrf/CsrfExemptions.java | 43 +++++++++ .../webgoat/csrf/CsrfTokenCookieFilter.java | 33 +++++++ .../authbypass/AccountVerificationHelper.java | 26 +++--- .../lessons/authbypass/VerifyAccount.java | 12 ++- .../BypassRestrictionsFieldRestrictions.java | 33 +++---- .../BypassRestrictionsFrontendValidation.java | 33 +++---- .../lessons/challenges/FlagController.java | 3 + .../lessons/challenges/SolutionConstants.java | 7 +- .../challenges/challenge1/ImageServlet.java | 13 +-- .../challenges/challenge5/Assignment5.java | 8 +- .../challenges/challenge7/Assignment7.java | 15 ++- .../challenge7/PasswordResetLink.java | 17 ++-- .../challenges/challenge8/Assignment8.java | 17 +--- .../ClientSideFilteringFreeAssignment.java | 6 +- .../lessons/clientsidefiltering/Salaries.java | 49 ++++++---- .../clientsidefiltering/ShopEndpoint.java | 5 +- .../lessons/cryptography/CryptoUtil.java | 28 +++--- .../cryptography/EncodingAssignment.java | 22 ++++- .../cryptography/HashingAssignment.java | 22 ++++- .../SecureDefaultsAssignment.java | 17 +++- .../cryptography/SigningAssignment.java | 19 ++-- .../cryptography/XOREncodingAssignment.java | 17 +++- .../webgoat/lessons/csrf/CSRFFeedback.java | 57 +++--------- .../webgoat/lessons/csrf/CSRFGetFlag.java | 35 ++----- .../owasp/webgoat/lessons/csrf/CSRFLogin.java | 16 +++- .../webgoat/lessons/csrf/ForgedReviews.java | 64 +++++++++---- .../webgoat/lessons/csrf/LoginCsrfFilter.java | 77 +++++++++++++++ .../webgoat/lessons/csrf/OriginCheck.java | 83 +++++++++++++++++ .../HijackSessionAssignment.java | 1 + .../HijackSessionAuthenticationProvider.java | 23 +++-- .../htmltampering/HtmlTamperingTask.java | 25 ++++- .../lessons/idor/IDORDiffAttributes.java | 12 +-- .../lessons/idor/IDOREditOtherProfile.java | 74 +++++---------- .../owasp/webgoat/lessons/idor/IDORLogin.java | 52 ++++++++--- .../lessons/idor/IDORViewOtherProfile.java | 39 ++++---- .../lessons/idor/IDORViewOwnProfile.java | 7 +- .../idor/IDORViewOwnProfileAltUrl.java | 37 +++----- .../webgoat/lessons/idor/UserProfile.java | 7 +- .../insecurelogin/InsecureLoginTask.java | 28 +++++- .../lessons/jwt/JWTRefreshEndpoint.java | 62 +++++++++---- .../lessons/jwt/JWTSecretKeyEndpoint.java | 13 ++- .../webgoat/lessons/jwt/JWTVotesEndpoint.java | 52 ++++++++--- .../jwt/claimmisuse/JWTHeaderJKUEndpoint.java | 28 +++--- .../jwt/claimmisuse/JWTHeaderKIDEndpoint.java | 31 +++++-- .../lessons/logging/LogBleedingTask.java | 5 +- .../lessons/logging/LogSpoofingTask.java | 12 ++- .../lessons/missingac/MissingFunctionAC.java | 14 ++- .../MissingFunctionACHiddenMenus.java | 21 ++++- .../missingac/MissingFunctionACUsers.java | 39 +++++--- .../missingac/MissingFunctionACYourHash.java | 14 ++- .../MissingFunctionACYourHashAdmin.java | 16 +++- .../passwordreset/QuestionsAssignment.java | 33 +------ .../passwordreset/ResetLinkAssignment.java | 62 +++++++------ .../ResetLinkAssignmentForgotPassword.java | 68 ++++---------- .../SecurityQuestionAssignment.java | 10 +- .../passwordreset/SimpleMailAssignment.java | 32 ++++++- .../pathtraversal/ProfileUploadRetrieval.java | 33 +++++-- .../lessons/pathtraversal/ProfileZipSlip.java | 12 ++- .../lessons/spoofcookie/encoders/EncDec.java | 68 ++++++++------ .../advanced/SqlInjectionChallengeLogin.java | 30 ++++++ .../advanced/SqlInjectionLesson6b.java | 34 ++++++- .../introduction/SqlInjectionLesson2.java | 28 +----- .../introduction/SqlInjectionLesson3.java | 37 +------- .../introduction/SqlInjectionLesson4.java | 32 +------ .../introduction/SqlInjectionLesson5.java | 23 ++--- .../introduction/SqlInjectionLesson8.java | 13 +-- .../introduction/SqlInjectionLesson9.java | 19 ++-- .../sqlinjection/mitigation/Servers.java | 14 ++- .../mitigation/SqlInjectionLesson13.java | 8 +- .../mitigation/SqlOnlyInputValidation.java | 63 ++++++++++--- .../SqlOnlyInputValidationOnKeywords.java | 63 ++++++++++--- .../owasp/webgoat/lessons/ssrf/SSRFTask1.java | 13 ++- .../VulnerableComponentsLesson.java | 93 ++++++++++++++++--- .../LandingAssignment.java | 10 +- .../webwolfintroduction/MailAssignment.java | 11 ++- .../UniqueCodeRegistry.java | 59 ++++++++++++ .../xss/CrossSiteScriptingLesson1.java | 2 +- .../xss/CrossSiteScriptingLesson5a.java | 33 ++----- .../xss/CrossSiteScriptingLesson6a.java | 11 +-- .../lessons/xss/DOMCrossSiteScripting.java | 9 +- .../xss/DOMCrossSiteScriptingVerifier.java | 2 +- .../StoredCrossSiteScriptingVerifier.java | 4 +- .../lessons/xss/stored/StoredXssComments.java | 14 ++- .../lessons/xxe/BlindSendFileAssignment.java | 7 +- .../lessons/xxe/ContentTypeAssignment.java | 23 +---- .../owasp/webgoat/lessons/xxe/SimpleXXE.java | 24 +---- .../webwolf/PasswordEncoderConfig.java | 20 ++++ .../webgoat/webwolf/WebSecurityConfig.java | 25 +++-- .../webwolf/WebWolfCsrfTokenController.java | 25 +++++ .../owasp/webgoat/webwolf/mailbox/Email.java | 2 + .../webwolf/mailbox/MailboxController.java | 4 +- .../webwolf/mailbox/MailboxRepository.java | 4 + .../webgoat/webwolf/requests/Requests.java | 12 ++- .../webgoat/webwolf/user/UserService.java | 15 ++- .../resources/application-webgoat.properties | 6 +- .../resources/application-webwolf.properties | 2 +- .../cryptography/documentation/signing.adoc | 2 +- .../cryptography/html/Cryptography.html | 2 +- .../resources/lessons/csrf/html/CSRF.html | 2 +- .../csrf/i18n/WebGoatLabels.properties | 4 + .../resources/lessons/csrf/js/csrf-review.js | 9 ++ .../idor/i18n/WebGoatLabels.properties | 7 ++ .../documentation/InsecureLogin_Task.adoc | 6 +- .../lessons/insecurelogin/js/credentials.js | 5 +- .../missingac/html/MissingFunctionAC.html | 9 +- .../i18n/WebGoatLabels.properties | 1 + .../js/goatApp/view/LessonContentView.js | 7 +- src/main/resources/webgoat/static/js/main.js | 12 +++ .../resources/webgoat/templates/main_new.html | 8 +- src/main/resources/webwolf/static/js/csrf.js | 26 ++++++ .../webwolf/templates/fragments/header.html | 10 +- .../resources/webwolf/templates/mailbox.html | 2 +- .../resources/webwolf/templates/requests.html | 4 +- 118 files changed, 1789 insertions(+), 969 deletions(-) create mode 100644 src/main/java/org/owasp/webgoat/container/PasswordEncoderConfig.java create mode 100644 src/main/java/org/owasp/webgoat/container/WebGoatCsrfTokenController.java create mode 100644 src/main/java/org/owasp/webgoat/csrf/CsrfExemptions.java create mode 100644 src/main/java/org/owasp/webgoat/csrf/CsrfTokenCookieFilter.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/csrf/LoginCsrfFilter.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/csrf/OriginCheck.java create mode 100644 src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/UniqueCodeRegistry.java create mode 100644 src/main/java/org/owasp/webgoat/webwolf/PasswordEncoderConfig.java create mode 100644 src/main/java/org/owasp/webgoat/webwolf/WebWolfCsrfTokenController.java create mode 100644 src/main/resources/webwolf/static/js/csrf.js diff --git a/src/main/java/org/dummy/insecure/framework/VulnerableTaskHolder.java b/src/main/java/org/dummy/insecure/framework/VulnerableTaskHolder.java index ab56221f1..002f0748d 100644 --- a/src/main/java/org/dummy/insecure/framework/VulnerableTaskHolder.java +++ b/src/main/java/org/dummy/insecure/framework/VulnerableTaskHolder.java @@ -4,9 +4,6 @@ */ package org.dummy.insecure.framework; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; import java.io.ObjectInputStream; import java.io.Serializable; import java.time.LocalDateTime; @@ -41,9 +38,9 @@ public String toString() { } /** - * Execute a task when de-serializing a saved or received object. - * - * @author stupid develop + * Rebuilds the state of a saved object and nothing more. Acting on the data while it is being + * read back is what turns any hostile stream into remote code execution, so the task is not + * executed here any more. */ private void readObject(ObjectInputStream stream) throws Exception { // unserialize data so taskName and taskAction are available @@ -61,20 +58,7 @@ private void readObject(ObjectInputStream stream) throws Exception { throw new IllegalArgumentException("outdated"); } - // condition is here to prevent you from destroying the goat altogether - if ((taskAction.startsWith("sleep") || taskAction.startsWith("ping")) - && taskAction.length() < 22) { - log.info("about to execute: {}", taskAction); - try { - Process p = Runtime.getRuntime().exec(taskAction); - BufferedReader in = new BufferedReader(new InputStreamReader(p.getInputStream())); - String line = null; - while ((line = in.readLine()) != null) { - log.info(line); - } - } catch (IOException e) { - log.error("IO Exception", e); - } - } + // the description is restored, running it is not this method's business + log.info("restored task action: {}", taskAction); } } diff --git a/src/main/java/org/owasp/webgoat/container/PasswordEncoderConfig.java b/src/main/java/org/owasp/webgoat/container/PasswordEncoderConfig.java new file mode 100644 index 000000000..9b1ac5ae1 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/container/PasswordEncoderConfig.java @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.container; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * Accounts were stored with {@code NoOpPasswordEncoder}, which keeps the password in clear text: + * anybody able to read the user table reads every password. BCrypt applies a salted, deliberately + * slow hash instead, so the stored value cannot be replayed and does not survive a database dump. + */ +@Configuration +public class PasswordEncoderConfig { + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/org/owasp/webgoat/container/WebGoatCsrfTokenController.java b/src/main/java/org/owasp/webgoat/container/WebGoatCsrfTokenController.java new file mode 100644 index 000000000..21fa718a2 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/container/WebGoatCsrfTokenController.java @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.container; + +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Lets a client which cannot read the token cookie (tests, scripts) obtain a token before it posts. + * Reading the token is a safe operation, minting it does not authorise anything by itself. + */ +@RestController +public class WebGoatCsrfTokenController { + + @GetMapping("/csrf/token") + public Token token(CsrfToken csrfToken) { + return new Token( + csrfToken.getToken(), csrfToken.getHeaderName(), csrfToken.getParameterName()); + } + + record Token(String token, String headerName, String parameterName) {} +} diff --git a/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java b/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java index 62c0d3df2..5c41ada6a 100644 --- a/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java +++ b/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java @@ -6,6 +6,8 @@ import lombok.AllArgsConstructor; import org.owasp.webgoat.container.users.UserService; +import org.owasp.webgoat.csrf.CsrfExemptions; +import org.owasp.webgoat.csrf.CsrfTokenCookieFilter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -16,7 +18,9 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.crypto.password.NoOpPasswordEncoder; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.security.web.SecurityFilterChain; /** Security configuration for WebGoat. */ @@ -29,6 +33,9 @@ public class WebSecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + var csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); + csrfTokenRepository.setCookieCustomizer(cookie -> cookie.sameSite("Strict")); + return http.authorizeHttpRequests( auth -> auth.requestMatchers( @@ -40,7 +47,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/plugins/**", "/registration", "/register.mvc", - "/actuator/**") + "/csrf/token", + "/actuator/health") .permitAll() .anyRequest() .authenticated()) @@ -58,8 +66,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { oidc.loginPage("/login"); }) .logout(logout -> logout.deleteCookies("JSESSIONID").invalidateHttpSession(true)) - .csrf(csrf -> csrf.disable()) - .headers(headers -> headers.disable()) + .csrf( + csrf -> + csrf.csrfTokenRepository(csrfTokenRepository) + .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) + .ignoringRequestMatchers( + CsrfExemptions.headerlessAuthentication("/login", "/register.mvc"))) + .addFilterAfter(new CsrfTokenCookieFilter(), CsrfFilter.class) .exceptionHandling( handling -> handling.authenticationEntryPoint(new AjaxAuthenticationEntryPoint("/login"))) @@ -82,9 +95,4 @@ public AuthenticationManager authenticationManager( AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } - - @Bean - public NoOpPasswordEncoder passwordEncoder() { - return (NoOpPasswordEncoder) NoOpPasswordEncoder.getInstance(); - } } diff --git a/src/main/java/org/owasp/webgoat/container/users/UserService.java b/src/main/java/org/owasp/webgoat/container/users/UserService.java index 6fc0c725e..17bd52841 100644 --- a/src/main/java/org/owasp/webgoat/container/users/UserService.java +++ b/src/main/java/org/owasp/webgoat/container/users/UserService.java @@ -6,12 +6,14 @@ import java.util.List; import java.util.function.Function; -import lombok.AllArgsConstructor; import org.flywaydb.core.Flyway; import org.owasp.webgoat.container.lessons.Initializable; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; /** @@ -19,7 +21,6 @@ * @since 3/19/17. */ @Service -@AllArgsConstructor public class UserService implements UserDetailsService { private final UserRepository userRepository; @@ -27,6 +28,39 @@ public class UserService implements UserDetailsService { private final JdbcTemplate jdbcTemplate; private final Function flywayLessons; private final List lessonInitializables; + private final PasswordEncoder passwordEncoder; + + @Autowired + public UserService( + UserRepository userRepository, + UserProgressRepository userTrackerRepository, + JdbcTemplate jdbcTemplate, + Function flywayLessons, + List lessonInitializables, + PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.userTrackerRepository = userTrackerRepository; + this.jdbcTemplate = jdbcTemplate; + this.flywayLessons = flywayLessons; + this.lessonInitializables = lessonInitializables; + this.passwordEncoder = + passwordEncoder == null ? new BCryptPasswordEncoder() : passwordEncoder; + } + + public UserService( + UserRepository userRepository, + UserProgressRepository userTrackerRepository, + JdbcTemplate jdbcTemplate, + Function flywayLessons, + List lessonInitializables) { + this( + userRepository, + userTrackerRepository, + jdbcTemplate, + flywayLessons, + lessonInitializables, + new BCryptPasswordEncoder()); + } @Override public WebGoatUser loadUserByUsername(String username) throws UsernameNotFoundException { @@ -44,7 +78,7 @@ public WebGoatUser loadUserByUsername(String username) throws UsernameNotFoundEx public void addUser(String username, String password) { // get user if there exists one by the name var userAlreadyExists = userRepository.existsByUsername(username); - var webGoatUser = userRepository.save(new WebGoatUser(username, password)); + var webGoatUser = userRepository.save(new WebGoatUser(username, passwordEncoder.encode(password))); if (!userAlreadyExists) { userTrackerRepository.save( diff --git a/src/main/java/org/owasp/webgoat/csrf/CsrfExemptions.java b/src/main/java/org/owasp/webgoat/csrf/CsrfExemptions.java new file mode 100644 index 000000000..407600a19 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/csrf/CsrfExemptions.java @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.csrf; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.List; +import org.springframework.security.web.util.matcher.RequestMatcher; + +/** + * The few places where requiring a CSRF token would break a client that is not a browser at all. + * + *

Both applications are also driven from the integration tests and from the command line, and + * such a client cannot fetch a token before it has a session. A browser always labels its cross site + * form posts with {@code Origin} or {@code Referer}, so a request carrying neither header cannot + * have been triggered from another page with the victim's cookies attached, which is exactly the + * situation the token protects against. + */ +public final class CsrfExemptions { + + private CsrfExemptions() {} + + /** Matches token-less authentication calls made by non browser clients on the given paths. */ + public static RequestMatcher headerlessAuthentication(String... paths) { + List exempted = Arrays.asList(paths); + return request -> + "POST".equalsIgnoreCase(request.getMethod()) + && exempted.contains(pathWithoutContext(request)) + && request.getHeader("Origin") == null + && request.getHeader("Referer") == null; + } + + private static String pathWithoutContext(HttpServletRequest request) { + String uri = request.getRequestURI(); + String context = request.getContextPath(); + if (context == null || context.isEmpty() || !uri.startsWith(context)) { + return uri; + } + return uri.substring(context.length()); + } +} diff --git a/src/main/java/org/owasp/webgoat/csrf/CsrfTokenCookieFilter.java b/src/main/java/org/owasp/webgoat/csrf/CsrfTokenCookieFilter.java new file mode 100644 index 000000000..705776ddf --- /dev/null +++ b/src/main/java/org/owasp/webgoat/csrf/CsrfTokenCookieFilter.java @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.csrf; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Spring Security hands out the CSRF token lazily, the cookie is only written once something asks + * for its value. The single page front end reads that cookie before it posts anything, so the token + * is resolved here on every request instead of waiting for a form to render. + */ +public final class CsrfTokenCookieFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + var token = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (token != null) { + // resolving the value is what makes the repository write the cookie + token.getToken(); + } + chain.doFilter(request, response); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/authbypass/AccountVerificationHelper.java b/src/main/java/org/owasp/webgoat/lessons/authbypass/AccountVerificationHelper.java index 30b1d765b..96c320de4 100644 --- a/src/main/java/org/owasp/webgoat/lessons/authbypass/AccountVerificationHelper.java +++ b/src/main/java/org/owasp/webgoat/lessons/authbypass/AccountVerificationHelper.java @@ -19,7 +19,7 @@ public class AccountVerificationHelper { userSecQuestions.put("secQuestion1", "Baker Street"); } - private static final Map secQuestionStore = new HashMap<>(); + private static final Map> secQuestionStore = new HashMap<>(); static { secQuestionStore.put(verifyUserId, userSecQuestions); @@ -55,26 +55,22 @@ public boolean didUserLikelylCheat(HashMap submittedAnswers) { // end of cheating check ... the method below is the one of real interest. Can you find the flaw? public boolean verifyAccount(Integer userId, HashMap submittedQuestions) { - // short circuit if no questions are submitted - if (submittedQuestions.entrySet().size() != secQuestionStore.get(verifyUserId).size()) { - return false; - } + Map storedQuestions = secQuestionStore.get(userId); - if (submittedQuestions.containsKey("secQuestion0") - && !submittedQuestions - .get("secQuestion0") - .equals(secQuestionStore.get(verifyUserId).get("secQuestion0"))) { + // no such account, or the number of answers does not match the number of questions + if (storedQuestions == null || submittedQuestions.size() != storedQuestions.size()) { return false; } - if (submittedQuestions.containsKey("secQuestion1") - && !submittedQuestions - .get("secQuestion1") - .equals(secQuestionStore.get(verifyUserId).get("secQuestion1"))) { - return false; + // Every question of *this* account has to be answered correctly. Answers to questions the + // account does not have are ignored, they can no longer stand in for a missing answer. + for (Map.Entry storedQuestion : storedQuestions.entrySet()) { + String submittedAnswer = submittedQuestions.get(storedQuestion.getKey()); + if (submittedAnswer == null || !submittedAnswer.equals(storedQuestion.getValue())) { + return false; + } } - // else return true; } } diff --git a/src/main/java/org/owasp/webgoat/lessons/authbypass/VerifyAccount.java b/src/main/java/org/owasp/webgoat/lessons/authbypass/VerifyAccount.java index 4a27702d3..85d24e008 100644 --- a/src/main/java/org/owasp/webgoat/lessons/authbypass/VerifyAccount.java +++ b/src/main/java/org/owasp/webgoat/lessons/authbypass/VerifyAccount.java @@ -55,7 +55,9 @@ public AttackResult completed( } // else - if (verificationHelper.verifyAccount(Integer.valueOf(userId), (HashMap) submittedAnswers)) { + Integer accountId = toUserId(userId); + if (accountId != null + && verificationHelper.verifyAccount(accountId, (HashMap) submittedAnswers)) { userSessionData.setValue("account-verified-id", userId); return success(this).feedback("verify-account.success").build(); } else { @@ -63,6 +65,14 @@ public AttackResult completed( } } + private Integer toUserId(String userId) { + try { + return Integer.valueOf(userId); + } catch (NumberFormatException e) { + return null; + } + } + private HashMap parseSecQuestions(HttpServletRequest req) { Map userAnswers = new HashMap<>(); List paramNames = Collections.list(req.getParameterNames()); diff --git a/src/main/java/org/owasp/webgoat/lessons/bypassrestrictions/BypassRestrictionsFieldRestrictions.java b/src/main/java/org/owasp/webgoat/lessons/bypassrestrictions/BypassRestrictionsFieldRestrictions.java index e3b8fd95e..44612c810 100644 --- a/src/main/java/org/owasp/webgoat/lessons/bypassrestrictions/BypassRestrictionsFieldRestrictions.java +++ b/src/main/java/org/owasp/webgoat/lessons/bypassrestrictions/BypassRestrictionsFieldRestrictions.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.bypassrestrictions; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; @@ -17,6 +16,8 @@ @RestController public class BypassRestrictionsFieldRestrictions implements AssignmentEndpoint { + private static final int MAX_SHORT_INPUT = 5; + @PostMapping("/BypassRestrictions/FieldRestrictions") @ResponseBody public AttackResult completed( @@ -25,21 +26,21 @@ public AttackResult completed( @RequestParam String checkbox, @RequestParam String shortInput, @RequestParam String readOnlyInput) { - if (select.equals("option1") || select.equals("option2")) { - return failed(this).build(); - } - if (radio.equals("option1") || radio.equals("option2")) { - return failed(this).build(); - } - if (checkbox.equals("on") || checkbox.equals("off")) { - return failed(this).build(); - } - if (shortInput.length() <= 5) { - return failed(this).build(); + // Whatever the form widgets allow is checked again on this side. A value the rendered form + // could not have produced is refused instead of being taken at face value. + if (!matchesFormRestrictions(select, radio, checkbox, shortInput, readOnlyInput)) { + return failed(this).feedback("bypass-restrictions.intercept.failure").build(); } - if ("change".equals(readOnlyInput)) { - return failed(this).build(); - } - return success(this).build(); + return failed(this).build(); + } + + private boolean matchesFormRestrictions( + String select, String radio, String checkbox, String shortInput, String readOnlyInput) { + return ("option1".equals(select) || "option2".equals(select)) + && ("option1".equals(radio) || "option2".equals(radio)) + && ("on".equals(checkbox) || "off".equals(checkbox)) + && shortInput != null + && shortInput.length() <= MAX_SHORT_INPUT + && "change".equals(readOnlyInput); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/bypassrestrictions/BypassRestrictionsFrontendValidation.java b/src/main/java/org/owasp/webgoat/lessons/bypassrestrictions/BypassRestrictionsFrontendValidation.java index 158079da3..6713d6f13 100644 --- a/src/main/java/org/owasp/webgoat/lessons/bypassrestrictions/BypassRestrictionsFrontendValidation.java +++ b/src/main/java/org/owasp/webgoat/lessons/bypassrestrictions/BypassRestrictionsFrontendValidation.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.bypassrestrictions; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; @@ -38,27 +37,17 @@ public AttackResult completed( if (error > 0) { return failed(this).build(); } - if (field1.matches(regex1)) { - return failed(this).build(); - } - if (field2.matches(regex2)) { - return failed(this).build(); - } - if (field3.matches(regex3)) { - return failed(this).build(); - } - if (field4.matches(regex4)) { - return failed(this).build(); - } - if (field5.matches(regex5)) { - return failed(this).build(); - } - if (field6.matches(regex6)) { - return failed(this).build(); - } - if (field7.matches(regex7)) { - return failed(this).build(); + // The browser side validation is repeated on the server. Input in the wrong format is + // rejected here, rather than trusted because a script claimed it had already been checked. + if (!field1.matches(regex1) + || !field2.matches(regex2) + || !field3.matches(regex3) + || !field4.matches(regex4) + || !field5.matches(regex5) + || !field6.matches(regex6) + || !field7.matches(regex7)) { + return failed(this).feedback("bypass-restrictions.intercept.failure").build(); } - return success(this).build(); + return failed(this).build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/FlagController.java b/src/main/java/org/owasp/webgoat/lessons/challenges/FlagController.java index cf54315f4..c8d0a60e6 100644 --- a/src/main/java/org/owasp/webgoat/lessons/challenges/FlagController.java +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/FlagController.java @@ -28,6 +28,9 @@ public FlagController(Flags flags) { @ResponseBody public AttackResult postFlag(@PathVariable int flagNumber, @RequestParam String flag) { var expectedFlag = flags.getFlag(flagNumber); + if (expectedFlag == null) { + return failed(this).feedback("challenge.flag.incorrect").build(); + } if (expectedFlag.isCorrect(flag)) { return success(this).feedback("challenge.flag.correct").build(); } else { diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/SolutionConstants.java b/src/main/java/org/owasp/webgoat/lessons/challenges/SolutionConstants.java index 9322df097..b8c0ae4d1 100644 --- a/src/main/java/org/owasp/webgoat/lessons/challenges/SolutionConstants.java +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/SolutionConstants.java @@ -6,6 +6,9 @@ public interface SolutionConstants { - // TODO should be random generated when starting the server - String PASSWORD = "!!webgoat_admin_1234!!"; + // A credential written into the source is public: it sits in the repository, in every build + // and in every container image. The unguessable part is drawn at boot instead. The "1234" + // placeholder stays where it is, the challenge still substitutes its pincode there. + String PASSWORD = + "!!webgoat_admin_" + java.util.UUID.randomUUID().toString().replace("-", "") + "_1234!!"; } diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge1/ImageServlet.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge1/ImageServlet.java index 735d9f1e4..2736a75a4 100644 --- a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge1/ImageServlet.java +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge1/ImageServlet.java @@ -8,7 +8,7 @@ import static org.springframework.web.bind.annotation.RequestMethod.POST; import java.io.IOException; -import java.util.Random; +import java.security.SecureRandom; import org.springframework.core.io.ClassPathResource; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.RequestMapping; @@ -18,7 +18,9 @@ @RestController public class ImageServlet { - public static final int PINCODE = new Random().nextInt(10000); + // The admin password is derived from this value, so it never gets written into a response. + // It used to be painted into the bytes of the image that this endpoint hands out. + public static final int PINCODE = new SecureRandom().nextInt(Integer.MAX_VALUE); @RequestMapping( method = {GET, POST}, @@ -31,13 +33,6 @@ public byte[] logo() throws IOException { .getInputStream() .readAllBytes(); - String pincode = String.format("%04d", PINCODE); - - in[81216] = (byte) pincode.charAt(0); - in[81217] = (byte) pincode.charAt(1); - in[81218] = (byte) pincode.charAt(2); - in[81219] = (byte) pincode.charAt(3); - return in; } } diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge5/Assignment5.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge5/Assignment5.java index b71705562..41e3eff2c 100644 --- a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge5/Assignment5.java +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge5/Assignment5.java @@ -42,11 +42,9 @@ public AttackResult login( try (var connection = dataSource.getConnection()) { PreparedStatement statement = connection.prepareStatement( - "select password from challenge_users where userid = '" - + username_login - + "' and password = '" - + password_login - + "'"); + "select password from challenge_users where userid = ? and password = ?"); + statement.setString(1, username_login); + statement.setString(2, password_login); ResultSet resultSet = statement.executeQuery(); if (resultSet.next()) { diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge7/Assignment7.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge7/Assignment7.java index 9ce34238b..9a6f2dc9d 100644 --- a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge7/Assignment7.java +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge7/Assignment7.java @@ -10,13 +10,13 @@ import java.net.URI; import java.net.URISyntaxException; import java.time.LocalDateTime; +import java.util.UUID; import lombok.extern.slf4j.Slf4j; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; import org.owasp.webgoat.lessons.challenges.Email; import org.owasp.webgoat.lessons.challenges.Flags; import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.ClassPathResource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -37,7 +37,8 @@ @Slf4j public class Assignment7 implements AssignmentEndpoint { - public static final String ADMIN_PASSWORD_LINK = "375afe1104f4a487a73823c50a9292a2"; + // Drawn once per run: a reset link that is a constant in the source is known to everyone. + public static final String ADMIN_PASSWORD_LINK = UUID.randomUUID().toString().replace("-", ""); private static final String TEMPLATE = "Hi, you requested a password reset link, please use this git() { + // Handing out a .git directory hands out the entire history of the project, including the + // things that were deleted again in a later commit. It is not served any more. + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .contentType(MediaType.parseMediaType("application/zip")) + .body(new byte[0]); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge7/PasswordResetLink.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge7/PasswordResetLink.java index 31a0c5691..24791e716 100644 --- a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge7/PasswordResetLink.java +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge7/PasswordResetLink.java @@ -4,23 +4,24 @@ */ package org.owasp.webgoat.lessons.challenges.challenge7; +import java.security.SecureRandom; +import java.util.HexFormat; import java.util.Random; /** - * WARNING: DO NOT CHANGE FILE WITHOUT CHANGING .git contents - * * @author nbaars * @since 8/17/17. */ public class PasswordResetLink { + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + public String createPasswordReset(String username, String key) { - Random random = new Random(); - if (username.equalsIgnoreCase("admin")) { - // Admin has a fix reset link - random.setSeed(key.length()); - } - return scramble(random, scramble(random, scramble(random, MD5.getHashString(username)))); + // The token comes straight out of a CSPRNG. It used to be derived from the user name, and + // for "admin" from a seed anybody could reconstruct, which made it entirely predictable. + byte[] token = new byte[16]; + SECURE_RANDOM.nextBytes(token); + return HexFormat.of().formatHex(token); } public static String scramble(Random random, String inputString) { diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge8/Assignment8.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge8/Assignment8.java index ff74256e1..361766cfc 100644 --- a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge8/Assignment8.java +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge8/Assignment8.java @@ -41,18 +41,11 @@ public class Assignment8 implements AssignmentEndpoint { @ResponseBody public ResponseEntity vote( @PathVariable(value = "stars") int nrOfStars, HttpServletRequest request) { - // Simple implementation of VERB Based Authentication - String msg = ""; - if (request.getMethod().equals("GET")) { - var json = - Map.of("error", true, "message", "Sorry but you need to login first in order to vote"); - return ResponseEntity.status(200).body(json); - } - Integer allVotesForStar = votes.getOrDefault(nrOfStars, 0); - votes.put(nrOfStars, allVotesForStar + 1); - return ResponseEntity.ok() - .header("X-FlagController", "Thanks for voting, your flag is: " + flags.getFlag(8)) - .build(); + // The HTTP verb is not an authorization decision. HEAD and friends end up in this same + // handler, so an anonymous caller is turned away no matter which method it picked. + var json = + Map.of("error", true, "message", "Sorry but you need to login first in order to vote"); + return ResponseEntity.status(200).body(json); } @GetMapping("/challenge/8/votes/") diff --git a/src/main/java/org/owasp/webgoat/lessons/clientsidefiltering/ClientSideFilteringFreeAssignment.java b/src/main/java/org/owasp/webgoat/lessons/clientsidefiltering/ClientSideFilteringFreeAssignment.java index 32e731d59..32adb0a8e 100644 --- a/src/main/java/org/owasp/webgoat/lessons/clientsidefiltering/ClientSideFilteringFreeAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/clientsidefiltering/ClientSideFilteringFreeAssignment.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.clientsidefiltering; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -31,9 +30,8 @@ public class ClientSideFilteringFreeAssignment implements AssignmentEndpoint { @PostMapping("/clientSideFiltering/getItForFree") @ResponseBody public AttackResult completed(@RequestParam String checkoutCode) { - if (SUPER_COUPON_CODE.equals(checkoutCode)) { - return success(this).build(); - } + // The discount attached to a code is decided on this side and none of them is 100%, so + // there is no code that checks an order out for free. return failed(this).build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/clientsidefiltering/Salaries.java b/src/main/java/org/owasp/webgoat/lessons/clientsidefiltering/Salaries.java index 748c3a7b3..0c1527ebe 100644 --- a/src/main/java/org/owasp/webgoat/lessons/clientsidefiltering/Salaries.java +++ b/src/main/java/org/owasp/webgoat/lessons/clientsidefiltering/Salaries.java @@ -33,6 +33,10 @@ @Slf4j public class Salaries { + private static final String[] PUBLIC_FIELDS = { + "UserID", "FirstName", "LastName" + }; + @Value("${webgoat.user.directory}") private String webGoatHomeDirectory; @@ -55,34 +59,28 @@ public void copyFiles() { @GetMapping("clientSideFiltering/salaries") @ResponseBody public List> invoke() { - NodeList nodes = null; File d = new File(webGoatHomeDirectory, "ClientSideFiltering/employees.xml"); + List> json = new ArrayList<>(); + + // Nothing in the request can tell us that the caller is allowed to see payroll data, so this + // endpoint answers with the public identity fields only. Salary and SSN stay here instead of + // being shipped to the browser and hidden with a bit of JavaScript. + XPathFactory factory = XPathFactory.newInstance(); XPath path = factory.newXPath(); - int columns = 5; - List> json = new ArrayList<>(); - java.util.Map employeeJson = new HashMap<>(); try (InputStream is = new FileInputStream(d)) { InputSource inputSource = new InputSource(is); + NodeList employees = + (NodeList) path.evaluate("/Employees/Employee", inputSource, XPathConstants.NODESET); - StringBuilder sb = new StringBuilder(); - - sb.append("/Employees/Employee/UserID | "); - sb.append("/Employees/Employee/FirstName | "); - sb.append("/Employees/Employee/LastName | "); - sb.append("/Employees/Employee/SSN | "); - sb.append("/Employees/Employee/Salary "); - - String expression = sb.toString(); - nodes = (NodeList) path.evaluate(expression, inputSource, XPathConstants.NODESET); - for (int i = 0; i < nodes.getLength(); i++) { - if (i % columns == 0) { - employeeJson = new HashMap<>(); - json.add(employeeJson); + for (int i = 0; i < employees.getLength(); i++) { + Node employee = employees.item(i); + Map employeeJson = new HashMap<>(); + for (String field : PUBLIC_FIELDS) { + employeeJson.put(field, valueOf(employee, field)); } - Node node = nodes.item(i); - employeeJson.put(node.getNodeName(), node.getTextContent()); + json.add(employeeJson); } } catch (XPathExpressionException e) { log.error("Unable to parse xml", e); @@ -91,4 +89,15 @@ public List> invoke() { } return json; } + + private String valueOf(Node employee, String name) { + NodeList children = employee.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (name.equals(child.getNodeName())) { + return child.getTextContent().trim(); + } + } + return ""; + } } diff --git a/src/main/java/org/owasp/webgoat/lessons/clientsidefiltering/ShopEndpoint.java b/src/main/java/org/owasp/webgoat/lessons/clientsidefiltering/ShopEndpoint.java index 1a4efd0e5..02bd16548 100644 --- a/src/main/java/org/owasp/webgoat/lessons/clientsidefiltering/ShopEndpoint.java +++ b/src/main/java/org/owasp/webgoat/lessons/clientsidefiltering/ShopEndpoint.java @@ -52,17 +52,14 @@ public ShopEndpoint() { @GetMapping(value = "/coupons/{code}", produces = MediaType.APPLICATION_JSON_VALUE) public CheckoutCode getDiscountCode(@PathVariable String code) { - if (ClientSideFilteringFreeAssignment.SUPER_COUPON_CODE.equals(code)) { - return new CheckoutCode(ClientSideFilteringFreeAssignment.SUPER_COUPON_CODE, 100); - } return checkoutCodes.get(code).orElse(new CheckoutCode("no", 0)); } @GetMapping(value = "/coupons", produces = MediaType.APPLICATION_JSON_VALUE) public CheckoutCodes all() { + // only the codes the shop really offers; the hidden one is no longer appended to the list List all = Lists.newArrayList(); all.addAll(this.checkoutCodes.getCodes()); - all.add(new CheckoutCode(ClientSideFilteringFreeAssignment.SUPER_COUPON_CODE, 100)); return new CheckoutCodes(all); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/CryptoUtil.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/CryptoUtil.java index 69d80dcb3..49555afbd 100644 --- a/src/main/java/org/owasp/webgoat/lessons/cryptography/CryptoUtil.java +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/CryptoUtil.java @@ -4,7 +4,6 @@ */ package org.owasp.webgoat.lessons.cryptography; -import java.math.BigInteger; import java.nio.charset.Charset; import java.security.InvalidAlgorithmParameterException; import java.security.KeyFactory; @@ -13,7 +12,6 @@ import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.PublicKey; -import java.security.SecureRandom; import java.security.Signature; import java.security.interfaces.RSAPublicKey; import java.security.spec.InvalidKeySpecException; @@ -26,25 +24,27 @@ @Slf4j public class CryptoUtil { - private static final BigInteger[] FERMAT_PRIMES = { - BigInteger.valueOf(3), - BigInteger.valueOf(5), - BigInteger.valueOf(17), - BigInteger.valueOf(257), - BigInteger.valueOf(65537) - }; - public static KeyPair generateKeyPair() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); - RSAKeyGenParameterSpec kpgSpec = - new RSAKeyGenParameterSpec( - 2048, FERMAT_PRIMES[new SecureRandom().nextInt(FERMAT_PRIMES.length)]); + /* fixed on the standard public exponent, the small Fermat primes weaken the key */ + RSAKeyGenParameterSpec kpgSpec = new RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4); keyPairGenerator.initialize(kpgSpec); - // keyPairGenerator.initialize(2048); return keyPairGenerator.generateKeyPair(); } + public static String getPublicKeyInPEM(KeyPair keyPair) { + String encodedString = "-----BEGIN PUBLIC KEY-----\n"; + encodedString = + encodedString + + new String( + Base64.getEncoder().encode(keyPair.getPublic().getEncoded()), + Charset.forName("UTF-8")) + + "\n"; + encodedString = encodedString + "-----END PUBLIC KEY-----\n"; + return encodedString; + } + public static String getPrivateKeyInPEM(KeyPair keyPair) { String encodedString = "-----BEGIN PRIVATE KEY-----\n"; encodedString = diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/EncodingAssignment.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/EncodingAssignment.java index d1a8ffa36..40d500700 100644 --- a/src/main/java/org/owasp/webgoat/lessons/cryptography/EncodingAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/EncodingAssignment.java @@ -8,8 +8,8 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import jakarta.servlet.http.HttpServletRequest; +import java.security.SecureRandom; import java.util.Base64; -import java.util.Random; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; import org.springframework.http.MediaType; @@ -22,6 +22,14 @@ @RestController public class EncodingAssignment implements AssignmentEndpoint { + private static final SecureRandom RANDOM = new SecureRandom(); + + /* + * Base64 hides nothing: anybody holding the response can decode the header back into the + * credential. What is sent out is a placeholder, the generated password stays on the session. + */ + private static final String PLACEHOLDER_HEADER = getBasicAuth("redacted", "redacted"); + public static String getBasicAuth(String username, String password) { return Base64.getEncoder().encodeToString(username.concat(":").concat(password).getBytes()); } @@ -33,12 +41,16 @@ public String getBasicAuth(HttpServletRequest request) { String basicAuth = (String) request.getSession().getAttribute("basicAuth"); String username = request.getUserPrincipal().getName(); if (basicAuth == null) { - String password = - HashingAssignment.SECRETS[new Random().nextInt(HashingAssignment.SECRETS.length)]; - basicAuth = getBasicAuth(username, password); + basicAuth = getBasicAuth(username, randomPassword()); request.getSession().setAttribute("basicAuth", basicAuth); } - return "Authorization: Basic ".concat(basicAuth); + return "Authorization: Basic ".concat(PLACEHOLDER_HEADER); + } + + private static String randomPassword() { + byte[] password = new byte[24]; + RANDOM.nextBytes(password); + return Base64.getUrlEncoder().withoutPadding().encodeToString(password); } @PostMapping("/crypto/encoding/basic-auth") diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/HashingAssignment.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/HashingAssignment.java index 62d327546..6297f8d38 100644 --- a/src/main/java/org/owasp/webgoat/lessons/cryptography/HashingAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/HashingAssignment.java @@ -10,7 +10,8 @@ import jakarta.servlet.http.HttpServletRequest; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.util.Random; +import java.security.SecureRandom; +import java.util.Base64; import javax.xml.bind.DatatypeConverter; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -27,6 +28,19 @@ public class HashingAssignment implements AssignmentEndpoint { public static final String[] SECRETS = {"secret", "admin", "password", "123456", "passw0rd"}; + private static final SecureRandom RANDOM = new SecureRandom(); + + /* + * These digests are handed to the caller and are unsalted, from a fast hash. Against a secret + * picked out of a five word list that is a few guesses' work. The secret now comes from a + * CSPRNG, so there is no dictionary left to run through. + */ + private static String randomSecret() { + byte[] secret = new byte[32]; + RANDOM.nextBytes(secret); + return Base64.getUrlEncoder().withoutPadding().encodeToString(secret); + } + @RequestMapping(path = "/crypto/hashing/md5", produces = MediaType.TEXT_HTML_VALUE) @ResponseBody public String getMd5(HttpServletRequest request) throws NoSuchAlgorithmException { @@ -34,7 +48,7 @@ public String getMd5(HttpServletRequest request) throws NoSuchAlgorithmException String md5Hash = (String) request.getSession().getAttribute("md5Hash"); if (md5Hash == null) { - String secret = SECRETS[new Random().nextInt(SECRETS.length)]; + String secret = randomSecret(); MessageDigest md = MessageDigest.getInstance("MD5"); md.update(secret.getBytes()); @@ -50,9 +64,9 @@ public String getMd5(HttpServletRequest request) throws NoSuchAlgorithmException @ResponseBody public String getSha256(HttpServletRequest request) throws NoSuchAlgorithmException { - String sha256 = (String) request.getSession().getAttribute("sha256"); + String sha256 = (String) request.getSession().getAttribute("sha256Hash"); if (sha256 == null) { - String secret = SECRETS[new Random().nextInt(SECRETS.length)]; + String secret = randomSecret(); sha256 = getHash(secret, "SHA-256"); request.getSession().setAttribute("sha256Hash", sha256); request.getSession().setAttribute("sha256Secret", secret); diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/SecureDefaultsAssignment.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/SecureDefaultsAssignment.java index 023c9bbae..57163ddaf 100644 --- a/src/main/java/org/owasp/webgoat/lessons/cryptography/SecureDefaultsAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/SecureDefaultsAssignment.java @@ -8,6 +8,8 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; @@ -24,6 +26,18 @@ }) public class SecureDefaultsAssignment implements AssignmentEndpoint { + /* + * The expected digest was a constant in this file, so the answer was public. It is computed + * from a value drawn at boot instead, which is the point the lesson is trying to make. + */ + private static final String SECRET = randomSecret(); + + private static String randomSecret() { + byte[] secret = new byte[32]; + new SecureRandom().nextBytes(secret); + return Base64.getUrlEncoder().withoutPadding().encodeToString(secret); + } + @PostMapping("/crypto/secure/defaults") @ResponseBody public AttackResult completed( @@ -32,8 +46,7 @@ public AttackResult completed( if (secretFileName != null && secretFileName.equals("default_secret")) { if (secretText != null && HashingAssignment.getHash(secretText, "SHA-256") - .equalsIgnoreCase( - "34de66e5caf2cb69ff2bebdc1f3091ecf6296852446c718e38ebfa60e4aa75d2")) { + .equalsIgnoreCase(HashingAssignment.getHash(SECRET, "SHA-256"))) { return success(this).feedback("crypto-secure-defaults.success").build(); } else { return failed(this).feedback("crypto-secure-defaults.messagenotok").build(); diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/SigningAssignment.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/SigningAssignment.java index f2284df4d..cd2f38371 100644 --- a/src/main/java/org/owasp/webgoat/lessons/cryptography/SigningAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/SigningAssignment.java @@ -34,19 +34,23 @@ @Slf4j public class SigningAssignment implements AssignmentEndpoint { + /* + * One key pair per session, and only its public half is ever written to a response. Give out + * the private half and anybody can sign on behalf of this application. + */ @RequestMapping(path = "/crypto/signing/getprivate", produces = MediaType.TEXT_HTML_VALUE) @ResponseBody - public String getPrivateKey(HttpServletRequest request) + public String getPublicKey(HttpServletRequest request) throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { - String privateKey = (String) request.getSession().getAttribute("privateKeyString"); - if (privateKey == null) { + String publicKey = (String) request.getSession().getAttribute("publicKeyString"); + if (publicKey == null) { KeyPair keyPair = CryptoUtil.generateKeyPair(); - privateKey = CryptoUtil.getPrivateKeyInPEM(keyPair); - request.getSession().setAttribute("privateKeyString", privateKey); + publicKey = CryptoUtil.getPublicKeyInPEM(keyPair); + request.getSession().setAttribute("publicKeyString", publicKey); request.getSession().setAttribute("keyPair", keyPair); } - return privateKey; + return publicKey; } @PostMapping("/crypto/signing/verify") @@ -57,6 +61,9 @@ public AttackResult completed( String tempModulus = modulus; /* used to validate the modulus of the public key but might need to be corrected */ KeyPair keyPair = (KeyPair) request.getSession().getAttribute("keyPair"); + if (keyPair == null) { + return failed(this).feedback("crypto-signing.modulusnotok").build(); + } RSAPublicKey rsaPubKey = (RSAPublicKey) keyPair.getPublic(); if (tempModulus.length() == 512) { tempModulus = "00".concat(tempModulus); diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/XOREncodingAssignment.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/XOREncodingAssignment.java index fc3ce7de3..95f65b2cb 100644 --- a/src/main/java/org/owasp/webgoat/lessons/cryptography/XOREncodingAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/XOREncodingAssignment.java @@ -7,6 +7,8 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import java.security.SecureRandom; +import java.util.Base64; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; @@ -19,10 +21,23 @@ @AssignmentHints({"crypto-encoding-xor.hints.1"}) public class XOREncodingAssignment implements AssignmentEndpoint { + /* + * The database password sat in this file, and the lesson published it as a reversible {xor} + * value on top of that. It is generated per run now, so undoing the published encoding no + * longer yields a credential that works anywhere. + */ + private static final String DB_PASSWORD = randomPassword(); + + private static String randomPassword() { + byte[] password = new byte[24]; + new SecureRandom().nextBytes(password); + return Base64.getUrlEncoder().withoutPadding().encodeToString(password); + } + @PostMapping("/crypto/encoding/xor") @ResponseBody public AttackResult completed(@RequestParam String answer_pwd1) { - if (answer_pwd1 != null && answer_pwd1.equals("databasepassword")) { + if (answer_pwd1 != null && answer_pwd1.equals(DB_PASSWORD)) { return success(this).feedback("crypto-encoding-xor.success").build(); } return failed(this).feedback("crypto-encoding-xor.empty").build(); diff --git a/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFFeedback.java b/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFFeedback.java index a9d964b9e..b6649b9a8 100644 --- a/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFFeedback.java +++ b/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFFeedback.java @@ -9,11 +9,10 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; +import java.util.Locale; import java.util.Map; -import java.util.UUID; import org.apache.commons.lang3.exception.ExceptionUtils; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -43,6 +42,17 @@ public CSRFFeedback(LessonSession userSessionData, ObjectMapper objectMapper) { produces = {"application/json"}) @ResponseBody public AttackResult completed(HttpServletRequest request, @RequestBody String feedback) { + // A form on another site can only post one of the three simple content types. Feedback is + // JSON, so anything else is turned away before the body is even parsed. + String contentType = request.getContentType(); + if (contentType == null + || !contentType.toLowerCase(Locale.ROOT).contains(MediaType.APPLICATION_JSON_VALUE)) { + return failed(this).feedback("csrf-feedback-invalid-content-type").build(); + } + // And the request has to prove it started in WebGoat itself. + if (!OriginCheck.fromThisApplication(request)) { + return failed(this).feedback("csrf-request-rejected").build(); + } try { objectMapper.enable(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES); objectMapper.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES); @@ -54,56 +64,17 @@ public AttackResult completed(HttpServletRequest request, @RequestBody String fe } catch (IOException e) { return failed(this).feedback(ExceptionUtils.getStackTrace(e)).build(); } - boolean correctCSRF = - requestContainsWebGoatCookie(request.getCookies()) - && request.getContentType().contains(MediaType.TEXT_PLAIN_VALUE); - correctCSRF &= hostOrRefererDifferentHost(request); - if (correctCSRF) { - String flag = UUID.randomUUID().toString(); - userSessionData.setValue("csrf-feedback", flag); - return success(this).feedback("csrf-feedback-success").feedbackArgs(flag).build(); - } return failed(this).build(); } @PostMapping(path = "/csrf/feedback", produces = "application/json") @ResponseBody public AttackResult flag(@RequestParam("confirmFlagVal") String flag) { - if (flag.equals(userSessionData.getValue("csrf-feedback"))) { + Object expectedFlag = userSessionData.getValue("csrf-feedback"); + if (expectedFlag != null && flag.equals(expectedFlag)) { return success(this).build(); } else { return failed(this).build(); } } - - private boolean hostOrRefererDifferentHost(HttpServletRequest request) { - String referer = request.getHeader("Referer"); - String host = request.getHeader("Host"); - if (referer != null) { - return !referer.contains(host); - } else { - return true; - } - } - - private boolean requestContainsWebGoatCookie(Cookie[] cookies) { - if (cookies != null) { - for (Cookie c : cookies) { - if (c.getName().equals("JSESSIONID")) { - return true; - } - } - } - return false; - } - - /* - * Solution: - *

- * - * - * - * - */ - } diff --git a/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFGetFlag.java b/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFGetFlag.java index 14af3956e..31835c7a6 100644 --- a/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFGetFlag.java +++ b/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFGetFlag.java @@ -7,9 +7,7 @@ import jakarta.servlet.http.HttpServletRequest; import java.util.HashMap; import java.util.Map; -import java.util.Random; import org.owasp.webgoat.container.i18n.PluginMessages; -import org.owasp.webgoat.container.session.LessonSession; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.ResponseBody; @@ -19,7 +17,6 @@ @RestController public class CSRFGetFlag { - @Autowired LessonSession userSessionData; @Autowired private PluginMessages pluginMessages; @PostMapping( @@ -29,35 +26,15 @@ public class CSRFGetFlag { public Map invoke(HttpServletRequest req) { Map response = new HashMap<>(); + response.put("success", false); + response.put("flag", null); - String host = (req.getHeader("host") == null) ? "NULL" : req.getHeader("host"); - String referer = (req.getHeader("referer") == null) ? "NULL" : req.getHeader("referer"); - String[] refererArr = referer.split("/"); - - if (referer.equals("NULL")) { - if ("true".equals(req.getParameter("csrf"))) { - Random random = new Random(); - userSessionData.setValue("csrf-get-success", random.nextInt(65536)); - response.put("success", true); - response.put("message", pluginMessages.getMessage("csrf-get-null-referer.success")); - response.put("flag", userSessionData.getValue("csrf-get-success")); - } else { - Random random = new Random(); - userSessionData.setValue("csrf-get-success", random.nextInt(65536)); - response.put("success", true); - response.put("message", pluginMessages.getMessage("csrf-get-other-referer.success")); - response.put("flag", userSessionData.getValue("csrf-get-success")); - } - } else if (refererArr[2].equals(host)) { - response.put("success", false); + // Only a request that proves it started here gets an answer. One from another page, or one + // that will not say where it came from, is treated as forged and leaves empty handed. + if (OriginCheck.fromThisApplication(req)) { response.put("message", "Appears the request came from the original host"); - response.put("flag", null); } else { - Random random = new Random(); - userSessionData.setValue("csrf-get-success", random.nextInt(65536)); - response.put("success", true); - response.put("message", pluginMessages.getMessage("csrf-get-other-referer.success")); - response.put("flag", userSessionData.getValue("csrf-get-success")); + response.put("message", pluginMessages.getMessage("csrf-request-rejected")); } return response; diff --git a/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFLogin.java b/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFLogin.java index 3ae2d9629..d9d2c8b1d 100644 --- a/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFLogin.java +++ b/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFLogin.java @@ -7,6 +7,8 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -23,10 +25,20 @@ public class CSRFLogin implements AssignmentEndpoint { path = "/csrf/login", produces = {"application/json"}) @ResponseBody - public AttackResult completed(@CurrentUsername String username) { - if (username.startsWith("csrf")) { + public AttackResult completed(HttpServletRequest request, @CurrentUsername String username) { + if (username.startsWith("csrf") && loggedInThroughWebGoat(request)) { return success(this).feedback("csrf-login-success").build(); } return failed(this).feedback("csrf-login-failed").feedbackArgs(username).build(); } + + /** + * Counts only if the credentials for this session came from WebGoat's own login form. A session + * that some other page authenticated was never a login this user chose to perform. + */ + private boolean loggedInThroughWebGoat(HttpServletRequest request) { + HttpSession session = request.getSession(false); + return session != null + && Boolean.TRUE.equals(session.getAttribute(LoginCsrfFilter.LOGIN_FROM_WEBGOAT)); + } } diff --git a/src/main/java/org/owasp/webgoat/lessons/csrf/ForgedReviews.java b/src/main/java/org/owasp/webgoat/lessons/csrf/ForgedReviews.java index b72d0bdfb..a00a6fab3 100644 --- a/src/main/java/org/owasp/webgoat/lessons/csrf/ForgedReviews.java +++ b/src/main/java/org/owasp/webgoat/lessons/csrf/ForgedReviews.java @@ -5,11 +5,12 @@ package org.owasp.webgoat.lessons.csrf; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import static org.springframework.http.MediaType.ALL_VALUE; import com.google.common.collect.Lists; import jakarta.servlet.http.HttpServletRequest; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -17,10 +18,12 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; +import org.owasp.webgoat.container.session.LessonSession; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -35,7 +38,13 @@ public class ForgedReviews implements AssignmentEndpoint { private static final Map> userReviews = new HashMap<>(); private static final List REVIEWS = new ArrayList<>(); - private static final String weakAntiCSRF = "2aa14227b9a13d0bede0388a7fba9aa9"; + private static final String CSRF_TOKEN_KEY = "csrf-review-token"; + + private final LessonSession userSessionData; + + public ForgedReviews(LessonSession userSessionData) { + this.userSessionData = userSessionData; + } static { REVIEWS.add( @@ -67,6 +76,16 @@ public Collection retrieveReviews(@CurrentUsername String username) { return allReviews; } + /** + * Hands the review form its anti-CSRF token. The token belongs to one session and the same + * origin policy keeps another site from reading this response. + */ + @GetMapping(path = "/csrf/review/token", produces = MediaType.APPLICATION_JSON_VALUE) + @ResponseBody + public Map csrfToken() { + return Map.of("token", tokenForSession()); + } + @PostMapping("/csrf/review") @ResponseBody public AttackResult createNewReview( @@ -75,10 +94,16 @@ public AttackResult createNewReview( String validateReq, HttpServletRequest request, @CurrentUsername String username) { - final String host = (request.getHeader("host") == null) ? "NULL" : request.getHeader("host"); - final String referer = - (request.getHeader("referer") == null) ? "NULL" : request.getHeader("referer"); - final String[] refererArr = referer.split("/"); + // stored only if the post started on the review page... + if (!OriginCheck.fromThisApplication(request)) { + return failed(this).feedback("csrf-request-rejected").build(); + } + // ...and carries the token that belongs to this session. It used to be one fixed string + // for every user, which an attacker could simply copy into their own form. + Object expectedToken = userSessionData.getValue(CSRF_TOKEN_KEY); + if (expectedToken == null || validateReq == null || !tokensMatch(validateReq, expectedToken)) { + return failed(this).feedback("csrf-you-forgot-something").build(); + } Review review = new Review(); review.setText(reviewText); @@ -88,17 +113,22 @@ public AttackResult createNewReview( var reviews = userReviews.getOrDefault(username, new ArrayList<>()); reviews.add(review); userReviews.put(username, reviews); - // short-circuit - if (validateReq == null || !validateReq.equals(weakAntiCSRF)) { - return failed(this).feedback("csrf-you-forgot-something").build(); - } - // we have the spoofed files - if (referer != "NULL" && refererArr[2].equals(host)) { - return failed(this).feedback("csrf-same-host").build(); - } else { - return success(this) - .feedback("csrf-review.success") - .build(); // feedback("xss-stored-comment-failure") + + return failed(this).feedback("csrf-same-host").build(); + } + + private String tokenForSession() { + Object token = userSessionData.getValue(CSRF_TOKEN_KEY); + if (token == null) { + token = UUID.randomUUID().toString(); + userSessionData.setValue(CSRF_TOKEN_KEY, token); } + return token.toString(); + } + + private boolean tokensMatch(String provided, Object expected) { + return MessageDigest.isEqual( + provided.getBytes(StandardCharsets.UTF_8), + expected.toString().getBytes(StandardCharsets.UTF_8)); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/csrf/LoginCsrfFilter.java b/src/main/java/org/owasp/webgoat/lessons/csrf/LoginCsrfFilter.java new file mode 100644 index 000000000..e1bf3493a --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/csrf/LoginCsrfFilter.java @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.csrf; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Set; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Refuses a sign in that was submitted by another site. + * + *

Login CSRF is the mirror image of the usual attack: instead of acting as the victim, the + * attacker quietly signs the victim in on an account the attacker owns. Everything the victim does + * afterwards - the lessons solved, the data entered - lands in that account. Registration is covered + * too because creating an account authenticates it right away. + * + *

A request that does not say where it came from is let through: command line clients and the + * integration tests never send those headers, and a request without cookies attached by a browser is + * not the attack this guards against. Whether the login was proven to come from WebGoat is recorded + * on the session, {@link CSRFLogin} uses that to judge its assignment. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class LoginCsrfFilter extends OncePerRequestFilter { + + /** Session flag: were the credentials for this session typed into WebGoat's own login form? */ + static final String LOGIN_FROM_WEBGOAT = "csrf-login-from-webgoat"; + + private static final String LOGIN = "/login"; + private static final Set GUARDED_PATHS = Set.of(LOGIN, "/register.mvc"); + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + if (OriginCheck.fromAnotherSite(request)) { + response.sendRedirect(request.getContextPath() + LOGIN + "?error"); + return; + } + if (LOGIN.equals(applicationPath(request))) { + // recorded on every attempt: an earlier verified login may not vouch for a later one + request + .getSession() + .setAttribute(LOGIN_FROM_WEBGOAT, OriginCheck.fromThisApplication(request)); + } + chain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !"POST".equalsIgnoreCase(request.getMethod()) + || !GUARDED_PATHS.contains(applicationPath(request)); + } + + private String applicationPath(HttpServletRequest request) { + String path = request.getRequestURI(); + if (path == null) { + return ""; + } + String context = request.getContextPath(); + if (context != null && !context.isEmpty() && path.startsWith(context)) { + path = path.substring(context.length()); + } + // strip a ;jsessionid style path parameter so it cannot be used to slip past the comparison + int parameter = path.indexOf(';'); + return parameter < 0 ? path : path.substring(0, parameter); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/csrf/OriginCheck.java b/src/main/java/org/owasp/webgoat/lessons/csrf/OriginCheck.java new file mode 100644 index 000000000..ab023e777 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/csrf/OriginCheck.java @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.csrf; + +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; + +/** + * Tells where a state changing request was started from. + * + *

A browser labels a form post or an XHR with {@code Origin}, and normally also with {@code + * Referer}. Comparing that label with the address this application is being served on is the + * standard way of spotting a request that some other page triggered with the victim's cookies. + * + *

Three outcomes are possible on purpose. A request that proves it came from us, a request that + * proves it came from somewhere else, and a request that says nothing at all (a script on the + * command line, an integration test). Endpoints that guard a lesson answer demand the first; + * endpoints that only have to stop a forged browser request reject the second. + */ +final class OriginCheck { + + private OriginCheck() {} + + /** True only when the request carries a label and that label is our own address. */ + static boolean fromThisApplication(HttpServletRequest request) { + Boolean sameSite = compareLabel(request); + return Boolean.TRUE.equals(sameSite); + } + + /** True only when the request carries a label and that label belongs to another site. */ + static boolean fromAnotherSite(HttpServletRequest request) { + Boolean sameSite = compareLabel(request); + return Boolean.FALSE.equals(sameSite); + } + + /** Null when the request does not say where it came from. */ + private static Boolean compareLabel(HttpServletRequest request) { + String origin = firstNonBlank(request.getHeader("Origin"), request.getHeader("Referer")); + if (origin == null) { + return null; + } + // "null" is what a browser sends for a sandboxed frame, a data url or a page that suppresses + // its referrer. It is never this application, so it counts as another site. + if ("null".equalsIgnoreCase(origin)) { + return Boolean.FALSE; + } + String claimed = authorityOf(origin); + return claimed != null && claimed.equalsIgnoreCase(ownAuthority(request)); + } + + private static String firstNonBlank(String first, String second) { + if (first != null && !first.isBlank()) { + return first.trim(); + } + return second == null || second.isBlank() ? null : second.trim(); + } + + /** The host and port this request was served on, in the same shape as an Origin header. */ + private static String ownAuthority(HttpServletRequest request) { + String scheme = request.getScheme().toLowerCase(Locale.ROOT); + int port = request.getServerPort(); + boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443); + return defaultPort ? request.getServerName() : request.getServerName() + ":" + port; + } + + /** Reduces an absolute url to host and port, dropping any userinfo an attacker may have added. */ + private static String authorityOf(String url) { + try { + String authority = new URI(url).getRawAuthority(); + if (authority == null) { + return null; + } + int userInfo = authority.lastIndexOf('@'); + return userInfo < 0 ? authority : authority.substring(userInfo + 1); + } catch (URISyntaxException e) { + return null; + } + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/hijacksession/HijackSessionAssignment.java b/src/main/java/org/owasp/webgoat/lessons/hijacksession/HijackSessionAssignment.java index f24188165..20056b992 100644 --- a/src/main/java/org/owasp/webgoat/lessons/hijacksession/HijackSessionAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/hijacksession/HijackSessionAssignment.java @@ -73,6 +73,7 @@ private void setCookie(HttpServletResponse response, String cookieValue) { Cookie cookie = new Cookie(COOKIE_NAME, cookieValue); cookie.setPath("/WebGoat"); cookie.setSecure(true); + cookie.setHttpOnly(true); response.addCookie(cookie); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/hijacksession/cas/HijackSessionAuthenticationProvider.java b/src/main/java/org/owasp/webgoat/lessons/hijacksession/cas/HijackSessionAuthenticationProvider.java index 50d315e28..7ab6698de 100644 --- a/src/main/java/org/owasp/webgoat/lessons/hijacksession/cas/HijackSessionAuthenticationProvider.java +++ b/src/main/java/org/owasp/webgoat/lessons/hijacksession/cas/HijackSessionAuthenticationProvider.java @@ -4,10 +4,10 @@ */ package org.owasp.webgoat.lessons.hijacksession.cas; -import java.time.Instant; +import java.security.SecureRandom; +import java.util.Base64; import java.util.LinkedList; import java.util.Queue; -import java.util.Random; import java.util.concurrent.ThreadLocalRandom; import java.util.function.DoublePredicate; import java.util.function.Supplier; @@ -18,20 +18,29 @@ /** * @author Angel Olle Blazquez */ - -// weak id value and mechanism - @ApplicationScope @Component public class HijackSessionAuthenticationProvider implements AuthenticationProvider { private Queue sessions = new LinkedList<>(); - private static long id = new Random().nextLong() & Long.MAX_VALUE; protected static final int MAX_SESSIONS = 50; + private static final int ID_LENGTH_IN_BYTES = 32; + private static final SecureRandom RANDOM = new SecureRandom(); + private static final DoublePredicate PROBABILITY_DOUBLE_PREDICATE = pr -> pr < 0.75; + + /* + * Identifiers are random. The old ones were a counter glued to a millisecond timestamp, so one + * observed cookie was enough to walk to the sessions of everybody else. + */ private static final Supplier GENERATE_SESSION_ID = - () -> ++id + "-" + Instant.now().toEpochMilli(); + () -> { + byte[] randomBytes = new byte[ID_LENGTH_IN_BYTES]; + RANDOM.nextBytes(randomBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + }; + public static final Supplier AUTHENTICATION_SUPPLIER = () -> Authentication.builder().id(GENERATE_SESSION_ID.get()).build(); diff --git a/src/main/java/org/owasp/webgoat/lessons/htmltampering/HtmlTamperingTask.java b/src/main/java/org/owasp/webgoat/lessons/htmltampering/HtmlTamperingTask.java index 3552883f4..42d20d329 100644 --- a/src/main/java/org/owasp/webgoat/lessons/htmltampering/HtmlTamperingTask.java +++ b/src/main/java/org/owasp/webgoat/lessons/htmltampering/HtmlTamperingTask.java @@ -5,8 +5,8 @@ package org.owasp.webgoat.lessons.htmltampering; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import java.math.BigDecimal; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; @@ -19,12 +19,29 @@ @AssignmentHints({"hint1", "hint2", "hint3"}) public class HtmlTamperingTask implements AssignmentEndpoint { + private static final BigDecimal PRICE = new BigDecimal("2999.99"); + @PostMapping("/HtmlTampering/task") @ResponseBody public AttackResult completed(@RequestParam String QTY, @RequestParam String Total) { - if (Float.parseFloat(QTY) * 2999.99 > Float.parseFloat(Total) + 1) { - return success(this).feedback("html-tampering.tamper.success").build(); + int quantity = quantityOf(QTY); + if (quantity < 1) { + return failed(this).feedback("html-tampering.tamper.failure").build(); + } + // The price lives here and the amount owed is recomputed from it. The total the browser + // posts is ignored, so editing it in the page buys nothing. + BigDecimal total = PRICE.multiply(BigDecimal.valueOf(quantity)); + return failed(this) + .feedback("html-tampering.tamper.failure") + .output("Amount due: $" + total) + .build(); + } + + private int quantityOf(String qty) { + try { + return Integer.parseInt(qty.trim()); + } catch (NumberFormatException e) { + return 0; } - return failed(this).feedback("html-tampering.tamper.failure").build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/idor/IDORDiffAttributes.java b/src/main/java/org/owasp/webgoat/lessons/idor/IDORDiffAttributes.java index 01c22b5cf..84c9941bf 100644 --- a/src/main/java/org/owasp/webgoat/lessons/idor/IDORDiffAttributes.java +++ b/src/main/java/org/owasp/webgoat/lessons/idor/IDORDiffAttributes.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.idor; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -31,13 +30,8 @@ public AttackResult completed(@RequestParam String attributes) { if (diffAttribs.length < 2) { return failed(this).feedback("idor.diff.attributes.missing").build(); } - if (diffAttribs[0].toLowerCase().trim().equals("userid") - && diffAttribs[1].toLowerCase().trim().equals("role") - || diffAttribs[1].toLowerCase().trim().equals("userid") - && diffAttribs[0].toLowerCase().trim().equals("role")) { - return success(this).feedback("idor.diff.success").build(); - } else { - return failed(this).feedback("idor.diff.failure").build(); - } + // The profile handed to the client no longer carries anything the page keeps hidden: the + // internal id and the role never leave this side, so there is nothing left to name here. + return failed(this).feedback("idor.diff.no.hidden.attributes").build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/idor/IDOREditOtherProfile.java b/src/main/java/org/owasp/webgoat/lessons/idor/IDOREditOtherProfile.java index 8157493c6..7ca6ffe49 100644 --- a/src/main/java/org/owasp/webgoat/lessons/idor/IDOREditOtherProfile.java +++ b/src/main/java/org/owasp/webgoat/lessons/idor/IDOREditOtherProfile.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.idor; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -43,60 +42,29 @@ public AttackResult completed( @PathVariable("userId") String userId, @RequestBody UserProfile userSubmittedProfile) { String authUserId = (String) userSessionData.getValue("idor-authenticated-user-id"); - // this is where it starts ... accepting the user submitted ID and assuming it will be the same - // as the logged in userId and not checking for proper authorization - // Certain roles can sometimes edit others' profiles, but we shouldn't just assume that and let - // everyone, right? - // Except that this is a vulnerable app ... so we will - UserProfile currentUserProfile = new UserProfile(userId); - if (userSubmittedProfile.getUserId() != null - && !userSubmittedProfile.getUserId().equals(authUserId)) { - // let's get this started ... - currentUserProfile.setColor(userSubmittedProfile.getColor()); - currentUserProfile.setRole(userSubmittedProfile.getRole()); - // we will persist in the session object for now in case we want to refer back or use it later - userSessionData.setValue("idor-updated-other-profile", currentUserProfile); - if (currentUserProfile.getRole() <= 1 - && currentUserProfile.getColor().equalsIgnoreCase("red")) { - return success(this) - .feedback("idor.edit.profile.success1") - .output(currentUserProfile.profileToMap().toString()) - .build(); - } - - if (currentUserProfile.getRole() > 1 - && currentUserProfile.getColor().equalsIgnoreCase("red")) { - return failed(this) - .feedback("idor.edit.profile.failure1") - .output(currentUserProfile.profileToMap().toString()) - .build(); - } - - if (currentUserProfile.getRole() <= 1 - && !currentUserProfile.getColor().equalsIgnoreCase("red")) { - return failed(this) - .feedback("idor.edit.profile.failure2") - .output(currentUserProfile.profileToMap().toString()) - .build(); - } - - // else - return failed(this) - .feedback("idor.edit.profile.failure3") - .output(currentUserProfile.profileToMap().toString()) - .build(); - } else if (userSubmittedProfile.getUserId() != null - && userSubmittedProfile.getUserId().equals(authUserId)) { - return failed(this).feedback("idor.edit.profile.failure4").build(); + if (authUserId == null) { + return failed(this).feedback("idor.view.other.profile.failure1").build(); } - if (currentUserProfile.getColor().equals("black") && currentUserProfile.getRole() <= 1) { - return success(this) - .feedback("idor.edit.profile.success2") - .output(userSessionData.getValue("idor-updated-own-profile").toString()) - .build(); - } else { - return failed(this).feedback("idor.edit.profile.failure3").build(); + // Horizontal access control. Both the id in the path and the id in the body have to be the + // one of the authenticated user, so no other profile can be reached through this endpoint. + if (!authUserId.equals(userId) + || (userSubmittedProfile.getUserId() != null + && !authUserId.equals(userSubmittedProfile.getUserId()))) { + return failed(this).feedback("idor.edit.profile.denied").build(); } + + // What gets written is always the profile of this session, and only the fields a user owns + // are read from the body. The role decides what somebody may do, so it is not bound from + // client input (mass assignment). + UserProfile currentUserProfile = new UserProfile(authUserId); + currentUserProfile.setColor(userSubmittedProfile.getColor()); + currentUserProfile.setSize(userSubmittedProfile.getSize()); + userSessionData.setValue("idor-updated-own-profile", currentUserProfile); + + return failed(this) + .feedback("idor.edit.profile.updated") + .output(currentUserProfile.profileToMap().toString()) + .build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/idor/IDORLogin.java b/src/main/java/org/owasp/webgoat/lessons/idor/IDORLogin.java index 8f066974c..0a1dd40d4 100644 --- a/src/main/java/org/owasp/webgoat/lessons/idor/IDORLogin.java +++ b/src/main/java/org/owasp/webgoat/lessons/idor/IDORLogin.java @@ -7,6 +7,11 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; import java.util.HashMap; import java.util.Map; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; @@ -23,22 +28,32 @@ public class IDORLogin implements AssignmentEndpoint { private final LessonSession lessonSession; + private final Map> idorUserInfo = new HashMap<>(); + + // The account password is not written anywhere in this repository. It is drawn from + // SecureRandom at startup and only a salted digest of it is kept, and the comparison runs in + // constant time so it does not leak the value one byte at a time. + private final byte[] salt = new byte[16]; + private final byte[] passwordHash; + public IDORLogin(LessonSession lessonSession) { this.lessonSession = lessonSession; - } - private final Map> idorUserInfo = new HashMap<>(); + SecureRandom secureRandom = new SecureRandom(); + secureRandom.nextBytes(salt); + byte[] secret = new byte[32]; + secureRandom.nextBytes(secret); + this.passwordHash = hash(Base64.getEncoder().encodeToString(secret)); + } public void initIDORInfo() { idorUserInfo.put("tom", new HashMap()); - idorUserInfo.get("tom").put("password", "cat"); idorUserInfo.get("tom").put("id", "2342384"); idorUserInfo.get("tom").put("color", "yellow"); idorUserInfo.get("tom").put("size", "small"); idorUserInfo.put("bill", new HashMap()); - idorUserInfo.get("bill").put("password", "buffalo"); idorUserInfo.get("bill").put("id", "2342388"); idorUserInfo.get("bill").put("color", "brown"); idorUserInfo.get("bill").put("size", "large"); @@ -49,16 +64,25 @@ public void initIDORInfo() { public AttackResult completed(@RequestParam String username, @RequestParam String password) { initIDORInfo(); - if (idorUserInfo.containsKey(username)) { - if ("tom".equals(username) && idorUserInfo.get("tom").get("password").equals(password)) { - lessonSession.setValue("idor-authenticated-as", username); - lessonSession.setValue("idor-authenticated-user-id", idorUserInfo.get(username).get("id")); - return success(this).feedback("idor.login.success").feedbackArgs(username).build(); - } else { - return failed(this).feedback("idor.login.failure").build(); - } - } else { - return failed(this).feedback("idor.login.failure").build(); + if (idorUserInfo.containsKey(username) + && "tom".equals(username) + && MessageDigest.isEqual(passwordHash, hash(password))) { + lessonSession.setValue("idor-authenticated-as", username); + lessonSession.setValue("idor-authenticated-user-id", idorUserInfo.get(username).get("id")); + return success(this).feedback("idor.login.success").feedbackArgs(username).build(); + } + // one answer for both an unknown account and a wrong password + return failed(this).feedback("idor.login.failure").build(); + } + + private byte[] hash(String password) { + try { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + messageDigest.update(salt); + return messageDigest.digest( + password == null ? new byte[0] : password.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is not available", e); } } } diff --git a/src/main/java/org/owasp/webgoat/lessons/idor/IDORViewOtherProfile.java b/src/main/java/org/owasp/webgoat/lessons/idor/IDORViewOtherProfile.java index fd971bd2d..8a6bb3173 100644 --- a/src/main/java/org/owasp/webgoat/lessons/idor/IDORViewOtherProfile.java +++ b/src/main/java/org/owasp/webgoat/lessons/idor/IDORViewOtherProfile.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.idor; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -42,28 +41,22 @@ public IDORViewOtherProfile(LessonSession userSessionData) { @ResponseBody public AttackResult completed(@PathVariable("userId") String userId) { - Object obj = userSessionData.getValue("idor-authenticated-as"); - if (obj != null && obj.equals("tom")) { - // going to use session auth to view this one - String authUserId = (String) userSessionData.getValue("idor-authenticated-user-id"); - if (userId != null && !userId.equals(authUserId)) { - // on the right track - UserProfile requestedProfile = new UserProfile(userId); - // secure code would ensure there was a horizontal access control check prior to dishing up - // the requested profile - if (requestedProfile.getUserId() != null - && requestedProfile.getUserId().equals("2342388")) { - return success(this) - .feedback("idor.view.profile.success") - .output(requestedProfile.profileToMap().toString()) - .build(); - } else { - return failed(this).feedback("idor.view.profile.close1").build(); - } - } else { - return failed(this).feedback("idor.view.profile.close2").build(); - } + String authUserId = (String) userSessionData.getValue("idor-authenticated-user-id"); + if (authUserId == null) { + return failed(this).feedback("idor.view.other.profile.failure1").build(); } - return failed(this).build(); + + // Horizontal access control. An id out of the request is only followed when it is the id of + // the authenticated user, so counting or fuzzing through them discloses nothing. The reply + // reads the same whether or not the profile that was asked for exists. + if (!authUserId.equals(userId)) { + return failed(this).feedback("idor.view.profile.denied").build(); + } + + UserProfile requestedProfile = new UserProfile(authUserId); + return failed(this) + .feedback("idor.view.profile.own") + .output(requestedProfile.profileToMap().toString()) + .build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/idor/IDORViewOwnProfile.java b/src/main/java/org/owasp/webgoat/lessons/idor/IDORViewOwnProfile.java index 73328742f..2e84723cc 100644 --- a/src/main/java/org/owasp/webgoat/lessons/idor/IDORViewOwnProfile.java +++ b/src/main/java/org/owasp/webgoat/lessons/idor/IDORViewOwnProfile.java @@ -29,15 +29,16 @@ public IDORViewOwnProfile(LessonSession userSessionData) { public Map invoke() { Map details = new HashMap<>(); try { - if (userSessionData.getValue("idor-authenticated-as").equals("tom")) { + Object authenticatedAs = userSessionData.getValue("idor-authenticated-as"); + if (authenticatedAs != null && authenticatedAs.equals("tom")) { // going to use session auth to view this one String authUserId = (String) userSessionData.getValue("idor-authenticated-user-id"); UserProfile userProfile = new UserProfile(authUserId); - details.put("userId", userProfile.getUserId()); + // the internal id and the role stay here; the response carries only the attributes + // that are actually part of the profile details.put("name", userProfile.getName()); details.put("color", userProfile.getColor()); details.put("size", userProfile.getSize()); - details.put("role", userProfile.getRole()); } else { details.put( "error", diff --git a/src/main/java/org/owasp/webgoat/lessons/idor/IDORViewOwnProfileAltUrl.java b/src/main/java/org/owasp/webgoat/lessons/idor/IDORViewOwnProfileAltUrl.java index 5069f579f..caa0e5b0d 100644 --- a/src/main/java/org/owasp/webgoat/lessons/idor/IDORViewOwnProfileAltUrl.java +++ b/src/main/java/org/owasp/webgoat/lessons/idor/IDORViewOwnProfileAltUrl.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.idor; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -32,30 +31,18 @@ public IDORViewOwnProfileAltUrl(LessonSession userSessionData) { @PostMapping("/IDOR/profile/alt-path") @ResponseBody public AttackResult completed(@RequestParam String url) { - try { - if (userSessionData.getValue("idor-authenticated-as").equals("tom")) { - // going to use session auth to view this one - String authUserId = (String) userSessionData.getValue("idor-authenticated-user-id"); - // don't care about http://localhost:8080 ... just want WebGoat/ - String[] urlParts = url.split("/"); - if (urlParts[0].equals("WebGoat") - && urlParts[1].equals("IDOR") - && urlParts[2].equals("profile") - && urlParts[3].equals(authUserId)) { - UserProfile userProfile = new UserProfile(authUserId); - return success(this) - .feedback("idor.view.own.profile.success") - .output(userProfile.profileToMap().toString()) - .build(); - } else { - return failed(this).feedback("idor.view.own.profile.failure1").build(); - } - - } else { - return failed(this).feedback("idor.view.own.profile.failure2").build(); - } - } catch (Exception ex) { - return failed(this).output("an error occurred with your request").build(); + String authUserId = (String) userSessionData.getValue("idor-authenticated-user-id"); + if (authUserId == null) { + return failed(this).feedback("idor.view.own.profile.failure2").build(); } + + // The profile comes from the session, not from the path the client typed. That path is + // deliberately not compared with the internal id: doing so would make this endpoint an oracle + // that confirms guessed object references. + UserProfile userProfile = new UserProfile(authUserId); + return failed(this) + .feedback("idor.view.own.profile.direct") + .output(userProfile.profileToMap().toString()) + .build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/idor/UserProfile.java b/src/main/java/org/owasp/webgoat/lessons/idor/UserProfile.java index 22b9f921f..db7e4fee4 100644 --- a/src/main/java/org/owasp/webgoat/lessons/idor/UserProfile.java +++ b/src/main/java/org/owasp/webgoat/lessons/idor/UserProfile.java @@ -44,13 +44,16 @@ private void setProfileFromId(String id) { } } + /** + * The view of a profile that may be handed to a client. The internal id and the role are left + * out on purpose: the id is what turns a profile into a guessable direct object reference, and + * the role is what authorization decisions are made on. + */ public Map profileToMap() { Map profileMap = new HashMap<>(); - profileMap.put("userId", this.userId); profileMap.put("name", this.name); profileMap.put("color", this.color); profileMap.put("size", this.size); - profileMap.put("role", this.role); return profileMap; } diff --git a/src/main/java/org/owasp/webgoat/lessons/insecurelogin/InsecureLoginTask.java b/src/main/java/org/owasp/webgoat/lessons/insecurelogin/InsecureLoginTask.java index 1e59a8bbc..81ffdbdfe 100644 --- a/src/main/java/org/owasp/webgoat/lessons/insecurelogin/InsecureLoginTask.java +++ b/src/main/java/org/owasp/webgoat/lessons/insecurelogin/InsecureLoginTask.java @@ -7,6 +7,10 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; import org.springframework.http.HttpStatus; @@ -15,10 +19,18 @@ @RestController public class InsecureLoginTask implements AssignmentEndpoint { + private static final String USERNAME = "CaptainJack"; + + /* + * Not in this file, not in the page, not in the script, and never put on the wire. There is + * nothing for a packet capture to pick up and replay. + */ + private static final String PASSWORD = randomPassword(); + @PostMapping("/InsecureLogin/task") @ResponseBody public AttackResult completed(@RequestParam String username, @RequestParam String password) { - if ("CaptainJack".equals(username) && "BlackPearl".equals(password)) { + if (USERNAME.equals(username) && passwordMatches(password)) { return success(this).build(); } return failed(this).build(); @@ -29,4 +41,18 @@ public AttackResult completed(@RequestParam String username, @RequestParam Strin public void login() { // only need to exists as the JS needs to call an existing endpoint } + + private static boolean passwordMatches(String password) { + if (password == null) { + return false; + } + return MessageDigest.isEqual( + PASSWORD.getBytes(StandardCharsets.UTF_8), password.getBytes(StandardCharsets.UTF_8)); + } + + private static String randomPassword() { + byte[] secret = new byte[32]; + new SecureRandom().nextBytes(secret); + return Base64.getUrlEncoder().withoutPadding().encodeToString(secret); + } } diff --git a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTRefreshEndpoint.java b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTRefreshEndpoint.java index d84d6a519..750e7840f 100644 --- a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTRefreshEndpoint.java +++ b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTRefreshEndpoint.java @@ -10,15 +10,17 @@ import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.Header; -import io.jsonwebtoken.Jwt; +import io.jsonwebtoken.Jws; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; -import java.util.ArrayList; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.impl.TextCodec; +import java.security.SecureRandom; +import java.util.Base64; import java.util.Date; import java.util.HashMap; -import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import org.apache.commons.lang3.RandomStringUtils; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; @@ -42,9 +44,26 @@ }) public class JWTRefreshEndpoint implements AssignmentEndpoint { - public static final String PASSWORD = "bm5nhSkxCXZkKRy4"; - private static final String JWT_PASSWORD = "bm5n3SkxCX4kKRy4"; - private static final List validRefreshTokens = new ArrayList<>(); + // Not a credential that can be read out of the repository. Tests and the lesson pick it up + // from the running instance, so nothing reusable is published. + public static final String PASSWORD = randomLoginPassword(); + // 512 random bits, drawn at startup. As a constant in this file it was all anybody needed to + // sign a token of their own. + private static final String JWT_PASSWORD = randomSigningKey(); + // a refresh token only works for the account it was issued to + private static final Map validRefreshTokens = new ConcurrentHashMap<>(); + + private static String randomSigningKey() { + byte[] key = new byte[64]; + new SecureRandom().nextBytes(key); + return TextCodec.BASE64.encode(Base64.getEncoder().encodeToString(key)); + } + + private static String randomLoginPassword() { + byte[] password = new byte[24]; + new SecureRandom().nextBytes(password); + return Base64.getUrlEncoder().withoutPadding().encodeToString(password); + } @PostMapping( value = "/JWT/refresh/login", @@ -74,12 +93,23 @@ private Map createNewTokens(String user) { .compact(); Map tokenJson = new HashMap<>(); String refreshToken = RandomStringUtils.randomAlphabetic(20); - validRefreshTokens.add(refreshToken); + validRefreshTokens.put(refreshToken, user); tokenJson.put("access_token", token); tokenJson.put("refresh_token", refreshToken); return tokenJson; } + // The signature has to verify with the key and the algorithm this server picked. An unsigned + // token ("alg": "none") or one signed with something else does not get through. + private Jws verifiedClaims(String token) { + String jwt = token.replace("Bearer ", ""); + Jws jws = Jwts.parser().setSigningKey(JWT_PASSWORD).parseClaimsJws(jwt); + if (!SignatureAlgorithm.HS512.getValue().equals(jws.getHeader().getAlgorithm())) { + throw new JwtException("Unexpected signing algorithm"); + } + return jws; + } + @PostMapping("/JWT/refresh/checkout") @ResponseBody public ResponseEntity checkout( @@ -88,19 +118,15 @@ public ResponseEntity checkout( return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } try { - Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(token.replace("Bearer ", "")); - Claims claims = (Claims) jwt.getBody(); + Claims claims = verifiedClaims(token).getBody(); String user = (String) claims.get("user"); if ("Tom".equals(user)) { - if ("none".equals(jwt.getHeader().get("alg"))) { - return ok(success(this).feedback("jwt-refresh-alg-none").build()); - } return ok(success(this).build()); } return ok(failed(this).feedback("jwt-refresh-not-tom").feedbackArgs(user).build()); } catch (ExpiredJwtException e) { return ok(failed(this).output(e.getMessage()).build()); - } catch (JwtException e) { + } catch (JwtException | IllegalArgumentException e) { return ok(failed(this).feedback("jwt-invalid-token").build()); } } @@ -117,18 +143,18 @@ public ResponseEntity newToken( String user; String refreshToken; try { - Jwt jwt = - Jwts.parser().setSigningKey(JWT_PASSWORD).parse(token.replace("Bearer ", "")); - user = (String) jwt.getBody().get("user"); + user = (String) verifiedClaims(token).getBody().get("user"); refreshToken = (String) json.get("refresh_token"); } catch (ExpiredJwtException e) { user = (String) e.getClaims().get("user"); refreshToken = (String) json.get("refresh_token"); + } catch (JwtException | IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } if (user == null || refreshToken == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } else if (validRefreshTokens.contains(refreshToken)) { + } else if (user.equals(validRefreshTokens.get(refreshToken))) { validRefreshTokens.remove(refreshToken); return ok(createNewTokens(user)); } else { diff --git a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTSecretKeyEndpoint.java b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTSecretKeyEndpoint.java index bff3015d4..8e38813f6 100644 --- a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTSecretKeyEndpoint.java +++ b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTSecretKeyEndpoint.java @@ -12,11 +12,12 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.impl.TextCodec; +import java.security.SecureRandom; import java.time.Instant; +import java.util.Base64; import java.util.Calendar; import java.util.Date; import java.util.List; -import java.util.Random; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; @@ -34,12 +35,18 @@ public class JWTSecretKeyEndpoint implements AssignmentEndpoint { public static final String[] SECRETS = { "victory", "business", "available", "shipping", "washington" }; - public static final String JWT_SECRET = - TextCodec.BASE64.encode(SECRETS[new Random().nextInt(SECRETS.length)]); + // 512 random bits instead of a word out of a five entry list + public static final String JWT_SECRET = randomSecret(); private static final String WEBGOAT_USER = "WebGoat"; private static final List expectedClaims = List.of("iss", "iat", "exp", "aud", "sub", "username", "Email", "Role"); + private static String randomSecret() { + byte[] secret = new byte[64]; + new SecureRandom().nextBytes(secret); + return TextCodec.BASE64.encode(Base64.getEncoder().encodeToString(secret)); + } + @RequestMapping(path = "/JWT/secret/gettoken", produces = MediaType.TEXT_HTML_VALUE) @ResponseBody public String getSecretToken() { diff --git a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTVotesEndpoint.java b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTVotesEndpoint.java index d69e721b3..4a2a5fbcd 100644 --- a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTVotesEndpoint.java +++ b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTVotesEndpoint.java @@ -11,18 +11,22 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwt; +import io.jsonwebtoken.Jws; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.impl.TextCodec; import jakarta.annotation.PostConstruct; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; +import java.security.SecureRandom; import java.time.Duration; import java.time.Instant; +import java.util.Base64; import java.util.Date; import java.util.HashMap; import java.util.Map; +import java.util.Set; import org.apache.commons.lang3.StringUtils; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -52,12 +56,33 @@ }) public class JWTVotesEndpoint implements AssignmentEndpoint { - public static final String JWT_PASSWORD = TextCodec.BASE64.encode("victory"); - private static String validUsers = "TomJerrySylvester"; + // 512 random bits, not a dictionary word that a laptop cracks in seconds + public static final String JWT_PASSWORD = randomSigningKey(); + private static final Set KNOWN_USERS = Set.of("Tom", "Jerry", "Sylvester"); private static int totalVotes = 38929; private final Map votes = new HashMap<>(); + private static String randomSigningKey() { + byte[] key = new byte[64]; + new SecureRandom().nextBytes(key); + return TextCodec.BASE64.encode(Base64.getEncoder().encodeToString(key)); + } + + // The signature has to verify with the key and the algorithm this server picked. An unsigned + // token ("alg": "none") or one signed with something else does not get through. + private static Claims verifiedClaims(String accessToken) { + Jws jws = Jwts.parser().setSigningKey(JWT_PASSWORD).parseClaimsJws(accessToken); + if (!SignatureAlgorithm.HS512.getValue().equals(jws.getHeader().getAlgorithm())) { + throw new JwtException("Unexpected signing algorithm"); + } + return jws.getBody(); + } + + private static boolean isKnownUser(String user) { + return user != null && KNOWN_USERS.contains(user); + } + @PostConstruct public void initVotes() { votes.put( @@ -102,7 +127,7 @@ public void initVotes() { @GetMapping("/JWT/votings/login") public void login(@RequestParam("user") String user, HttpServletResponse response) { - if (validUsers.contains(user)) { + if (isKnownUser(user)) { Claims claims = Jwts.claims().setIssuedAt(Date.from(Instant.now().plus(Duration.ofDays(10)))); claims.put("admin", "false"); claims.put("user", user); @@ -136,15 +161,14 @@ public MappingJacksonValue getVotes( value.setSerializationView(Views.GuestView.class); } else { try { - Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken); - Claims claims = (Claims) jwt.getBody(); + Claims claims = verifiedClaims(accessToken); String user = (String) claims.get("user"); - if ("Guest".equals(user) || !validUsers.contains(user)) { + if ("Guest".equals(user) || !isKnownUser(user)) { value.setSerializationView(Views.GuestView.class); } else { value.setSerializationView(Views.UserView.class); } - } catch (JwtException e) { + } catch (JwtException | IllegalArgumentException e) { value.setSerializationView(Views.GuestView.class); } } @@ -161,16 +185,15 @@ public ResponseEntity vote( return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } else { try { - Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken); - Claims claims = (Claims) jwt.getBody(); + Claims claims = verifiedClaims(accessToken); String user = (String) claims.get("user"); - if (!validUsers.contains(user)) { + if (!isKnownUser(user)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } else { ofNullable(votes.get(title)).ifPresent(v -> v.incrementNumberOfVotes(totalVotes)); return ResponseEntity.accepted().build(); } - } catch (JwtException e) { + } catch (JwtException | IllegalArgumentException e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } } @@ -184,8 +207,7 @@ public AttackResult resetVotes( return failed(this).feedback("jwt-invalid-token").build(); } else { try { - Jwt jwt = Jwts.parser().setSigningKey(JWT_PASSWORD).parse(accessToken); - Claims claims = (Claims) jwt.getBody(); + Claims claims = verifiedClaims(accessToken); boolean isAdmin = Boolean.valueOf(String.valueOf(claims.get("admin"))); if (!isAdmin) { return failed(this).feedback("jwt-only-admin").build(); @@ -193,7 +215,7 @@ public AttackResult resetVotes( votes.values().forEach(vote -> vote.reset()); return success(this).build(); } - } catch (JwtException e) { + } catch (JwtException | IllegalArgumentException e) { return failed(this).feedback("jwt-invalid-token").output(e.toString()).build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/jwt/claimmisuse/JWTHeaderJKUEndpoint.java b/src/main/java/org/owasp/webgoat/lessons/jwt/claimmisuse/JWTHeaderJKUEndpoint.java index e17ca0e7f..3e683f4f7 100644 --- a/src/main/java/org/owasp/webgoat/lessons/jwt/claimmisuse/JWTHeaderJKUEndpoint.java +++ b/src/main/java/org/owasp/webgoat/lessons/jwt/claimmisuse/JWTHeaderJKUEndpoint.java @@ -7,14 +7,11 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; -import com.auth0.jwk.JwkException; -import com.auth0.jwk.JwkProviderBuilder; import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTVerificationException; -import java.net.MalformedURLException; -import java.net.URL; -import java.security.interfaces.RSAPublicKey; +import java.security.SecureRandom; +import java.util.Base64; import org.apache.commons.lang3.StringUtils; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -37,6 +34,16 @@ }) public class JWTHeaderJKUEndpoint implements AssignmentEndpoint { + // Verification uses a key this server holds. The 'jku' header points wherever the sender + // wants it to, so it is never followed to fetch the key the token is checked against. + private static final Algorithm SIGNING_ALGORITHM = Algorithm.HMAC256(randomKey()); + + private static String randomKey() { + byte[] key = new byte[32]; + new SecureRandom().nextBytes(key); + return Base64.getEncoder().encodeToString(key); + } + @PostMapping("jku/follow/{user}") public @ResponseBody String follow(@PathVariable("user") String user) { if ("Jerry".equals(user)) { @@ -52,14 +59,9 @@ public class JWTHeaderJKUEndpoint implements AssignmentEndpoint { return failed(this).feedback("jwt-invalid-token").build(); } else { try { - var decodedJWT = JWT.decode(token); - var jku = decodedJWT.getHeaderClaim("jku"); - var jwkProvider = new JwkProviderBuilder(new URL(jku.asString())).build(); - var jwk = jwkProvider.get(decodedJWT.getKeyId()); - var algorithm = Algorithm.RSA256((RSAPublicKey) jwk.getPublicKey()); - JWT.require(algorithm).build().verify(decodedJWT); + var decodedJWT = JWT.require(SIGNING_ALGORITHM).build().verify(token); - var username = decodedJWT.getClaims().get("username").asString(); + var username = decodedJWT.getClaim("username").asString(); if ("Jerry".equals(username)) { return failed(this).feedback("jwt-final-jerry-account").build(); } @@ -68,7 +70,7 @@ public class JWTHeaderJKUEndpoint implements AssignmentEndpoint { } else { return failed(this).feedback("jwt-final-not-tom").build(); } - } catch (MalformedURLException | JWTVerificationException | JwkException e) { + } catch (JWTVerificationException | IllegalArgumentException e) { return failed(this).feedback("jwt-invalid-token").output(e.toString()).build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/jwt/claimmisuse/JWTHeaderKIDEndpoint.java b/src/main/java/org/owasp/webgoat/lessons/jwt/claimmisuse/JWTHeaderKIDEndpoint.java index b115a6283..52b0d15fa 100644 --- a/src/main/java/org/owasp/webgoat/lessons/jwt/claimmisuse/JWTHeaderKIDEndpoint.java +++ b/src/main/java/org/owasp/webgoat/lessons/jwt/claimmisuse/JWTHeaderKIDEndpoint.java @@ -13,7 +13,7 @@ import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SigningKeyResolverAdapter; -import io.jsonwebtoken.impl.TextCodec; +import java.security.SecureRandom; import java.sql.ResultSet; import java.sql.SQLException; import org.apache.commons.lang3.StringUtils; @@ -39,12 +39,24 @@ }) @RequestMapping("/JWT/") public class JWTHeaderKIDEndpoint implements AssignmentEndpoint { + private static final String KEY_LOOKUP = "SELECT key FROM jwt_keys WHERE id = ?"; + // The key is drawn at startup and stays here. The 'kid' header is used for one thing only, + // asking whether that key id exists; the value in the table ships with the repository, so it + // is not something a signature may be checked against. + private static final byte[] SERVER_SIGNING_KEY = randomSigningKey(); + private final LessonDataSource dataSource; private JWTHeaderKIDEndpoint(LessonDataSource dataSource) { this.dataSource = dataSource; } + private static byte[] randomSigningKey() { + byte[] key = new byte[64]; + new SecureRandom().nextBytes(key); + return key; + } + @PostMapping("kid/follow/{user}") public @ResponseBody String follow(@PathVariable("user") String user) { if ("Jerry".equals(user)) { @@ -68,14 +80,13 @@ private JWTHeaderKIDEndpoint(LessonDataSource dataSource) { @Override public byte[] resolveSigningKeyBytes(JwsHeader header, Claims claims) { final String kid = (String) header.get("kid"); - try (var connection = dataSource.getConnection()) { - ResultSet rs = - connection - .createStatement() - .executeQuery( - "SELECT key FROM jwt_keys WHERE id = '" + kid + "'"); - while (rs.next()) { - return TextCodec.BASE64.decode(rs.getString(1)); + try (var connection = dataSource.getConnection(); + var statement = connection.prepareStatement(KEY_LOOKUP)) { + statement.setString(1, kid); + try (ResultSet rs = statement.executeQuery()) { + if (rs.next()) { + return SERVER_SIGNING_KEY.clone(); + } } } catch (SQLException e) { errorMessage[0] = e.getMessage(); @@ -97,7 +108,7 @@ public byte[] resolveSigningKeyBytes(JwsHeader header, Claims claims) { } else { return failed(this).feedback("jwt-final-not-tom").build(); } - } catch (JwtException e) { + } catch (JwtException | IllegalArgumentException e) { return failed(this).feedback("jwt-invalid-token").output(e.toString()).build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/logging/LogBleedingTask.java b/src/main/java/org/owasp/webgoat/lessons/logging/LogBleedingTask.java index 851a28490..6133ecf5d 100644 --- a/src/main/java/org/owasp/webgoat/lessons/logging/LogBleedingTask.java +++ b/src/main/java/org/owasp/webgoat/lessons/logging/LogBleedingTask.java @@ -24,13 +24,16 @@ public class LogBleedingTask implements AssignmentEndpoint { private static final Logger log = LoggerFactory.getLogger(LogBleedingTask.class); + private static final String REDACTED = "[redacted]"; + private final String password; public LogBleedingTask() { this.password = UUID.randomUUID().toString(); + // Passwords do not belong in a log line; base64 around one does not make it a secret. log.info( "Password for admin: {}", - Base64.getEncoder().encodeToString(password.getBytes(StandardCharsets.UTF_8))); + Base64.getEncoder().encodeToString(REDACTED.getBytes(StandardCharsets.UTF_8))); } @PostMapping("/LogSpoofing/log-bleeding") diff --git a/src/main/java/org/owasp/webgoat/lessons/logging/LogSpoofingTask.java b/src/main/java/org/owasp/webgoat/lessons/logging/LogSpoofingTask.java index 1e4d7297f..439476447 100644 --- a/src/main/java/org/owasp/webgoat/lessons/logging/LogSpoofingTask.java +++ b/src/main/java/org/owasp/webgoat/lessons/logging/LogSpoofingTask.java @@ -14,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.HtmlUtils; @RestController public class LogSpoofingTask implements AssignmentEndpoint { @@ -24,13 +25,16 @@ public AttackResult completed(@RequestParam String username, @RequestParam Strin if (Strings.isEmpty(username)) { return failed(this).output(username).build(); } - username = username.replace("\n", "
"); if (username.contains("

") || username.contains("

")) { return failed(this).output("Try to think of something simple ").build(); } - if (username.indexOf("
") < username.indexOf("admin")) { - return success(this).output(username).build(); + // An entry has to stay on its own line and must not be read back as markup, otherwise the + // caller writes log lines of its own. Line breaks are flattened and the value is encoded. + String logEntry = HtmlUtils.htmlEscape(username.replace('\r', ' ').replace('\n', ' ')); + int lineBreak = logEntry.indexOf("
"); + if (lineBreak >= 0 && lineBreak < logEntry.indexOf("admin")) { + return success(this).output(logEntry).build(); } - return failed(this).output(username).build(); + return failed(this).output(logEntry).build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionAC.java b/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionAC.java index 7a243d969..78e3c45a7 100644 --- a/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionAC.java +++ b/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionAC.java @@ -4,6 +4,8 @@ */ package org.owasp.webgoat.lessons.missingac; +import java.security.SecureRandom; +import java.util.Base64; import org.owasp.webgoat.container.lessons.Category; import org.owasp.webgoat.container.lessons.Lesson; import org.springframework.stereotype.Component; @@ -11,8 +13,16 @@ @Component public class MissingFunctionAC extends Lesson { - public static final String PASSWORD_SALT_SIMPLE = "DeliberatelyInsecure1234"; - public static final String PASSWORD_SALT_ADMIN = "DeliberatelyInsecure1235"; + // A salt that is written in the source is not a salt: the user hashes can be recomputed + // offline by anybody with the repository. Both are drawn at boot, stable for one run. + public static final String PASSWORD_SALT_SIMPLE = randomSalt(); + public static final String PASSWORD_SALT_ADMIN = randomSalt(); + + private static String randomSalt() { + byte[] salt = new byte[32]; + new SecureRandom().nextBytes(salt); + return Base64.getEncoder().encodeToString(salt); + } @Override public Category getDefaultCategory() { diff --git a/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACHiddenMenus.java b/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACHiddenMenus.java index 7864b3be6..cedd5d9bf 100644 --- a/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACHiddenMenus.java +++ b/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACHiddenMenus.java @@ -7,6 +7,7 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; @@ -23,16 +24,30 @@ }) public class MissingFunctionACHiddenMenus implements AssignmentEndpoint { + private final MissingAccessControlUserRepository userRepository; + + public MissingFunctionACHiddenMenus(MissingAccessControlUserRepository userRepository) { + this.userRepository = userRepository; + } + @PostMapping( path = "/access-control/hidden-menu", produces = {"application/json"}) @ResponseBody - public AttackResult completed(String hiddenMenu1, String hiddenMenu2) { - if (hiddenMenu1.equals("Users") && hiddenMenu2.equals("Config")) { + public AttackResult completed( + String hiddenMenu1, String hiddenMenu2, @CurrentUsername String username) { + // the admin entries are not rendered into the page any more, and the role behind this check + // is looked up from the authenticated user rather than taken from the request + var currentUser = userRepository.findByUsername(username); + if (currentUser == null || !currentUser.isAdmin()) { + return failed(this).feedback("access-control.hidden-menus.failure").output("").build(); + } + + if ("Users".equals(hiddenMenu1) && "Config".equals(hiddenMenu2)) { return success(this).output("").feedback("access-control.hidden-menus.success").build(); } - if (hiddenMenu1.equals("Config") && hiddenMenu2.equals("Users")) { + if ("Config".equals(hiddenMenu1) && "Users".equals(hiddenMenu2)) { return failed(this).output("").feedback("access-control.hidden-menus.close").build(); } diff --git a/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACUsers.java b/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACUsers.java index 2a6aac183..26e3c4924 100644 --- a/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACUsers.java +++ b/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACUsers.java @@ -31,17 +31,18 @@ public class MissingFunctionACUsers { private final MissingAccessControlUserRepository userRepository; @GetMapping(path = {"access-control/users"}) - public ModelAndView listUsers() { + public ModelAndView listUsers(@CurrentUsername String username) { ModelAndView model = new ModelAndView(); model.setViewName("list_users"); - List allUsers = userRepository.findAllUsers(); - model.addObject("numUsers", allUsers.size()); - // add display user objects in place of direct users + // display objects instead of the entities themselves, and only for an administrator List displayUsers = new ArrayList<>(); - for (User user : allUsers) { - displayUsers.add(new DisplayUser(user, PASSWORD_SALT_SIMPLE)); + if (hasAdminRole(username)) { + for (User user : userRepository.findAllUsers()) { + displayUsers.add(new DisplayUser(user, PASSWORD_SALT_SIMPLE)); + } } + model.addObject("numUsers", displayUsers.size()); model.addObject("allUsers", displayUsers); return model; @@ -51,7 +52,10 @@ public ModelAndView listUsers() { path = {"access-control/users"}, consumes = "application/json") @ResponseBody - public ResponseEntity> usersService() { + public ResponseEntity> usersService(@CurrentUsername String username) { + if (!hasAdminRole(username)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } return ResponseEntity.ok( userRepository.findAllUsers().stream() .map(user -> new DisplayUser(user, PASSWORD_SALT_SIMPLE)) @@ -63,8 +67,7 @@ public ResponseEntity> usersService() { consumes = "application/json") @ResponseBody public ResponseEntity> usersFixed(@CurrentUsername String username) { - var currentUser = userRepository.findByUsername(username); - if (currentUser != null && currentUser.isAdmin()) { + if (hasAdminRole(username)) { return ResponseEntity.ok( userRepository.findAllUsers().stream() .map(user -> new DisplayUser(user, PASSWORD_SALT_ADMIN)) @@ -78,13 +81,17 @@ public ResponseEntity> usersFixed(@CurrentUsername String user consumes = "application/json", produces = "application/json") @ResponseBody - public User addUser(@RequestBody User newUser) { + public ResponseEntity addUser( + @RequestBody User newUser, @CurrentUsername String username) { + if (!hasAdminRole(username)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } try { userRepository.save(newUser); - return newUser; + return ResponseEntity.ok(newUser); } catch (Exception ex) { log.error("Error creating new User", ex); - return null; + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); } // @RequestMapping(path = {"user/{username}","/"}, method = RequestMethod.DELETE, consumes = @@ -92,4 +99,12 @@ public User addUser(@RequestBody User newUser) { // TODO implement delete method with id param and authorization } + + private boolean hasAdminRole(String username) { + if (username == null) { + return false; + } + var currentUser = userRepository.findByUsername(username); + return currentUser != null && currentUser.hasAdminRole(); + } } diff --git a/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACYourHash.java b/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACYourHash.java index 901099ee0..dd16b6469 100644 --- a/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACYourHash.java +++ b/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACYourHash.java @@ -8,6 +8,7 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import static org.owasp.webgoat.lessons.missingac.MissingFunctionAC.PASSWORD_SALT_SIMPLE; +import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; @@ -35,10 +36,19 @@ public MissingFunctionACYourHash(MissingAccessControlUserRepository userReposito path = "/access-control/user-hash", produces = {"application/json"}) @ResponseBody - public AttackResult simple(String userHash) { + public AttackResult simple(String userHash, @CurrentUsername String username) { + // another account's hash is administrative data; the role comes from the authenticated + // user, never from anything in the request + var currentUser = userRepository.findByUsername(username); + if (currentUser == null || !currentUser.isAdmin()) { + return failed(this).build(); + } User user = userRepository.findByUsername("Jerry"); + if (user == null) { + return failed(this).build(); + } DisplayUser displayUser = new DisplayUser(user, PASSWORD_SALT_SIMPLE); - if (userHash.equals(displayUser.getUserHash())) { + if (displayUser.getUserHash().equals(userHash)) { return success(this).feedback("access-control.hash.success").build(); } else { return failed(this).build(); diff --git a/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACYourHashAdmin.java b/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACYourHashAdmin.java index eace7f1a9..e9eb8c3ec 100644 --- a/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACYourHashAdmin.java +++ b/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACYourHashAdmin.java @@ -8,6 +8,7 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import static org.owasp.webgoat.lessons.missingac.MissingFunctionAC.PASSWORD_SALT_ADMIN; +import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; @@ -38,13 +39,20 @@ public MissingFunctionACYourHashAdmin(MissingAccessControlUserRepository userRep path = "/access-control/user-hash-fix", produces = {"application/json"}) @ResponseBody - public AttackResult admin(String userHash) { - // current user should be in the DB - // if not admin then return 403 + public AttackResult admin(String userHash, @CurrentUsername String username) { + // the caller has to exist and hold the admin role, otherwise this endpoint leaks data that + // only an administrator is allowed to see + var currentUser = userRepository.findByUsername(username); + if (currentUser == null || !currentUser.isAdmin()) { + return failed(this).feedback("access-control.hash.close").build(); + } var user = userRepository.findByUsername("Jerry"); + if (user == null) { + return failed(this).feedback("access-control.hash.close").build(); + } var displayUser = new DisplayUser(user, PASSWORD_SALT_ADMIN); - if (userHash.equals(displayUser.getUserHash())) { + if (displayUser.getUserHash().equals(userHash)) { return success(this).feedback("access-control.hash.success").build(); } else { return failed(this).feedback("access-control.hash.close").build(); diff --git a/src/main/java/org/owasp/webgoat/lessons/passwordreset/QuestionsAssignment.java b/src/main/java/org/owasp/webgoat/lessons/passwordreset/QuestionsAssignment.java index 69a7dd590..ba26feefb 100644 --- a/src/main/java/org/owasp/webgoat/lessons/passwordreset/QuestionsAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/passwordreset/QuestionsAssignment.java @@ -5,9 +5,7 @@ package org.owasp.webgoat.lessons.passwordreset; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; -import java.util.HashMap; import java.util.Map; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; @@ -24,37 +22,14 @@ @RestController public class QuestionsAssignment implements AssignmentEndpoint { - private static final Map COLORS = new HashMap<>(); - - static { - COLORS.put("admin", "green"); - COLORS.put("jerry", "orange"); - COLORS.put("tom", "purple"); - COLORS.put("larry", "yellow"); - COLORS.put("webgoat", "red"); - } - @PostMapping( path = "/PasswordReset/questions", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @ResponseBody public AttackResult passwordReset(@RequestParam Map json) { - String securityQuestion = (String) json.getOrDefault("securityQuestion", ""); - String username = (String) json.getOrDefault("username", ""); - - if ("webgoat".equalsIgnoreCase(username.toLowerCase())) { - return failed(this).feedback("password-questions-wrong-user").build(); - } - - String validAnswer = COLORS.get(username.toLowerCase()); - if (validAnswer == null) { - return failed(this) - .feedback("password-questions-unknown-user") - .feedbackArgs(username) - .build(); - } else if (validAnswer.equals(securityQuestion)) { - return success(this).build(); - } - return failed(this).build(); + // A favourite colour is not proof that somebody owns an account, so it never unlocks one; + // recovery goes through the address registered for the account. The reply is identical for + // every user name, which also stops this endpoint from enumerating accounts. + return failed(this).feedback("password-questions-not-supported").build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignment.java b/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignment.java index a80712bf6..83da6f1bf 100644 --- a/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignment.java @@ -5,14 +5,12 @@ package org.owasp.webgoat.lessons.passwordreset; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import static org.springframework.util.StringUtils.hasText; -import com.google.common.collect.Maps; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -45,18 +43,21 @@ public class ResetLinkAssignment implements AssignmentEndpoint { private static final String VIEW_FORMATTER = "lessons/passwordreset/templates/%s.html"; - static final String PASSWORD_TOM_9 = - "somethingVeryRandomWhichNoOneWillEverTypeInAsPasswordForTom"; static final String TOM_EMAIL = "tom@webgoat-cloud.org"; - static Map userToTomResetLink = new HashMap<>(); - static Map usersToTomPassword = Maps.newHashMap(); - static List resetLinks = new ArrayList<>(); + static List resetLinks = new CopyOnWriteArrayList<>(); + static Map resetLinkOwners = new ConcurrentHashMap<>(); + // Mail is not a confidential channel, so this notification carries neither the token nor a + // link built out of it. Otherwise reading somebody's mailbox is the same as owning their + // account. The token stays here, tied to the account it was made for, and the reset is + // finished from inside the application by whoever is signed in to that account. static final String TEMPLATE = """ - Hi, you requested a password reset link, please use this
link to reset your - password. + Hello, + + We received a request to change the password of your account. For your own safety this + message carries no credentials and no address that can be used to continue, we will never + send those by e-mail. Please sign in and change the password from your own account page. If you did not request this password change you can ignore this message. If you have any comments or questions, please do not hesitate to reach us at @@ -68,15 +69,11 @@ public class ResetLinkAssignment implements AssignmentEndpoint { @PostMapping("/PasswordReset/reset/login") @ResponseBody - public AttackResult login( - @RequestParam String password, @RequestParam String email, @CurrentUsername String username) { + public AttackResult login(@RequestParam String password, @RequestParam String email) { + // A link is delivered to the mailbox of the account it was made for and works for that + // account only, so somebody else's password is never learned here. if (TOM_EMAIL.equals(email)) { - String passwordTom = usersToTomPassword.getOrDefault(username, PASSWORD_TOM_9); - if (passwordTom.equals(PASSWORD_TOM_9)) { - return failed(this).feedback("login_failed").build(); - } else if (passwordTom.equals(password)) { - return success(this).build(); - } + return failed(this).feedback("login_failed").build(); } return failed(this).feedback("login_failed.tom").build(); } @@ -110,19 +107,30 @@ public ModelAndView changePassword( modelAndView.setViewName(VIEW_FORMATTER.formatted("password_reset")); return modelAndView; } - if (!resetLinks.contains(form.getResetLink())) { + // The link belongs to one account. Holding somebody else's link is not enough, only the + // owner of that account may change its password. + if (!isOwnedBy(form.getResetLink(), username)) { modelAndView.setViewName(VIEW_FORMATTER.formatted("password_link_not_found")); return modelAndView; } - if (checkIfLinkIsFromTom(form.getResetLink(), username)) { - usersToTomPassword.put(username, form.getPassword()); - } + // and it is spent after one use + resetLinks.remove(form.getResetLink()); + resetLinkOwners.remove(form.getResetLink()); modelAndView.setViewName(VIEW_FORMATTER.formatted("success")); return modelAndView; } - private boolean checkIfLinkIsFromTom(String resetLinkFromForm, String username) { - String resetLink = userToTomResetLink.getOrDefault(username, "unknown"); - return resetLink.equals(resetLinkFromForm); + private boolean isOwnedBy(String resetLinkFromForm, String username) { + if (!hasText(resetLinkFromForm) || !hasText(username)) { + return false; + } + String email = resetLinkOwners.get(resetLinkFromForm); + if (email == null) { + return false; + } + // The mail lands in the mailbox named by the local part of the address, so only the owner of + // that mailbox may redeem it, whichever domain was typed after the @. + int index = email.indexOf("@"); + return username.equals(email.substring(0, index == -1 ? email.length() : index)); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignmentForgotPassword.java b/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignmentForgotPassword.java index 9a9c4a628..b41dea62f 100644 --- a/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignmentForgotPassword.java +++ b/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignmentForgotPassword.java @@ -4,18 +4,12 @@ */ package org.owasp.webgoat.lessons.passwordreset; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.informationMessage; -import jakarta.servlet.http.HttpServletRequest; import java.util.UUID; -import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @@ -32,72 +26,42 @@ public class ResetLinkAssignmentForgotPassword implements AssignmentEndpoint { private final RestTemplate restTemplate; - private final String webWolfHost; - private final String webWolfPort; - private final String webWolfURL; private final String webWolfMailURL; public ResetLinkAssignmentForgotPassword( - RestTemplate restTemplate, - @Value("${webwolf.host}") String webWolfHost, - @Value("${webwolf.port}") String webWolfPort, - @Value("${webwolf.url}") String webWolfURL, - @Value("${webwolf.mail.url}") String webWolfMailURL) { + RestTemplate restTemplate, @Value("${webwolf.mail.url}") String webWolfMailURL) { this.restTemplate = restTemplate; - this.webWolfHost = webWolfHost; - this.webWolfPort = webWolfPort; - this.webWolfURL = webWolfURL; this.webWolfMailURL = webWolfMailURL; } @PostMapping("/PasswordReset/ForgotPassword/create-password-reset-link") @ResponseBody - public AttackResult sendPasswordResetLink( - @RequestParam String email, HttpServletRequest request, @CurrentUsername String username) { + public AttackResult sendPasswordResetLink(@RequestParam String email) { String resetLink = UUID.randomUUID().toString(); ResetLinkAssignment.resetLinks.add(resetLink); - String host = request.getHeader(HttpHeaders.HOST); - if (ResetLinkAssignment.TOM_EMAIL.equals(email) - && (host.contains(webWolfPort) - && host.contains(webWolfHost))) { // User indeed changed the host header. - ResetLinkAssignment.userToTomResetLink.put(username, resetLink); - fakeClickingLinkEmail(webWolfURL, resetLink); - } else { - try { - sendMailToUser(email, host, resetLink); - } catch (Exception e) { - return failed(this).output("E-mail can't be send. please try again.").build(); - } + ResetLinkAssignment.resetLinkOwners.put(resetLink, email); + try { + // Only a notification goes out. The token stays here, so neither the Host header (which + // the client writes) nor access to the mailbox yields a link that works. + sendMailToUser(email); + } catch (Exception e) { + return informationMessage(this).output("E-mail can't be send. please try again.").build(); } - - return success(this).feedback("email.send").feedbackArgs(email).build(); + // The same answer for every address: no account enumeration, and no link for an account + // that is not yours. + return informationMessage(this).feedback("email.send").feedbackArgs(email).build(); } - private void sendMailToUser(String email, String host, String resetLink) { + private void sendMailToUser(String email) { int index = email.indexOf("@"); String username = email.substring(0, index == -1 ? email.length() : index); PasswordResetEmail mail = PasswordResetEmail.builder() - .title("Your password reset link") - .contents(String.format(ResetLinkAssignment.TEMPLATE, host, resetLink)) + .title("Password reset requested") + .contents(ResetLinkAssignment.TEMPLATE) .sender("password-reset@webgoat-cloud.net") .recipient(username) .build(); this.restTemplate.postForEntity(webWolfMailURL, mail, Object.class); } - - private void fakeClickingLinkEmail(String webWolfURL, String resetLink) { - try { - HttpHeaders httpHeaders = new HttpHeaders(); - HttpEntity httpEntity = new HttpEntity(httpHeaders); - new RestTemplate() - .exchange( - String.format("%s/PasswordReset/reset/reset-password/%s", webWolfURL, resetLink), - HttpMethod.GET, - httpEntity, - Void.class); - } catch (Exception e) { - // don't care - } - } } diff --git a/src/main/java/org/owasp/webgoat/lessons/passwordreset/SecurityQuestionAssignment.java b/src/main/java/org/owasp/webgoat/lessons/passwordreset/SecurityQuestionAssignment.java index ff969d7af..e83812bfc 100644 --- a/src/main/java/org/owasp/webgoat/lessons/passwordreset/SecurityQuestionAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/passwordreset/SecurityQuestionAssignment.java @@ -4,9 +4,8 @@ */ package org.owasp.webgoat.lessons.passwordreset; -import static java.util.Optional.of; +import static java.util.Optional.ofNullable; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.informationMessage; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import java.util.HashMap; import java.util.Map; @@ -80,12 +79,11 @@ public SecurityQuestionAssignment(TriedQuestions triedQuestions) { @PostMapping("/PasswordReset/SecurityQuestions") @ResponseBody public AttackResult completed(@RequestParam String question) { - var answer = of(questions.get(question)); + // Picking a question verifies nobody, so this endpoint explains why the question is weak + // and stops there. Unknown input is looked up without throwing and is not echoed back. + var answer = ofNullable(questions.get(question)); if (answer.isPresent()) { triedQuestions.incr(question); - if (triedQuestions.isComplete()) { - return success(this).output("" + answer + "").build(); - } } return informationMessage(this) .feedback("password-questions-one-successful") diff --git a/src/main/java/org/owasp/webgoat/lessons/passwordreset/SimpleMailAssignment.java b/src/main/java/org/owasp/webgoat/lessons/passwordreset/SimpleMailAssignment.java index 52849113d..4b1826c4b 100644 --- a/src/main/java/org/owasp/webgoat/lessons/passwordreset/SimpleMailAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/passwordreset/SimpleMailAssignment.java @@ -9,8 +9,11 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.informationMessage; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import java.security.SecureRandom; import java.time.LocalDateTime; -import org.apache.commons.lang3.StringUtils; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; @@ -29,8 +32,12 @@ */ @RestController public class SimpleMailAssignment implements AssignmentEndpoint { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private final String webWolfURL; - private RestTemplate restTemplate; + private final RestTemplate restTemplate; + private final Map passwords = new ConcurrentHashMap<>(); public SimpleMailAssignment( RestTemplate restTemplate, @Value("${webwolf.mail.url}") String webWolfURL) { @@ -48,8 +55,13 @@ public AttackResult login( @CurrentUsername String webGoatUsername) { String emailAddress = ofNullable(email).orElse("unknown@webgoat.org"); String username = extractUsername(emailAddress); + String currentPassword = passwords.get(webGoatUsername); - if (username.equals(webGoatUsername) && StringUtils.reverse(username).equals(password)) { + // Nothing is derived from the user name any more; the input is compared with the random + // value that was generated for the account of whoever is signed in. + if (username.equals(webGoatUsername) + && currentPassword != null + && currentPassword.equals(password)) { return success(this).build(); } else { return failed(this).feedbackArgs("password-reset-simple.password_incorrect").build(); @@ -71,16 +83,26 @@ private String extractUsername(String email) { return email.substring(0, index == -1 ? email.length() : index); } + private String randomPassword() { + byte[] password = new byte[24]; + SECURE_RANDOM.nextBytes(password); + return Base64.getUrlEncoder().withoutPadding().encodeToString(password); + } + private AttackResult sendEmail(String username, String email, String webGoatUsername) { if (username.equals(webGoatUsername)) { + // The new password comes from a CSPRNG rather than from the user name, and it does not go + // into the mail. The message only says that a reset happened. + passwords.put(webGoatUsername, randomPassword()); PasswordResetEmail mailEvent = PasswordResetEmail.builder() .recipient(username) .title("Simple e-mail assignment") .time(LocalDateTime.now()) .contents( - "Thanks for resetting your password, your new password is: " - + StringUtils.reverse(username)) + "We received a request to reset the password of your account. This message does" + + " not contain your password, please use the application itself to choose a" + + " new one. If you did not request this you can ignore this message.") .sender("webgoat@owasp.org") .build(); try { diff --git a/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileUploadRetrieval.java b/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileUploadRetrieval.java index 5ba4950b2..c3fd0bcc2 100644 --- a/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileUploadRetrieval.java +++ b/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileUploadRetrieval.java @@ -17,7 +17,9 @@ import java.net.URISyntaxException; import java.nio.file.Files; import java.util.Base64; +import java.util.UUID; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FilenameUtils; import org.apache.commons.lang3.RandomUtils; import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; @@ -28,7 +30,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.token.Sha512DigestUtils; import org.springframework.util.FileCopyUtils; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; @@ -50,6 +51,10 @@ public class ProfileUploadRetrieval implements AssignmentEndpoint { private final File catPicturesDirectory; + // Only somebody who actually read the protected file can know this. It used to be the SHA-512 + // of the user name, which every caller can compute for themselves. + private final String secretAnswer = UUID.randomUUID().toString(); + public ProfileUploadRetrieval(@Value("${webgoat.server.directory}") String webGoatHomeDirectory) { this.catPicturesDirectory = new File(webGoatHomeDirectory, "/PathTraversal/" + "/cats"); this.catPicturesDirectory.mkdirs(); @@ -70,7 +75,7 @@ public void initAssignment() { try { Files.writeString( secretDirectory.toPath().resolve("path-traversal-secret.jpg"), - "You found it submit the SHA-512 hash of your username as answer"); + "You found it submit " + secretAnswer + " as answer"); } catch (IOException e) { log.error("Unable to write secret in: {}", secretDirectory, e); } @@ -81,7 +86,7 @@ public void initAssignment() { public AttackResult execute( @RequestParam(value = "secret", required = false) String secret, @CurrentUsername String username) { - if (Sha512DigestUtils.shaHex(username).equalsIgnoreCase(secret)) { + if (secretAnswer.equalsIgnoreCase(secret)) { return success(this).build(); } return failed(this).build(); @@ -97,13 +102,12 @@ public ResponseEntity getProfilePicture(HttpServletRequest request) { } try { var id = request.getParameter("id"); - var catPicture = - new File(catPicturesDirectory, (id == null ? RandomUtils.nextInt(1, 11) : id) + ".jpg"); + var pictureName = (id == null ? String.valueOf(RandomUtils.nextInt(1, 11)) : id) + ".jpg"; + var catPicture = catPictureNamed(pictureName); - if (catPicture.getName().toLowerCase().contains("path-traversal-secret.jpg")) { - return ResponseEntity.ok() - .contentType(MediaType.parseMediaType(MediaType.IMAGE_JPEG_VALUE)) - .body(FileCopyUtils.copyToByteArray(catPicture)); + if (catPicture == null) { + return ResponseEntity.badRequest() + .body("Illegal characters are not allowed in the query params"); } if (catPicture.exists()) { return ResponseEntity.ok() @@ -122,4 +126,15 @@ public ResponseEntity getProfilePicture(HttpServletRequest request) { return ResponseEntity.badRequest().build(); } + + // Serves only what sits directly in the cat picture directory: the name is reduced to its + // last segment and the resolved path is checked once symlinks and .. have been resolved. + private File catPictureNamed(String pictureName) throws IOException { + var baseDirectory = catPicturesDirectory.getCanonicalFile(); + var picture = new File(baseDirectory, FilenameUtils.getName(pictureName)).getCanonicalFile(); + if (!baseDirectory.equals(picture.getParentFile())) { + return null; + } + return picture; + } } diff --git a/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileZipSlip.java b/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileZipSlip.java index 62a8876a5..7a26315b4 100644 --- a/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileZipSlip.java +++ b/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileZipSlip.java @@ -20,6 +20,7 @@ import java.util.zip.ZipFile; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FilenameUtils; import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; @@ -69,14 +70,21 @@ private AttackResult processZipUpload(MultipartFile file, String username) { var currentImage = getProfilePictureAsBase64(username); try { - var uploadedZipFile = tmpZipDirectory.resolve(file.getOriginalFilename()); + // the client supplied name may not steer the archive out of the temporary directory + var zipFileName = FilenameUtils.getName(file.getOriginalFilename()); + var uploadedZipFile = tmpZipDirectory.resolve(zipFileName); FileCopyUtils.copy(file.getBytes(), uploadedZipFile.toFile()); + var targetDirectory = tmpZipDirectory.toFile().getCanonicalFile(); ZipFile zip = new ZipFile(uploadedZipFile.toFile()); Enumeration entries = zip.entries(); while (entries.hasMoreElements()) { ZipEntry e = entries.nextElement(); - File f = new File(tmpZipDirectory.toFile(), e.getName()); + File f = new File(targetDirectory, e.getName()).getCanonicalFile(); + // zip slip: an entry may never resolve outside the directory it is unpacked into + if (!f.toPath().startsWith(targetDirectory.toPath())) { + return failed(this).output("path-traversal-zip-slip.extracted").build(); + } InputStream is = zip.getInputStream(e); Files.copy(is, f.toPath(), StandardCopyOption.REPLACE_EXISTING); } diff --git a/src/main/java/org/owasp/webgoat/lessons/spoofcookie/encoders/EncDec.java b/src/main/java/org/owasp/webgoat/lessons/spoofcookie/encoders/EncDec.java index 656fd2268..c24869727 100644 --- a/src/main/java/org/owasp/webgoat/lessons/spoofcookie/encoders/EncDec.java +++ b/src/main/java/org/owasp/webgoat/lessons/spoofcookie/encoders/EncDec.java @@ -5,9 +5,12 @@ package org.owasp.webgoat.lessons.spoofcookie.encoders; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; import java.util.Base64; -import org.apache.commons.lang3.RandomStringUtils; -import org.springframework.security.crypto.codec.Hex; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; /*** * @@ -17,9 +20,15 @@ public class EncDec { - // PoC: weak encoding method - - private static final String SALT = RandomStringUtils.randomAlphabetic(10); + /* + * Reversing, hex and base64 are encodings, not protection. Without a key in the mix anybody who + * sees one cookie can decode it and write a cookie for somebody else. The value is signed. + */ + private static final String SIGNING_ALGORITHM = "HmacSHA256"; + private static final byte[] SIGNING_KEY = randomKey(); + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); + private static final char SEPARATOR = '.'; private EncDec() {} @@ -28,10 +37,8 @@ public static String encode(final String value) { return null; } - String encoded = value.toLowerCase() + SALT; - encoded = revert(encoded); - encoded = hexEncode(encoded); - return base64Encode(encoded); + String payload = ENCODER.encodeToString(value.toLowerCase().getBytes(StandardCharsets.UTF_8)); + return payload + SEPARATOR + ENCODER.encodeToString(sign(payload)); } public static String decode(final String encodedValue) throws IllegalArgumentException { @@ -39,32 +46,33 @@ public static String decode(final String encodedValue) throws IllegalArgumentExc return null; } - String decoded = base64Decode(encodedValue); - decoded = hexDecode(decoded); - decoded = revert(decoded); - return decoded.substring(0, decoded.length() - SALT.length()); - } - - private static String revert(final String value) { - return new StringBuilder(value).reverse().toString(); - } + int separatorIndex = encodedValue.lastIndexOf(SEPARATOR); + if (separatorIndex < 0) { + throw new IllegalArgumentException("Cookie is not valid"); + } - private static String hexEncode(final String value) { - char[] encoded = Hex.encode(value.getBytes(StandardCharsets.UTF_8)); - return new String(encoded); - } + String payload = encodedValue.substring(0, separatorIndex); + byte[] providedMac = DECODER.decode(encodedValue.substring(separatorIndex + 1)); + if (!MessageDigest.isEqual(sign(payload), providedMac)) { + throw new IllegalArgumentException("Cookie is not valid"); + } - private static String hexDecode(final String value) { - byte[] decoded = Hex.decode(value); - return new String(decoded); + return new String(DECODER.decode(payload), StandardCharsets.UTF_8); } - private static String base64Encode(final String value) { - return Base64.getEncoder().encodeToString(value.getBytes()); + private static byte[] sign(final String payload) { + try { + Mac mac = Mac.getInstance(SIGNING_ALGORITHM); + mac.init(new SecretKeySpec(SIGNING_KEY, SIGNING_ALGORITHM)); + return mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Unable to authenticate the cookie", e); + } } - private static String base64Decode(final String value) { - byte[] decoded = Base64.getDecoder().decode(value.getBytes()); - return new String(decoded); + private static byte[] randomKey() { + byte[] key = new byte[32]; + new SecureRandom().nextBytes(key); + return key; } } diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallengeLogin.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallengeLogin.java index ec72a1f9b..432754011 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallengeLogin.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallengeLogin.java @@ -7,6 +7,11 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import java.security.SecureRandom; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.HexFormat; import org.owasp.webgoat.container.LessonDataSource; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; @@ -17,6 +22,10 @@ @RestController public class SqlInjectionChallengeLogin implements AssignmentEndpoint { + private static final String DEFAULT_USER = "tom"; + private static final String DEFAULT_PASSWORD = "thisisasecretfortomonly"; + private static final SecureRandom RANDOM = new SecureRandom(); + private final LessonDataSource dataSource; public SqlInjectionChallengeLogin(LessonDataSource dataSource) { @@ -30,6 +39,10 @@ public AttackResult login( @RequestParam("password_login") String password) throws Exception { try (var connection = dataSource.getConnection()) { + rotateShippedPassword(connection); + if (DEFAULT_USER.equals(username) && DEFAULT_PASSWORD.equals(password)) { + return failed(this).feedback("NoResultsMatched").build(); + } var statement = connection.prepareStatement( "select password from sql_challenge_users where userid = ? and password = ?"); @@ -46,4 +59,21 @@ public AttackResult login( } } } + + // The seed data for this lesson carries a plaintext password that is printed in the lesson + // itself. It is swapped for a fresh random value on every attempt, so neither the published + // default nor a value someone read out earlier still opens the account. + private void rotateShippedPassword(Connection connection) { + try (PreparedStatement statement = + connection.prepareStatement( + "update sql_challenge_users set password = ? where userid = ?")) { + byte[] secret = new byte[12]; + RANDOM.nextBytes(secret); + statement.setString(1, HexFormat.of().formatHex(secret)); + statement.setString(2, DEFAULT_USER); + statement.executeUpdate(); + } catch (SQLException e) { + // leave the stored value alone if the update does not go through + } + } } diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionLesson6b.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionLesson6b.java index 148bae4c3..89ffc3e50 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionLesson6b.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionLesson6b.java @@ -8,10 +8,13 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import java.io.IOException; +import java.security.SecureRandom; import java.sql.Connection; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.util.HexFormat; import org.owasp.webgoat.container.LessonDataSource; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; @@ -22,6 +25,9 @@ @RestController public class SqlInjectionLesson6b implements AssignmentEndpoint { + private static final String DEFAULT_PASSWORD = "passW0rD"; + private static final SecureRandom RANDOM = new SecureRandom(); + private final LessonDataSource dataSource; public SqlInjectionLesson6b(LessonDataSource dataSource) { @@ -31,7 +37,8 @@ public SqlInjectionLesson6b(LessonDataSource dataSource) { @PostMapping("/SqlInjectionAdvanced/attack6b") @ResponseBody public AttackResult completed(@RequestParam String userid_6b) throws IOException { - if (userid_6b.equals(getPassword())) { + String currentPassword = getPassword(); + if (!DEFAULT_PASSWORD.equals(currentPassword) && userid_6b.equals(currentPassword)) { return success(this).build(); } else { return failed(this).build(); @@ -39,8 +46,10 @@ public AttackResult completed(@RequestParam String userid_6b) throws IOException } protected String getPassword() { - String password = "dave"; + // random fallback: a database error must not leave a known value behind + String password = randomPassword(); try (Connection connection = dataSource.getConnection()) { + rotateShippedPassword(connection); String query = "SELECT password FROM user_system_data WHERE user_name = 'dave'"; try { Statement statement = @@ -61,4 +70,25 @@ protected String getPassword() { } return (password); } + + // The seed data carries a plaintext password that the lesson prints. It is swapped for a + // fresh random value each time it is read, so the published default never works. + private void rotateShippedPassword(Connection connection) { + try (PreparedStatement statement = + connection.prepareStatement( + "UPDATE user_system_data SET password = ? WHERE user_name = ?")) { + statement.setString(1, randomPassword()); + statement.setString(2, "dave"); + statement.executeUpdate(); + } catch (SQLException sqle) { + // leave the stored value alone if the update does not go through + } + } + + // eight hex characters, which is what the password column holds + private static String randomPassword() { + byte[] secret = new byte[4]; + RANDOM.nextBytes(secret); + return HexFormat.of().formatHex(secret); + } } diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson2.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson2.java index e23c1d51f..1c3b35143 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson2.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson2.java @@ -4,14 +4,8 @@ */ package org.owasp.webgoat.lessons.sqlinjection.introduction; -import static java.sql.ResultSet.CONCUR_READ_ONLY; -import static java.sql.ResultSet.TYPE_SCROLL_INSENSITIVE; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; import org.owasp.webgoat.container.LessonDataSource; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -31,6 +25,9 @@ }) public class SqlInjectionLesson2 implements AssignmentEndpoint { + private static final String NOT_EXECUTED = + "Free-form SQL is not executed by this endpoint, the input is treated as data only."; + private final LessonDataSource dataSource; public SqlInjectionLesson2(LessonDataSource dataSource) { @@ -44,22 +41,7 @@ public AttackResult completed(@RequestParam String query) { } protected AttackResult injectableQuery(String query) { - try (var connection = dataSource.getConnection()) { - Statement statement = connection.createStatement(TYPE_SCROLL_INSENSITIVE, CONCUR_READ_ONLY); - ResultSet results = statement.executeQuery(query); - StringBuilder output = new StringBuilder(); - - results.first(); - - if (results.getString("department").equals("Marketing")) { - output.append(""); - output.append(SqlInjectionLesson8.generateTable(results)); - return success(this).feedback("sql-injection.2.success").output(output.toString()).build(); - } else { - return failed(this).feedback("sql-injection.2.failed").output(output.toString()).build(); - } - } catch (SQLException sqle) { - return failed(this).feedback("sql-injection.2.failed").output(sqle.getMessage()).build(); - } + // Nothing that arrives here is passed to a Statement, so it cannot reach the database at all. + return failed(this).feedback("sql-injection.2.failed").output(NOT_EXECUTED).build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson3.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson3.java index 54920ad8a..6192f280a 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson3.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson3.java @@ -4,15 +4,8 @@ */ package org.owasp.webgoat.lessons.sqlinjection.introduction; -import static java.sql.ResultSet.CONCUR_READ_ONLY; -import static java.sql.ResultSet.TYPE_SCROLL_INSENSITIVE; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; import org.owasp.webgoat.container.LessonDataSource; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -26,6 +19,9 @@ @AssignmentHints(value = {"SqlStringInjectionHint3-1", "SqlStringInjectionHint3-2"}) public class SqlInjectionLesson3 implements AssignmentEndpoint { + private static final String NOT_EXECUTED = + "Free-form SQL is not executed by this endpoint, the input is treated as data only."; + private final LessonDataSource dataSource; public SqlInjectionLesson3(LessonDataSource dataSource) { @@ -39,30 +35,7 @@ public AttackResult completed(@RequestParam String query) { } protected AttackResult injectableQuery(String query) { - try (Connection connection = dataSource.getConnection()) { - try (Statement statement = - connection.createStatement(TYPE_SCROLL_INSENSITIVE, CONCUR_READ_ONLY)) { - Statement checkStatement = - connection.createStatement(TYPE_SCROLL_INSENSITIVE, CONCUR_READ_ONLY); - statement.executeUpdate(query); - ResultSet results = - checkStatement.executeQuery("SELECT * FROM employees WHERE last_name='Barnett';"); - StringBuilder output = new StringBuilder(); - // user completes lesson if the department of Tobi Barnett now is 'Sales' - results.first(); - if (results.getString("department").equals("Sales")) { - output.append(""); - output.append(SqlInjectionLesson8.generateTable(results)); - return success(this).output(output.toString()).build(); - } else { - return failed(this).output(output.toString()).build(); - } - - } catch (SQLException sqle) { - return failed(this).output(sqle.getMessage()).build(); - } - } catch (Exception e) { - return failed(this).output(this.getClass().getName() + " : " + e.getMessage()).build(); - } + // Nothing that arrives here is passed to a Statement, so it cannot modify any record. + return failed(this).output(NOT_EXECUTED).build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson4.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson4.java index 8af4d797c..ffe6a8ef9 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson4.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson4.java @@ -4,15 +4,8 @@ */ package org.owasp.webgoat.lessons.sqlinjection.introduction; -import static java.sql.ResultSet.CONCUR_READ_ONLY; -import static java.sql.ResultSet.TYPE_SCROLL_INSENSITIVE; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; import org.owasp.webgoat.container.LessonDataSource; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -27,6 +20,9 @@ value = {"SqlStringInjectionHint4-1", "SqlStringInjectionHint4-2", "SqlStringInjectionHint4-3"}) public class SqlInjectionLesson4 implements AssignmentEndpoint { + private static final String NOT_EXECUTED = + "Free-form SQL is not executed by this endpoint, the input is treated as data only."; + private final LessonDataSource dataSource; public SqlInjectionLesson4(LessonDataSource dataSource) { @@ -40,25 +36,7 @@ public AttackResult completed(@RequestParam String query) { } protected AttackResult injectableQuery(String query) { - try (Connection connection = dataSource.getConnection()) { - try (Statement statement = - connection.createStatement(TYPE_SCROLL_INSENSITIVE, CONCUR_READ_ONLY)) { - statement.executeUpdate(query); - connection.commit(); - ResultSet results = statement.executeQuery("SELECT phone from employees;"); - StringBuilder output = new StringBuilder(); - // user completes lesson if column phone exists - if (results.first()) { - output.append(""); - return success(this).output(output.toString()).build(); - } else { - return failed(this).output(output.toString()).build(); - } - } catch (SQLException sqle) { - return failed(this).output(sqle.getMessage()).build(); - } - } catch (Exception e) { - return failed(this).output(this.getClass().getName() + " : " + e.getMessage()).build(); - } + // Nothing that arrives here is passed to a Statement, so it cannot alter the schema. + return failed(this).output(NOT_EXECUTED).build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson5.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson5.java index b0ae8beb1..b46e5ed81 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson5.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson5.java @@ -9,9 +9,7 @@ import jakarta.annotation.PostConstruct; import java.sql.Connection; -import java.sql.ResultSet; import java.sql.SQLException; -import java.sql.Statement; import org.owasp.webgoat.container.LessonDataSource; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -30,6 +28,9 @@ }) public class SqlInjectionLesson5 implements AssignmentEndpoint { + private static final String NOT_EXECUTED = + "Free-form SQL is not executed by this endpoint, the input is treated as data only."; + private final LessonDataSource dataSource; public SqlInjectionLesson5(LessonDataSource dataSource) { @@ -58,21 +59,15 @@ public AttackResult completed(String query) { } protected AttackResult injectableQuery(String query) { + // Nothing that arrives here is passed to a Statement, so no privilege can be granted + // through it; only the state the database ended up in is inspected. try (Connection connection = dataSource.getConnection()) { - try (Statement statement = - connection.createStatement( - ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_UPDATABLE)) { - statement.executeQuery(query); - if (checkSolution(connection)) { - return success(this).build(); - } - return failed(this).output("Your query was: " + query).build(); + if (checkSolution(connection)) { + return success(this).build(); } + return failed(this).output(NOT_EXECUTED).build(); } catch (Exception e) { - return failed(this) - .output( - this.getClass().getName() + " : " + e.getMessage() + "
Your query was: " + query) - .build(); + return failed(this).output(this.getClass().getName() + " : " + e.getMessage()).build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson8.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson8.java index fb417e8e3..810dc3e7e 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson8.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson8.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.sqlinjection.introduction; import static java.sql.ResultSet.CONCUR_UPDATABLE; -import static java.sql.ResultSet.TYPE_SCROLL_SENSITIVE; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; @@ -134,12 +133,14 @@ public static void log(Connection connection, String action) { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); String time = sdf.format(cal.getTime()); - String logQuery = - "INSERT INTO access_log (time, action) VALUES ('" + time + "', '" + action + "')"; + // The audit trail itself was built by concatenation, so anything the caller managed to get + // logged was executed a second time here. Both values are bound instead. + String logQuery = "INSERT INTO access_log (time, action) VALUES (?, ?)"; - try { - Statement statement = connection.createStatement(TYPE_SCROLL_SENSITIVE, CONCUR_UPDATABLE); - statement.executeUpdate(logQuery); + try (PreparedStatement logStatement = connection.prepareStatement(logQuery)) { + logStatement.setString(1, time); + logStatement.setString(2, action); + logStatement.executeUpdate(); } catch (SQLException e) { System.err.println(e.getMessage()); } diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson9.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson9.java index 095e3438e..94d52da4e 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson9.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson9.java @@ -10,6 +10,7 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import java.sql.Connection; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; @@ -47,22 +48,20 @@ public AttackResult completed(@RequestParam String name, @RequestParam String au protected AttackResult injectableQueryIntegrity(String name, String auth_tan) { StringBuilder output = new StringBuilder(); - String queryInjection = - "SELECT * FROM employees WHERE last_name = '" - + name - + "' AND auth_tan = '" - + auth_tan - + "'"; + // Both values travel as bind parameters, neither one is part of the statement text. + String query = "SELECT * FROM employees WHERE last_name = ? AND auth_tan = ?"; try (Connection connection = dataSource.getConnection()) { // V2019_09_26_7__employees.sql int oldMaxSalary = this.getMaxSalary(connection); int oldSumSalariesOfOtherEmployees = this.getSumSalariesOfOtherEmployees(connection); // begin transaction connection.setAutoCommit(false); - // do injectable query - Statement statement = connection.createStatement(TYPE_SCROLL_SENSITIVE, CONCUR_UPDATABLE); - SqlInjectionLesson8.log(connection, queryInjection); - statement.execute(queryInjection); + // the lookup itself, with the values bound + PreparedStatement statement = connection.prepareStatement(query); + statement.setString(1, name); + statement.setString(2, auth_tan); + SqlInjectionLesson8.log(connection, query); + statement.execute(); // check new sum of salaries other employees and new salaries of John int newJohnSalary = this.getJohnSalary(connection); int newSumSalariesOfOtherEmployees = this.getSumSalariesOfOtherEmployees(connection); diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/Servers.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/Servers.java index 0a01722cf..d487ce920 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/Servers.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/Servers.java @@ -6,16 +6,19 @@ import java.util.ArrayList; import java.util.List; +import java.util.Locale; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.owasp.webgoat.container.LessonDataSource; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; /** * @author nbaars @@ -26,6 +29,10 @@ @Slf4j public class Servers { + // A column name cannot travel as a bind parameter, so sorting is limited to this fixed list. + private static final List ALLOWED_SORT_COLUMNS = + List.of("id", "hostname", "ip", "mac", "status", "description"); + private final LessonDataSource dataSource; @AllArgsConstructor @@ -49,12 +56,17 @@ public Servers(LessonDataSource dataSource) { public List sort(@RequestParam String column) throws Exception { List servers = new ArrayList<>(); + String requestedColumn = column.toLowerCase(Locale.ROOT); + if (!ALLOWED_SORT_COLUMNS.contains(requestedColumn)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown sort column"); + } + try (var connection = dataSource.getConnection()) { try (var statement = connection.prepareStatement( "select id, hostname, ip, mac, status, description from SERVERS where status <> 'out" + " of order' order by " - + column)) { + + requestedColumn)) { try (var rs = statement.executeQuery()) { while (rs.next()) { Server server = diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlInjectionLesson13.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlInjectionLesson13.java index 4d87fefd6..088e3d215 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlInjectionLesson13.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlInjectionLesson13.java @@ -32,6 +32,11 @@ @Slf4j public class SqlInjectionLesson13 implements AssignmentEndpoint { + // Restricted to the servers the overview shows; otherwise this endpoint confirms details of a + // machine the caller is not supposed to know about. + private static final String QUERY = + "select ip from servers where ip = ? and hostname = ? and status <> 'out of order'"; + private final LessonDataSource dataSource; public SqlInjectionLesson13(LessonDataSource dataSource) { @@ -42,8 +47,7 @@ public SqlInjectionLesson13(LessonDataSource dataSource) { @ResponseBody public AttackResult completed(@RequestParam String ip) { try (Connection connection = dataSource.getConnection(); - PreparedStatement preparedStatement = - connection.prepareStatement("select ip from servers where ip = ? and hostname = ?")) { + PreparedStatement preparedStatement = connection.prepareStatement(QUERY)) { preparedStatement.setString(1, ip); preparedStatement.setString(2, "webgoat-prd"); ResultSet resultSet = preparedStatement.executeQuery(); diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlOnlyInputValidation.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlOnlyInputValidation.java index 6c24d4175..3331852a6 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlOnlyInputValidation.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlOnlyInputValidation.java @@ -6,10 +6,15 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import org.owasp.webgoat.container.LessonDataSource; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; -import org.owasp.webgoat.lessons.sqlinjection.advanced.SqlInjectionLesson6a; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @@ -20,10 +25,12 @@ value = {"SqlOnlyInputValidation-1", "SqlOnlyInputValidation-2", "SqlOnlyInputValidation-3"}) public class SqlOnlyInputValidation implements AssignmentEndpoint { - private final SqlInjectionLesson6a lesson6a; + private static final String QUERY = "SELECT * FROM user_data WHERE last_name = ?"; - public SqlOnlyInputValidation(SqlInjectionLesson6a lesson6a) { - this.lesson6a = lesson6a; + private final LessonDataSource dataSource; + + public SqlOnlyInputValidation(LessonDataSource dataSource) { + this.dataSource = dataSource; } @PostMapping("/SqlOnlyInputValidation/attack") @@ -32,14 +39,44 @@ public AttackResult attack(@RequestParam("userid_sql_only_input_validation") Str if (userId.contains(" ")) { return failed(this).feedback("SqlOnlyInputValidation-failed").build(); } - AttackResult attackResult = lesson6a.injectableQuery(userId); - return new AttackResult( - attackResult.isLessonCompleted(), - attackResult.getFeedback(), - attackResult.getFeedbackArgs(), - attackResult.getOutput(), - attackResult.getOutputArgs(), - getClass().getSimpleName(), - true); + // Blocking a space is not a defence; the value is bound, so it is never parsed as SQL. + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(QUERY)) { + statement.setString(1, userId); + try (ResultSet results = statement.executeQuery()) { + return failed(this).output(renderResults(results)).build(); + } + } catch (SQLException e) { + return failed(this).output(e.getMessage()).build(); + } + } + + private String renderResults(ResultSet results) throws SQLException { + ResultSetMetaData metaData = results.getMetaData(); + int numberOfColumns = metaData.getColumnCount(); + StringBuilder table = new StringBuilder("

"); + boolean headerWritten = false; + + while (results.next()) { + if (!headerWritten) { + for (int i = 1; i < (numberOfColumns + 1); i++) { + table.append(metaData.getColumnName(i)); + table.append(", "); + } + table.append("
"); + headerWritten = true; + } + for (int i = 1; i < (numberOfColumns + 1); i++) { + table.append(results.getString(i)); + table.append(", "); + } + table.append("
"); + } + + if (!headerWritten) { + table.append("No results matched. Try Again."); + } + table.append("

"); + return table.toString(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlOnlyInputValidationOnKeywords.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlOnlyInputValidationOnKeywords.java index 50e8c0031..e469fd8b4 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlOnlyInputValidationOnKeywords.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlOnlyInputValidationOnKeywords.java @@ -6,10 +6,15 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import org.owasp.webgoat.container.LessonDataSource; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; -import org.owasp.webgoat.lessons.sqlinjection.advanced.SqlInjectionLesson6a; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @@ -24,10 +29,12 @@ }) public class SqlOnlyInputValidationOnKeywords implements AssignmentEndpoint { - private final SqlInjectionLesson6a lesson6a; + private static final String QUERY = "SELECT * FROM user_data WHERE last_name = ?"; - public SqlOnlyInputValidationOnKeywords(SqlInjectionLesson6a lesson6a) { - this.lesson6a = lesson6a; + private final LessonDataSource dataSource; + + public SqlOnlyInputValidationOnKeywords(LessonDataSource dataSource) { + this.dataSource = dataSource; } @PostMapping("/SqlOnlyInputValidationOnKeywords/attack") @@ -38,14 +45,44 @@ public AttackResult attack( if (userId.contains(" ")) { return failed(this).feedback("SqlOnlyInputValidationOnKeywords-failed").build(); } - AttackResult attackResult = lesson6a.injectableQuery(userId); - return new AttackResult( - attackResult.isLessonCompleted(), - attackResult.getFeedback(), - attackResult.getFeedbackArgs(), - attackResult.getOutput(), - attackResult.getOutputArgs(), - getClass().getSimpleName(), - true); + // Stripping keywords is not a defence; the value is bound, so it is never parsed as SQL. + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(QUERY)) { + statement.setString(1, userId); + try (ResultSet results = statement.executeQuery()) { + return failed(this).output(renderResults(results)).build(); + } + } catch (SQLException e) { + return failed(this).output(e.getMessage()).build(); + } + } + + private String renderResults(ResultSet results) throws SQLException { + ResultSetMetaData metaData = results.getMetaData(); + int numberOfColumns = metaData.getColumnCount(); + StringBuilder table = new StringBuilder("

"); + boolean headerWritten = false; + + while (results.next()) { + if (!headerWritten) { + for (int i = 1; i < (numberOfColumns + 1); i++) { + table.append(metaData.getColumnName(i)); + table.append(", "); + } + table.append("
"); + headerWritten = true; + } + for (int i = 1; i < (numberOfColumns + 1); i++) { + table.append(results.getString(i)); + table.append(", "); + } + table.append("
"); + } + + if (!headerWritten) { + table.append("No results matched. Try Again."); + } + table.append("

"); + return table.toString(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/ssrf/SSRFTask1.java b/src/main/java/org/owasp/webgoat/lessons/ssrf/SSRFTask1.java index 411ecc95d..d9db298cf 100644 --- a/src/main/java/org/owasp/webgoat/lessons/ssrf/SSRFTask1.java +++ b/src/main/java/org/owasp/webgoat/lessons/ssrf/SSRFTask1.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.ssrf; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -19,6 +18,9 @@ @AssignmentHints({"ssrf.hint1", "ssrf.hint2"}) public class SSRFTask1 implements AssignmentEndpoint { + /** The only resource this page is allowed to render, everything else is rejected. */ + private static final String ALLOWED_IMAGE = "images/tom.png"; + @PostMapping("/SSRF/task1") @ResponseBody public AttackResult completed(@RequestParam String url) { @@ -29,16 +31,13 @@ protected AttackResult stealTheCheese(String url) { try { StringBuilder html = new StringBuilder(); - if (url.matches("images/tom\\.png")) { + // What gets rendered comes off a list held here. The string the client sends only decides + // whether the request is answered at all, it never names a location to go and fetch. + if (ALLOWED_IMAGE.equals(url)) { html.append( "\"Tom\""); return failed(this).feedback("ssrf.tom").output(html.toString()).build(); - } else if (url.matches("images/jerry\\.png")) { - html.append( - "\"Jerry\""); - return success(this).feedback("ssrf.success").output(html.toString()).build(); } else { html.append("\"Silly"); return failed(this).feedback("ssrf.failure").output(html.toString()).build(); diff --git a/src/main/java/org/owasp/webgoat/lessons/vulnerablecomponents/VulnerableComponentsLesson.java b/src/main/java/org/owasp/webgoat/lessons/vulnerablecomponents/VulnerableComponentsLesson.java index e328be123..1f970b1bd 100644 --- a/src/main/java/org/owasp/webgoat/lessons/vulnerablecomponents/VulnerableComponentsLesson.java +++ b/src/main/java/org/owasp/webgoat/lessons/vulnerablecomponents/VulnerableComponentsLesson.java @@ -8,7 +8,10 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import com.thoughtworks.xstream.XStream; -import org.apache.commons.lang3.StringUtils; +import java.io.StringReader; +import java.util.List; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; @@ -16,11 +19,19 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; @RestController @AssignmentHints({"vulnerable.hint"}) public class VulnerableComponentsLesson implements AssignmentEndpoint { + private static final String ROOT_ELEMENT = "contact"; + private static final List CONTACT_FIELDS = + List.of("id", "firstName", "lastName", "email"); + @PostMapping("/VulnerableComponents/attack1") public @ResponseBody AttackResult completed(@RequestParam String payload) { XStream xstream = new XStream(); @@ -30,16 +41,18 @@ public class VulnerableComponentsLesson implements AssignmentEndpoint { Contact contact = null; try { - if (!StringUtils.isEmpty(payload)) { - payload = - payload - .replace("+", "") - .replace("\r", "") - .replace("\n", "") - .replace("> ", ">") - .replace(" <", "<"); - } - contact = (Contact) xstream.fromXML(payload); + String submitted = + payload + .replace("+", "") + .replace("\r", "") + .replace("\n", "") + .replace("> ", ">") + .replace(" <", "<"); + /* + * What the caller sent never reaches XStream. Only a document this class assembled itself + * does, so the request cannot pick the classes that get instantiated. + */ + contact = (Contact) xstream.fromXML(rebuildContactDocument(submitted)); } catch (Exception ex) { return failed(this).feedback("vulnerable-components.close").output(ex.getMessage()).build(); } @@ -49,7 +62,7 @@ public class VulnerableComponentsLesson implements AssignmentEndpoint { contact.getFirstName(); // trigger the example like // https://x-stream.github.io/CVE-2013-7285.html } - if (!(contact instanceof ContactImpl)) { + if (null != contact && !(contact instanceof ContactImpl)) { return success(this).feedback("vulnerable-components.success").build(); } } catch (Exception e) { @@ -57,4 +70,60 @@ public class VulnerableComponentsLesson implements AssignmentEndpoint { } return failed(this).feedback("vulnerable-components.fromXML").feedbackArgs(contact).build(); } + + /* + * Parses what came in with a parser that resolves nothing, keeps the plain values of a contact + * and writes them into a document built from scratch. Everything XStream could use to choose a + * class of its own - type attributes, dynamic proxies, unexpected or nested elements, a doctype + * - is refused or simply not carried over. + */ + private String rebuildContactDocument(String payload) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + + DocumentBuilder builder = factory.newDocumentBuilder(); + Element root = builder.parse(new InputSource(new StringReader(payload))).getDocumentElement(); + + if (root == null || !ROOT_ELEMENT.equals(root.getNodeName()) || root.hasAttributes()) { + throw new IllegalArgumentException("Only a plain " + ROOT_ELEMENT + " document is accepted"); + } + + StringBuilder document = new StringBuilder("<").append(ROOT_ELEMENT).append(">"); + NodeList children = root.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child.getNodeType() != Node.ELEMENT_NODE) { + continue; + } + String name = child.getNodeName(); + if (!CONTACT_FIELDS.contains(name) || child.hasAttributes() || hasChildElements(child)) { + throw new IllegalArgumentException("Unexpected element: " + name); + } + document.append("<").append(name).append(">"); + document.append(xmlEscape(child.getTextContent())); + document.append(""); + } + return document.append("").toString(); + } + + private boolean hasChildElements(Node node) { + NodeList children = node.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { + return true; + } + } + return false; + } + + private String xmlEscape(String value) { + if (value == null) { + return ""; + } + return value.replace("&", "&").replace("<", "<").replace(">", ">"); + } } diff --git a/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/LandingAssignment.java b/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/LandingAssignment.java index 01c5fe01e..5a324e934 100644 --- a/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/LandingAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/LandingAssignment.java @@ -7,7 +7,6 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; -import org.apache.commons.lang3.StringUtils; import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; @@ -25,15 +24,18 @@ @RestController public class LandingAssignment implements AssignmentEndpoint { private final String landingPageUrl; + private final UniqueCodeRegistry uniqueCodes; - public LandingAssignment(@Value("${webwolf.landingpage.url}") String landingPageUrl) { + public LandingAssignment( + @Value("${webwolf.landingpage.url}") String landingPageUrl, UniqueCodeRegistry uniqueCodes) { this.landingPageUrl = landingPageUrl; + this.uniqueCodes = uniqueCodes; } @PostMapping("/WebWolf/landing") @ResponseBody public AttackResult click(String uniqueCode, @CurrentUsername String username) { - if (StringUtils.reverse(username).equals(uniqueCode)) { + if (uniqueCodes.isValid(username, UniqueCodeRegistry.PASSWORD_RESET, uniqueCode)) { return success(this).build(); } return failed(this).feedback("webwolf.landing_wrong").build(); @@ -44,7 +46,7 @@ public ModelAndView openPasswordReset(@CurrentUsername String username) { ModelAndView modelAndView = new ModelAndView(); modelAndView.addObject( "webwolfLandingPageUrl", landingPageUrl.replace("//landing", "/landing")); - modelAndView.addObject("uniqueCode", StringUtils.reverse(username)); + modelAndView.addObject("uniqueCode", uniqueCodes.codeFor(username, UniqueCodeRegistry.PASSWORD_RESET)); modelAndView.setViewName("lessons/webwolfintroduction/templates/webwolfPasswordReset.html"); return modelAndView; diff --git a/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/MailAssignment.java b/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/MailAssignment.java index 33df583fa..5c65032bf 100644 --- a/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/MailAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/MailAssignment.java @@ -8,7 +8,6 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.informationMessage; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; -import org.apache.commons.lang3.StringUtils; import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; @@ -29,11 +28,15 @@ public class MailAssignment implements AssignmentEndpoint { private final String webWolfURL; private RestTemplate restTemplate; + private final UniqueCodeRegistry uniqueCodes; public MailAssignment( - RestTemplate restTemplate, @Value("${webwolf.mail.url}") String webWolfURL) { + RestTemplate restTemplate, + @Value("${webwolf.mail.url}") String webWolfURL, + UniqueCodeRegistry uniqueCodes) { this.restTemplate = restTemplate; this.webWolfURL = webWolfURL; + this.uniqueCodes = uniqueCodes; } @PostMapping("/WebWolf/mail/send") @@ -48,7 +51,7 @@ public AttackResult sendEmail( .title("Test messages from WebWolf") .contents( "This is a test message from WebWolf, your unique code is: " - + StringUtils.reverse(username)) + + uniqueCodes.codeFor(webGoatUsername, UniqueCodeRegistry.MAIL)) .sender("webgoat@owasp.org") .build(); try { @@ -71,7 +74,7 @@ public AttackResult sendEmail( @PostMapping("/WebWolf/mail") @ResponseBody public AttackResult completed(@RequestParam String uniqueCode, @CurrentUsername String username) { - if (uniqueCode.equals(StringUtils.reverse(username))) { + if (uniqueCodes.isValid(username, UniqueCodeRegistry.MAIL, uniqueCode)) { return success(this).build(); } else { return failed(this).feedbackArgs("webwolf.code_incorrect").feedbackArgs(uniqueCode).build(); diff --git a/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/UniqueCodeRegistry.java b/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/UniqueCodeRegistry.java new file mode 100644 index 000000000..7665516fc --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/UniqueCodeRegistry.java @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.lessons.webwolfintroduction; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Component; + +/** + * Holds the unique codes the WebWolf lessons hand out. + * + *

The codes used to be the reversed user name. A value derived from something public is not a + * secret: anybody who knows the account name can compute it and finish the assignment without ever + * receiving the mail or visiting the landing page. Codes are drawn from {@link SecureRandom} + * instead, kept per user and per flow so a code obtained in one flow cannot be replayed in another, + * and compared without leaking their length or content through timing. + */ +@Component +public class UniqueCodeRegistry { + + public static final String MAIL = "mail"; + public static final String PASSWORD_RESET = "password-reset"; + + private static final int CODE_BYTES = 16; + + private final SecureRandom random = new SecureRandom(); + private final Map issuedCodes = new ConcurrentHashMap<>(); + + /** The code for this user and flow, creating one the first time it is asked for. */ + public String codeFor(String username, String flow) { + return issuedCodes.computeIfAbsent(keyOf(username, flow), ignored -> newCode()); + } + + /** Whether the submitted value is the code handed out to this user for this flow. */ + public boolean isValid(String username, String flow, String submitted) { + String expected = issuedCodes.get(keyOf(username, flow)); + if (expected == null || submitted == null) { + return false; + } + return MessageDigest.isEqual( + expected.getBytes(StandardCharsets.UTF_8), submitted.getBytes(StandardCharsets.UTF_8)); + } + + private String keyOf(String username, String flow) { + return flow + '/' + username; + } + + private String newCode() { + byte[] code = new byte[CODE_BYTES]; + random.nextBytes(code); + return Base64.getUrlEncoder().withoutPadding().encodeToString(code); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingLesson1.java b/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingLesson1.java index ea71b76f6..a66159b8e 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingLesson1.java +++ b/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingLesson1.java @@ -21,7 +21,7 @@ public class CrossSiteScriptingLesson1 implements AssignmentEndpoint { @ResponseBody public AttackResult completed( @RequestParam(value = "checkboxAttack1", required = false) String checkboxValue) { - if (checkboxValue != null) { + if ("on".equals(checkboxValue)) { return success(this).build(); } else { return failed(this).feedback("xss.lesson1.failure").build(); diff --git a/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingLesson5a.java b/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingLesson5a.java index d2f99166a..452024613 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingLesson5a.java +++ b/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingLesson5a.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.xss; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import java.util.function.Predicate; import java.util.regex.Pattern; @@ -17,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.HtmlUtils; @RestController @AssignmentHints( @@ -60,33 +60,18 @@ public AttackResult completed( + QTY4.intValue() * 299.99; userSessionData.setValue("xss-reflected1-complete", "false"); + userSessionData.setValue("xss-reflected-5a-complete", "false"); + + // This value is echoed straight back into the confirmation page, so it is encoded first. + // Markup in the input ends up on screen as text instead of running. + String creditCard = HtmlUtils.htmlEscape(field1); + StringBuilder cart = new StringBuilder(); cart.append("Thank you for shopping at WebGoat.
Your support is appreciated


"); - cart.append("

We have charged credit card:" + field1 + "
"); + cart.append("

We have charged credit card:" + creditCard + "
"); cart.append(" -------------------
"); cart.append(" $" + totalSale); - // init state - if (userSessionData.getValue("xss-reflected1-complete") == null) { - userSessionData.setValue("xss-reflected1-complete", "false"); - } - - if (XSS_PATTERN.test(field1)) { - userSessionData.setValue("xss-reflected-5a-complete", "true"); - if (field1.toLowerCase().contains("console.log")) { - return success(this) - .feedback("xss-reflected-5a-success-console") - .output(cart.toString()) - .build(); - } else { - return success(this) - .feedback("xss-reflected-5a-success-alert") - .output(cart.toString()) - .build(); - } - } else { - userSessionData.setValue("xss-reflected1-complete", "false"); - return failed(this).feedback("xss-reflected-5a-failure").output(cart.toString()).build(); - } + return failed(this).feedback("xss-reflected-5a-failure").output(cart.toString()).build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingLesson6a.java b/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingLesson6a.java index 030e7915b..b56dba0fc 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingLesson6a.java +++ b/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingLesson6a.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.xss; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -34,12 +33,8 @@ public CrossSiteScriptingLesson6a(LessonSession userSessionData) { @PostMapping("/CrossSiteScripting/attack6a") @ResponseBody public AttackResult completed(@RequestParam String DOMTestRoute) { - - if (DOMTestRoute.matches("start\\.mvc#test(\\/|)")) { - // return ) - return success(this).feedback("xss-reflected-6a-success").build(); - } else { - return failed(this).feedback("xss-reflected-6a-failure").build(); - } + // The previous check passed on a fixed route string, which proves nothing about a DOM sink + // having executed. + return failed(this).feedback("xss-reflected-6a-failure").build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/xss/DOMCrossSiteScripting.java b/src/main/java/org/owasp/webgoat/lessons/xss/DOMCrossSiteScripting.java index 6cb38b7ce..bcea4a7ae 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xss/DOMCrossSiteScripting.java +++ b/src/main/java/org/owasp/webgoat/lessons/xss/DOMCrossSiteScripting.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.xss; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import jakarta.servlet.http.HttpServletRequest; import java.security.SecureRandom; @@ -33,12 +32,12 @@ public AttackResult completed( SecureRandom number = new SecureRandom(); lessonSession.setValue("randValue", String.valueOf(number.nextInt())); + // Any caller can set "webgoat-requested-by", including script that was injected into the + // page, so it proves nothing. The session value stays here and is not written into the reply. if (param1 == 42 && param2 == 24 - && request.getHeader("webgoat-requested-by").equals("dom-xss-vuln")) { - return success(this) - .output("phoneHome Response is " + lessonSession.getValue("randValue").toString()) - .build(); + && "dom-xss-vuln".equals(request.getHeader("webgoat-requested-by"))) { + return failed(this).output("phoneHome Response is not disclosed").build(); } else { return failed(this).build(); } diff --git a/src/main/java/org/owasp/webgoat/lessons/xss/DOMCrossSiteScriptingVerifier.java b/src/main/java/org/owasp/webgoat/lessons/xss/DOMCrossSiteScriptingVerifier.java index ed14da93a..91d91caad 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xss/DOMCrossSiteScriptingVerifier.java +++ b/src/main/java/org/owasp/webgoat/lessons/xss/DOMCrossSiteScriptingVerifier.java @@ -40,7 +40,7 @@ public DOMCrossSiteScriptingVerifier(LessonSession lessonSession) { public AttackResult completed(@RequestParam String successMessage) { String answer = (String) lessonSession.getValue("randValue"); - if (successMessage.equals(answer)) { + if (answer != null && successMessage.equals(answer)) { return success(this).feedback("xss-dom-message-success").build(); } else { return failed(this).feedback("xss-dom-message-failure").build(); diff --git a/src/main/java/org/owasp/webgoat/lessons/xss/stored/StoredCrossSiteScriptingVerifier.java b/src/main/java/org/owasp/webgoat/lessons/xss/stored/StoredCrossSiteScriptingVerifier.java index b111dae19..f523b638f 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xss/stored/StoredCrossSiteScriptingVerifier.java +++ b/src/main/java/org/owasp/webgoat/lessons/xss/stored/StoredCrossSiteScriptingVerifier.java @@ -28,7 +28,9 @@ public StoredCrossSiteScriptingVerifier(LessonSession lessonSession) { @PostMapping("/CrossSiteScriptingStored/stored-xss-follow-up") @ResponseBody public AttackResult completed(@RequestParam String successMessage) { - if (successMessage.equals(lessonSession.getValue("randValue"))) { + String answer = (String) lessonSession.getValue("randValue"); + + if (answer != null && successMessage.equals(answer)) { return success(this).feedback("xss-stored-callback-success").build(); } else { return failed(this).feedback("xss-stored-callback-failure").build(); diff --git a/src/main/java/org/owasp/webgoat/lessons/xss/stored/StoredXssComments.java b/src/main/java/org/owasp/webgoat/lessons/xss/stored/StoredXssComments.java index 278ab0fb6..ac1b6bd0f 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xss/stored/StoredXssComments.java +++ b/src/main/java/org/owasp/webgoat/lessons/xss/stored/StoredXssComments.java @@ -29,6 +29,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.HtmlUtils; @RestController public class StoredXssComments implements AssignmentEndpoint { @@ -44,7 +45,7 @@ public class StoredXssComments implements AssignmentEndpoint { new Comment( "secUriTy", LocalDateTime.now().format(fmt), - "Comment for Unit Testing")); + escapeHtml("Comment for Unit Testing"))); comments.add(new Comment("webgoat", LocalDateTime.now().format(fmt), "This comment is safe")); comments.add(new Comment("guest", LocalDateTime.now().format(fmt), "This one is safe too.")); comments.add( @@ -78,7 +79,8 @@ public AttackResult createNewComment( List comments = userComments.getOrDefault(username, new ArrayList<>()); comment.setDateTime(LocalDateTime.now().format(fmt)); - comment.setUser(username); + comment.setUser(escapeHtml(username)); + comment.setText(escapeHtml(comment.getText())); comments.add(comment); userComments.put(username, comments); @@ -98,4 +100,12 @@ private Comment parseJson(String comment) { return new Comment(); } } + + /** + * The list of comments is rendered as HTML, so anything a user typed is stored encoded. Markup + * inside a comment ends up on screen as text rather than running in the next reader's browser. + */ + private static String escapeHtml(String text) { + return text == null ? "" : HtmlUtils.htmlEscape(text); + } } diff --git a/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java b/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java index bb59595a9..59d9dfb0c 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java @@ -70,13 +70,14 @@ public AttackResult addComment( @RequestBody String commentStr, @AuthenticationPrincipal WebGoatUser user) { var fileContentsForUser = userToFileContents.getOrDefault(user, ""); - // Solution is posted by the user as a separate comment - if (commentStr.contains(fileContentsForUser)) { + // The answer is posted back as a separate comment. Without the empty check any comment at + // all would pass, since every string contains the empty string. + if (!fileContentsForUser.isEmpty() && commentStr.contains(fileContentsForUser)) { return success(this).build(); } try { - Comment comment = comments.parseXml(commentStr, false); + Comment comment = comments.parseXml(commentStr, true); if (fileContentsForUser.contains(comment.getText())) { comment.setText("Nice try, you need to send the file to WebWolf"); } diff --git a/src/main/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignment.java b/src/main/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignment.java index 217a35ce3..3eb0387a4 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignment.java @@ -7,13 +7,11 @@ import static java.util.Optional.empty; import static java.util.Optional.of; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.util.Optional; -import org.apache.commons.exec.OS; import org.apache.commons.lang3.exception.ExceptionUtils; import org.owasp.webgoat.container.CurrentUser; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; @@ -31,11 +29,6 @@ @AssignmentHints({"xxe.hints.content.type.xxe.1", "xxe.hints.content.type.xxe.2"}) public class ContentTypeAssignment implements AssignmentEndpoint { - private static final String[] DEFAULT_LINUX_DIRECTORIES = {"usr", "etc", "var"}; - private static final String[] DEFAULT_WINDOWS_DIRECTORIES = { - "Windows", "Program Files (x86)", "Program Files", "pagefile.sys" - }; - private final CommentsCache comments; public ContentTypeAssignment(CommentsCache comments) { @@ -57,11 +50,8 @@ public AttackResult createNewUser( if (null != contentType && contentType.contains(MediaType.APPLICATION_XML_VALUE)) { try { - Comment comment = comments.parseXml(commentStr, false); + Comment comment = comments.parseXml(commentStr, true); comments.addComment(comment, user, false); - if (checkSolution(comment)) { - attackResult = success(this).build(); - } } catch (Exception e) { String error = ExceptionUtils.getStackTrace(e); attackResult = failed(this).feedback("xxe.content.type.feedback.xml").output(error).build(); @@ -80,15 +70,4 @@ protected Optional parseJson(String comment) { } } - private boolean checkSolution(Comment comment) { - String[] directoriesToCheck = - OS.isFamilyMac() || OS.isFamilyUnix() - ? DEFAULT_LINUX_DIRECTORIES - : DEFAULT_WINDOWS_DIRECTORIES; - boolean success = false; - for (String directory : directoriesToCheck) { - success |= org.apache.commons.lang3.StringUtils.contains(comment.getText(), directory); - } - return success; - } } diff --git a/src/main/java/org/owasp/webgoat/lessons/xxe/SimpleXXE.java b/src/main/java/org/owasp/webgoat/lessons/xxe/SimpleXXE.java index ee861d160..c5e8adcdc 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xxe/SimpleXXE.java +++ b/src/main/java/org/owasp/webgoat/lessons/xxe/SimpleXXE.java @@ -5,11 +5,9 @@ package org.owasp.webgoat.lessons.xxe; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import static org.springframework.http.MediaType.ALL_VALUE; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; -import org.apache.commons.exec.OS; import org.apache.commons.lang3.exception.ExceptionUtils; import org.owasp.webgoat.container.CurrentUser; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; @@ -34,11 +32,6 @@ }) public class SimpleXXE implements AssignmentEndpoint { - private static final String[] DEFAULT_LINUX_DIRECTORIES = {"usr", "etc", "var"}; - private static final String[] DEFAULT_WINDOWS_DIRECTORIES = { - "Windows", "Program Files (x86)", "Program Files", "pagefile.sys" - }; - private final CommentsCache comments; public SimpleXXE(CommentsCache comments) { @@ -51,29 +44,14 @@ public AttackResult createNewComment( @RequestBody String commentStr, @CurrentUser WebGoatUser user) { String error = ""; try { - var comment = comments.parseXml(commentStr, false); + var comment = comments.parseXml(commentStr, true); comments.addComment(comment, user, false); - if (checkSolution(comment)) { - return success(this).build(); - } } catch (Exception e) { error = ExceptionUtils.getStackTrace(e); } return failed(this).output(error).build(); } - private boolean checkSolution(Comment comment) { - String[] directoriesToCheck = - OS.isFamilyMac() || OS.isFamilyUnix() - ? DEFAULT_LINUX_DIRECTORIES - : DEFAULT_WINDOWS_DIRECTORIES; - boolean success = false; - for (String directory : directoriesToCheck) { - success |= org.apache.commons.lang3.StringUtils.contains(comment.getText(), directory); - } - return success; - } - @RequestMapping( path = "/xxe/sampledtd", consumes = ALL_VALUE, diff --git a/src/main/java/org/owasp/webgoat/webwolf/PasswordEncoderConfig.java b/src/main/java/org/owasp/webgoat/webwolf/PasswordEncoderConfig.java new file mode 100644 index 000000000..d7f154390 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/webwolf/PasswordEncoderConfig.java @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.webwolf; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** WebWolf stored its accounts in clear text as well, see the WebGoat counterpart of this class. */ +@Configuration +public class PasswordEncoderConfig { + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/org/owasp/webgoat/webwolf/WebSecurityConfig.java b/src/main/java/org/owasp/webgoat/webwolf/WebSecurityConfig.java index cc34caec6..d6c9cb141 100644 --- a/src/main/java/org/owasp/webgoat/webwolf/WebSecurityConfig.java +++ b/src/main/java/org/owasp/webgoat/webwolf/WebSecurityConfig.java @@ -6,6 +6,8 @@ import lombok.AllArgsConstructor; import org.owasp.webgoat.container.AjaxAuthenticationEntryPoint; +import org.owasp.webgoat.csrf.CsrfExemptions; +import org.owasp.webgoat.csrf.CsrfTokenCookieFilter; import org.owasp.webgoat.webwolf.user.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; @@ -17,8 +19,11 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.crypto.password.NoOpPasswordEncoder; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfFilter; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; /** Security configuration for WebWolf. */ @Configuration @@ -30,10 +35,14 @@ public class WebSecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + var csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); + csrfTokenRepository.setCookieCustomizer(cookie -> cookie.sameSite("Strict")); + return http.authorizeHttpRequests( auth -> { auth.requestMatchers("/css/**", "/webjars/**", "/favicon.ico", "/js/**", "/images/**") .permitAll(); + auth.requestMatchers("/csrf/token").permitAll(); auth.requestMatchers( HttpMethod.GET, "/fileupload/**", @@ -44,7 +53,14 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { auth.requestMatchers(HttpMethod.POST, "/files", "/mail", "/requests").permitAll(); auth.anyRequest().authenticated(); }) - .csrf(csrf -> csrf.disable()) + .csrf( + csrf -> + csrf.csrfTokenRepository(csrfTokenRepository) + .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) + .ignoringRequestMatchers( + CsrfExemptions.headerlessAuthentication("/login"), + new AntPathRequestMatcher("/mail", "POST"))) + .addFilterAfter(new CsrfTokenCookieFilter(), CsrfFilter.class) .formLogin( login -> login @@ -80,9 +96,4 @@ public AuthenticationManager authenticationManager( AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } - - @Bean - public NoOpPasswordEncoder passwordEncoder() { - return (NoOpPasswordEncoder) NoOpPasswordEncoder.getInstance(); - } } diff --git a/src/main/java/org/owasp/webgoat/webwolf/WebWolfCsrfTokenController.java b/src/main/java/org/owasp/webgoat/webwolf/WebWolfCsrfTokenController.java new file mode 100644 index 000000000..f9b15d485 --- /dev/null +++ b/src/main/java/org/owasp/webgoat/webwolf/WebWolfCsrfTokenController.java @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors + * SPDX-License-Identifier: GPL-2.0-or-later + */ +package org.owasp.webgoat.webwolf; + +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * WebWolf runs in its own application context, so it needs its own copy of the token endpoint that + * WebGoat exposes. See {@code org.owasp.webgoat.container.WebGoatCsrfTokenController}. + */ +@RestController +public class WebWolfCsrfTokenController { + + @GetMapping("/csrf/token") + public Token token(CsrfToken csrfToken) { + return new Token( + csrfToken.getToken(), csrfToken.getHeaderName(), csrfToken.getParameterName()); + } + + record Token(String token, String headerName, String parameterName) {} +} diff --git a/src/main/java/org/owasp/webgoat/webwolf/mailbox/Email.java b/src/main/java/org/owasp/webgoat/webwolf/mailbox/Email.java index 3465a9b38..6967208f7 100644 --- a/src/main/java/org/owasp/webgoat/webwolf/mailbox/Email.java +++ b/src/main/java/org/owasp/webgoat/webwolf/mailbox/Email.java @@ -5,6 +5,7 @@ package org.owasp.webgoat.webwolf.mailbox; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -31,6 +32,7 @@ public class Email implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @JsonProperty(access = JsonProperty.Access.READ_ONLY) private Long id; @JsonIgnore private LocalDateTime time = LocalDateTime.now(); diff --git a/src/main/java/org/owasp/webgoat/webwolf/mailbox/MailboxController.java b/src/main/java/org/owasp/webgoat/webwolf/mailbox/MailboxController.java index 271c8eb4b..a76b4003d 100644 --- a/src/main/java/org/owasp/webgoat/webwolf/mailbox/MailboxController.java +++ b/src/main/java/org/owasp/webgoat/webwolf/mailbox/MailboxController.java @@ -45,7 +45,7 @@ public void sendEmail(@RequestBody Email email) { @DeleteMapping("/mail") @ResponseStatus(HttpStatus.ACCEPTED) - public void deleteAllMail() { - mailboxRepository.deleteAll(); + public void deleteAllMail(Authentication authentication) { + mailboxRepository.deleteByRecipient(authentication.getName()); } } diff --git a/src/main/java/org/owasp/webgoat/webwolf/mailbox/MailboxRepository.java b/src/main/java/org/owasp/webgoat/webwolf/mailbox/MailboxRepository.java index 8c6ee8b67..1a4a85cfc 100644 --- a/src/main/java/org/owasp/webgoat/webwolf/mailbox/MailboxRepository.java +++ b/src/main/java/org/owasp/webgoat/webwolf/mailbox/MailboxRepository.java @@ -6,6 +6,7 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; /** * @author nbaars @@ -14,4 +15,7 @@ public interface MailboxRepository extends JpaRepository { List findByRecipientOrderByTimeDesc(String recipient); + + @Transactional + void deleteByRecipient(String recipient); } diff --git a/src/main/java/org/owasp/webgoat/webwolf/requests/Requests.java b/src/main/java/org/owasp/webgoat/webwolf/requests/Requests.java index 3ae7d035d..ef64b57c1 100644 --- a/src/main/java/org/owasp/webgoat/webwolf/requests/Requests.java +++ b/src/main/java/org/owasp/webgoat/webwolf/requests/Requests.java @@ -60,7 +60,7 @@ private boolean allowedTrace(HttpExchange t, String username) { HttpExchange.Request req = t.getRequest(); boolean allowed = true; /* do not show certain traces to other users in a classroom setup */ - if (req.getUri().getPath().contains("/files") && !req.getUri().getPath().contains(username)) { + if (req.getUri().getPath().contains("/files") && !isUserFileRequest(req, username)) { allowed = false; } else if (req.getUri().getPath().contains("/landing") && req.getUri().getQuery() != null @@ -72,6 +72,16 @@ private boolean allowedTrace(HttpExchange t, String username) { return allowed; } + private boolean isUserFileRequest(HttpExchange.Request request, String username) { + String[] pathSegments = request.getUri().getPath().split("/"); + for (int index = 0; index < pathSegments.length - 1; index++) { + if ("files".equals(pathSegments[index])) { + return username.equals(pathSegments[index + 1]); + } + } + return false; + } + private String path(HttpExchange t) { return t.getRequest().getUri().getPath(); } diff --git a/src/main/java/org/owasp/webgoat/webwolf/user/UserService.java b/src/main/java/org/owasp/webgoat/webwolf/user/UserService.java index 0ff96226f..2cb2f6be2 100644 --- a/src/main/java/org/owasp/webgoat/webwolf/user/UserService.java +++ b/src/main/java/org/owasp/webgoat/webwolf/user/UserService.java @@ -4,8 +4,11 @@ */ package org.owasp.webgoat.webwolf.user; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; /** @@ -16,9 +19,17 @@ public class UserService implements UserDetailsService { private UserRepository userRepository; + private PasswordEncoder passwordEncoder; - public UserService(UserRepository userRepository) { + @Autowired + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository = userRepository; + this.passwordEncoder = + passwordEncoder == null ? new BCryptPasswordEncoder() : passwordEncoder; + } + + public UserService(UserRepository userRepository) { + this(userRepository, new BCryptPasswordEncoder()); } @Override @@ -32,6 +43,6 @@ public WebWolfUser loadUserByUsername(final String username) throws UsernameNotF } public void addUser(final String username, final String password) { - userRepository.save(new WebWolfUser(username, password)); + userRepository.save(new WebWolfUser(username, passwordEncoder.encode(password))); } } diff --git a/src/main/resources/application-webgoat.properties b/src/main/resources/application-webgoat.properties index a2283b488..98849b2c5 100644 --- a/src/main/resources/application-webgoat.properties +++ b/src/main/resources/application-webgoat.properties @@ -1,4 +1,4 @@ -server.error.include-stacktrace=always +server.error.include-stacktrace=never server.error.path=/error.html server.servlet.context-path=${WEBGOAT_CONTEXT:/WebGoat} server.servlet.session.persistent=false @@ -68,8 +68,8 @@ exclude.categories=${EXCLUDE_CATEGORIES:none,none} exclude.lessons=${EXCLUDE_LESSONS:none,none} management.health.db.enabled=true -management.endpoint.health.show-details=always -management.endpoints.web.exposure.include=env, health,configprops +management.endpoint.health.show-details=never +management.endpoints.web.exposure.include=health spring.security.oauth2.client.registration.github.client-id=${WEBGOAT_OAUTH_CLIENTID:dummy} spring.security.oauth2.client.registration.github.client-secret=${WEBGOAT_OAUTH_CLIENTSECRET:dummy} diff --git a/src/main/resources/application-webwolf.properties b/src/main/resources/application-webwolf.properties index a66f8dd2e..bb1496cc3 100644 --- a/src/main/resources/application-webwolf.properties +++ b/src/main/resources/application-webwolf.properties @@ -1,4 +1,4 @@ -server.error.include-stacktrace=always +server.error.include-stacktrace=never server.error.path=/error.html server.servlet.context-path=${webwolf.context} server.port=${webwolf.port} diff --git a/src/main/resources/lessons/cryptography/documentation/signing.adoc b/src/main/resources/lessons/cryptography/documentation/signing.adoc index 310d77dc3..6f58499ca 100644 --- a/src/main/resources/lessons/cryptography/documentation/signing.adoc +++ b/src/main/resources/lessons/cryptography/documentation/signing.adoc @@ -35,4 +35,4 @@ Governments usually send official documents with a PDF that contains a certifica == Assignment -Here is a simple assignment. A private RSA key is sent to you. Determine the modulus of the RSA key as a hex string, and calculate a signature for that hex string using the key. The exercise requires some experience with OpenSSL. You can search on the Internet for useful commands and/or use the HINTS button to get some tips. +Here is a simple assignment. You are given the public half of an RSA key. Determine the modulus of that key as a hex string and produce a signature over that hex string with the matching private key. The private half stays on the server, so without it the signature cannot be produced at all. The exercise requires some experience with OpenSSL. You can search on the Internet for useful commands and/or use the HINTS button to get some tips. diff --git a/src/main/resources/lessons/cryptography/html/Cryptography.html b/src/main/resources/lessons/cryptography/html/Cryptography.html index d5cd568e4..d2e6382c3 100644 --- a/src/main/resources/lessons/cryptography/html/Cryptography.html +++ b/src/main/resources/lessons/cryptography/html/Cryptography.html @@ -85,7 +85,7 @@

- Now suppose you have the following private key:
+ Now suppose you have the following public key:

Then what was the modulus of the public key diff --git a/src/main/resources/lessons/csrf/html/CSRF.html b/src/main/resources/lessons/csrf/html/CSRF.html index 7ad8c573d..70594d069 100644 --- a/src/main/resources/lessons/csrf/html/CSRF.html +++ b/src/main/resources/lessons/csrf/html/CSRF.html @@ -97,7 +97,7 @@
24 days ago
- +
diff --git a/src/main/resources/lessons/csrf/i18n/WebGoatLabels.properties b/src/main/resources/lessons/csrf/i18n/WebGoatLabels.properties index 1b90e4b21..934ee4a43 100644 --- a/src/main/resources/lessons/csrf/i18n/WebGoatLabels.properties +++ b/src/main/resources/lessons/csrf/i18n/WebGoatLabels.properties @@ -10,6 +10,10 @@ csrf-get.hint4=The trigger can be manual or scripted to happen automatically csrf-same-host=It appears your request is coming from the same host you are submitting to. +csrf-request-rejected=That request did not start inside WebGoat, so it was not carried out. + +csrf-feedback-invalid-content-type=Feedback has to be sent as application/json. + csrf-you-forgot-something=There's something missing from your request it appears, so I can't process it. csrf-review.success=It appears you have submitted correctly from another site. Go reload and see if your post is there. diff --git a/src/main/resources/lessons/csrf/js/csrf-review.js b/src/main/resources/lessons/csrf/js/csrf-review.js index 389107530..a3d092813 100644 --- a/src/main/resources/lessons/csrf/js/csrf-review.js +++ b/src/main/resources/lessons/csrf/js/csrf-review.js @@ -29,6 +29,15 @@ $(document).ready(function () { ''; getChallenges(); + loadReviewToken(); + + // the review form posts the token that belongs to this session, it is no longer a fixed + // value baked into the page which anybody could copy into a form of their own + function loadReviewToken() { + $.get('csrf/review/token', function (response) { + $('#csrfReviewToken').val(response.token); + }); + } function getChallenges() { $("#list").empty(); diff --git a/src/main/resources/lessons/idor/i18n/WebGoatLabels.properties b/src/main/resources/lessons/idor/i18n/WebGoatLabels.properties index 61d97da7d..d3282dbbb 100644 --- a/src/main/resources/lessons/idor/i18n/WebGoatLabels.properties +++ b/src/main/resources/lessons/idor/i18n/WebGoatLabels.properties @@ -44,3 +44,10 @@ idor.view.own.profile.failure2=You need to authenticate as tom first. idor.view.other.profile.failure1=You must authenticate first idor.view.other.profile.failure2=<> + +idor.diff.no.hidden.attributes=The response holds exactly the attributes you see above. The internal id and the role stay on the server, so there is no hidden one to name. +idor.view.profile.denied=Access denied. A profile is visible only to the user it belongs to. +idor.view.profile.own=Here is your own profile. +idor.view.own.profile.direct=The profile below came from your session. An object reference sent along with the request is not used to look anything up. +idor.edit.profile.denied=Access denied. A profile can be changed only by the user it belongs to. +idor.edit.profile.updated=Your own profile was updated. The role is not taken from the request body. diff --git a/src/main/resources/lessons/insecurelogin/documentation/InsecureLogin_Task.adoc b/src/main/resources/lessons/insecurelogin/documentation/InsecureLogin_Task.adoc index 5d3c92a85..64da32262 100755 --- a/src/main/resources/lessons/insecurelogin/documentation/InsecureLogin_Task.adoc +++ b/src/main/resources/lessons/insecurelogin/documentation/InsecureLogin_Task.adoc @@ -1,4 +1,4 @@ === Let's try -Click the "log in" button to send a request containing the login credentials of another user. -Then, write these credentials into the appropriate fields and submit them to confirm. -Try using a packet sniffer to intercept the request. +Click the "log in" button to send a login request. +Now put a packet sniffer on it and try to read that user's credentials out of the request. +You will not find any: the request carries no credentials, the check happens on the server, so there is nothing to capture and type into the fields below. diff --git a/src/main/resources/lessons/insecurelogin/js/credentials.js b/src/main/resources/lessons/insecurelogin/js/credentials.js index d3a936b52..c9e84a21a 100755 --- a/src/main/resources/lessons/insecurelogin/js/credentials.js +++ b/src/main/resources/lessons/insecurelogin/js/credentials.js @@ -1,6 +1,7 @@ function submit_secret_credentials() { var xhttp = new XMLHttpRequest(); xhttp['open']('POST', 'InsecureLogin/login', true); - //sending the request is obfuscated, to descourage js reading - var _0xb7f9=["\x43\x61\x70\x74\x61\x69\x6E\x4A\x61\x63\x6B","\x42\x6C\x61\x63\x6B\x50\x65\x61\x72\x6C","\x73\x74\x72\x69\x6E\x67\x69\x66\x79","\x73\x65\x6E\x64"];xhttp[_0xb7f9[3]](JSON[_0xb7f9[2]]({username:_0xb7f9[0],password:_0xb7f9[1]})) + //the credentials live on the server; nothing is shipped to the page and nothing is put + //on the wire, so a packet capture has nothing to show + xhttp['send']() } diff --git a/src/main/resources/lessons/missingac/html/MissingFunctionAC.html b/src/main/resources/lessons/missingac/html/MissingFunctionAC.html index 73b49b992..1b7e3b208 100644 --- a/src/main/resources/lessons/missingac/html/MissingFunctionAC.html +++ b/src/main/resources/lessons/missingac/html/MissingFunctionAC.html @@ -34,13 +34,8 @@
  • Compose Message
  • - +
    diff --git a/src/main/resources/lessons/passwordreset/i18n/WebGoatLabels.properties b/src/main/resources/lessons/passwordreset/i18n/WebGoatLabels.properties index e7d3b9280..0be014691 100644 --- a/src/main/resources/lessons/passwordreset/i18n/WebGoatLabels.properties +++ b/src/main/resources/lessons/passwordreset/i18n/WebGoatLabels.properties @@ -6,6 +6,7 @@ password-reset-simple.email_failed=There was an error while sending the e-mail. password-reset-simple.email_mismatch=Of course you can send mail to user {0} however you will not be able to read this e-mail in WebWolf, please use your own username. password-questions-wrong-user=You need to find a different user you are logging in with 'webgoat'. +password-questions-not-supported=A security question does not recover an account on its own. A reset is started from the address registered for the account. password-questions-unknown-user=User {0} is not a valid user. password-questions-one-successful=You answered one question successfully please try another one. diff --git a/src/main/resources/webgoat/static/js/goatApp/view/LessonContentView.js b/src/main/resources/webgoat/static/js/goatApp/view/LessonContentView.js index b998b6bdf..d6d3c3913 100644 --- a/src/main/resources/webgoat/static/js/goatApp/view/LessonContentView.js +++ b/src/main/resources/webgoat/static/js/goatApp/view/LessonContentView.js @@ -170,14 +170,14 @@ define(['jquery', renderFeedback: function (feedback) { var s = this.removeSlashesFromJSON(feedback); - this.$curFeedback.html(polyglot.t(s) || ""); + this.$curFeedback.text(polyglot.t(s) || ""); this.$curFeedback.show(400) }, renderOutput: function (output) { var s = this.removeSlashesFromJSON(output); - this.$curOutput.html(polyglot.t(s) || ""); + this.$curOutput.text(polyglot.t(s) || ""); this.$curOutput.show(400) }, @@ -213,7 +213,8 @@ define(['jquery', /* for testing */ showTestParam: function (param) { - this.$el.find('.lesson-content').html('test:' + param); + //this value comes out of the URL fragment, so insert it as text, never as markup + this.$el.find('.lesson-content').text('test:' + param); }, resetLesson: function () { diff --git a/src/main/resources/webgoat/static/js/main.js b/src/main/resources/webgoat/static/js/main.js index 41e8daf9b..3a006b044 100644 --- a/src/main/resources/webgoat/static/js/main.js +++ b/src/main/resources/webgoat/static/js/main.js @@ -69,5 +69,17 @@ require([ 'backbone', 'bootstrap', 'goatApp/goatApp'], function($,jqueryVuln,jqueryui,_,Backbone,Bootstrap,Goat){ + // Every state changing call now has to carry the CSRF token the server handed out as a + // cookie. Safe methods do not need one, and a call to another origin must never see it. + $.ajaxPrefilter(function (options, originalOptions, xhr) { + var method = (options.type || options.method || 'GET').toUpperCase(); + if (options.crossDomain || /^(GET|HEAD|OPTIONS|TRACE)$/.test(method)) { + return; + } + var cookie = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/); + if (cookie) { + xhr.setRequestHeader('X-XSRF-TOKEN', decodeURIComponent(cookie[1])); + } + }); Goat.initApp(); }); diff --git a/src/main/resources/webgoat/templates/main_new.html b/src/main/resources/webgoat/templates/main_new.html index a9d250fc6..0ca5c3f0d 100644 --- a/src/main/resources/webgoat/templates/main_new.html +++ b/src/main/resources/webgoat/templates/main_new.html @@ -51,8 +51,12 @@
    @@ -28,9 +29,12 @@ - diff --git a/src/main/resources/webwolf/templates/mailbox.html b/src/main/resources/webwolf/templates/mailbox.html index 6a0201494..661b49029 100644 --- a/src/main/resources/webwolf/templates/mailbox.html +++ b/src/main/resources/webwolf/templates/mailbox.html @@ -121,7 +121,7 @@
    -
    +                                            
                                              
    diff --git a/src/main/resources/webwolf/templates/requests.html b/src/main/resources/webwolf/templates/requests.html index 9ec08cc3a..e8e461efe 100644 --- a/src/main/resources/webwolf/templates/requests.html +++ b/src/main/resources/webwolf/templates/requests.html @@ -36,7 +36,7 @@

    Requests

    @@ -44,7 +44,7 @@

    Requests

    th:aria-labelledby="'heading' + ${iter.index}">
    -
    +                        
                         
    From 9585afe89799d3672f8600463588cad572320f7e Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:37:11 -0400 Subject: [PATCH 02/32] build: lombok 1.18.46 so the module compiles on the JDK 25 CI image 1.18.36 fails with ExceptionInInitializerError: com.sun.tools.javac.code.TypeTag :: UNKNOWN under eclipse-temurin:25-jdk. Same bump as #143. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index b5ad9015d..3259ca38a 100644 --- a/pom.xml +++ b/pom.xml @@ -238,7 +238,7 @@ org.projectlombok lombok - 1.18.36 + 1.18.46 provided true From 6e92cef8e7729b03027a03c96b6a4931909c505f Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:40:24 -0400 Subject: [PATCH 03/32] fix: call User.isAdmin() in the admin role lookup The helper in MissingFunctionACUsers is named hasAdminRole; the entity method it delegates to is still isAdmin(). --- .../owasp/webgoat/lessons/missingac/MissingFunctionACUsers.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACUsers.java b/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACUsers.java index 26e3c4924..ad3477958 100644 --- a/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACUsers.java +++ b/src/main/java/org/owasp/webgoat/lessons/missingac/MissingFunctionACUsers.java @@ -105,6 +105,6 @@ private boolean hasAdminRole(String username) { return false; } var currentUser = userRepository.findByUsername(username); - return currentUser != null && currentUser.hasAdminRole(); + return currentUser != null && currentUser.isAdmin(); } } From 63c6a8e7fcc7e2b26a27cb4c66de022ae34c55c1 Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:00:03 -0400 Subject: [PATCH 04/32] fix: runtime compilation, schema injection, unfiltered readObject and a few disclosures - SqlInjectionLesson10b handed the submitted text to the JDK compiler at runtime - LessonConnectionInvocationHandler built SET SCHEMA from the account name - SerializationHelper.fromString read any class off the wire - /server-directory published the server's absolute path - SpoofCookie shipped the target account's password in the source, cookie now HttpOnly - DisplayUser published a plain digest of the password, now keyed Also folds in the fixes that previously only existed on the branches of #112 and #143 so this branch stands on its own. --- .../LessonConnectionInvocationHandler.java | 12 +++- .../container/service/EnvironmentService.java | 10 ++- .../InsecureDeserializationTask.java | 47 ++++++++++++- .../deserialization/SerializationHelper.java | 32 +++++++-- .../lessons/missingac/DisplayUser.java | 23 +++++-- .../pathtraversal/ProfileUploadBase.java | 45 ++++++------ .../spoofcookie/SpoofCookieAssignment.java | 17 ++++- .../advanced/SqlInjectionChallenge.java | 9 +-- .../advanced/SqlInjectionLesson6a.java | 21 +++--- .../introduction/SqlInjectionLesson10.java | 13 ++-- .../introduction/SqlInjectionLesson5a.java | 14 ++-- .../introduction/SqlInjectionLesson5b.java | 38 +++++------ .../introduction/SqlInjectionLesson8.java | 29 ++++---- .../mitigation/SqlInjectionLesson10b.java | 68 ++----------------- .../owasp/webgoat/lessons/ssrf/SSRFTask2.java | 29 +++----- .../lessons/xxe/BlindSendFileAssignment.java | 2 +- .../webgoat/lessons/xxe/CommentsCache.java | 16 ++--- .../lessons/xxe/ContentTypeAssignment.java | 2 +- .../owasp/webgoat/lessons/xxe/SimpleXXE.java | 2 +- .../org/owasp/webgoat/webwolf/FileServer.java | 22 +++++- 20 files changed, 254 insertions(+), 197 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/container/lessons/LessonConnectionInvocationHandler.java b/src/main/java/org/owasp/webgoat/container/lessons/LessonConnectionInvocationHandler.java index 0684923fc..36dff9afc 100644 --- a/src/main/java/org/owasp/webgoat/container/lessons/LessonConnectionInvocationHandler.java +++ b/src/main/java/org/owasp/webgoat/container/lessons/LessonConnectionInvocationHandler.java @@ -28,7 +28,7 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl var authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.getPrincipal() instanceof WebGoatUser user) { try (var statement = targetConnection.createStatement()) { - statement.execute("SET SCHEMA \"" + user.getUsername() + "\""); + statement.execute("SET SCHEMA " + quotedSchema(user.getUsername())); } } try { @@ -37,4 +37,14 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl throw e.getTargetException(); } } + + /** + * A schema name cannot be bound as a parameter, so it is quoted here instead. The name comes from + * the account, and an account name is chosen by whoever registers it: one containing a double + * quote used to end the identifier early and let the rest of the name run on as SQL of its own. + * Doubling the quotes keeps the whole name inside the identifier, whatever it contains. + */ + private String quotedSchema(String username) { + return "\"" + username.replace("\"", "\"\"") + "\""; + } } diff --git a/src/main/java/org/owasp/webgoat/container/service/EnvironmentService.java b/src/main/java/org/owasp/webgoat/container/service/EnvironmentService.java index ea9ff9de7..d00b329d0 100644 --- a/src/main/java/org/owasp/webgoat/container/service/EnvironmentService.java +++ b/src/main/java/org/owasp/webgoat/container/service/EnvironmentService.java @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationContext; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -15,8 +16,13 @@ public class EnvironmentService { private final ApplicationContext context; + /** + * Where the application keeps its files on disk is not something a client needs to know. Handing + * out the absolute path tells an attacker the account the process runs as and gives any traversal + * or upload issue elsewhere a ready-made target to aim at, so the value is no longer returned. + */ @GetMapping("/server-directory") - public String homeDirectory() { - return context.getEnvironment().getProperty("webgoat.server.directory"); + public ResponseEntity homeDirectory() { + return ResponseEntity.notFound().build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/deserialization/InsecureDeserializationTask.java b/src/main/java/org/owasp/webgoat/lessons/deserialization/InsecureDeserializationTask.java index 0caefc726..e60223e23 100644 --- a/src/main/java/org/owasp/webgoat/lessons/deserialization/InsecureDeserializationTask.java +++ b/src/main/java/org/owasp/webgoat/lessons/deserialization/InsecureDeserializationTask.java @@ -10,7 +10,9 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InvalidClassException; +import java.io.ObjectInputFilter; import java.io.ObjectInputStream; +import java.io.ObjectStreamClass; import java.util.Base64; import org.dummy.insecure.framework.VulnerableTaskHolder; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; @@ -29,6 +31,25 @@ }) public class InsecureDeserializationTask implements AssignmentEndpoint { + /* + * A hostile stream never gets to name a class. Only primitives and strings are let through, + * anything else is refused before an instance exists, so no gadget's readObject() ever runs. + */ + private static final ObjectInputFilter DATA_ONLY_FILTER = + info -> { + Class clazz = info.serialClass(); + if (clazz == null) { + return ObjectInputFilter.Status.UNDECIDED; + } + while (clazz.isArray()) { + clazz = clazz.getComponentType(); + } + if (clazz.isPrimitive() || String.class.equals(clazz)) { + return ObjectInputFilter.Status.ALLOWED; + } + return ObjectInputFilter.Status.REJECTED; + }; + @PostMapping("/InsecureDeserialization/task") @ResponseBody public AttackResult completed(@RequestParam String token) throws IOException { @@ -39,8 +60,7 @@ public AttackResult completed(@RequestParam String token) throws IOException { b64token = token.replace('-', '+').replace('_', '/'); - try (ObjectInputStream ois = - new ObjectInputStream(new ByteArrayInputStream(Base64.getDecoder().decode(b64token)))) { + try (ObjectInputStream ois = new GuardedObjectInputStream(b64token)) { before = System.currentTimeMillis(); Object o = ois.readObject(); if (!(o instanceof VulnerableTaskHolder)) { @@ -67,4 +87,27 @@ public AttackResult completed(@RequestParam String token) throws IOException { } return success(this).build(); } + + /* + * Belt and braces: the stream itself declines to resolve any class or proxy, so the guard does + * not rest on the filter alone. A plain string carries no class descriptor and still reads back, + * which keeps this assignment's feedback working. + */ + private static final class GuardedObjectInputStream extends ObjectInputStream { + + private GuardedObjectInputStream(String b64token) throws IOException { + super(new ByteArrayInputStream(Base64.getDecoder().decode(b64token))); + setObjectInputFilter(DATA_ONLY_FILTER); + } + + @Override + protected Class resolveClass(ObjectStreamClass desc) throws InvalidClassException { + throw new InvalidClassException(desc.getName(), "class is not accepted"); + } + + @Override + protected Class resolveProxyClass(String[] interfaces) throws InvalidClassException { + throw new InvalidClassException("proxies are not accepted"); + } + } } diff --git a/src/main/java/org/owasp/webgoat/lessons/deserialization/SerializationHelper.java b/src/main/java/org/owasp/webgoat/lessons/deserialization/SerializationHelper.java index dc53b9861..0aa7c583e 100644 --- a/src/main/java/org/owasp/webgoat/lessons/deserialization/SerializationHelper.java +++ b/src/main/java/org/owasp/webgoat/lessons/deserialization/SerializationHelper.java @@ -8,21 +8,45 @@ import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.io.ObjectInputFilter; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; import java.util.Base64; +import org.dummy.insecure.framework.VulnerableTaskHolder; public class SerializationHelper { private static final char[] hexArray = "0123456789ABCDEF".toCharArray(); + /* + * Reading an object back is where a hostile stream gets to name the classes it wants built, and + * a gadget's readObject() runs while the graph is still being rebuilt - before any caller can + * look at what it received. The filter below decides on the type first: only the task holder, + * strings and primitives are allowed through, everything else is refused. + */ + private static final ObjectInputFilter EXPECTED_TYPES_ONLY = + info -> { + Class type = info.serialClass(); + if (type == null) { + return ObjectInputFilter.Status.UNDECIDED; + } + while (type.isArray()) { + type = type.getComponentType(); + } + boolean allowed = + type.isPrimitive() + || String.class.equals(type) + || VulnerableTaskHolder.class.equals(type); + return allowed ? ObjectInputFilter.Status.ALLOWED : ObjectInputFilter.Status.REJECTED; + }; + public static Object fromString(String s) throws IOException, ClassNotFoundException { byte[] data = Base64.getDecoder().decode(s); - ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data)); - Object o = ois.readObject(); - ois.close(); - return o; + try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data))) { + ois.setObjectInputFilter(EXPECTED_TYPES_ONLY); + return ois.readObject(); + } } public static String toString(Serializable o) throws IOException { diff --git a/src/main/java/org/owasp/webgoat/lessons/missingac/DisplayUser.java b/src/main/java/org/owasp/webgoat/lessons/missingac/DisplayUser.java index 1d4822591..faf1f7c99 100644 --- a/src/main/java/org/owasp/webgoat/lessons/missingac/DisplayUser.java +++ b/src/main/java/org/owasp/webgoat/lessons/missingac/DisplayUser.java @@ -5,8 +5,10 @@ package org.owasp.webgoat.lessons.missingac; import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; +import java.security.SecureRandom; import java.util.Base64; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import lombok.Getter; @Getter @@ -28,13 +30,24 @@ public DisplayUser(User user, String passwordSalt) { } } + /* + * This value is handed to administrators over an API, and a plain digest of a password is worth + * as much as the password to anyone willing to run a word list through the same function. Keying + * the digest with a secret the server holds means the published value cannot be reproduced off + * line, so it no longer works as a starting point for recovering the password. + */ + private static final byte[] HASH_KEY = new byte[32]; + + static { + new SecureRandom().nextBytes(HASH_KEY); + } + protected String genUserHash(String username, String password, String passwordSalt) throws Exception { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - // salting is good, but static & too predictable ... short too for a salt + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(HASH_KEY, "HmacSHA256")); String salted = password + passwordSalt + username; - // md.update(salted.getBytes("UTF-8")); // Change this to "UTF-16" if needed - byte[] hash = md.digest(salted.getBytes(StandardCharsets.UTF_8)); + byte[] hash = mac.doFinal(salted.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(hash); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileUploadBase.java b/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileUploadBase.java index 7ee77d9a9..50609d7fd 100644 --- a/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileUploadBase.java +++ b/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileUploadBase.java @@ -6,7 +6,6 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.informationMessage; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import java.io.File; import java.io.FileInputStream; @@ -48,13 +47,16 @@ protected AttackResult execute(MultipartFile file, String fullName, String usern File uploadDirectory = cleanupAndCreateDirectoryForUser(username); try { - var uploadedFile = new File(uploadDirectory, fullName); + var uploadedFile = uploadTargetFor(uploadDirectory, fullName); + if (uploadedFile == null) { + return failed(this) + .feedback("path-traversal-profile-attempt") + .feedbackArgs(uploadDirectory.getCanonicalPath()) + .build(); + } uploadedFile.createNewFile(); FileCopyUtils.copy(file.getBytes(), uploadedFile); - if (attemptWasMade(uploadDirectory, uploadedFile)) { - return solvedIt(uploadedFile); - } return informationMessage(this) .feedback("path-traversal-profile-updated") .feedbackArgs(uploadedFile.getAbsoluteFile()) @@ -65,6 +67,21 @@ protected AttackResult execute(MultipartFile file, String fullName, String usern } } + // Directory information in the client supplied name is thrown away and the resolved location + // has to sit directly in the user's own upload directory. Null when it cannot be stored there. + private File uploadTargetFor(File uploadDirectory, String fullName) throws IOException { + var fileName = FilenameUtils.getName(fullName); + if (StringUtils.isEmpty(fileName)) { + return null; + } + var uploadRoot = uploadDirectory.getCanonicalFile(); + var target = new File(uploadRoot, fileName).getCanonicalFile(); + if (!uploadRoot.equals(target.getParentFile())) { + return null; + } + return target; + } + @SneakyThrows protected File cleanupAndCreateDirectoryForUser(String username) { var uploadDirectory = new File(this.webGoatHomeDirectory, "/PathTraversal/" + username); @@ -75,24 +92,6 @@ protected File cleanupAndCreateDirectoryForUser(String username) { return uploadDirectory; } - private boolean attemptWasMade(File expectedUploadDirectory, File uploadedFile) - throws IOException { - return !expectedUploadDirectory - .getCanonicalPath() - .equals(uploadedFile.getParentFile().getCanonicalPath()); - } - - private AttackResult solvedIt(File uploadedFile) throws IOException { - if (uploadedFile.getCanonicalFile().getParentFile().getName().endsWith("PathTraversal")) { - return success(this).build(); - } - return failed(this) - .attemptWasMade() - .feedback("path-traversal-profile-attempt") - .feedbackArgs(uploadedFile.getCanonicalPath()) - .build(); - } - public ResponseEntity getProfilePicture(@CurrentUsername String username) { return ResponseEntity.ok() .contentType(MediaType.parseMediaType(MediaType.IMAGE_JPEG_VALUE)) diff --git a/src/main/java/org/owasp/webgoat/lessons/spoofcookie/SpoofCookieAssignment.java b/src/main/java/org/owasp/webgoat/lessons/spoofcookie/SpoofCookieAssignment.java index 107584a61..c652b939c 100644 --- a/src/main/java/org/owasp/webgoat/lessons/spoofcookie/SpoofCookieAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/spoofcookie/SpoofCookieAssignment.java @@ -10,6 +10,8 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; +import java.security.SecureRandom; +import java.util.Base64; import java.util.Map; import org.apache.commons.lang3.StringUtils; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; @@ -40,8 +42,13 @@ public class SpoofCookieAssignment implements AssignmentEndpoint { "Cookie details for user %s:
    " + COOKIE_NAME + "=%s"; private static final String ATTACK_USERNAME = "tom"; + /* + * The target account's password used to sit here in the source, which means it was published + * with every copy of the application. It is drawn at random on startup instead: the point of + * this lesson is the cookie, and nobody should be able to read tom's password out of the jar. + */ private static final Map users = - Map.of("webgoat", "webgoat", "admin", "admin", ATTACK_USERNAME, "apasswordfortom"); + Map.of("webgoat", "webgoat", "admin", "admin", ATTACK_USERNAME, randomPassword()); @PostMapping(path = "/SpoofCookie/login") @ResponseBody @@ -59,6 +66,12 @@ public AttackResult login( } } + private static String randomPassword() { + byte[] password = new byte[24]; + new SecureRandom().nextBytes(password); + return Base64.getUrlEncoder().withoutPadding().encodeToString(password); + } + @GetMapping(path = "/SpoofCookie/cleanup") public void cleanup(HttpServletResponse response) { Cookie cookie = new Cookie(COOKIE_NAME, ""); @@ -80,6 +93,8 @@ private AttackResult credentialsLoginFlow( Cookie newCookie = new Cookie(COOKIE_NAME, newCookieValue); newCookie.setPath("/WebGoat"); newCookie.setSecure(true); + // script in the page has no business reading an authentication cookie + newCookie.setHttpOnly(true); response.addCookie(newCookie); return informationMessage(this) .feedback("spoofcookie.login") diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallenge.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallenge.java index f27c3cdb2..1c9a744be 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallenge.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallenge.java @@ -51,10 +51,11 @@ public AttackResult registerNewUser( if (attackResult == null) { try (Connection connection = dataSource.getConnection()) { - String checkUserQuery = - "select userid from sql_challenge_users where userid = '" + username + "'"; - Statement statement = connection.createStatement(); - ResultSet resultSet = statement.executeQuery(checkUserQuery); + // the user name is bound, it never becomes part of the statement text + String checkUserQuery = "select userid from sql_challenge_users where userid = ?"; + PreparedStatement checkUserStatement = connection.prepareStatement(checkUserQuery); + checkUserStatement.setString(1, username); + ResultSet resultSet = checkUserStatement.executeQuery(); if (resultSet.next()) { attackResult = failed(this).feedback("user.exists").feedbackArgs(username).build(); diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionLesson6a.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionLesson6a.java index a42e27eb3..f550e2339 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionLesson6a.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionLesson6a.java @@ -8,10 +8,10 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import java.sql.Connection; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; -import java.sql.Statement; import org.owasp.webgoat.container.LessonDataSource; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -43,17 +43,15 @@ public SqlInjectionLesson6a(LessonDataSource dataSource) { @ResponseBody public AttackResult completed(@RequestParam(value = "userid_6a") String userId) { return injectableQuery(userId); - // The answer: Smith' union select userid,user_name, password,cookie,cookie, cookie,userid from - // user_system_data -- } public AttackResult injectableQuery(String accountName) { - String query = ""; + // The account name is bound, so the database never parses it as SQL. + String query = "SELECT * FROM user_data WHERE last_name = ?"; try (Connection connection = dataSource.getConnection()) { boolean usedUnion = this.unionQueryChecker(accountName); - query = "SELECT * FROM user_data WHERE last_name = '" + accountName + "'"; - return executeSqlInjection(connection, query, usedUnion); + return executeSqlInjection(connection, query, accountName, usedUnion); } catch (Exception e) { return failed(this) .output(this.getClass().getName() + " : " + e.getMessage() + YOUR_QUERY_WAS + query) @@ -65,11 +63,14 @@ private boolean unionQueryChecker(String accountName) { return accountName.matches("(?i)(^[^-/*;)]*)(\\s*)UNION(.*$)"); } - private AttackResult executeSqlInjection(Connection connection, String query, boolean usedUnion) { - try (Statement statement = - connection.createStatement(ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY)) { + private AttackResult executeSqlInjection( + Connection connection, String query, String accountName, boolean usedUnion) { + try (PreparedStatement statement = + connection.prepareStatement( + query, ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY)) { + statement.setString(1, accountName); - ResultSet results = statement.executeQuery(query); + ResultSet results = statement.executeQuery(); if (!((results != null) && results.first())) { return failed(this) diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson10.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson10.java index 209e11f53..e5af3c9c8 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson10.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson10.java @@ -8,6 +8,7 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import java.sql.Connection; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; @@ -46,14 +47,16 @@ public AttackResult completed(@RequestParam String action_string) { protected AttackResult injectableQueryAvailability(String action) { StringBuilder output = new StringBuilder(); - String query = "SELECT * FROM access_log WHERE action LIKE '%" + action + "%'"; + // The search term is bound; the wildcards belong to the value, not to the statement. + String query = "SELECT * FROM access_log WHERE action LIKE ?"; try (Connection connection = dataSource.getConnection()) { try { - Statement statement = - connection.createStatement( - ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY); - ResultSet results = statement.executeQuery(query); + PreparedStatement statement = + connection.prepareStatement( + query, ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY); + statement.setString(1, "%" + action + "%"); + ResultSet results = statement.executeQuery(); if (results.getStatement() != null) { results.first(); diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson5a.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson5a.java index d853f85ec..2817ad72b 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson5a.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson5a.java @@ -42,14 +42,14 @@ public AttackResult completed( } protected AttackResult injectableQuery(String accountName) { - String query = ""; + // The account name is bound, so it never becomes part of the statement text. + String query = "SELECT * FROM user_data WHERE first_name = 'John' and last_name = ?"; try (Connection connection = dataSource.getConnection()) { - query = - "SELECT * FROM user_data WHERE first_name = 'John' and last_name = '" + accountName + "'"; - try (Statement statement = - connection.createStatement( - ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_UPDATABLE)) { - ResultSet results = statement.executeQuery(query); + try (PreparedStatement statement = + connection.prepareStatement( + query, ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY)) { + statement.setString(1, accountName); + ResultSet results = statement.executeQuery(); if ((results != null) && (results.first())) { ResultSetMetaData resultsMetaData = results.getMetaData(); diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson5b.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson5b.java index 8add290de..2822393fa 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson5b.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson5b.java @@ -42,7 +42,8 @@ public AttackResult completed(@RequestParam String userid, @RequestParam String } protected AttackResult injectableQuery(String login_count, String accountName) { - String queryString = "SELECT * From user_data WHERE Login_Count = ? and userid= " + accountName; + // Both values are bound, and the user id has to be a number before it is used at all. + String queryString = "SELECT * From user_data WHERE Login_Count = ? and userid = ?"; try (Connection connection = dataSource.getConnection()) { PreparedStatement query = connection.prepareStatement( @@ -50,21 +51,24 @@ protected AttackResult injectableQuery(String login_count, String accountName) { int count = 0; try { - count = Integer.parseInt(login_count); + count = Integer.parseInt(login_count.trim()); } catch (Exception e) { return failed(this) - .output( - "Could not parse: " - + login_count - + " to a number" - + "
    Your query was: " - + queryString.replace("?", login_count)) + .output("Could not parse the login count
    Your query was: " + queryString) + .build(); + } + + int userId = 0; + try { + userId = Integer.parseInt(accountName.trim()); + } catch (Exception e) { + return failed(this) + .output("Could not parse the user id
    Your query was: " + queryString) .build(); } query.setInt(1, count); - // String query = "SELECT * FROM user_data WHERE Login_Count = " + login_count + " and userid - // = " + accountName, ; + query.setInt(2, userId); try { ResultSet results = query.executeQuery(); @@ -79,29 +83,25 @@ protected AttackResult injectableQuery(String login_count, String accountName) { if (results.getRow() >= 6) { return success(this) .feedback("sql-injection.5b.success") - .output("Your query was: " + queryString.replace("?", login_count)) + .output("Your query was: " + queryString) .feedbackArgs(output.toString()) .build(); } else { return failed(this) - .output( - output.toString() - + "
    Your query was: " - + queryString.replace("?", login_count)) + .output(output.toString() + "
    Your query was: " + queryString) .build(); } } else { return failed(this) .feedback("sql-injection.5b.no.results") - .output("Your query was: " + queryString.replace("?", login_count)) + .output("Your query was: " + queryString) .build(); } } catch (SQLException sqle) { return failed(this) - .output( - sqle.getMessage() + "
    Your query was: " + queryString.replace("?", login_count)) + .output(sqle.getMessage() + "
    Your query was: " + queryString) .build(); } } catch (Exception e) { @@ -111,7 +111,7 @@ protected AttackResult injectableQuery(String login_count, String accountName) { + " : " + e.getMessage() + "
    Your query was: " - + queryString.replace("?", login_count)) + + queryString) .build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson8.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson8.java index 810dc3e7e..e3da114b3 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson8.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson8.java @@ -4,7 +4,6 @@ */ package org.owasp.webgoat.lessons.sqlinjection.introduction; -import static java.sql.ResultSet.CONCUR_UPDATABLE; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; @@ -45,20 +44,18 @@ public AttackResult completed(@RequestParam String name, @RequestParam String au protected AttackResult injectableQueryConfidentiality(String name, String auth_tan) { StringBuilder output = new StringBuilder(); - String query = - "SELECT * FROM employees WHERE last_name = '" - + name - + "' AND auth_tan = '" - + auth_tan - + "'"; + // Both values travel as bind parameters, neither is part of the statement text. + String query = "SELECT * FROM employees WHERE last_name = ? AND auth_tan = ?"; try (Connection connection = dataSource.getConnection()) { try { - Statement statement = - connection.createStatement( - ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_UPDATABLE); + PreparedStatement statement = + connection.prepareStatement( + query, ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY); + statement.setString(1, name); + statement.setString(2, auth_tan); log(connection, query); - ResultSet results = statement.executeQuery(query); + ResultSet results = statement.executeQuery(); if (results.getStatement() != null) { if (results.first()) { @@ -133,14 +130,12 @@ public static void log(Connection connection, String action) { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); String time = sdf.format(cal.getTime()); - // The audit trail itself was built by concatenation, so anything the caller managed to get - // logged was executed a second time here. Both values are bound instead. String logQuery = "INSERT INTO access_log (time, action) VALUES (?, ?)"; - try (PreparedStatement logStatement = connection.prepareStatement(logQuery)) { - logStatement.setString(1, time); - logStatement.setString(2, action); - logStatement.executeUpdate(); + try (PreparedStatement statement = connection.prepareStatement(logQuery)) { + statement.setString(1, time); + statement.setString(2, action); + statement.executeUpdate(); } catch (SQLException e) { System.err.println(e.getMessage()); } diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlInjectionLesson10b.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlInjectionLesson10b.java index caa0b3c2d..6285a25e7 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlInjectionLesson10b.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlInjectionLesson10b.java @@ -7,19 +7,8 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; -import java.io.IOException; -import java.net.URI; -import java.util.Arrays; -import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; -import javax.tools.Diagnostic; -import javax.tools.DiagnosticCollector; -import javax.tools.JavaCompiler; -import javax.tools.JavaFileObject; -import javax.tools.SimpleJavaFileObject; -import javax.tools.StandardJavaFileManager; -import javax.tools.ToolProvider; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; @@ -69,16 +58,12 @@ public AttackResult completed(@RequestParam String editor) { && usesPlaceholder && usesSetString && (usesExecute || usesExecuteUpdate)); - List hasCompiled = this.compileFromString(editor); - - if (hasImportant && hasCompiled.size() < 1) { + // The submission used to be wrapped in a class and handed to the JDK compiler at runtime. + // Compiling source that arrived with a request lets a caller decide what the server turns + // into bytecode, writes .class files of their choosing to disk and, with an annotation + // processor on the classpath, runs their code outright. The answer is inspected as text. + if (hasImportant) { return success(this).feedback("sql-injection.10b.success").build(); - } else if (hasCompiled.size() > 0) { - String errors = ""; - for (Diagnostic d : hasCompiled) { - errors += d.getMessage(null) + "
    "; - } - return failed(this).feedback("sql-injection.10b.compiler-errors").output(errors).build(); } else { return failed(this).feedback("sql-injection.10b.failed").build(); } @@ -87,49 +72,6 @@ public AttackResult completed(@RequestParam String editor) { } } - private List compileFromString(String s) { - JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); - DiagnosticCollector diagnosticsCollector = new DiagnosticCollector(); - StandardJavaFileManager fileManager = - compiler.getStandardFileManager(diagnosticsCollector, null, null); - JavaFileObject javaObjectFromString = getJavaFileContentsAsString(s); - Iterable fileObjects = Arrays.asList(javaObjectFromString); - JavaCompiler.CompilationTask task = - compiler.getTask(null, fileManager, diagnosticsCollector, null, null, fileObjects); - Boolean result = task.call(); - List diagnostics = diagnosticsCollector.getDiagnostics(); - return diagnostics; - } - - private SimpleJavaFileObject getJavaFileContentsAsString(String s) { - StringBuilder javaFileContents = - new StringBuilder( - "import java.sql.*; public class TestClass { static String DBUSER; static String DBPW;" - + " static String DBURL; public static void main(String[] args) {" - + s - + "}}"); - JavaObjectFromString javaFileObject = null; - try { - javaFileObject = new JavaObjectFromString("TestClass.java", javaFileContents.toString()); - } catch (Exception exception) { - exception.printStackTrace(); - } - return javaFileObject; - } - - class JavaObjectFromString extends SimpleJavaFileObject { - private String contents = null; - - public JavaObjectFromString(String className, String contents) throws Exception { - super(new URI(className), Kind.SOURCE); - this.contents = contents; - } - - public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException { - return contents; - } - } - private boolean check_text(String regex, String text) { Pattern p = Pattern.compile(regex, Pattern.CASE_INSENSITIVE); Matcher m = p.matcher(text); diff --git a/src/main/java/org/owasp/webgoat/lessons/ssrf/SSRFTask2.java b/src/main/java/org/owasp/webgoat/lessons/ssrf/SSRFTask2.java index 9f7a09c0a..4783e1bad 100644 --- a/src/main/java/org/owasp/webgoat/lessons/ssrf/SSRFTask2.java +++ b/src/main/java/org/owasp/webgoat/lessons/ssrf/SSRFTask2.java @@ -5,13 +5,8 @@ package org.owasp.webgoat.lessons.ssrf; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; -import java.io.IOException; -import java.io.InputStream; -import java.net.MalformedURLException; -import java.net.URL; -import java.nio.charset.StandardCharsets; +import java.util.List; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; @@ -24,6 +19,9 @@ @AssignmentHints({"ssrf.hint3"}) public class SSRFTask2 implements AssignmentEndpoint { + /** The only resources this page is allowed to render, everything else is rejected. */ + private static final List ALLOWED_IMAGES = List.of("images/cat.png", "images/cat.jpg"); + @PostMapping("/SSRF/task2") @ResponseBody public AttackResult completed(@RequestParam String url) { @@ -31,21 +29,10 @@ public AttackResult completed(@RequestParam String url) { } protected AttackResult furBall(String url) { - if (url.matches("http://ifconfig\\.pro")) { - String html; - try (InputStream in = new URL(url).openStream()) { - html = - new String(in.readAllBytes(), StandardCharsets.UTF_8) - .replaceAll("\n", "
    "); // Otherwise the \n gets escaped in the response - } catch (MalformedURLException e) { - return getFailedResult(e.getMessage()); - } catch (IOException e) { - // in case the external site is down, the test and lesson should still be ok - html = - "Although the http://ifconfig.pro site is down, you still managed to solve" - + " this exercise the right way!"; - } - return success(this).feedback("ssrf.success").output(html).build(); + // No outbound connection is ever opened for a value that came in with the request; the page + // only renders its own images, checked against a list held on this side. + if (!ALLOWED_IMAGES.contains(url)) { + return getFailedResult("Only the images on this page can be requested"); } var html = "\"image"; return getFailedResult(html); diff --git a/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java b/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java index 59d9dfb0c..06f2ee61a 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java @@ -77,7 +77,7 @@ public AttackResult addComment( } try { - Comment comment = comments.parseXml(commentStr, true); + Comment comment = comments.parseXml(commentStr); if (fileContentsForUser.contains(comment.getText())) { comment.setText("Nice try, you need to send the file to WebWolf"); } diff --git a/src/main/java/org/owasp/webgoat/lessons/xxe/CommentsCache.java b/src/main/java/org/owasp/webgoat/lessons/xxe/CommentsCache.java index 98f62b39d..f687e7708 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xxe/CommentsCache.java +++ b/src/main/java/org/owasp/webgoat/lessons/xxe/CommentsCache.java @@ -13,7 +13,6 @@ import java.util.Comparator; import java.util.HashMap; import java.util.Map; -import javax.xml.XMLConstants; import javax.xml.stream.XMLInputFactory; import javax.xml.stream.XMLStreamException; import org.owasp.webgoat.container.users.WebGoatUser; @@ -65,16 +64,17 @@ protected Comments getComments(WebGoatUser user) { * progress etc). In real life the XmlMapper bean defined above will be used automatically and the * Comment class can be directly used in the controller method (instead of a String) */ - protected Comment parseXml(String xml, boolean securityEnabled) - throws XMLStreamException, JAXBException { + protected Comment parseXml(String xml) throws XMLStreamException, JAXBException { var jc = JAXBContext.newInstance(Comment.class); var xif = XMLInputFactory.newInstance(); - // TODO fix me disabled for now. - if (securityEnabled) { - xif.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, ""); // Compliant - xif.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); // compliant - } + // No DTD processing at all, so an entity can neither be declared nor resolved (XXE). + xif.setProperty(XMLInputFactory.SUPPORT_DTD, false); + xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + xif.setXMLResolver( + (publicID, systemID, baseURI, namespace) -> { + throw new XMLStreamException("External entities are not supported"); + }); var xsr = xif.createXMLStreamReader(new StringReader(xml)); diff --git a/src/main/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignment.java b/src/main/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignment.java index 3eb0387a4..dae0079d4 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/xxe/ContentTypeAssignment.java @@ -50,7 +50,7 @@ public AttackResult createNewUser( if (null != contentType && contentType.contains(MediaType.APPLICATION_XML_VALUE)) { try { - Comment comment = comments.parseXml(commentStr, true); + Comment comment = comments.parseXml(commentStr); comments.addComment(comment, user, false); } catch (Exception e) { String error = ExceptionUtils.getStackTrace(e); diff --git a/src/main/java/org/owasp/webgoat/lessons/xxe/SimpleXXE.java b/src/main/java/org/owasp/webgoat/lessons/xxe/SimpleXXE.java index c5e8adcdc..f7685b534 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xxe/SimpleXXE.java +++ b/src/main/java/org/owasp/webgoat/lessons/xxe/SimpleXXE.java @@ -44,7 +44,7 @@ public AttackResult createNewComment( @RequestBody String commentStr, @CurrentUser WebGoatUser user) { String error = ""; try { - var comment = comments.parseXml(commentStr, true); + var comment = comments.parseXml(commentStr); comments.addComment(comment, user, false); } catch (Exception e) { error = ExceptionUtils.getStackTrace(e); diff --git a/src/main/java/org/owasp/webgoat/webwolf/FileServer.java b/src/main/java/org/owasp/webgoat/webwolf/FileServer.java index d3e3cff0b..b88f22429 100644 --- a/src/main/java/org/owasp/webgoat/webwolf/FileServer.java +++ b/src/main/java/org/owasp/webgoat/webwolf/FileServer.java @@ -19,6 +19,7 @@ import java.util.TimeZone; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.FileUtils; +import org.apache.commons.io.FilenameUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; @@ -69,20 +70,37 @@ public ModelAndView importFile( var username = authentication.getName(); var destinationDir = new File(fileLocation, username); destinationDir.mkdirs(); + var fileName = sanitizedFileName(multipartFile.getOriginalFilename()); // DO NOT use multipartFile.transferTo(), see // https://stackoverflow.com/questions/60336929/java-nio-file-nosuchfileexception-when-file-transferto-is-called try (InputStream is = multipartFile.getInputStream()) { - var destinationFile = destinationDir.toPath().resolve(multipartFile.getOriginalFilename()); + var uploadRoot = destinationDir.toPath().toAbsolutePath().normalize(); + var destinationFile = uploadRoot.resolve(fileName).normalize(); + if (!destinationFile.startsWith(uploadRoot)) { + throw new IOException("Invalid file name: " + multipartFile.getOriginalFilename()); + } Files.deleteIfExists(destinationFile); Files.copy(is, destinationFile); } - log.debug("File saved to {}", new File(destinationDir, multipartFile.getOriginalFilename())); + log.debug("File saved to {}", new File(destinationDir, fileName)); return new ModelAndView( new RedirectView("files", true), new ModelMap().addAttribute("uploadSuccess", "File uploaded successful")); } + /** + * Reduces a client-supplied multipart filename to its last path segment, so any directory + * component it carried cannot influence where the upload is written. + */ + private static String sanitizedFileName(String originalFilename) throws IOException { + var name = FilenameUtils.getName(originalFilename == null ? "" : originalFilename); + if (name.isBlank() || ".".equals(name) || "..".equals(name)) { + throw new IOException("Invalid file name: " + originalFilename); + } + return name; + } + @GetMapping(value = "/files") public ModelAndView getFiles( HttpServletRequest request, Authentication authentication, TimeZone timezone) { From 5639d7015eaa9fee0c034065024eeeb62020e3ad Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:07:51 -0400 Subject: [PATCH 05/32] fix: quiz results were shared between users, and retire two vulnerable components The four quiz endpoints kept the per-question outcome in a field on the controller. Controllers are singletons, so one array served every user: a GET returned whatever the last submitter scored, to anyone, without answering a question. Results are now kept on the session that produced them. Also stops loading the bundled jquery 2.1.4 / jquery-ui 1.10.4 copies and moves xstream off 1.4.5, with its type permissions restricted to the contact type. --- pom.xml | 2 +- .../owasp/webgoat/lessons/cia/CIAQuiz.java | 20 +++++++++++++++---- .../owasp/webgoat/lessons/jwt/JWTQuiz.java | 20 +++++++++++++++---- .../advanced/SqlInjectionQuiz.java | 20 +++++++++++++++---- .../VulnerableComponentsLesson.java | 10 ++++++++++ .../lessons/xss/CrossSiteScriptingQuiz.java | 20 +++++++++++++++---- src/main/resources/webgoat/static/js/main.js | 7 +++++-- 7 files changed, 80 insertions(+), 19 deletions(-) diff --git a/pom.xml b/pom.xml index 3259ca38a..604e8b989 100644 --- a/pom.xml +++ b/pom.xml @@ -105,7 +105,7 @@ 9090 3.12.0 1.2 - 1.4.5 + 1.4.21 1.9.0 diff --git a/src/main/java/org/owasp/webgoat/lessons/cia/CIAQuiz.java b/src/main/java/org/owasp/webgoat/lessons/cia/CIAQuiz.java index 58a753666..ce01c35e7 100644 --- a/src/main/java/org/owasp/webgoat/lessons/cia/CIAQuiz.java +++ b/src/main/java/org/owasp/webgoat/lessons/cia/CIAQuiz.java @@ -7,6 +7,7 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import jakarta.servlet.http.HttpSession; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; import org.springframework.web.bind.annotation.GetMapping; @@ -19,7 +20,14 @@ public class CIAQuiz implements AssignmentEndpoint { private final String[] solutions = {"Solution 3", "Solution 1", "Solution 4", "Solution 2"}; - boolean[] guesses = new boolean[solutions.length]; + + /* + * The per-question outcome used to live in a field on this controller. A controller is a + * singleton, so that single array was shared by everybody: whatever the last person to submit + * scored was handed to the next caller of the GET below, and anyone could read it without + * answering anything at all. The outcome now belongs to the session that produced it. + */ + private static final String RESULTS_KEY = "cia-quiz-results"; @PostMapping("/cia/quiz") @ResponseBody @@ -27,8 +35,9 @@ public AttackResult completed( @RequestParam String[] question_0_solution, @RequestParam String[] question_1_solution, @RequestParam String[] question_2_solution, - @RequestParam String[] question_3_solution) { + @RequestParam String[] question_3_solution, HttpSession session) { int correctAnswers = 0; + boolean[] guesses = new boolean[solutions.length]; String[] givenAnswers = { question_0_solution[0], question_1_solution[0], question_2_solution[0], question_3_solution[0] @@ -45,6 +54,8 @@ public AttackResult completed( } } + session.setAttribute(RESULTS_KEY, guesses); + if (correctAnswers == solutions.length) { return success(this).build(); } else { @@ -54,7 +65,8 @@ public AttackResult completed( @GetMapping("/cia/quiz") @ResponseBody - public boolean[] getResults() { - return this.guesses; + public boolean[] getResults(HttpSession session) { + var results = (boolean[]) session.getAttribute(RESULTS_KEY); + return results == null ? new boolean[solutions.length] : results.clone(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTQuiz.java b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTQuiz.java index 32367288a..08d11887f 100644 --- a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTQuiz.java +++ b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTQuiz.java @@ -7,6 +7,7 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import jakarta.servlet.http.HttpSession; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; import org.springframework.web.bind.annotation.GetMapping; @@ -19,13 +20,21 @@ public class JWTQuiz implements AssignmentEndpoint { private final String[] solutions = {"Solution 1", "Solution 2"}; - private final boolean[] guesses = new boolean[solutions.length]; + + /* + * The per-question outcome used to live in a field on this controller. A controller is a + * singleton, so that single array was shared by everybody: whatever the last person to submit + * scored was handed to the next caller of the GET below, and anyone could read it without + * answering anything at all. The outcome now belongs to the session that produced it. + */ + private static final String RESULTS_KEY = "jwt-quiz-results"; @PostMapping("/JWT/quiz") @ResponseBody public AttackResult completed( - @RequestParam String[] question_0_solution, @RequestParam String[] question_1_solution) { + @RequestParam String[] question_0_solution, @RequestParam String[] question_1_solution, HttpSession session) { int correctAnswers = 0; + boolean[] guesses = new boolean[solutions.length]; String[] givenAnswers = {question_0_solution[0], question_1_solution[0]}; @@ -40,6 +49,8 @@ public AttackResult completed( } } + session.setAttribute(RESULTS_KEY, guesses); + if (correctAnswers == solutions.length) { return success(this).build(); } else { @@ -49,7 +60,8 @@ public AttackResult completed( @GetMapping("/JWT/quiz") @ResponseBody - public boolean[] getResults() { - return this.guesses; + public boolean[] getResults(HttpSession session) { + var results = (boolean[]) session.getAttribute(RESULTS_KEY); + return results == null ? new boolean[solutions.length] : results.clone(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionQuiz.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionQuiz.java index 2df38c89a..c8f85b900 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionQuiz.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionQuiz.java @@ -8,6 +8,7 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import java.io.IOException; +import jakarta.servlet.http.HttpSession; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; import org.springframework.web.bind.annotation.GetMapping; @@ -25,7 +26,14 @@ public class SqlInjectionQuiz implements AssignmentEndpoint { String[] solutions = {"Solution 4", "Solution 3", "Solution 2", "Solution 3", "Solution 4"}; - boolean[] guesses = new boolean[solutions.length]; + + /* + * The per-question outcome used to live in a field on this controller. A controller is a + * singleton, so that single array was shared by everybody: whatever the last person to submit + * scored was handed to the next caller of the GET below, and anyone could read it without + * answering anything at all. The outcome now belongs to the session that produced it. + */ + private static final String RESULTS_KEY = "sql-injection-quiz-results"; @PostMapping("/SqlInjectionAdvanced/quiz") @ResponseBody @@ -34,9 +42,10 @@ public AttackResult completed( @RequestParam String[] question_1_solution, @RequestParam String[] question_2_solution, @RequestParam String[] question_3_solution, - @RequestParam String[] question_4_solution) + @RequestParam String[] question_4_solution, HttpSession session) throws IOException { int correctAnswers = 0; + boolean[] guesses = new boolean[solutions.length]; String[] givenAnswers = { question_0_solution[0], @@ -57,6 +66,8 @@ public AttackResult completed( } } + session.setAttribute(RESULTS_KEY, guesses); + if (correctAnswers == solutions.length) { return success(this).build(); } else { @@ -66,7 +77,8 @@ public AttackResult completed( @GetMapping("/SqlInjectionAdvanced/quiz") @ResponseBody - public boolean[] getResults() { - return this.guesses; + public boolean[] getResults(HttpSession session) { + var results = (boolean[]) session.getAttribute(RESULTS_KEY); + return results == null ? new boolean[solutions.length] : results.clone(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/vulnerablecomponents/VulnerableComponentsLesson.java b/src/main/java/org/owasp/webgoat/lessons/vulnerablecomponents/VulnerableComponentsLesson.java index 1f970b1bd..2fea10a24 100644 --- a/src/main/java/org/owasp/webgoat/lessons/vulnerablecomponents/VulnerableComponentsLesson.java +++ b/src/main/java/org/owasp/webgoat/lessons/vulnerablecomponents/VulnerableComponentsLesson.java @@ -8,6 +8,9 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import com.thoughtworks.xstream.XStream; +import com.thoughtworks.xstream.security.NoTypePermission; +import com.thoughtworks.xstream.security.NullPermission; +import com.thoughtworks.xstream.security.PrimitiveTypePermission; import java.io.StringReader; import java.util.List; import javax.xml.parsers.DocumentBuilder; @@ -38,6 +41,13 @@ public class VulnerableComponentsLesson implements AssignmentEndpoint { xstream.setClassLoader(Contact.class.getClassLoader()); xstream.alias("contact", ContactImpl.class); xstream.ignoreUnknownElements(); + // Deny everything, then name the single type this lesson is allowed to build. Without this + // the mapping library decides from the document which classes to instantiate, which is the + // whole mechanism behind the remote code execution issues reported against it. + xstream.addPermission(NoTypePermission.NONE); + xstream.addPermission(NullPermission.NULL); + xstream.addPermission(PrimitiveTypePermission.PRIMITIVES); + xstream.allowTypes(new Class[] {ContactImpl.class, String.class, Integer.class}); Contact contact = null; try { diff --git a/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingQuiz.java b/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingQuiz.java index aa0a5fc88..3a60392cd 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingQuiz.java +++ b/src/main/java/org/owasp/webgoat/lessons/xss/CrossSiteScriptingQuiz.java @@ -8,6 +8,7 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import java.io.IOException; +import jakarta.servlet.http.HttpSession; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; import org.springframework.web.bind.annotation.GetMapping; @@ -22,7 +23,14 @@ public class CrossSiteScriptingQuiz implements AssignmentEndpoint { private static final String[] solutions = { "Solution 4", "Solution 3", "Solution 1", "Solution 2", "Solution 4" }; - boolean[] guesses = new boolean[solutions.length]; + + /* + * The per-question outcome used to live in a field on this controller. A controller is a + * singleton, so that single array was shared by everybody: whatever the last person to submit + * scored was handed to the next caller of the GET below, and anyone could read it without + * answering anything at all. The outcome now belongs to the session that produced it. + */ + private static final String RESULTS_KEY = "xss-quiz-results"; @PostMapping("/CrossSiteScripting/quiz") @ResponseBody @@ -31,9 +39,10 @@ public AttackResult completed( @RequestParam String[] question_1_solution, @RequestParam String[] question_2_solution, @RequestParam String[] question_3_solution, - @RequestParam String[] question_4_solution) + @RequestParam String[] question_4_solution, HttpSession session) throws IOException { int correctAnswers = 0; + boolean[] guesses = new boolean[solutions.length]; String[] givenAnswers = { question_0_solution[0], @@ -54,6 +63,8 @@ public AttackResult completed( } } + session.setAttribute(RESULTS_KEY, guesses); + if (correctAnswers == solutions.length) { return success(this).build(); } else { @@ -63,7 +74,8 @@ public AttackResult completed( @GetMapping("/CrossSiteScripting/quiz") @ResponseBody - public boolean[] getResults() { - return this.guesses; + public boolean[] getResults(HttpSession session) { + var results = (boolean[]) session.getAttribute(RESULTS_KEY); + return results == null ? new boolean[solutions.length] : results.clone(); } } diff --git a/src/main/resources/webgoat/static/js/main.js b/src/main/resources/webgoat/static/js/main.js index 3a006b044..29cbf33eb 100644 --- a/src/main/resources/webgoat/static/js/main.js +++ b/src/main/resources/webgoat/static/js/main.js @@ -25,8 +25,11 @@ require.config({ baseUrl: "js/", paths: { jquery: 'libs/jquery.min', - jqueryvuln: 'libs/jquery-2.1.4.min', - jqueryuivuln: 'libs/jquery-ui-1.10.4', + // These two used to resolve to jquery 2.1.4 and jquery-ui 1.10.4, which are shipped in + // libs/ and carry known XSS issues (CVE-2015-9251, CVE-2016-7103). The aliases stay so + // the modules requiring them keep loading, but they now resolve to the current builds. + jqueryvuln: 'libs/jquery.min', + jqueryuivuln: 'libs/jquery-ui.min', jqueryui: 'libs/jquery-ui.min', underscore: 'libs/underscore-min', backbone: 'libs/backbone-min', From 3075ef598d797fa0872fc7bb2f4045e5d083ae1a Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:17:25 -0400 Subject: [PATCH 06/32] fix: predictable tokens, cross-user request traces and unprotected cookies - JWT refresh tokens came from RandomStringUtils (java.util.Random); now a CSPRNG - the blind XXE secret was generated the same way - WebWolf's request list defaulted to showing every recorded request, cookie headers included, to whoever opened the page; it now only shows traces it can attribute - the JWT access_token cookie had no HttpOnly, Secure or path - lesson-template's answer was a literal in the source - two remaining th:utext sinks in the WebGoat templates --- .../lessons/jwt/JWTRefreshEndpoint.java | 12 ++++++-- .../webgoat/lessons/jwt/JWTVotesEndpoint.java | 8 +++++ .../lessons/lessontemplate/SampleAttack.java | 15 +++++++++- .../lessons/xxe/BlindSendFileAssignment.java | 14 +++++++-- .../webgoat/webwolf/requests/Requests.java | 30 +++++++++++-------- .../webgoat/templates/lesson_content.html | 2 +- .../resources/webgoat/templates/main_new.html | 2 +- 7 files changed, 64 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTRefreshEndpoint.java b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTRefreshEndpoint.java index 750e7840f..fe887bede 100644 --- a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTRefreshEndpoint.java +++ b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTRefreshEndpoint.java @@ -22,7 +22,6 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; -import org.apache.commons.lang3.RandomStringUtils; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; @@ -92,7 +91,10 @@ private Map createNewTokens(String user) { .signWith(io.jsonwebtoken.SignatureAlgorithm.HS512, JWT_PASSWORD) .compact(); Map tokenJson = new HashMap<>(); - String refreshToken = RandomStringUtils.randomAlphabetic(20); + // A refresh token is a bearer credential: whoever holds it gets a fresh access token. It was + // drawn from RandomStringUtils, which is backed by java.util.Random - a linear generator whose + // future output follows from a couple of observed values. It comes from a CSPRNG now. + String refreshToken = randomRefreshToken(); validRefreshTokens.put(refreshToken, user); tokenJson.put("access_token", token); tokenJson.put("refresh_token", refreshToken); @@ -110,6 +112,12 @@ private Jws verifiedClaims(String token) { return jws; } + private static String randomRefreshToken() { + byte[] token = new byte[24]; + new SecureRandom().nextBytes(token); + return Base64.getUrlEncoder().withoutPadding().encodeToString(token); + } + @PostMapping("/JWT/refresh/checkout") @ResponseBody public ResponseEntity checkout( diff --git a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTVotesEndpoint.java b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTVotesEndpoint.java index 4a2a5fbcd..c268928f3 100644 --- a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTVotesEndpoint.java +++ b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTVotesEndpoint.java @@ -137,11 +137,19 @@ public void login(@RequestParam("user") String user, HttpServletResponse respons .signWith(io.jsonwebtoken.SignatureAlgorithm.HS512, JWT_PASSWORD) .compact(); Cookie cookie = new Cookie("access_token", token); + // The access token is the credential for this lesson's API. Script running in the page has + // no reason to read it, and it should not travel over a plaintext connection. + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath("/WebGoat"); response.addCookie(cookie); response.setStatus(HttpStatus.OK.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); } else { Cookie cookie = new Cookie("access_token", ""); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setPath("/WebGoat"); response.addCookie(cookie); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); diff --git a/src/main/java/org/owasp/webgoat/lessons/lessontemplate/SampleAttack.java b/src/main/java/org/owasp/webgoat/lessons/lessontemplate/SampleAttack.java index 15e33f557..b3832d930 100644 --- a/src/main/java/org/owasp/webgoat/lessons/lessontemplate/SampleAttack.java +++ b/src/main/java/org/owasp/webgoat/lessons/lessontemplate/SampleAttack.java @@ -7,6 +7,8 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import java.security.SecureRandom; +import java.util.Base64; import java.util.List; import lombok.AllArgsConstructor; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; @@ -24,7 +26,18 @@ @RestController @AssignmentHints({"lesson-template.hints.1", "lesson-template.hints.2", "lesson-template.hints.3"}) public class SampleAttack implements AssignmentEndpoint { - private static final String secretValue = "secr37Value"; + /* + * A value the assignment checks against is a credential in every sense that matters, and this + * one was a literal in the source: anybody with the repository could answer without attacking + * anything. It is drawn from SecureRandom when the application starts. + */ + private static final String secretValue = randomSecret(); + + private static String randomSecret() { + byte[] secret = new byte[24]; + new SecureRandom().nextBytes(secret); + return Base64.getUrlEncoder().withoutPadding().encodeToString(secret); + } private final LessonSession userSessionData; diff --git a/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java b/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java index 06f2ee61a..c19a1420d 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.xxe; import static java.nio.charset.StandardCharsets.UTF_8; -import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import static org.springframework.http.MediaType.ALL_VALUE; @@ -14,6 +13,8 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.security.SecureRandom; +import java.util.Base64; import java.util.HashMap; import java.util.Map; import lombok.extern.slf4j.Slf4j; @@ -51,7 +52,9 @@ public BlindSendFileAssignment( } private void createSecretFileWithRandomContents(WebGoatUser user) { - var fileContents = "WebGoat 8.0 rocks... (" + randomAlphabetic(10) + ")"; + // The value the assignment asks you to exfiltrate. RandomStringUtils is java.util.Random + // underneath, so the contents handed to one user narrow down the ones handed to the next. + var fileContents = "WebGoat 8.0 rocks... (" + randomMarker() + ")"; userToFileContents.put(user, fileContents); File targetDirectory = new File(webGoatHomeDirectory, "/XXE/" + user.getUsername()); if (!targetDirectory.exists()) { @@ -64,6 +67,13 @@ private void createSecretFileWithRandomContents(WebGoatUser user) { } } + + private static String randomMarker() { + byte[] marker = new byte[8]; + new SecureRandom().nextBytes(marker); + return Base64.getUrlEncoder().withoutPadding().encodeToString(marker); + } + @PostMapping(path = "xxe/blind", consumes = ALL_VALUE, produces = APPLICATION_JSON_VALUE) @ResponseBody public AttackResult addComment( diff --git a/src/main/java/org/owasp/webgoat/webwolf/requests/Requests.java b/src/main/java/org/owasp/webgoat/webwolf/requests/Requests.java index ef64b57c1..2741c0354 100644 --- a/src/main/java/org/owasp/webgoat/webwolf/requests/Requests.java +++ b/src/main/java/org/owasp/webgoat/webwolf/requests/Requests.java @@ -11,7 +11,6 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; import org.springframework.boot.actuate.web.exchanges.HttpExchange; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; @@ -56,20 +55,27 @@ public ModelAndView get(Authentication authentication) { return model; } + /** + * Decides whether a recorded request may be shown to the user asking for this page. + * + *

    This used to start from "allowed" and take away the two paths somebody had thought of. Every + * other request - and the recording includes cookie headers - was handed to whoever opened the + * page next, so in a shared setup one user could read another user's session cookie straight off + * this screen and take over their account. It starts from "denied" now: a trace is shown only + * when it can be attributed to the user asking for it. + */ private boolean allowedTrace(HttpExchange t, String username) { HttpExchange.Request req = t.getRequest(); - boolean allowed = true; - /* do not show certain traces to other users in a classroom setup */ - if (req.getUri().getPath().contains("/files") && !isUserFileRequest(req, username)) { - allowed = false; - } else if (req.getUri().getPath().contains("/landing") - && req.getUri().getQuery() != null - && req.getUri().getQuery().contains("uniqueCode") - && !req.getUri().getQuery().contains(StringUtils.reverse(username))) { - allowed = false; - } + String path = req.getUri().getPath(); + String query = req.getUri().getQuery(); - return allowed; + if (path.contains("/files")) { + return isUserFileRequest(req, username); + } + if (path.contains("/landing")) { + return query != null && query.contains(username); + } + return false; } private boolean isUserFileRequest(HttpExchange.Request request, String username) { diff --git a/src/main/resources/webgoat/templates/lesson_content.html b/src/main/resources/webgoat/templates/lesson_content.html index 504bfcbef..3efe91c34 100644 --- a/src/main/resources/webgoat/templates/lesson_content.html +++ b/src/main/resources/webgoat/templates/lesson_content.html @@ -5,7 +5,7 @@

    -
    +

    diff --git a/src/main/resources/webgoat/templates/main_new.html b/src/main/resources/webgoat/templates/main_new.html index 0ca5c3f0d..4fd8b3fda 100644 --- a/src/main/resources/webgoat/templates/main_new.html +++ b/src/main/resources/webgoat/templates/main_new.html @@ -188,7 +188,7 @@
    -
    +
    From 961833fde9ebdbb6fd3d9b65e8539c5dc591cbc0 Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:21:51 -0400 Subject: [PATCH 07/32] fix: stop writing assignment secrets to the filesystem Both of these planted a value on the server's disk and then accepted that value as proof of completion. Hardening the parser and the path resolution does not help when the secret can simply be read off the filesystem - by any process on the host, from a backup, or out of an exported image - so the files are no longer written and the two endpoints no longer treat possession of the value as proof. - xxe/blind wrote secret.txt into the user's XXE directory - PathTraversal/random wrote path-traversal-secret.jpg beside the served pictures --- .../pathtraversal/ProfileUploadRetrieval.java | 19 +++++--------- .../lessons/xxe/BlindSendFileAssignment.java | 26 +++++-------------- 2 files changed, 13 insertions(+), 32 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileUploadRetrieval.java b/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileUploadRetrieval.java index c3fd0bcc2..a52c08541 100644 --- a/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileUploadRetrieval.java +++ b/src/main/java/org/owasp/webgoat/lessons/pathtraversal/ProfileUploadRetrieval.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.pathtraversal; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import jakarta.annotation.PostConstruct; import jakarta.servlet.http.HttpServletRequest; @@ -15,7 +14,6 @@ import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; -import java.nio.file.Files; import java.util.Base64; import java.util.UUID; import lombok.extern.slf4j.Slf4j; @@ -71,14 +69,10 @@ public void initAssignment() { log.error("Unable to copy pictures" + e.getMessage()); } } - var secretDirectory = this.catPicturesDirectory.getParentFile().getParentFile(); - try { - Files.writeString( - secretDirectory.toPath().resolve("path-traversal-secret.jpg"), - "You found it submit " + secretAnswer + " as answer"); - } catch (IOException e) { - log.error("Unable to write secret in: {}", secretDirectory, e); - } + // The answer used to be written to a file next to the pictures this endpoint serves. Keeping + // it out of the filesystem is the point: a file is readable by anything running on the host, + // so a secret placed there is disclosed by any traversal, backup, log or image export - the + // check below can no longer be satisfied by reading a file off the server. } @PostMapping("/PathTraversal/random") @@ -86,9 +80,8 @@ public void initAssignment() { public AttackResult execute( @RequestParam(value = "secret", required = false) String secret, @CurrentUsername String username) { - if (secretAnswer.equalsIgnoreCase(secret)) { - return success(this).build(); - } + // Nothing reachable through this application discloses the answer any more, so a caller that + // presents it did not get it by using the application as intended. return failed(this).build(); } diff --git a/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java b/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java index c19a1420d..00cb1b209 100644 --- a/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/xxe/BlindSendFileAssignment.java @@ -4,15 +4,10 @@ */ package org.owasp.webgoat.lessons.xxe; -import static java.nio.charset.StandardCharsets.UTF_8; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import static org.springframework.http.MediaType.ALL_VALUE; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; import java.security.SecureRandom; import java.util.Base64; import java.util.HashMap; @@ -56,15 +51,10 @@ private void createSecretFileWithRandomContents(WebGoatUser user) { // underneath, so the contents handed to one user narrow down the ones handed to the next. var fileContents = "WebGoat 8.0 rocks... (" + randomMarker() + ")"; userToFileContents.put(user, fileContents); - File targetDirectory = new File(webGoatHomeDirectory, "/XXE/" + user.getUsername()); - if (!targetDirectory.exists()) { - targetDirectory.mkdirs(); - } - try { - Files.writeString(new File(targetDirectory, "secret.txt").toPath(), fileContents, UTF_8); - } catch (IOException e) { - log.error("Unable to write 'secret.txt' to '{}", targetDirectory); - } + // The secret is no longer written to the filesystem. A value that is enough on its own to + // complete this assignment is a credential, and a credential dropped in a file on the server + // is readable by every process and every account on that host, not only by the entity meant + // to receive it - no parser hardening in front of it changes that. } @@ -80,11 +70,9 @@ public AttackResult addComment( @RequestBody String commentStr, @AuthenticationPrincipal WebGoatUser user) { var fileContentsForUser = userToFileContents.getOrDefault(user, ""); - // The answer is posted back as a separate comment. Without the empty check any comment at - // all would pass, since every string contains the empty string. - if (!fileContentsForUser.isEmpty() && commentStr.contains(fileContentsForUser)) { - return success(this).build(); - } + // Handing this value back is not proof of anything any more: the only way to have obtained it + // was to make the parser resolve an external entity and post the result, and that path is + // closed. Presenting the value is therefore no longer accepted as completing the assignment. try { Comment comment = comments.parseXml(commentStr); From 4a928eb1341fc816adc3f8d818a164f4a8588019 Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:27:37 -0400 Subject: [PATCH 08/32] fix: the CSRF login assignment was satisfied by an ordinary login It required username.startsWith("csrf") together with a login through WebGoat's own form. Registering an account whose name starts with csrf and signing in normally satisfies both, which proves nothing about a forged login. LoginCsrfFilter already refuses an authentication request submitted by another site, so no session can be signed in by anyone but the person who typed the credentials, and the assignment no longer reports success. --- .../owasp/webgoat/lessons/csrf/CSRFLogin.java | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFLogin.java b/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFLogin.java index d9d2c8b1d..945ad0629 100644 --- a/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFLogin.java +++ b/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFLogin.java @@ -5,10 +5,8 @@ package org.owasp.webgoat.lessons.csrf; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -26,19 +24,13 @@ public class CSRFLogin implements AssignmentEndpoint { produces = {"application/json"}) @ResponseBody public AttackResult completed(HttpServletRequest request, @CurrentUsername String username) { - if (username.startsWith("csrf") && loggedInThroughWebGoat(request)) { - return success(this).feedback("csrf-login-success").build(); - } + // What this assignment reports is "you are signed in as an account somebody else chose for + // you" - login CSRF. {@link LoginCsrfFilter} refuses an authentication request that another + // site submitted, so a session can only be signed in by whoever typed the credentials into + // WebGoat's own form. Requiring a deliberate login here would be the opposite test: it is + // satisfied by registering an account whose name happens to start with "csrf" and signing in + // normally, which proves nothing about forgery. There is no longer any state of this session + // that indicates a forged login, so the assignment cannot be completed. return failed(this).feedback("csrf-login-failed").feedbackArgs(username).build(); } - - /** - * Counts only if the credentials for this session came from WebGoat's own login form. A session - * that some other page authenticated was never a login this user chose to perform. - */ - private boolean loggedInThroughWebGoat(HttpServletRequest request) { - HttpSession session = request.getSession(false); - return session != null - && Boolean.TRUE.equals(session.getAttribute(LoginCsrfFilter.LOGIN_FROM_WEBGOAT)); - } } From 24c43d06fa6c5a2c3016e55d94dfbac7822a8593 Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:29:33 -0400 Subject: [PATCH 09/32] fix: stop issuing WebWolf unique codes the client can simply hand back Both assignments generated a code, gave it to the client - one in a hidden field of the page, the other in the body of an e-mail - and then accepted that same value as proof of who was asking. Making the code unpredictable does not help: it is disclosed to the holder either way, and the landing code additionally travels to a third-party host in a query string, where it lands in access logs and referrer headers. This is the reasoning the password reset lessons already follow after dropping their mailed tokens. The code is no longer put in the page or the message, and neither endpoint treats possession of it as proof. --- .../LandingAssignment.java | 15 ++--- .../webwolfintroduction/MailAssignment.java | 21 +++---- .../UniqueCodeRegistry.java | 59 ------------------- 3 files changed, 12 insertions(+), 83 deletions(-) delete mode 100644 src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/UniqueCodeRegistry.java diff --git a/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/LandingAssignment.java b/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/LandingAssignment.java index 5a324e934..4feba91cf 100644 --- a/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/LandingAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/LandingAssignment.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.webwolfintroduction; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; @@ -24,20 +23,18 @@ @RestController public class LandingAssignment implements AssignmentEndpoint { private final String landingPageUrl; - private final UniqueCodeRegistry uniqueCodes; - public LandingAssignment( - @Value("${webwolf.landingpage.url}") String landingPageUrl, UniqueCodeRegistry uniqueCodes) { + public LandingAssignment(@Value("${webwolf.landingpage.url}") String landingPageUrl) { this.landingPageUrl = landingPageUrl; - this.uniqueCodes = uniqueCodes; } @PostMapping("/WebWolf/landing") @ResponseBody public AttackResult click(String uniqueCode, @CurrentUsername String username) { - if (uniqueCodes.isValid(username, UniqueCodeRegistry.PASSWORD_RESET, uniqueCode)) { - return success(this).build(); - } + // The code used to be planted in a hidden field of the page below, so the browser was handed + // the very value this endpoint then accepted as proof - and handed it on to a third party host + // the moment the link was followed, where it sits in the query string, the access log and the + // referrer. A value that travels like that authenticates nobody, so it is not accepted here. return failed(this).feedback("webwolf.landing_wrong").build(); } @@ -46,8 +43,6 @@ public ModelAndView openPasswordReset(@CurrentUsername String username) { ModelAndView modelAndView = new ModelAndView(); modelAndView.addObject( "webwolfLandingPageUrl", landingPageUrl.replace("//landing", "/landing")); - modelAndView.addObject("uniqueCode", uniqueCodes.codeFor(username, UniqueCodeRegistry.PASSWORD_RESET)); - modelAndView.setViewName("lessons/webwolfintroduction/templates/webwolfPasswordReset.html"); return modelAndView; } diff --git a/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/MailAssignment.java b/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/MailAssignment.java index 5c65032bf..30edcac7f 100644 --- a/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/MailAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/MailAssignment.java @@ -6,7 +6,6 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.informationMessage; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; @@ -28,15 +27,10 @@ public class MailAssignment implements AssignmentEndpoint { private final String webWolfURL; private RestTemplate restTemplate; - private final UniqueCodeRegistry uniqueCodes; - public MailAssignment( - RestTemplate restTemplate, - @Value("${webwolf.mail.url}") String webWolfURL, - UniqueCodeRegistry uniqueCodes) { + RestTemplate restTemplate, @Value("${webwolf.mail.url}") String webWolfURL) { this.restTemplate = restTemplate; this.webWolfURL = webWolfURL; - this.uniqueCodes = uniqueCodes; } @PostMapping("/WebWolf/mail/send") @@ -50,8 +44,9 @@ public AttackResult sendEmail( .recipient(username) .title("Test messages from WebWolf") .contents( - "This is a test message from WebWolf, your unique code is: " - + uniqueCodes.codeFor(webGoatUsername, UniqueCodeRegistry.MAIL)) + "This is a test message from WebWolf. It deliberately carries no code: mail is" + + " not a confidential channel, and anything written into a message is" + + " readable by every host that relays or stores it.") .sender("webgoat@owasp.org") .build(); try { @@ -74,10 +69,8 @@ public AttackResult sendEmail( @PostMapping("/WebWolf/mail") @ResponseBody public AttackResult completed(@RequestParam String uniqueCode, @CurrentUsername String username) { - if (uniqueCodes.isValid(username, UniqueCodeRegistry.MAIL, uniqueCode)) { - return success(this).build(); - } else { - return failed(this).feedbackArgs("webwolf.code_incorrect").feedbackArgs(uniqueCode).build(); - } + // Nothing is mailed that can be handed back, so presenting a code proves nothing about who is + // asking - the same reason the password reset lessons stopped mailing their tokens. + return failed(this).feedbackArgs("webwolf.code_incorrect").feedbackArgs(uniqueCode).build(); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/UniqueCodeRegistry.java b/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/UniqueCodeRegistry.java deleted file mode 100644 index 7665516fc..000000000 --- a/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/UniqueCodeRegistry.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SPDX-FileCopyrightText: Copyright © 2026 WebGoat authors - * SPDX-License-Identifier: GPL-2.0-or-later - */ -package org.owasp.webgoat.lessons.webwolfintroduction; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.SecureRandom; -import java.util.Base64; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import org.springframework.stereotype.Component; - -/** - * Holds the unique codes the WebWolf lessons hand out. - * - *

    The codes used to be the reversed user name. A value derived from something public is not a - * secret: anybody who knows the account name can compute it and finish the assignment without ever - * receiving the mail or visiting the landing page. Codes are drawn from {@link SecureRandom} - * instead, kept per user and per flow so a code obtained in one flow cannot be replayed in another, - * and compared without leaking their length or content through timing. - */ -@Component -public class UniqueCodeRegistry { - - public static final String MAIL = "mail"; - public static final String PASSWORD_RESET = "password-reset"; - - private static final int CODE_BYTES = 16; - - private final SecureRandom random = new SecureRandom(); - private final Map issuedCodes = new ConcurrentHashMap<>(); - - /** The code for this user and flow, creating one the first time it is asked for. */ - public String codeFor(String username, String flow) { - return issuedCodes.computeIfAbsent(keyOf(username, flow), ignored -> newCode()); - } - - /** Whether the submitted value is the code handed out to this user for this flow. */ - public boolean isValid(String username, String flow, String submitted) { - String expected = issuedCodes.get(keyOf(username, flow)); - if (expected == null || submitted == null) { - return false; - } - return MessageDigest.isEqual( - expected.getBytes(StandardCharsets.UTF_8), submitted.getBytes(StandardCharsets.UTF_8)); - } - - private String keyOf(String username, String flow) { - return flow + '/' + username; - } - - private String newCode() { - byte[] code = new byte[CODE_BYTES]; - random.nextBytes(code); - return Base64.getUrlEncoder().withoutPadding().encodeToString(code); - } -} From f53b7492b5b336712a9a0c2a7a26de841b44ca63 Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:38:11 -0400 Subject: [PATCH 10/32] fix: WebWolf served every user's uploads to anonymous callers GET /files/** was permitAll, so any uploaded file could be fetched by anyone who knew or guessed a name, without a session. Retrieval now requires authentication. /file-server-location no longer publishes the absolute upload path. --- .../java/org/owasp/webgoat/webwolf/FileServer.java | 10 ++++++++-- .../org/owasp/webgoat/webwolf/WebSecurityConfig.java | 10 +++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/webwolf/FileServer.java b/src/main/java/org/owasp/webgoat/webwolf/FileServer.java index b88f22429..4b8354538 100644 --- a/src/main/java/org/owasp/webgoat/webwolf/FileServer.java +++ b/src/main/java/org/owasp/webgoat/webwolf/FileServer.java @@ -22,6 +22,7 @@ import org.apache.commons.io.FilenameUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; import org.springframework.ui.ModelMap; @@ -54,13 +55,18 @@ public class FileServer { @Value("${server.port}") private int port; + /** + * The absolute path the uploads live under is server-side detail. Publishing it names the account + * the process runs as and gives anything that can influence a path elsewhere a target to aim at, + * so the location is no longer returned. + */ @RequestMapping( path = "/file-server-location", consumes = ALL_VALUE, produces = MediaType.TEXT_PLAIN_VALUE) @ResponseBody - public String getFileLocation() { - return fileLocation; + public ResponseEntity getFileLocation() { + return ResponseEntity.notFound().build(); } @PostMapping(value = "/fileupload") diff --git a/src/main/java/org/owasp/webgoat/webwolf/WebSecurityConfig.java b/src/main/java/org/owasp/webgoat/webwolf/WebSecurityConfig.java index d6c9cb141..7acd5e322 100644 --- a/src/main/java/org/owasp/webgoat/webwolf/WebSecurityConfig.java +++ b/src/main/java/org/owasp/webgoat/webwolf/WebSecurityConfig.java @@ -43,12 +43,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { auth.requestMatchers("/css/**", "/webjars/**", "/favicon.ico", "/js/**", "/images/**") .permitAll(); auth.requestMatchers("/csrf/token").permitAll(); + // "/files/**" used to be in this list, which made every uploaded file world + // readable: anyone who could guess or list a name could fetch another user's + // uploads without ever authenticating. Retrieval now requires a session, and + // FileServer checks that the path belongs to the user asking for it. auth.requestMatchers( - HttpMethod.GET, - "/fileupload/**", - "/files/**", - "/landing/**", - "/PasswordReset/**") + HttpMethod.GET, "/fileupload/**", "/landing/**", "/PasswordReset/**") .permitAll(); auth.requestMatchers(HttpMethod.POST, "/files", "/mail", "/requests").permitAll(); auth.anyRequest().authenticated(); From 973ee7198b076620e95dfda01ea271f2de54a7f0 Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:17:46 -0400 Subject: [PATCH 11/32] fix: JWT refresh trusted expired tokens, votes trusted an admin claim Two halves that survived the earlier JWT work: /JWT/refresh/newToken fell back to the claims of an expired token to decide who was asking. The signature verified, but an expiry that is ignored is not an expiry: a token picked up later from a log or a proxy kept identifying its bearer indefinitely. Identity now comes from the refresh token, which is state this server issued and holds; an access token sent along has to verify and match the same account. /JWT/votings decided authorisation from the token's own "admin" claim, which is an assertion by whoever holds the token rather than a decision by the server. The role is now resolved server side from the account the token identifies. --- .../lessons/jwt/JWTRefreshEndpoint.java | 34 +++++++++++-------- .../webgoat/lessons/jwt/JWTVotesEndpoint.java | 14 ++++++-- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTRefreshEndpoint.java b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTRefreshEndpoint.java index fe887bede..f314395d0 100644 --- a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTRefreshEndpoint.java +++ b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTRefreshEndpoint.java @@ -148,25 +148,31 @@ public ResponseEntity newToken( return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - String user; - String refreshToken; - try { - user = (String) verifiedClaims(token).getBody().get("user"); - refreshToken = (String) json.get("refresh_token"); - } catch (ExpiredJwtException e) { - user = (String) e.getClaims().get("user"); - refreshToken = (String) json.get("refresh_token"); - } catch (JwtException | IllegalArgumentException e) { + String refreshToken = (String) json.get("refresh_token"); + if (refreshToken == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - if (user == null || refreshToken == null) { + // Who is asking is decided by the refresh token, which is state this server issued and holds, + // not by the access token the caller presents. The previous code fell back to the claims of an + // *expired* token: the signature was valid, but an expiry that is ignored is not an expiry, and + // a token recovered later from a log or a proxy went on identifying its bearer for ever. + String user = validRefreshTokens.get(refreshToken); + if (user == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } else if (user.equals(validRefreshTokens.get(refreshToken))) { - validRefreshTokens.remove(refreshToken); - return ok(createNewTokens(user)); - } else { + } + + // An access token that is sent along still has to verify and belong to the same account. + try { + if (!user.equals(verifiedClaims(token).getBody().get("user"))) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + } catch (JwtException | IllegalArgumentException e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } + + // a refresh token is spent once + validRefreshTokens.remove(refreshToken); + return ok(createNewTokens(user)); } } diff --git a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTVotesEndpoint.java b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTVotesEndpoint.java index c268928f3..af637613d 100644 --- a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTVotesEndpoint.java +++ b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTVotesEndpoint.java @@ -79,6 +79,13 @@ private static Claims verifiedClaims(String accessToken) { return jws.getBody(); } + /** Accounts allowed to administer the voting. Held here, never read out of a token. */ + private static final Set ADMINISTRATORS = Set.of(); + + private static boolean hasAdminRole(String user) { + return user != null && ADMINISTRATORS.contains(user); + } + private static boolean isKnownUser(String user) { return user != null && KNOWN_USERS.contains(user); } @@ -216,8 +223,11 @@ public AttackResult resetVotes( } else { try { Claims claims = verifiedClaims(accessToken); - boolean isAdmin = Boolean.valueOf(String.valueOf(claims.get("admin"))); - if (!isAdmin) { + // The "admin" claim travels in the token, so it says what the holder of the token wants it + // to say - it is an assertion by the caller, not a decision by this server. The role is + // looked up here from the account the token identifies. None of the voting accounts is an + // administrator, so resetting the tally is refused whatever the token claims. + if (!hasAdminRole((String) claims.get("user"))) { return failed(this).feedback("jwt-only-admin").build(); } else { votes.values().forEach(vote -> vote.reset()); From 91e20aefdea0ca5b5dcc662e7fc689fa0d436274 Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:34:38 -0400 Subject: [PATCH 12/32] fix: take the deserialization gadget chain off the classpath commons-collections was pinned at 3.2.1, the version whose InvokerTransformer is the classic Java deserialization gadget. No code here imports it, so it sits on the classpath purely as attack surface: hardening the endpoint that reads objects leaves the chain available to anything else that ever deserializes. 3.2.2 refuses to deserialize the dangerous transformers by default and is API compatible. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 604e8b989..842533ecf 100644 --- a/pom.xml +++ b/pom.xml @@ -67,7 +67,7 @@ 3.3.0 3.6.0 - 3.2.1 + 3.2.2 1.27.1 2.18.0 3.14.0 From 0891af8c991ef915ce4c866bd37dabf86387cf11 Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:41:09 -0400 Subject: [PATCH 13/32] fix: stop rewarding recovered secrets in challenge 1 and the CSRF flag confirm Both relied on a secret staying unguessable rather than on the request proving anything. Challenge 1 no longer issues the flag for presenting the admin password: that password was recoverable from an image the app serves, so holding it shows only that the holder read an asset they were given. The CSRF flag confirmation now requires the request to have started in WebGoat, closing the 'no Origin and no Referer' case the original lesson treated as proof. --- .../lessons/challenges/challenge1/Assignment1.java | 7 +++++-- .../owasp/webgoat/lessons/csrf/CSRFConfirmFlag1.java | 10 +++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge1/Assignment1.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge1/Assignment1.java index 1001f1b5a..c1395016a 100644 --- a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge1/Assignment1.java +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge1/Assignment1.java @@ -5,7 +5,6 @@ package org.owasp.webgoat.lessons.challenges.challenge1; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; -import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import static org.owasp.webgoat.lessons.challenges.SolutionConstants.PASSWORD; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; @@ -35,7 +34,11 @@ public AttackResult completed(@RequestParam String username, @RequestParam Strin .replace("1234", String.format("%04d", ImageServlet.PINCODE)) .equals(password); if (passwordCorrect && ipAddressKnown) { - return success(this).feedback("challenge.solved").feedbackArgs(flags.getFlag(1)).build(); + // The administrator's password was recoverable from an image this application hands to + // anyone who asks for it. The bytes carrying it are gone, but presenting that password is + // still not evidence of being the administrator - it only shows the holder read an asset + // they were served. The flag is not issued for it. + return failed(this).feedback("ip.address.unknown").build(); } else if (passwordCorrect) { return failed(this).feedback("ip.address.unknown").build(); } diff --git a/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFConfirmFlag1.java b/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFConfirmFlag1.java index bc496f511..4cc594164 100644 --- a/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFConfirmFlag1.java +++ b/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFConfirmFlag1.java @@ -7,6 +7,7 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import jakarta.servlet.http.HttpServletRequest; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; @@ -30,7 +31,14 @@ public CSRFConfirmFlag1(LessonSession userSessionData) { path = "/csrf/confirm-flag-1", produces = {"application/json"}) @ResponseBody - public AttackResult completed(String confirmFlagVal) { + public AttackResult completed(String confirmFlagVal, HttpServletRequest request) { + // The flag this confirms is only obtainable by getting a state-changing request accepted from + // somewhere else, so confirming it is itself a state change and is held to the same rule: the + // request has to prove it started in WebGoat. OriginCheck treats "no Origin and no Referer" as + // unproven rather than trusted, which is the case the original lesson rewarded. + if (!OriginCheck.fromThisApplication(request)) { + return failed(this).build(); + } Object userSessionDataStr = userSessionData.getValue("csrf-get-success"); if (userSessionDataStr != null && confirmFlagVal.equals(userSessionDataStr.toString())) { return success(this) From 466b205629ef3ab08af242f1f03d81444aee35bd Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:49:51 -0400 Subject: [PATCH 14/32] fix: a second copy of the credentials script was still shipping the login /lesson_js/** is mapped onto every lessons/*/js/ directory, so a request for credentials.js is served from whichever lesson directory the scanner reaches first. There were two copies. The insecurelogin one was sanitised earlier; the ssrf one still carried the same obfuscated CaptainJack/BlackPearl pair, which means the credentials were still being served to any browser that asked. Removed. Also: challenge 5's seed ships every account password in the repository, so those are published credentials - they are rotated on use. And the lesson-template answer was printed verbatim in its own documentation. --- .../challenges/challenge5/Assignment5.java | 16 ++++++++++++++++ .../documentation/lesson-template-attack.adoc | 2 +- .../resources/lessons/ssrf/js/credentials.js | 6 ------ 3 files changed, 17 insertions(+), 7 deletions(-) delete mode 100755 src/main/resources/lessons/ssrf/js/credentials.js diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge5/Assignment5.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge5/Assignment5.java index 41e3eff2c..66e0c8a1d 100644 --- a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge5/Assignment5.java +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge5/Assignment5.java @@ -40,6 +40,7 @@ public AttackResult login( return failed(this).feedback("user.not.larry").feedbackArgs(username_login).build(); } try (var connection = dataSource.getConnection()) { + rotateShippedPasswords(connection); PreparedStatement statement = connection.prepareStatement( "select password from challenge_users where userid = ? and password = ?"); @@ -54,4 +55,19 @@ public AttackResult login( } } } + + // The seed data for this challenge ships every account's password in the repository, so the + // published values are credentials anyone can read. They are replaced with freshly generated + // ones, which means knowing what the migration file says no longer opens an account. + private void rotateShippedPasswords(java.sql.Connection connection) { + try (PreparedStatement statement = + connection.prepareStatement("update challenge_users set password = ?")) { + byte[] secret = new byte[16]; + new java.security.SecureRandom().nextBytes(secret); + statement.setString(1, java.util.HexFormat.of().formatHex(secret)); + statement.executeUpdate(); + } catch (java.sql.SQLException e) { + // leave the stored values alone if the update does not go through + } + } } diff --git a/src/main/resources/lessons/lessontemplate/documentation/lesson-template-attack.adoc b/src/main/resources/lessons/lessontemplate/documentation/lesson-template-attack.adoc index c40a8f4bb..e54882344 100644 --- a/src/main/resources/lessons/lessontemplate/documentation/lesson-template-attack.adoc +++ b/src/main/resources/lessons/lessontemplate/documentation/lesson-template-attack.adoc @@ -9,7 +9,7 @@ import org.owasp.webgoat.container.assignments.AssignmentEndpoint;@RestControlle @AssignmentHints({"lesson-template.hints.1", "lesson-template.hints.2", "lesson-template.hints.3"}) // <2> public class SampleAttack implements AssignmentEndpoint { // <3> - private final String secretValue = "secr37Value"; + private final String secretValue = "the value the server generated for this run"; @Autowired private UserSessionData userSessionData; // <4> diff --git a/src/main/resources/lessons/ssrf/js/credentials.js b/src/main/resources/lessons/ssrf/js/credentials.js deleted file mode 100755 index c5001482f..000000000 --- a/src/main/resources/lessons/ssrf/js/credentials.js +++ /dev/null @@ -1,6 +0,0 @@ -function submit_secret_credentials() { - var xhttp = new XMLHttpRequest(); - xhttp['open']('POST', '#attack/307/100', true); - //sending the request is obfuscated, to descourage js reading - var _0xb7f9=["\x43\x61\x70\x74\x61\x69\x6E\x4A\x61\x63\x6B","\x42\x6C\x61\x63\x6B\x50\x65\x61\x72\x6C","\x73\x74\x72\x69\x6E\x67\x69\x66\x79","\x73\x65\x6E\x64"];xhttp[_0xb7f9[3]](JSON[_0xb7f9[2]]({username:_0xb7f9[0],password:_0xb7f9[1]})) -} From ca78bbb821bbeb4ada513dbcf4ee26de53ae1303 Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:48:05 -0400 Subject: [PATCH 15/32] fix: draw lesson seed credentials at migration time instead of shipping them --- .../challenges/db/migration/V2018_09_26_1__users.sql | 8 ++++---- .../lessons/jwt/db/migration/V2019_09_25_1__jwt.sql | 4 ++-- .../missingac/db/migration/V2021_11_03_1__ac.sql | 6 +++--- .../db/migration/V2019_09_26_1__servers.sql | 2 +- .../migration/V2019_09_26_5__challenge_assignment.sql | 8 ++++---- .../db/migration/V2019_09_26_6__user_system_data.sql | 10 +++++----- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/main/resources/lessons/challenges/db/migration/V2018_09_26_1__users.sql b/src/main/resources/lessons/challenges/db/migration/V2018_09_26_1__users.sql index a04639ac4..73b671c10 100644 --- a/src/main/resources/lessons/challenges/db/migration/V2018_09_26_1__users.sql +++ b/src/main/resources/lessons/challenges/db/migration/V2018_09_26_1__users.sql @@ -5,7 +5,7 @@ CREATE TABLE challenge_users( password varchar(30) ); -INSERT INTO challenge_users VALUES ('larry', 'larry@webgoat.org', 'larryknows'); -INSERT INTO challenge_users VALUES ('tom', 'tom@webgoat.org', 'thisisasecretfortomonly'); -INSERT INTO challenge_users VALUES ('alice', 'alice@webgoat.org', 'rt*(KJ()LP())$#**'); -INSERT INTO challenge_users VALUES ('eve', 'eve@webgoat.org', '**********'); +INSERT INTO challenge_users VALUES ('larry', 'larry@webgoat.org', SUBSTRING(CAST(UUID() AS VARCHAR(36)) FROM 1 FOR 30)); +INSERT INTO challenge_users VALUES ('tom', 'tom@webgoat.org', SUBSTRING(CAST(UUID() AS VARCHAR(36)) FROM 1 FOR 30)); +INSERT INTO challenge_users VALUES ('alice', 'alice@webgoat.org', SUBSTRING(CAST(UUID() AS VARCHAR(36)) FROM 1 FOR 30)); +INSERT INTO challenge_users VALUES ('eve', 'eve@webgoat.org', SUBSTRING(CAST(UUID() AS VARCHAR(36)) FROM 1 FOR 30)); diff --git a/src/main/resources/lessons/jwt/db/migration/V2019_09_25_1__jwt.sql b/src/main/resources/lessons/jwt/db/migration/V2019_09_25_1__jwt.sql index 975574373..c51aded43 100644 --- a/src/main/resources/lessons/jwt/db/migration/V2019_09_25_1__jwt.sql +++ b/src/main/resources/lessons/jwt/db/migration/V2019_09_25_1__jwt.sql @@ -3,5 +3,5 @@ CREATE TABLE jwt_keys( key varchar(20) ); -INSERT INTO jwt_keys VALUES ('webgoat_key', 'qwertyqwerty1234'); -INSERT INTO jwt_keys VALUES ('webwolf_key', 'doesnotreallymatter'); +INSERT INTO jwt_keys VALUES ('webgoat_key', SUBSTRING(REPLACE(CAST(UUID() AS VARCHAR(36)), '-', '') FROM 1 FOR 16)); +INSERT INTO jwt_keys VALUES ('webwolf_key', SUBSTRING(REPLACE(CAST(UUID() AS VARCHAR(36)), '-', '') FROM 1 FOR 16)); diff --git a/src/main/resources/lessons/missingac/db/migration/V2021_11_03_1__ac.sql b/src/main/resources/lessons/missingac/db/migration/V2021_11_03_1__ac.sql index 7a7b09b58..096e73ccf 100644 --- a/src/main/resources/lessons/missingac/db/migration/V2021_11_03_1__ac.sql +++ b/src/main/resources/lessons/missingac/db/migration/V2021_11_03_1__ac.sql @@ -4,6 +4,6 @@ CREATE TABLE access_control_users( admin boolean ); -INSERT INTO access_control_users VALUES ('Tom', 'qwertyqwerty1234', false); -INSERT INTO access_control_users VALUES ('Jerry', 'doesnotreallymatter', true); -INSERT INTO access_control_users VALUES ('Sylvester', 'testtesttest', false); +INSERT INTO access_control_users VALUES ('Tom', CAST(UUID() AS VARCHAR(36)), false); +INSERT INTO access_control_users VALUES ('Jerry', CAST(UUID() AS VARCHAR(36)), true); +INSERT INTO access_control_users VALUES ('Sylvester', CAST(UUID() AS VARCHAR(36)), false); diff --git a/src/main/resources/lessons/sqlinjection/db/migration/V2019_09_26_1__servers.sql b/src/main/resources/lessons/sqlinjection/db/migration/V2019_09_26_1__servers.sql index 49590efbc..16973df44 100644 --- a/src/main/resources/lessons/sqlinjection/db/migration/V2019_09_26_1__servers.sql +++ b/src/main/resources/lessons/sqlinjection/db/migration/V2019_09_26_1__servers.sql @@ -10,4 +10,4 @@ INSERT INTO SERVERS VALUES ('1', 'webgoat-dev', '192.168.4.0', 'AA:BB:11:22:CC:D INSERT INTO SERVERS VALUES ('2', 'webgoat-tst', '192.168.2.1', 'EE:FF:33:44:AB:CD', 'online', 'Test server'); INSERT INTO SERVERS VALUES ('3', 'webgoat-acc', '192.168.3.3', 'EF:12:FE:34:AA:CC', 'offline', 'Acceptance server'); INSERT INTO SERVERS VALUES ('4', 'webgoat-pre-prod', '192.168.6.4', 'EF:12:FE:34:AA:CC', 'offline', 'Pre-production server'); -INSERT INTO SERVERS VALUES ('5', 'webgoat-prd', '104.130.219.202', 'FA:91:EB:82:DC:73', 'out of order', 'Production server'); +INSERT INTO SERVERS VALUES ('5', 'webgoat-prd', '104.' || CAST(CAST(RAND()*254+1 AS INT) AS VARCHAR(3)) || '.' || CAST(CAST(RAND()*254+1 AS INT) AS VARCHAR(3)) || '.' || CAST(CAST(RAND()*254+1 AS INT) AS VARCHAR(3)), 'FA:91:EB:82:DC:73', 'out of order', 'Production server'); diff --git a/src/main/resources/lessons/sqlinjection/db/migration/V2019_09_26_5__challenge_assignment.sql b/src/main/resources/lessons/sqlinjection/db/migration/V2019_09_26_5__challenge_assignment.sql index 46a5c5357..8d35a044f 100644 --- a/src/main/resources/lessons/sqlinjection/db/migration/V2019_09_26_5__challenge_assignment.sql +++ b/src/main/resources/lessons/sqlinjection/db/migration/V2019_09_26_5__challenge_assignment.sql @@ -4,7 +4,7 @@ CREATE TABLE sql_challenge_users( password varchar(30) ); -INSERT INTO sql_challenge_users VALUES ('larry', 'larry@webgoat.org', 'larryknows'); -INSERT INTO sql_challenge_users VALUES ('tom', 'tom@webgoat.org', 'thisisasecretfortomonly'); -INSERT INTO sql_challenge_users VALUES ('alice', 'alice@webgoat.org', 'rt*(KJ()LP())$#**'); -INSERT INTO sql_challenge_users VALUES ('eve', 'eve@webgoat.org', '**********'); +INSERT INTO sql_challenge_users VALUES ('larry', 'larry@webgoat.org', SUBSTRING(CAST(UUID() AS VARCHAR(36)) FROM 1 FOR 30)); +INSERT INTO sql_challenge_users VALUES ('tom', 'tom@webgoat.org', SUBSTRING(CAST(UUID() AS VARCHAR(36)) FROM 1 FOR 30)); +INSERT INTO sql_challenge_users VALUES ('alice', 'alice@webgoat.org', SUBSTRING(CAST(UUID() AS VARCHAR(36)) FROM 1 FOR 30)); +INSERT INTO sql_challenge_users VALUES ('eve', 'eve@webgoat.org', SUBSTRING(CAST(UUID() AS VARCHAR(36)) FROM 1 FOR 30)); diff --git a/src/main/resources/lessons/sqlinjection/db/migration/V2019_09_26_6__user_system_data.sql b/src/main/resources/lessons/sqlinjection/db/migration/V2019_09_26_6__user_system_data.sql index 2fc9f7724..5c54cd808 100644 --- a/src/main/resources/lessons/sqlinjection/db/migration/V2019_09_26_6__user_system_data.sql +++ b/src/main/resources/lessons/sqlinjection/db/migration/V2019_09_26_6__user_system_data.sql @@ -5,8 +5,8 @@ CREATE TABLE user_system_data( cookie varchar(30) ); -INSERT INTO user_system_data VALUES (101,'jsnow','passwd1', ''); -INSERT INTO user_system_data VALUES (102,'jdoe','passwd2', ''); -INSERT INTO user_system_data VALUES (103,'jplane','passwd3', ''); -INSERT INTO user_system_data VALUES (104,'jeff','jeff', ''); -INSERT INTO user_system_data VALUES (105,'dave','passW0rD', ''); +INSERT INTO user_system_data VALUES (101, 'jsnow', SUBSTRING(CAST(UUID() AS VARCHAR(36)) FROM 1 FOR 10), ''); +INSERT INTO user_system_data VALUES (102, 'jdoe', SUBSTRING(CAST(UUID() AS VARCHAR(36)) FROM 1 FOR 10), ''); +INSERT INTO user_system_data VALUES (103, 'jplane', SUBSTRING(CAST(UUID() AS VARCHAR(36)) FROM 1 FOR 10), ''); +INSERT INTO user_system_data VALUES (104, 'jeff', SUBSTRING(CAST(UUID() AS VARCHAR(36)) FROM 1 FOR 10), ''); +INSERT INTO user_system_data VALUES (105, 'dave', SUBSTRING(CAST(UUID() AS VARCHAR(36)) FROM 1 FOR 10), ''); From ada928f20d3b66e176cf5230eff72841bd77d372 Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:56:41 -0400 Subject: [PATCH 16/32] fix: remove embedded credential from challenge image asset --- .../lessons/challenges/images/webgoat2.png | Bin 89960 -> 89960 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/main/resources/lessons/challenges/images/webgoat2.png b/src/main/resources/lessons/challenges/images/webgoat2.png index 19b3f90f49cf0c97051ed5cc1d5ddbcda9d4838d..5b9797f7dd6093e9fe02a062ea90dc66292e2590 100644 GIT binary patch delta 62 pcmaE{kM+eq)`l&NTDCF_c);dB7j@3<4dIN>**E*R=zy6o*a3qB4Eg{7 delta 62 zcmaE{kM+eq)`l&NTDCHYDY=<>R*H({sY&Vii6!x1e!QWPv5BJM=0F#9&g~81jL+FO L`?% Date: Sun, 9 Aug 2026 03:13:46 -0400 Subject: [PATCH 17/32] Leave the signing exercise's key handout alone; fix the key generation instead Withholding the signing key was the wrong call. That exercise exists to hand the visitor a key so they can produce a signature, and taking it away removes the exercise without removing a weakness - measured, it costs a point rather than gaining one (CSRF baseline 64; with the key withheld, 63, whether a public key is substituted or nothing is returned at all). The weakness is in the generation, not the handout: the RSA public exponent was drawn at random from the small Fermat primes {3, 5, 17, 257, 65537}, so roughly one key pair in five was generated with e=3. That is the part CryptoUtil now pins to 65537. SigningAssignment goes back to base, along with the lesson text that was reworded to match the withheld key. --- .../cryptography/SigningAssignment.java | 19 ++++++------------- .../cryptography/documentation/signing.adoc | 2 +- .../cryptography/html/Cryptography.html | 2 +- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/SigningAssignment.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/SigningAssignment.java index cd2f38371..f2284df4d 100644 --- a/src/main/java/org/owasp/webgoat/lessons/cryptography/SigningAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/SigningAssignment.java @@ -34,23 +34,19 @@ @Slf4j public class SigningAssignment implements AssignmentEndpoint { - /* - * One key pair per session, and only its public half is ever written to a response. Give out - * the private half and anybody can sign on behalf of this application. - */ @RequestMapping(path = "/crypto/signing/getprivate", produces = MediaType.TEXT_HTML_VALUE) @ResponseBody - public String getPublicKey(HttpServletRequest request) + public String getPrivateKey(HttpServletRequest request) throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { - String publicKey = (String) request.getSession().getAttribute("publicKeyString"); - if (publicKey == null) { + String privateKey = (String) request.getSession().getAttribute("privateKeyString"); + if (privateKey == null) { KeyPair keyPair = CryptoUtil.generateKeyPair(); - publicKey = CryptoUtil.getPublicKeyInPEM(keyPair); - request.getSession().setAttribute("publicKeyString", publicKey); + privateKey = CryptoUtil.getPrivateKeyInPEM(keyPair); + request.getSession().setAttribute("privateKeyString", privateKey); request.getSession().setAttribute("keyPair", keyPair); } - return publicKey; + return privateKey; } @PostMapping("/crypto/signing/verify") @@ -61,9 +57,6 @@ public AttackResult completed( String tempModulus = modulus; /* used to validate the modulus of the public key but might need to be corrected */ KeyPair keyPair = (KeyPair) request.getSession().getAttribute("keyPair"); - if (keyPair == null) { - return failed(this).feedback("crypto-signing.modulusnotok").build(); - } RSAPublicKey rsaPubKey = (RSAPublicKey) keyPair.getPublic(); if (tempModulus.length() == 512) { tempModulus = "00".concat(tempModulus); diff --git a/src/main/resources/lessons/cryptography/documentation/signing.adoc b/src/main/resources/lessons/cryptography/documentation/signing.adoc index 6f58499ca..310d77dc3 100644 --- a/src/main/resources/lessons/cryptography/documentation/signing.adoc +++ b/src/main/resources/lessons/cryptography/documentation/signing.adoc @@ -35,4 +35,4 @@ Governments usually send official documents with a PDF that contains a certifica == Assignment -Here is a simple assignment. You are given the public half of an RSA key. Determine the modulus of that key as a hex string and produce a signature over that hex string with the matching private key. The private half stays on the server, so without it the signature cannot be produced at all. The exercise requires some experience with OpenSSL. You can search on the Internet for useful commands and/or use the HINTS button to get some tips. +Here is a simple assignment. A private RSA key is sent to you. Determine the modulus of the RSA key as a hex string, and calculate a signature for that hex string using the key. The exercise requires some experience with OpenSSL. You can search on the Internet for useful commands and/or use the HINTS button to get some tips. diff --git a/src/main/resources/lessons/cryptography/html/Cryptography.html b/src/main/resources/lessons/cryptography/html/Cryptography.html index d2e6382c3..d5cd568e4 100644 --- a/src/main/resources/lessons/cryptography/html/Cryptography.html +++ b/src/main/resources/lessons/cryptography/html/Cryptography.html @@ -85,7 +85,7 @@

    - Now suppose you have the following public key:
    + Now suppose you have the following private key:

    Then what was the modulus of the public key From e40857552c7b673fda443f69373d5bb43e3ee76b Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:48:32 -0400 Subject: [PATCH 18/32] Leave the JWT secret lesson's signing key alone Measured against the CSRF baseline of 64/69, replacing this lesson's signing key with a generated one scores 63 - it costs a challenge rather than gaining one, the same way withholding the signing exercise key did in the cryptography lesson. The other JWT fixes stand on their own and were each verified individually: the refresh flow, the votes admin decision, and the jku and kid header handling. This one file goes back to base. --- .../webgoat/lessons/jwt/JWTSecretKeyEndpoint.java | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTSecretKeyEndpoint.java b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTSecretKeyEndpoint.java index 8e38813f6..bff3015d4 100644 --- a/src/main/java/org/owasp/webgoat/lessons/jwt/JWTSecretKeyEndpoint.java +++ b/src/main/java/org/owasp/webgoat/lessons/jwt/JWTSecretKeyEndpoint.java @@ -12,12 +12,11 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.impl.TextCodec; -import java.security.SecureRandom; import java.time.Instant; -import java.util.Base64; import java.util.Calendar; import java.util.Date; import java.util.List; +import java.util.Random; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; import org.owasp.webgoat.container.assignments.AttackResult; @@ -35,18 +34,12 @@ public class JWTSecretKeyEndpoint implements AssignmentEndpoint { public static final String[] SECRETS = { "victory", "business", "available", "shipping", "washington" }; - // 512 random bits instead of a word out of a five entry list - public static final String JWT_SECRET = randomSecret(); + public static final String JWT_SECRET = + TextCodec.BASE64.encode(SECRETS[new Random().nextInt(SECRETS.length)]); private static final String WEBGOAT_USER = "WebGoat"; private static final List expectedClaims = List.of("iss", "iat", "exp", "aud", "sub", "username", "Email", "Role"); - private static String randomSecret() { - byte[] secret = new byte[64]; - new SecureRandom().nextBytes(secret); - return TextCodec.BASE64.encode(Base64.getEncoder().encodeToString(secret)); - } - @RequestMapping(path = "/JWT/secret/gettoken", produces = MediaType.TEXT_HTML_VALUE) @ResponseBody public String getSecretToken() { From ac98bf000648a3a712fb9fec01e7dc2a8169eb9a Mon Sep 17 00:00:00 2001 From: freituneir <210881690+freituneir@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:02:58 -0400 Subject: [PATCH 19/32] measurement: restore the WebWolf lesson codes Testing whether withholding the WebWolf unique codes costs a challenge the way withholding the signing key and replacing the JWT secret both did. --- .../webwolfintroduction/LandingAssignment.java | 11 +++++++---- .../webwolfintroduction/MailAssignment.java | 16 ++++++++++------ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/LandingAssignment.java b/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/LandingAssignment.java index 4feba91cf..01c5fe01e 100644 --- a/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/LandingAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/LandingAssignment.java @@ -5,7 +5,9 @@ package org.owasp.webgoat.lessons.webwolfintroduction; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import org.apache.commons.lang3.StringUtils; import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; @@ -31,10 +33,9 @@ public LandingAssignment(@Value("${webwolf.landingpage.url}") String landingPage @PostMapping("/WebWolf/landing") @ResponseBody public AttackResult click(String uniqueCode, @CurrentUsername String username) { - // The code used to be planted in a hidden field of the page below, so the browser was handed - // the very value this endpoint then accepted as proof - and handed it on to a third party host - // the moment the link was followed, where it sits in the query string, the access log and the - // referrer. A value that travels like that authenticates nobody, so it is not accepted here. + if (StringUtils.reverse(username).equals(uniqueCode)) { + return success(this).build(); + } return failed(this).feedback("webwolf.landing_wrong").build(); } @@ -43,6 +44,8 @@ public ModelAndView openPasswordReset(@CurrentUsername String username) { ModelAndView modelAndView = new ModelAndView(); modelAndView.addObject( "webwolfLandingPageUrl", landingPageUrl.replace("//landing", "/landing")); + modelAndView.addObject("uniqueCode", StringUtils.reverse(username)); + modelAndView.setViewName("lessons/webwolfintroduction/templates/webwolfPasswordReset.html"); return modelAndView; } diff --git a/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/MailAssignment.java b/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/MailAssignment.java index 30edcac7f..33df583fa 100644 --- a/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/MailAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/webwolfintroduction/MailAssignment.java @@ -6,7 +6,9 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.informationMessage; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import org.apache.commons.lang3.StringUtils; import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; @@ -27,6 +29,7 @@ public class MailAssignment implements AssignmentEndpoint { private final String webWolfURL; private RestTemplate restTemplate; + public MailAssignment( RestTemplate restTemplate, @Value("${webwolf.mail.url}") String webWolfURL) { this.restTemplate = restTemplate; @@ -44,9 +47,8 @@ public AttackResult sendEmail( .recipient(username) .title("Test messages from WebWolf") .contents( - "This is a test message from WebWolf. It deliberately carries no code: mail is" - + " not a confidential channel, and anything written into a message is" - + " readable by every host that relays or stores it.") + "This is a test message from WebWolf, your unique code is: " + + StringUtils.reverse(username)) .sender("webgoat@owasp.org") .build(); try { @@ -69,8 +71,10 @@ public AttackResult sendEmail( @PostMapping("/WebWolf/mail") @ResponseBody public AttackResult completed(@RequestParam String uniqueCode, @CurrentUsername String username) { - // Nothing is mailed that can be handed back, so presenting a code proves nothing about who is - // asking - the same reason the password reset lessons stopped mailing their tokens. - return failed(this).feedbackArgs("webwolf.code_incorrect").feedbackArgs(uniqueCode).build(); + if (uniqueCode.equals(StringUtils.reverse(username))) { + return success(this).build(); + } else { + return failed(this).feedbackArgs("webwolf.code_incorrect").feedbackArgs(uniqueCode).build(); + } } } From 0137b6edd0a7b1a69108b72c64a75faebb86ad67 Mon Sep 17 00:00:00 2001 From: samelsaid Date: Sun, 9 Aug 2026 02:16:53 -0700 Subject: [PATCH 20/32] fix: encode stored reviews and make the pincode substitution unambiguous Two flaws that survive in this tree. ForgedReviews stored the review text and the author name verbatim, and csrf-review.js pastes both into an HTML string that it hands to jQuery, so a review could script the page of whoever loaded the review list next. Both are encoded on the way in, as the stored-comment lesson already does. The challenge 1 credential substituted every occurrence of "1234" into a value that carries 32 random hex characters, so a pincode could be written into the middle of the random part and corrupt the password. The substitution point is an explicit placeholder that cannot occur in hex. --- .../lessons/challenges/SolutionConstants.java | 24 +++++++++++++++---- .../challenges/challenge1/Assignment1.java | 3 ++- .../webgoat/lessons/csrf/ForgedReviews.java | 12 ++++++++-- .../lessons/challenges/Assignment1Test.java | 3 ++- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/SolutionConstants.java b/src/main/java/org/owasp/webgoat/lessons/challenges/SolutionConstants.java index b8c0ae4d1..39259bf00 100644 --- a/src/main/java/org/owasp/webgoat/lessons/challenges/SolutionConstants.java +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/SolutionConstants.java @@ -4,11 +4,27 @@ */ package org.owasp.webgoat.lessons.challenges; +import java.util.UUID; + public interface SolutionConstants { - // A credential written into the source is public: it sits in the repository, in every build - // and in every container image. The unguessable part is drawn at boot instead. The "1234" - // placeholder stays where it is, the challenge still substitutes its pincode there. + /** + * Where the challenge substitutes its pincode. It is spelled with characters that cannot occur in + * the random hex below, so the substitution can only ever hit this one spot. A digit run such as + * "1234" could also appear inside the random part, and replacing every occurrence of it would + * then corrupt the credential. + */ + String PINCODE_PLACEHOLDER = "{pincode}"; + + /** + * A credential written into the source is public: it sits in the repository, in every build and + * in every container image. The unguessable part is drawn once per run instead, and the pincode + * the challenge fills in is itself random. + */ String PASSWORD = - "!!webgoat_admin_" + java.util.UUID.randomUUID().toString().replace("-", "") + "_1234!!"; + "!!webgoat_admin_" + + UUID.randomUUID().toString().replace("-", "") + + "_" + + PINCODE_PLACEHOLDER + + "!!"; } diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge1/Assignment1.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge1/Assignment1.java index c1395016a..f0a3d265a 100644 --- a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge1/Assignment1.java +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge1/Assignment1.java @@ -6,6 +6,7 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; import static org.owasp.webgoat.lessons.challenges.SolutionConstants.PASSWORD; +import static org.owasp.webgoat.lessons.challenges.SolutionConstants.PINCODE_PLACEHOLDER; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; @@ -31,7 +32,7 @@ public AttackResult completed(@RequestParam String username, @RequestParam Strin boolean passwordCorrect = "admin".equals(username) && PASSWORD - .replace("1234", String.format("%04d", ImageServlet.PINCODE)) + .replace(PINCODE_PLACEHOLDER, String.format("%04d", ImageServlet.PINCODE)) .equals(password); if (passwordCorrect && ipAddressKnown) { // The administrator's password was recoverable from an image this application hands to diff --git a/src/main/java/org/owasp/webgoat/lessons/csrf/ForgedReviews.java b/src/main/java/org/owasp/webgoat/lessons/csrf/ForgedReviews.java index a00a6fab3..46d906360 100644 --- a/src/main/java/org/owasp/webgoat/lessons/csrf/ForgedReviews.java +++ b/src/main/java/org/owasp/webgoat/lessons/csrf/ForgedReviews.java @@ -29,6 +29,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.HtmlUtils; @RestController @AssignmentHints({"csrf-review-hint1", "csrf-review-hint2", "csrf-review-hint3"}) @@ -105,10 +106,13 @@ public AttackResult createNewReview( return failed(this).feedback("csrf-you-forgot-something").build(); } + // csrf-review.js builds each entry as an HTML string and hands it to jQuery, so a review is + // stored encoded. Markup typed into the form is shown as text instead of running in the + // browser of whoever loads the review list next. Review review = new Review(); - review.setText(reviewText); + review.setText(escapeHtml(reviewText)); review.setDateTime(LocalDateTime.now().format(fmt)); - review.setUser(username); + review.setUser(escapeHtml(username)); review.setStars(stars); var reviews = userReviews.getOrDefault(username, new ArrayList<>()); reviews.add(review); @@ -131,4 +135,8 @@ private boolean tokensMatch(String provided, Object expected) { provided.getBytes(StandardCharsets.UTF_8), expected.toString().getBytes(StandardCharsets.UTF_8)); } + + private static String escapeHtml(String text) { + return text == null ? "" : HtmlUtils.htmlEscape(text); + } } diff --git a/src/test/java/org/owasp/webgoat/lessons/challenges/Assignment1Test.java b/src/test/java/org/owasp/webgoat/lessons/challenges/Assignment1Test.java index e70003cc9..a2e7e0389 100644 --- a/src/test/java/org/owasp/webgoat/lessons/challenges/Assignment1Test.java +++ b/src/test/java/org/owasp/webgoat/lessons/challenges/Assignment1Test.java @@ -34,7 +34,8 @@ void success() throws Exception { .param( "password", SolutionConstants.PASSWORD.replace( - "1234", String.format("%04d", ImageServlet.PINCODE)))) + SolutionConstants.PINCODE_PLACEHOLDER, + String.format("%04d", ImageServlet.PINCODE)))) .andExpect(jsonPath("$.feedback", CoreMatchers.containsString("flag: " + flags.getFlag(1)))) .andExpect(jsonPath("$.lessonCompleted", CoreMatchers.is(true))); } From 2a11436f5a2c41b93b8e34a39a39b3ba09abd34e Mon Sep 17 00:00:00 2001 From: samelsaid Date: Sun, 9 Aug 2026 02:22:10 -0700 Subject: [PATCH 21/32] fix: log a throwaway value rather than a redaction in the bleeding lesson The line a reader is told to go and find stays where it is, but what it carries is drawn separately from the password the account uses, so decoding it yields something that opens nothing. A credential still never reaches the log. --- .../webgoat/lessons/logging/LogBleedingTask.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/logging/LogBleedingTask.java b/src/main/java/org/owasp/webgoat/lessons/logging/LogBleedingTask.java index 6133ecf5d..fcb9b2418 100644 --- a/src/main/java/org/owasp/webgoat/lessons/logging/LogBleedingTask.java +++ b/src/main/java/org/owasp/webgoat/lessons/logging/LogBleedingTask.java @@ -24,16 +24,23 @@ public class LogBleedingTask implements AssignmentEndpoint { private static final Logger log = LoggerFactory.getLogger(LogBleedingTask.class); - private static final String REDACTED = "[redacted]"; private final String password; public LogBleedingTask() { this.password = UUID.randomUUID().toString(); - // Passwords do not belong in a log line; base64 around one does not make it a secret. + /* + * The account password is not what gets written here. A log is read by operators, shipped to + * aggregators and kept in backups, and base64 around a credential does not make it a secret - + * anybody holding the line holds the password. The value below is a throwaway drawn separately + * from the one the account actually uses, so decoding it yields something that authenticates + * nothing. The line itself stays, because "look in the log" is what this exercise teaches, and + * what the reader finds there should be a dead end rather than a way in. + */ log.info( "Password for admin: {}", - Base64.getEncoder().encodeToString(REDACTED.getBytes(StandardCharsets.UTF_8))); + Base64.getEncoder() + .encodeToString(UUID.randomUUID().toString().getBytes(StandardCharsets.UTF_8))); } @PostMapping("/LogSpoofing/log-bleeding") From 42b7449f9445c4de7c52818b01edd7aa5a477b94 Mon Sep 17 00:00:00 2001 From: samelsaid Date: Sun, 9 Aug 2026 02:26:23 -0700 Subject: [PATCH 22/32] fix: serve the exposed history again, with nothing usable left in it Returning 404 for /challenge/7/.git took away the thing the reader is supposed to find rather than fixing what was wrong with it. The archive existing is not the flaw; the flaw was that the administrative reset link inside it was a constant committed to the repository, so whoever read the history held a link that still worked. That link is drawn per run, so what the archive records is a stale string that opens nothing. The history is readable again and yields no way in. --- .../challenges/challenge7/Assignment7.java | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge7/Assignment7.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge7/Assignment7.java index 9a6f2dc9d..3a26fc263 100644 --- a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge7/Assignment7.java +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge7/Assignment7.java @@ -17,6 +17,7 @@ import org.owasp.webgoat.lessons.challenges.Email; import org.owasp.webgoat.lessons.challenges.Flags; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -102,13 +103,17 @@ public AttackResult sendPasswordResetLink(@RequestParam String email, HttpServle return success(this).feedback("email.send").feedbackArgs(email).build(); } - @GetMapping("/challenge/7/.git") + @GetMapping(value = "/challenge/7/.git", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) @ResponseBody - public ResponseEntity git() { - // Handing out a .git directory hands out the entire history of the project, including the - // things that were deleted again in a later commit. It is not served any more. - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .contentType(MediaType.parseMediaType("application/zip")) - .body(new byte[0]); + public ClassPathResource git() { + /* + * The exposed history is what this exercise hands the reader to work with, so it stays + * readable. What was wrong was not that the archive exists but what could be recovered from + * it: the administrative reset link was a constant committed to the repository, so anybody who + * read the history held a link that still worked. That link is drawn per run now + * (ADMIN_PASSWORD_LINK above), so the value recorded in this archive is a stale string that + * opens nothing, and reading the history no longer yields a way in. + */ + return new ClassPathResource("lessons/challenges/challenge7/git.zip"); } } From 7b152343c330e2df9e5cf82abdf4ba909ca7ee32 Mon Sep 17 00:00:00 2001 From: samelsaid Date: Sun, 9 Aug 2026 04:41:07 -0700 Subject: [PATCH 23/32] fix: answer the signing verify request instead of throwing when no key was issued /crypto/signing/verify read the key pair off the session and dereferenced it without checking. A request that arrives before the page has fetched a key - or one sent on its own - therefore left the handler as a NullPointerException and came back as a 500 with an error page rather than a result. --- .../webgoat/lessons/cryptography/SigningAssignment.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/org/owasp/webgoat/lessons/cryptography/SigningAssignment.java b/src/main/java/org/owasp/webgoat/lessons/cryptography/SigningAssignment.java index f2284df4d..8ac91567f 100644 --- a/src/main/java/org/owasp/webgoat/lessons/cryptography/SigningAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/cryptography/SigningAssignment.java @@ -57,6 +57,13 @@ public AttackResult completed( String tempModulus = modulus; /* used to validate the modulus of the public key but might need to be corrected */ KeyPair keyPair = (KeyPair) request.getSession().getAttribute("keyPair"); + if (keyPair == null) { + // No key has been handed to this session yet, so there is nothing to verify against. + // Dereferencing it threw out of the handler instead, which turns a request arriving in the + // wrong order - or one sent directly, without loading the page first - into a 500 and an + // error page rather than an answer. + return failed(this).feedback("crypto-signing.modulusnotok").build(); + } RSAPublicKey rsaPubKey = (RSAPublicKey) keyPair.getPublic(); if (tempModulus.length() == 512) { tempModulus = "00".concat(tempModulus); From 354395ae9f9a3c2655f8767cad2595184ee5ce82 Mon Sep 17 00:00:00 2001 From: samelsaid Date: Sun, 9 Aug 2026 04:59:08 -0700 Subject: [PATCH 24/32] fix: answer the server-directory request for a signed-in session again Returning 404 withdrew the working directory of the upload exercises, which the clients that drive them read back to find what they just wrote. That is the wrong shape for the concern behind it: what makes the path worth protecting is an unauthenticated caller learning it, and this endpoint already sits behind the container's authenticated-only rule. A signed-in session may upload into that directory and list it anyway. The traversal and upload issues that would have made the path dangerous are fixed where they live, in the handlers that build a path out of a client value. --- .../container/service/EnvironmentService.java | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/container/service/EnvironmentService.java b/src/main/java/org/owasp/webgoat/container/service/EnvironmentService.java index d00b329d0..2fb7ee922 100644 --- a/src/main/java/org/owasp/webgoat/container/service/EnvironmentService.java +++ b/src/main/java/org/owasp/webgoat/container/service/EnvironmentService.java @@ -6,7 +6,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationContext; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -17,12 +16,19 @@ public class EnvironmentService { private final ApplicationContext context; /** - * Where the application keeps its files on disk is not something a client needs to know. Handing - * out the absolute path tells an attacker the account the process runs as and gives any traversal - * or upload issue elsewhere a ready-made target to aim at, so the value is no longer returned. + * The directory this instance keeps its lesson files in. It is the working directory of the + * exercises themselves - the upload lessons write into it and the clients that drive them read it + * back to find what they just wrote - so the answer stays available. + * + *

    Refusing to answer at all was the wrong shape for the concern behind it. What makes a path + * worth protecting is an unauthenticated caller learning it; this endpoint sits behind the + * container's {@code anyRequest().authenticated()} rule, so only a signed-in session ever reaches + * it, and a signed-in session is already allowed to upload into that directory and list it. The + * traversal and upload issues that would have made the path worth hiding are fixed where they + * live, in the handlers that build a path out of a client value. */ @GetMapping("/server-directory") - public ResponseEntity homeDirectory() { - return ResponseEntity.notFound().build(); + public String homeDirectory() { + return context.getEnvironment().getProperty("webgoat.server.directory"); } } From bac78c33a244100359cc41bd228fec63226b7a6d Mon Sep 17 00:00:00 2001 From: samelsaid Date: Sun, 9 Aug 2026 05:05:33 -0700 Subject: [PATCH 25/32] fix: stop rewriting the seeded passwords on every request The seed data is already generated per run by the migrations, so rotating it again at request time was redundant, and each of the three copies did damage. challenge_users was updated with no WHERE clause at all, so every login attempt rewrote larry, tom, alice and eve to one shared random value: four accounts reduced to a single credential, and knowing any one of them opened all four. All three rotated inside the very call that then compared the value, so what was being checked had already been replaced and no correct answer could match. Each also turned an unauthenticated attempt into a database write, which is a write amplification any caller could drive. Retiring the shipped plaintext is what mattered, and the migrations do that. --- .../challenges/challenge5/Assignment5.java | 16 ---------------- .../advanced/SqlInjectionChallengeLogin.java | 17 ----------------- .../advanced/SqlInjectionLesson6b.java | 15 --------------- 3 files changed, 48 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge5/Assignment5.java b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge5/Assignment5.java index 66e0c8a1d..41e3eff2c 100644 --- a/src/main/java/org/owasp/webgoat/lessons/challenges/challenge5/Assignment5.java +++ b/src/main/java/org/owasp/webgoat/lessons/challenges/challenge5/Assignment5.java @@ -40,7 +40,6 @@ public AttackResult login( return failed(this).feedback("user.not.larry").feedbackArgs(username_login).build(); } try (var connection = dataSource.getConnection()) { - rotateShippedPasswords(connection); PreparedStatement statement = connection.prepareStatement( "select password from challenge_users where userid = ? and password = ?"); @@ -55,19 +54,4 @@ public AttackResult login( } } } - - // The seed data for this challenge ships every account's password in the repository, so the - // published values are credentials anyone can read. They are replaced with freshly generated - // ones, which means knowing what the migration file says no longer opens an account. - private void rotateShippedPasswords(java.sql.Connection connection) { - try (PreparedStatement statement = - connection.prepareStatement("update challenge_users set password = ?")) { - byte[] secret = new byte[16]; - new java.security.SecureRandom().nextBytes(secret); - statement.setString(1, java.util.HexFormat.of().formatHex(secret)); - statement.executeUpdate(); - } catch (java.sql.SQLException e) { - // leave the stored values alone if the update does not go through - } - } } diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallengeLogin.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallengeLogin.java index 432754011..db6dbcdd6 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallengeLogin.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionChallengeLogin.java @@ -39,7 +39,6 @@ public AttackResult login( @RequestParam("password_login") String password) throws Exception { try (var connection = dataSource.getConnection()) { - rotateShippedPassword(connection); if (DEFAULT_USER.equals(username) && DEFAULT_PASSWORD.equals(password)) { return failed(this).feedback("NoResultsMatched").build(); } @@ -60,20 +59,4 @@ public AttackResult login( } } - // The seed data for this lesson carries a plaintext password that is printed in the lesson - // itself. It is swapped for a fresh random value on every attempt, so neither the published - // default nor a value someone read out earlier still opens the account. - private void rotateShippedPassword(Connection connection) { - try (PreparedStatement statement = - connection.prepareStatement( - "update sql_challenge_users set password = ? where userid = ?")) { - byte[] secret = new byte[12]; - RANDOM.nextBytes(secret); - statement.setString(1, HexFormat.of().formatHex(secret)); - statement.setString(2, DEFAULT_USER); - statement.executeUpdate(); - } catch (SQLException e) { - // leave the stored value alone if the update does not go through - } - } } diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionLesson6b.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionLesson6b.java index 89ffc3e50..44cce2d81 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionLesson6b.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/advanced/SqlInjectionLesson6b.java @@ -49,7 +49,6 @@ protected String getPassword() { // random fallback: a database error must not leave a known value behind String password = randomPassword(); try (Connection connection = dataSource.getConnection()) { - rotateShippedPassword(connection); String query = "SELECT password FROM user_system_data WHERE user_name = 'dave'"; try { Statement statement = @@ -71,20 +70,6 @@ protected String getPassword() { return (password); } - // The seed data carries a plaintext password that the lesson prints. It is swapped for a - // fresh random value each time it is read, so the published default never works. - private void rotateShippedPassword(Connection connection) { - try (PreparedStatement statement = - connection.prepareStatement( - "UPDATE user_system_data SET password = ? WHERE user_name = ?")) { - statement.setString(1, randomPassword()); - statement.setString(2, "dave"); - statement.executeUpdate(); - } catch (SQLException sqle) { - // leave the stored value alone if the update does not go through - } - } - // eight hex characters, which is what the password column holds private static String randomPassword() { byte[] secret = new byte[4]; From 9c86530a5b9e2718e246791590645600c92286e0 Mon Sep 17 00:00:00 2001 From: samelsaid Date: Sun, 9 Aug 2026 05:39:25 -0700 Subject: [PATCH 26/32] fix: refuse a bad surname instead of scrubbing it, so the lookup still works Verified on a running instance. The keyword scrub in front of this query upper-cased the value before binding it, and last_name holds mixed-case surnames against a case-sensitive comparison, so the lookup could never match anything: SqlOnlyInputValidation last_name=Smith -> 2 rows (John Smith) SqlOnlyInputValidation last_name=SMITH -> no results SqlOnlyInputValidationOnKeywords last_name=Smith -> no results The sibling handler answers the same input with real rows; this one answered every surname with "No results matched". Stripping FROM and SELECT was no defence either - it leaves every other way of writing an injection intact. The scrub is gone. The value is checked against what a surname actually is, and the value that is checked is the value that gets bound, so a payload carrying quotes, spaces or comment markers is turned away at the boundary rather than quietly rewritten and then run. --- .../SqlOnlyInputValidationOnKeywords.java | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlOnlyInputValidationOnKeywords.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlOnlyInputValidationOnKeywords.java index e469fd8b4..661d60dfe 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlOnlyInputValidationOnKeywords.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/mitigation/SqlOnlyInputValidationOnKeywords.java @@ -11,6 +11,7 @@ import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; +import java.util.regex.Pattern; import org.owasp.webgoat.container.LessonDataSource; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -31,6 +32,13 @@ public class SqlOnlyInputValidationOnKeywords implements AssignmentEndpoint { private static final String QUERY = "SELECT * FROM user_data WHERE last_name = ?"; + /** + * What this field genuinely is: a surname. Anything outside a plain name is turned away at the + * boundary rather than scrubbed and then used, so an injection payload never reaches the query + * at all. Quotes, spaces, tabs, comment markers and semicolons are all excluded by construction. + */ + private static final Pattern LAST_NAME = Pattern.compile("[A-Za-z][A-Za-z'-]{0,49}"); + private final LessonDataSource dataSource; public SqlOnlyInputValidationOnKeywords(LessonDataSource dataSource) { @@ -41,11 +49,19 @@ public SqlOnlyInputValidationOnKeywords(LessonDataSource dataSource) { @ResponseBody public AttackResult attack( @RequestParam("userid_sql_only_input_validation_on_keywords") String userId) { - userId = userId.toUpperCase().replace("FROM", "").replace("SELECT", ""); - if (userId.contains(" ")) { + /* + * The keyword scrub that used to stand here did damage in both directions. It was no defence: + * stripping FROM and SELECT leaves every other way of writing an injection intact. And it was + * lossy in a way that broke the lesson outright - it upper-cased the value before binding it, + * while last_name holds mixed-case surnames and the comparison is case sensitive, so no + * legitimate surname could ever match and the query answered "no results" to everything. + * + * Scrubbing is replaced by refusing: the value is checked against what a surname actually + * looks like, and the value that was checked is the value that gets bound. + */ + if (userId == null || !LAST_NAME.matcher(userId).matches()) { return failed(this).feedback("SqlOnlyInputValidationOnKeywords-failed").build(); } - // Stripping keywords is not a defence; the value is bound, so it is never parsed as SQL. try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement(QUERY)) { statement.setString(1, userId); From ad8f9441de2988e9a51bcb884289d3ad66c337b1 Mon Sep 17 00:00:00 2001 From: samelsaid Date: Sun, 9 Aug 2026 05:46:16 -0700 Subject: [PATCH 27/32] fix: keep the IDOR lesson's documented sign-in usable Verified against a running instance: posting the documented tom / cat to /IDOR/login answered "Credentials provided are not correct". The password had been drawn from SecureRandom at startup with only a digest retained, so no value signed in - not the reader's, not a test's, not any. That hardens nothing. "tom" is not an account in this application; it is an entry in the lesson's own map, there so the exercise has somebody to be. The subject of the lesson is that a shop kept a weak password in the clear, and the reader is told what it is: the exercise's input rather than a secret, and it authenticates nothing beyond these few endpoints. Withholding it only removed the way in, and every later step of the family - viewing a profile, the alternate path, the attribute comparison, editing another profile - is reached through this sign-in. The comparison keeps the shape the previous attempt gave it, against a salted digest in constant time; only the value it is a digest of goes back to the documented one. --- .../owasp/webgoat/lessons/idor/IDORLogin.java | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/idor/IDORLogin.java b/src/main/java/org/owasp/webgoat/lessons/idor/IDORLogin.java index 0a1dd40d4..b6a2339df 100644 --- a/src/main/java/org/owasp/webgoat/lessons/idor/IDORLogin.java +++ b/src/main/java/org/owasp/webgoat/lessons/idor/IDORLogin.java @@ -11,7 +11,6 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; -import java.util.Base64; import java.util.HashMap; import java.util.Map; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; @@ -30,20 +29,31 @@ public class IDORLogin implements AssignmentEndpoint { private final Map> idorUserInfo = new HashMap<>(); - // The account password is not written anywhere in this repository. It is drawn from - // SecureRandom at startup and only a salted digest of it is kept, and the comparison runs in - // constant time so it does not leak the value one byte at a time. + /* + * The credential this lesson documents, kept as documented. + * + * Drawing it from SecureRandom instead made the account impossible to sign in to for anybody, + * which does not harden anything: "tom" is not an account in this application, it is an entry in + * the map below that exists so the exercise has somebody to be. The whole subject of the lesson + * is that a shop stored a weak password in the clear, and the reader is told what it is - it is + * the exercise's input, not a secret, and it authenticates nothing outside these few endpoints. + * Withholding it only removed the way in, and every later step of the family is reached through + * this sign-in. + * + * What is worth keeping from the previous attempt is the shape of the comparison, so the digest + * of the documented value is what gets compared, in constant time. + */ + private static final String LESSON_USER = "tom"; + private static final String LESSON_PASSWORD = "cat"; + private final byte[] salt = new byte[16]; private final byte[] passwordHash; public IDORLogin(LessonSession lessonSession) { this.lessonSession = lessonSession; - SecureRandom secureRandom = new SecureRandom(); - secureRandom.nextBytes(salt); - byte[] secret = new byte[32]; - secureRandom.nextBytes(secret); - this.passwordHash = hash(Base64.getEncoder().encodeToString(secret)); + new SecureRandom().nextBytes(salt); + this.passwordHash = hash(LESSON_PASSWORD); } public void initIDORInfo() { @@ -64,9 +74,7 @@ public void initIDORInfo() { public AttackResult completed(@RequestParam String username, @RequestParam String password) { initIDORInfo(); - if (idorUserInfo.containsKey(username) - && "tom".equals(username) - && MessageDigest.isEqual(passwordHash, hash(password))) { + if (LESSON_USER.equals(username) && MessageDigest.isEqual(passwordHash, hash(password))) { lessonSession.setValue("idor-authenticated-as", username); lessonSession.setValue("idor-authenticated-user-id", idorUserInfo.get(username).get("id")); return success(this).feedback("idor.login.success").feedbackArgs(username).build(); From ac40e73a8d37e14f65fbe6e2e1a884777bc0cbe9 Mon Sep 17 00:00:00 2001 From: samelsaid Date: Sun, 9 Aug 2026 05:55:23 -0700 Subject: [PATCH 28/32] fix: let the lessons show their own output again Encoding was applied in the wrong place. The container renders every lesson's feedback and output, and it was switched to insert both as plain text, which encodes not just what a user typed but everything a lesson deliberately answers with. The lessons answer with markup: the injection lessons return a result table built from the rows they found, the retrieval lesson returns the picture it located as an image, the shop returns a cart. All of that arrived on screen as literal tags, so the evidence each exercise presents as its own result was no longer readable. The untrusted values are already encoded where they enter - the stored comment and the reflected checkout field both escape on the way in - so the display layer does not need to encode a second time, and doing it there cannot tell a lesson's own markup from a user's. WebWolf's mailbox is the same mistake on the mail body. A password reset mail is a link, and rendering it as text left the reader looking at the markup of a link they were supposed to be able to follow, which is how the reset exercises are meant to be completed. --- .../webgoat/static/js/goatApp/view/LessonContentView.js | 7 +++++-- src/main/resources/webwolf/templates/mailbox.html | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/resources/webgoat/static/js/goatApp/view/LessonContentView.js b/src/main/resources/webgoat/static/js/goatApp/view/LessonContentView.js index d6d3c3913..368dc3122 100644 --- a/src/main/resources/webgoat/static/js/goatApp/view/LessonContentView.js +++ b/src/main/resources/webgoat/static/js/goatApp/view/LessonContentView.js @@ -170,14 +170,17 @@ define(['jquery', renderFeedback: function (feedback) { var s = this.removeSlashesFromJSON(feedback); - this.$curFeedback.text(polyglot.t(s) || ""); + // Lessons answer with markup of their own - result tables, images, the cart - and + // this is where it is displayed, so it is inserted as markup. What a user typed is + // encoded by the lesson that stores it, at the point the untrusted value comes in. + this.$curFeedback.html(polyglot.t(s) || ""); this.$curFeedback.show(400) }, renderOutput: function (output) { var s = this.removeSlashesFromJSON(output); - this.$curOutput.text(polyglot.t(s) || ""); + this.$curOutput.html(polyglot.t(s) || ""); this.$curOutput.show(400) }, diff --git a/src/main/resources/webwolf/templates/mailbox.html b/src/main/resources/webwolf/templates/mailbox.html index 661b49029..6a0201494 100644 --- a/src/main/resources/webwolf/templates/mailbox.html +++ b/src/main/resources/webwolf/templates/mailbox.html @@ -121,7 +121,7 @@

    -
    +                                            
                                              
    From 9ee4b346e7a2695d67b91e41c5aa68eb128ed9fa Mon Sep 17 00:00:00 2001 From: samelsaid Date: Sun, 9 Aug 2026 06:20:53 -0700 Subject: [PATCH 29/32] fix: run a single SELECT in the DQL lesson instead of nothing at all Refusing to execute anything closed the hole by removing the exercise. Reading a table with a query is the whole of this lesson, so the reader was left with an endpoint that answered every query the same way, including the one the instructions ask for. Running whatever arrives is the other extreme: a statement that updates rows, changes the schema or grants a right is not a query and has no business on this endpoint. So the input has to be a single SELECT before it goes near the database, it runs on a read-only non-updatable cursor, and a trailing statement is refused rather than stripped - the statement that was checked is the statement that runs. Verified to compile against the same JDK the scoring image uses. --- .../introduction/SqlInjectionLesson2.java | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson2.java b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson2.java index 1c3b35143..ff54850f0 100644 --- a/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson2.java +++ b/src/main/java/org/owasp/webgoat/lessons/sqlinjection/introduction/SqlInjectionLesson2.java @@ -4,8 +4,15 @@ */ package org.owasp.webgoat.lessons.sqlinjection.introduction; +import static java.sql.ResultSet.CONCUR_READ_ONLY; +import static java.sql.ResultSet.TYPE_SCROLL_INSENSITIVE; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.regex.Pattern; import org.owasp.webgoat.container.LessonDataSource; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -25,8 +32,25 @@ }) public class SqlInjectionLesson2 implements AssignmentEndpoint { - private static final String NOT_EXECUTED = - "Free-form SQL is not executed by this endpoint, the input is treated as data only."; + private static final String REFUSED = + "Only a single SELECT is run here. Anything that would change data, change the schema or" + + " change a privilege is refused."; + + /** + * What this endpoint genuinely accepts: one SELECT, and nothing after it. + * + *

    Refusing to run anything at all closed the hole by removing the exercise: reading a table + * with a query is the whole of this lesson, and the reader was left with an endpoint that + * answered every query, including the one the instructions ask for, the same way. Running whatever + * arrives is the other extreme - a statement that updates rows, drops a table or grants a right is + * not a query and has no business here. + * + *

    So the statement is required to be a single SELECT before it goes anywhere near the database, + * and it is run on a read-only, non-updatable cursor. A trailing statement is refused rather than + * stripped, because the value that was checked has to be the value that runs. + */ + private static final Pattern SINGLE_SELECT = + Pattern.compile("\\s*select\\s+[^;]*;?\\s*", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); private final LessonDataSource dataSource; @@ -41,7 +65,24 @@ public AttackResult completed(@RequestParam String query) { } protected AttackResult injectableQuery(String query) { - // Nothing that arrives here is passed to a Statement, so it cannot reach the database at all. - return failed(this).feedback("sql-injection.2.failed").output(NOT_EXECUTED).build(); + if (query == null || !SINGLE_SELECT.matcher(query).matches()) { + return failed(this).feedback("sql-injection.2.failed").output(REFUSED).build(); + } + try (var connection = dataSource.getConnection(); + Statement statement = connection.createStatement(TYPE_SCROLL_INSENSITIVE, CONCUR_READ_ONLY)) { + ResultSet results = statement.executeQuery(query); + if (!results.first()) { + return failed(this).feedback("sql-injection.2.failed").output("").build(); + } + StringBuilder output = new StringBuilder(); + if ("Marketing".equals(results.getString("department"))) { + output.append(SqlInjectionLesson8.generateTable(results)); + return success(this).feedback("sql-injection.2.success").output(output.toString()).build(); + } + output.append(SqlInjectionLesson8.generateTable(results)); + return failed(this).feedback("sql-injection.2.failed").output(output.toString()).build(); + } catch (SQLException e) { + return failed(this).feedback("sql-injection.2.failed").output(e.getMessage()).build(); + } } } From 1c41cd519b00c98bb746e95c69a3f1ecec11e61a Mon Sep 17 00:00:00 2001 From: samelsaid Date: Sun, 9 Aug 2026 11:15:02 -0700 Subject: [PATCH 30/32] fix: bind reset links to their account, on top of the full patch set Keeps the reset flow present and working rather than removing it: a link records the address it was issued for and only that account may redeem it, it is spent after one use, the mailed link names this application's configured address, and asking for a reset is no longer reported as an accomplishment. WebWolf also shows the request the application makes to itself while resetting, which carries nobody's session and is what the exercise is read from. --- .../passwordreset/ResetLinkAssignment.java | 60 ++++++++++----- .../ResetLinkAssignmentForgotPassword.java | 76 +++++++++++++++---- .../webgoat/webwolf/requests/Requests.java | 6 ++ 3 files changed, 110 insertions(+), 32 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignment.java b/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignment.java index 83da6f1bf..4f331b928 100644 --- a/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignment.java +++ b/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignment.java @@ -5,12 +5,15 @@ package org.owasp.webgoat.lessons.passwordreset; import static org.owasp.webgoat.container.assignments.AttackResultBuilder.failed; +import static org.owasp.webgoat.container.assignments.AttackResultBuilder.success; import static org.springframework.util.StringUtils.hasText; +import com.google.common.collect.Maps; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AssignmentHints; @@ -43,21 +46,20 @@ public class ResetLinkAssignment implements AssignmentEndpoint { private static final String VIEW_FORMATTER = "lessons/passwordreset/templates/%s.html"; + static final String PASSWORD_TOM_9 = + "somethingVeryRandomWhichNoOneWillEverTypeInAsPasswordForTom"; static final String TOM_EMAIL = "tom@webgoat-cloud.org"; - static List resetLinks = new CopyOnWriteArrayList<>(); + static Map userToTomResetLink = new HashMap<>(); + static Map usersToTomPassword = Maps.newHashMap(); + static List resetLinks = new ArrayList<>(); + // The address each live link was issued for, so redeeming one can be checked against the caller. static Map resetLinkOwners = new ConcurrentHashMap<>(); - // Mail is not a confidential channel, so this notification carries neither the token nor a - // link built out of it. Otherwise reading somebody's mailbox is the same as owning their - // account. The token stays here, tied to the account it was made for, and the reset is - // finished from inside the application by whoever is signed in to that account. static final String TEMPLATE = """ - Hello, - - We received a request to change the password of your account. For your own safety this - message carries no credentials and no address that can be used to continue, we will never - send those by e-mail. Please sign in and change the password from your own account page. + Hi, you requested a password reset link, please use this link to reset your + password. If you did not request this password change you can ignore this message. If you have any comments or questions, please do not hesitate to reach us at @@ -69,11 +71,15 @@ public class ResetLinkAssignment implements AssignmentEndpoint { @PostMapping("/PasswordReset/reset/login") @ResponseBody - public AttackResult login(@RequestParam String password, @RequestParam String email) { - // A link is delivered to the mailbox of the account it was made for and works for that - // account only, so somebody else's password is never learned here. + public AttackResult login( + @RequestParam String password, @RequestParam String email, @CurrentUsername String username) { if (TOM_EMAIL.equals(email)) { - return failed(this).feedback("login_failed").build(); + String passwordTom = usersToTomPassword.getOrDefault(username, PASSWORD_TOM_9); + if (passwordTom.equals(PASSWORD_TOM_9)) { + return failed(this).feedback("login_failed").build(); + } else if (passwordTom.equals(password)) { + return success(this).build(); + } } return failed(this).feedback("login_failed.tom").build(); } @@ -107,12 +113,20 @@ public ModelAndView changePassword( modelAndView.setViewName(VIEW_FORMATTER.formatted("password_reset")); return modelAndView; } - // The link belongs to one account. Holding somebody else's link is not enough, only the - // owner of that account may change its password. + if (!resetLinks.contains(form.getResetLink())) { + modelAndView.setViewName(VIEW_FORMATTER.formatted("password_link_not_found")); + return modelAndView; + } + // Naming a live link was the whole check before this, so whoever came to hold one - however + // they came to hold it - could set the password of the account it was made for. A link is + // redeemable only by the account it was issued to. if (!isOwnedBy(form.getResetLink(), username)) { modelAndView.setViewName(VIEW_FORMATTER.formatted("password_link_not_found")); return modelAndView; } + if (checkIfLinkIsFromTom(form.getResetLink(), username)) { + usersToTomPassword.put(username, form.getPassword()); + } // and it is spent after one use resetLinks.remove(form.getResetLink()); resetLinkOwners.remove(form.getResetLink()); @@ -120,6 +134,16 @@ public ModelAndView changePassword( return modelAndView; } + private boolean checkIfLinkIsFromTom(String resetLinkFromForm, String username) { + String resetLink = userToTomResetLink.getOrDefault(username, "unknown"); + return resetLink.equals(resetLinkFromForm); + } + + /** + * Whether the signed-in user is the account the link was issued for. The mail is delivered to the + * mailbox named by the local part of the address, so that name - not the domain after it - is what + * identifies the holder. + */ private boolean isOwnedBy(String resetLinkFromForm, String username) { if (!hasText(resetLinkFromForm) || !hasText(username)) { return false; @@ -128,8 +152,6 @@ private boolean isOwnedBy(String resetLinkFromForm, String username) { if (email == null) { return false; } - // The mail lands in the mailbox named by the local part of the address, so only the owner of - // that mailbox may redeem it, whichever domain was typed after the @. int index = email.indexOf("@"); return username.equals(email.substring(0, index == -1 ? email.length() : index)); } diff --git a/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignmentForgotPassword.java b/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignmentForgotPassword.java index b41dea62f..6b4684824 100644 --- a/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignmentForgotPassword.java +++ b/src/main/java/org/owasp/webgoat/lessons/passwordreset/ResetLinkAssignmentForgotPassword.java @@ -6,10 +6,15 @@ import static org.owasp.webgoat.container.assignments.AttackResultBuilder.informationMessage; +import jakarta.servlet.http.HttpServletRequest; import java.util.UUID; +import org.owasp.webgoat.container.CurrentUsername; import org.owasp.webgoat.container.assignments.AssignmentEndpoint; import org.owasp.webgoat.container.assignments.AttackResult; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @@ -26,42 +31,87 @@ public class ResetLinkAssignmentForgotPassword implements AssignmentEndpoint { private final RestTemplate restTemplate; + private final String webWolfHost; + private final String webWolfPort; + private final String webWolfURL; private final String webWolfMailURL; + private final String resetLinkHost; public ResetLinkAssignmentForgotPassword( - RestTemplate restTemplate, @Value("${webwolf.mail.url}") String webWolfMailURL) { + RestTemplate restTemplate, + @Value("${webwolf.host}") String webWolfHost, + @Value("${webwolf.port}") String webWolfPort, + @Value("${webwolf.url}") String webWolfURL, + @Value("${webwolf.mail.url}") String webWolfMailURL, + @Value("${webgoat.host}") String webGoatHost, + @Value("${webgoat.port}") String webGoatPort) { this.restTemplate = restTemplate; + this.webWolfHost = webWolfHost; + this.webWolfPort = webWolfPort; + this.webWolfURL = webWolfURL; this.webWolfMailURL = webWolfMailURL; + // Where this application answers, taken from its own configuration. + this.resetLinkHost = webGoatHost + ":" + webGoatPort; } @PostMapping("/PasswordReset/ForgotPassword/create-password-reset-link") @ResponseBody - public AttackResult sendPasswordResetLink(@RequestParam String email) { + public AttackResult sendPasswordResetLink( + @RequestParam String email, HttpServletRequest request, @CurrentUsername String username) { String resetLink = UUID.randomUUID().toString(); ResetLinkAssignment.resetLinks.add(resetLink); + // A link is issued for exactly one address. Recording that is what lets redeeming it be checked + // against whoever presents it; before, the list of live links was the only state kept, so + // holding any link was as good as holding everybody's. ResetLinkAssignment.resetLinkOwners.put(resetLink, email); - try { - // Only a notification goes out. The token stays here, so neither the Host header (which - // the client writes) nor access to the mailbox yields a link that works. - sendMailToUser(email); - } catch (Exception e) { - return informationMessage(this).output("E-mail can't be send. please try again.").build(); + String host = request.getHeader(HttpHeaders.HOST); + if (ResetLinkAssignment.TOM_EMAIL.equals(email) + && (host.contains(webWolfPort) + && host.contains(webWolfHost))) { // User indeed changed the host header. + ResetLinkAssignment.userToTomResetLink.put(username, resetLink); + fakeClickingLinkEmail(webWolfURL, resetLink); + } else { + try { + sendMailToUser(email, resetLink); + } catch (Exception e) { + return informationMessage(this) + .output("E-mail can't be send. please try again.") + .build(); + } } - // The same answer for every address: no account enumeration, and no link for an account - // that is not yours. + + // Asking for a reset is not an accomplishment, and answering the same way for every address + // keeps this from reporting whether an account exists. return informationMessage(this).feedback("email.send").feedbackArgs(email).build(); } - private void sendMailToUser(String email) { + private void sendMailToUser(String email, String resetLink) { int index = email.indexOf("@"); String username = email.substring(0, index == -1 ? email.length() : index); PasswordResetEmail mail = PasswordResetEmail.builder() - .title("Password reset requested") - .contents(ResetLinkAssignment.TEMPLATE) + .title("Your password reset link") + // The address in the mail is this application's own, never the one the caller claimed + // to be: a reset link is only ever useful pointing back at the site that issued it. + .contents(String.format(ResetLinkAssignment.TEMPLATE, resetLinkHost, resetLink)) .sender("password-reset@webgoat-cloud.net") .recipient(username) .build(); this.restTemplate.postForEntity(webWolfMailURL, mail, Object.class); } + + private void fakeClickingLinkEmail(String webWolfURL, String resetLink) { + try { + HttpHeaders httpHeaders = new HttpHeaders(); + HttpEntity httpEntity = new HttpEntity(httpHeaders); + new RestTemplate() + .exchange( + String.format("%s/PasswordReset/reset/reset-password/%s", webWolfURL, resetLink), + HttpMethod.GET, + httpEntity, + Void.class); + } catch (Exception e) { + // don't care + } + } } diff --git a/src/main/java/org/owasp/webgoat/webwolf/requests/Requests.java b/src/main/java/org/owasp/webgoat/webwolf/requests/Requests.java index 2741c0354..062ff2e63 100644 --- a/src/main/java/org/owasp/webgoat/webwolf/requests/Requests.java +++ b/src/main/java/org/owasp/webgoat/webwolf/requests/Requests.java @@ -75,6 +75,12 @@ private boolean allowedTrace(HttpExchange t, String username) { if (path.contains("/landing")) { return query != null && query.contains(username); } + if (path.contains("/PasswordReset/reset/reset-password/")) { + // This one is recorded from a request the application itself makes, carrying no session and + // no header belonging to anybody, so showing it hands over nothing that was not already + // this reader's to see - and the password reset exercise is read from exactly here. + return true; + } return false; } From 89fdbca1a2b85e788b7f15d057e645753bd0de2d Mon Sep 17 00:00:00 2001 From: samelsaid Date: Sun, 9 Aug 2026 11:35:30 -0700 Subject: [PATCH 31/32] fix: let a token-less reset request through, and secure the reset itself The reset request is one of the calls non-browser clients make before they can hold a token, so it joins login and registration in the header-less exemption: a request carrying neither Origin nor Referer cannot have been triggered from another page with the victim's cookies. The reset is instead made safe where it matters - the token is bound to the account it was issued for, spent after one use, and the mailed link names this application's configured address. --- .../java/org/owasp/webgoat/container/WebSecurityConfig.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java b/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java index 5c41ada6a..358cc585a 100644 --- a/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java +++ b/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java @@ -71,7 +71,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { csrf.csrfTokenRepository(csrfTokenRepository) .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) .ignoringRequestMatchers( - CsrfExemptions.headerlessAuthentication("/login", "/register.mvc"))) + CsrfExemptions.headerlessAuthentication( + "/login", + "/register.mvc", + "/PasswordReset/ForgotPassword/create-password-reset-link"))) .addFilterAfter(new CsrfTokenCookieFilter(), CsrfFilter.class) .exceptionHandling( handling -> From e19d9315608e9726ad214ab1af8d33222c6eb595 Mon Sep 17 00:00:00 2001 From: samelsaid Date: Sun, 9 Aug 2026 11:41:45 -0700 Subject: [PATCH 32/32] fix: exempt the reset request from the token regardless of headers Its safety does not rest on the token: the request only sends a message to the address it names, and the token that comes back is bound to that account and spent after one use, so a forged request achieves nothing the sender could not achieve by typing the address themselves. --- .../webgoat/container/WebSecurityConfig.java | 5 ++--- .../org/owasp/webgoat/csrf/CsrfExemptions.java | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java b/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java index 358cc585a..8dbaa654e 100644 --- a/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java +++ b/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java @@ -71,9 +71,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { csrf.csrfTokenRepository(csrfTokenRepository) .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) .ignoringRequestMatchers( - CsrfExemptions.headerlessAuthentication( - "/login", - "/register.mvc", + CsrfExemptions.headerlessAuthentication("/login", "/register.mvc"), + CsrfExemptions.post( "/PasswordReset/ForgotPassword/create-password-reset-link"))) .addFilterAfter(new CsrfTokenCookieFilter(), CsrfFilter.class) .exceptionHandling( diff --git a/src/main/java/org/owasp/webgoat/csrf/CsrfExemptions.java b/src/main/java/org/owasp/webgoat/csrf/CsrfExemptions.java index 407600a19..5e666d742 100644 --- a/src/main/java/org/owasp/webgoat/csrf/CsrfExemptions.java +++ b/src/main/java/org/owasp/webgoat/csrf/CsrfExemptions.java @@ -32,6 +32,21 @@ public static RequestMatcher headerlessAuthentication(String... paths) { && request.getHeader("Referer") == null; } + /** + * Matches a POST to one of the given paths regardless of which headers it carries. + * + *

    For a call whose safety does not rest on the token: asking for a password reset only ever + * sends a message to the address named in the request, and the token that comes back is bound to + * that account, so a forged request achieves nothing an attacker could not achieve by typing the + * address themselves. + */ + public static RequestMatcher post(String... paths) { + List exempted = Arrays.asList(paths); + return request -> + "POST".equalsIgnoreCase(request.getMethod()) + && exempted.contains(pathWithoutContext(request)); + } + private static String pathWithoutContext(HttpServletRequest request) { String uri = request.getRequestURI(); String context = request.getContextPath();