{@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 @@ +