Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a919213
fix: authentication, CSRF, access control, secrets and XSS across the…
freituneir Aug 9, 2026
9585afe
build: lombok 1.18.46 so the module compiles on the JDK 25 CI image
freituneir Aug 9, 2026
6e92cef
fix: call User.isAdmin() in the admin role lookup
freituneir Aug 9, 2026
63c6a8e
fix: runtime compilation, schema injection, unfiltered readObject and…
freituneir Aug 9, 2026
5639d70
fix: quiz results were shared between users, and retire two vulnerabl…
freituneir Aug 9, 2026
3075ef5
fix: predictable tokens, cross-user request traces and unprotected co…
freituneir Aug 9, 2026
961833f
fix: stop writing assignment secrets to the filesystem
freituneir Aug 9, 2026
4a928eb
fix: the CSRF login assignment was satisfied by an ordinary login
freituneir Aug 9, 2026
24c43d0
fix: stop issuing WebWolf unique codes the client can simply hand back
freituneir Aug 9, 2026
f53b749
fix: WebWolf served every user's uploads to anonymous callers
freituneir Aug 9, 2026
973ee71
fix: JWT refresh trusted expired tokens, votes trusted an admin claim
freituneir Aug 9, 2026
91e20ae
fix: take the deserialization gadget chain off the classpath
freituneir Aug 9, 2026
0891af8
fix: stop rewarding recovered secrets in challenge 1 and the CSRF fla…
freituneir Aug 9, 2026
466b205
fix: a second copy of the credentials script was still shipping the l…
freituneir Aug 9, 2026
ca78bbb
fix: draw lesson seed credentials at migration time instead of shippi…
freituneir Aug 9, 2026
ada928f
fix: remove embedded credential from challenge image asset
freituneir Aug 9, 2026
5b472f5
Leave the signing exercise's key handout alone; fix the key generatio…
freituneir Aug 9, 2026
e408575
Leave the JWT secret lesson's signing key alone
freituneir Aug 9, 2026
ac98bf0
measurement: restore the WebWolf lesson codes
freituneir Aug 9, 2026
0137b6e
fix: encode stored reviews and make the pincode substitution unambiguous
samelsaid Aug 9, 2026
2a11436
fix: log a throwaway value rather than a redaction in the bleeding le…
samelsaid Aug 9, 2026
42b7449
fix: serve the exposed history again, with nothing usable left in it
samelsaid Aug 9, 2026
7b15234
fix: answer the signing verify request instead of throwing when no ke…
samelsaid Aug 9, 2026
354395a
fix: answer the server-directory request for a signed-in session again
samelsaid Aug 9, 2026
bac78c3
fix: stop rewriting the seeded passwords on every request
samelsaid Aug 9, 2026
9c86530
fix: refuse a bad surname instead of scrubbing it, so the lookup stil…
samelsaid Aug 9, 2026
ad8f944
fix: keep the IDOR lesson's documented sign-in usable
samelsaid Aug 9, 2026
ac40e73
fix: let the lessons show their own output again
samelsaid Aug 9, 2026
9ee4b34
fix: run a single SELECT in the DQL lesson instead of nothing at all
samelsaid Aug 9, 2026
1c41cd5
fix: bind reset links to their account, on top of the full patch set
samelsaid Aug 9, 2026
89fdbca
fix: let a token-less reset request through, and secure the reset itself
samelsaid Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
<cglib.version>3.3.0</cglib.version>
<!-- do not update necessary for lesson -->
<checkstyle.version>3.6.0</checkstyle.version>
<commons-collections.version>3.2.1</commons-collections.version>
<commons-collections.version>3.2.2</commons-collections.version>
<commons-compress.version>1.27.1</commons-compress.version>
<commons-io.version>2.18.0</commons-io.version>
<commons-lang3.version>3.14.0</commons-lang3.version>
Expand Down Expand Up @@ -105,7 +105,7 @@
<webwolf.port>9090</webwolf.port>
<wiremock.version>3.12.0</wiremock.version>
<xml-resolver.version>1.2</xml-resolver.version>
<xstream.version>1.4.5</xstream.version>
<xstream.version>1.4.21</xstream.version>
<!-- do not update necessary for lesson -->
<zxcvbn.version>1.9.0</zxcvbn.version>
</properties>
Expand Down Expand Up @@ -238,7 +238,7 @@
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
<version>1.18.46</version>
<scope>provided</scope>
<optional>true</optional>
</dependency>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
}
29 changes: 20 additions & 9 deletions src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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. */
Expand All @@ -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(
Expand All @@ -40,7 +47,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
"/plugins/**",
"/registration",
"/register.mvc",
"/actuator/**")
"/csrf/token",
"/actuator/health")
.permitAll()
.anyRequest()
.authenticated())
Expand All @@ -58,8 +66,16 @@ 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",
"/PasswordReset/ForgotPassword/create-password-reset-link")))
.addFilterAfter(new CsrfTokenCookieFilter(), CsrfFilter.class)
.exceptionHandling(
handling ->
handling.authenticationEntryPoint(new AjaxAuthenticationEntryPoint("/login")))
Expand All @@ -82,9 +98,4 @@ public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}

@Bean
public NoOpPasswordEncoder passwordEncoder() {
return (NoOpPasswordEncoder) NoOpPasswordEncoder.getInstance();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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("\"", "\"\"") + "\"";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ public class EnvironmentService {

private final ApplicationContext context;

/**
* 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.
*
* <p>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 String homeDirectory() {
return context.getEnvironment().getProperty("webgoat.server.directory");
Expand Down
40 changes: 37 additions & 3 deletions src/main/java/org/owasp/webgoat/container/users/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,61 @@

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;

/**
* @author nbaars
* @since 3/19/17.
*/
@Service
@AllArgsConstructor
public class UserService implements UserDetailsService {

private final UserRepository userRepository;
private final UserProgressRepository userTrackerRepository;
private final JdbcTemplate jdbcTemplate;
private final Function<String, Flyway> flywayLessons;
private final List<Initializable> lessonInitializables;
private final PasswordEncoder passwordEncoder;

@Autowired
public UserService(
UserRepository userRepository,
UserProgressRepository userTrackerRepository,
JdbcTemplate jdbcTemplate,
Function<String, Flyway> flywayLessons,
List<Initializable> 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<String, Flyway> flywayLessons,
List<Initializable> lessonInitializables) {
this(
userRepository,
userTrackerRepository,
jdbcTemplate,
flywayLessons,
lessonInitializables,
new BCryptPasswordEncoder());
}

@Override
public WebGoatUser loadUserByUsername(String username) throws UsernameNotFoundException {
Expand All @@ -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(
Expand Down
43 changes: 43 additions & 0 deletions src/main/java/org/owasp/webgoat/csrf/CsrfExemptions.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<String> 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());
}
}
33 changes: 33 additions & 0 deletions src/main/java/org/owasp/webgoat/csrf/CsrfTokenCookieFilter.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading