Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
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
13 changes: 13 additions & 0 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,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;
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> 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);
}
}
57 changes: 57 additions & 0 deletions src/main/java/org/owasp/webgoat/lessons/csrf/CsrfProtection.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>{@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));
}
}
34 changes: 34 additions & 0 deletions src/main/resources/lessons/csrf/js/csrf-token.js
Original file line number Diff line number Diff line change
@@ -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();
}
})();
1 change: 1 addition & 0 deletions src/main/resources/webgoat/templates/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<link rel="stylesheet" type="text/css" th:href="@{/plugins/bootstrap/css/bootstrap.min.css}"/>
<link rel="stylesheet" type="text/css" th:href="@{/css/font-awesome.min.css}"/>
<link rel="stylesheet" type="text/css" th:href="@{/css/animate.css}"/>
<script th:src="@{/lesson_js/csrf-token.js}"></script>
</head>
<body>
<section id="container">
Expand Down
Loading