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 diff --git a/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java b/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java index 62c0d3df2..1ef11dc41 100644 --- a/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java +++ b/src/main/java/org/owasp/webgoat/container/WebSecurityConfig.java @@ -6,6 +6,7 @@ import lombok.AllArgsConstructor; import org.owasp.webgoat.container.users.UserService; +import org.owasp.webgoat.lessons.csrf.CsrfProtection; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -18,6 +19,7 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.NoOpPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; /** Security configuration for WebGoat. */ @Configuration @@ -36,10 +38,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/css/**", "/images/**", "/js/**", + "/lesson_js/**", "fonts/**", "/plugins/**", "/registration", "/register.mvc", + "/csrf/token", "/actuator/**") .permitAll() .anyRequest() @@ -58,7 +62,16 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { oidc.loginPage("/login"); }) .logout(logout -> logout.deleteCookies("JSESSIONID").invalidateHttpSession(true)) + // Spring Security's own CSRF support stays disabled application-wide, since most lesson + // endpoints never send a token and some lessons intentionally demonstrate that flaw. + // Login CSRF is closed separately by CsrfProtection, a self-contained filter scoped to + // just POST /login: login.html loads csrf-token.js, which fetches a fresh per-session + // token from CSRFTokenController and attaches it to the form, and CsrfProtection then + // requires that same value back on the submit. A forged cross-origin submission can + // trigger the POST but, blocked by the same-origin policy, never learns the token it + // would need to include. .csrf(csrf -> csrf.disable()) + .addFilterBefore(new CsrfProtection(), UsernamePasswordAuthenticationFilter.class) .headers(headers -> headers.disable()) .exceptionHandling( handling -> diff --git a/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFTokenController.java b/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFTokenController.java new file mode 100644 index 000000000..841fef50a --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/csrf/CSRFTokenController.java @@ -0,0 +1,39 @@ +/* + * 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.security.SecureRandom; +import java.util.Base64; +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * Issues a random, per-session token that {@link CsrfProtection} later requires on the login + * form's POST. The endpoint itself needs no authentication (a visitor hits it before they're + * logged in, while looking at the login page), and it hands the value back bound to whatever + * HTTP session the caller already has - a page on another origin can trigger a request here but, + * blocked by the same-origin policy, can never read the response body back into its own script, + * so it never learns the token it would need to submit. + */ +@RestController +public class CSRFTokenController { + + static final String SESSION_ATTRIBUTE = "webgoat.csrf.loginToken"; + + private static final SecureRandom RANDOM = new SecureRandom(); + + @GetMapping(path = "/csrf/token", produces = "application/json") + @ResponseBody + public Map issueLoginToken(HttpServletRequest request) { + byte[] raw = new byte[32]; + RANDOM.nextBytes(raw); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(raw); + request.getSession(true).setAttribute(SESSION_ATTRIBUTE, token); + return Map.of("token", token); + } +} diff --git a/src/main/java/org/owasp/webgoat/lessons/csrf/CsrfProtection.java b/src/main/java/org/owasp/webgoat/lessons/csrf/CsrfProtection.java new file mode 100644 index 000000000..9b962bbbe --- /dev/null +++ b/src/main/java/org/owasp/webgoat/lessons/csrf/CsrfProtection.java @@ -0,0 +1,57 @@ +/* + * 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 jakarta.servlet.http.HttpSession; +import java.io.IOException; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Stand-alone anti-CSRF check for the real WebGoat login form, independent of Spring Security's + * own (globally disabled, for the sake of the other lesson endpoints) CSRF support. + * + *

{@code login.html} loads {@code csrf-token.js}, which fetches a fresh token from {@link + * CSRFTokenController} and stuffs it into a hidden field before the form can be submitted. This + * filter then requires that same value to come back on the POST. A same-origin submission always + * carries it because the browser executed WebGoat's own script first; a forged submission fired + * from another page never does, because that page can trigger the request but - blocked by the + * same-origin policy - can't read the token back to replay it. The submission is rejected before + * Spring Security's authentication filter ever sees it, so a wrong/missing token never gets a + * chance to authenticate anyone. + */ +public class CsrfProtection extends OncePerRequestFilter { + + static final String PARAMETER_NAME = "csrf_token"; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + if (isLoginPost(request) && !suppliesValidToken(request)) { + response.sendError(HttpServletResponse.SC_FORBIDDEN, "Missing or invalid CSRF token"); + return; + } + chain.doFilter(request, response); + } + + private static boolean isLoginPost(HttpServletRequest request) { + return "POST".equalsIgnoreCase(request.getMethod()) && "/login".equals(request.getServletPath()); + } + + private static boolean suppliesValidToken(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return false; + } + Object expected = session.getAttribute(CSRFTokenController.SESSION_ATTRIBUTE); + return expected instanceof String expectedToken + && !expectedToken.isBlank() + && expectedToken.equals(request.getParameter(PARAMETER_NAME)); + } +} diff --git a/src/main/resources/lessons/csrf/js/csrf-token.js b/src/main/resources/lessons/csrf/js/csrf-token.js new file mode 100644 index 000000000..e98d11d78 --- /dev/null +++ b/src/main/resources/lessons/csrf/js/csrf-token.js @@ -0,0 +1,34 @@ +(function () { + "use strict"; + + function protect(form) { + var field = document.createElement("input"); + field.type = "hidden"; + field.name = "csrf_token"; + form.appendChild(field); + + fetch("csrf/token", {credentials: "same-origin"}) + .then(function (response) { + return response.json(); + }) + .then(function (body) { + field.value = body.token; + }) + .catch(function () { + // Leave the field empty; the server rejects the submission either way. + }); + } + + function init() { + var form = document.querySelector('form[action$="/login"]'); + if (form) { + protect(form); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/src/main/resources/webgoat/templates/login.html b/src/main/resources/webgoat/templates/login.html index c4531c764..9fa35ea59 100644 --- a/src/main/resources/webgoat/templates/login.html +++ b/src/main/resources/webgoat/templates/login.html @@ -7,6 +7,7 @@ +