From 0f0b2955158823457a589ce41d42d0d52aa9732e Mon Sep 17 00:00:00 2001 From: Isaries Date: Mon, 6 Jul 2026 22:29:42 +0800 Subject: [PATCH 1/2] fix(security): enable CSRF protection for state-changing requests CSRF protection was disabled site-wide, so any authenticated session could be driven by a forged cross-site request. Because authentication relies on a session cookie that the browser attaches automatically, a malicious page could trigger state-changing actions (password changes, account edits, run management) on behalf of a signed-in user without their knowledge. Enable CSRF using a cookie-based token repository so the token is readable by the single page application and echoed back in a request header, matching the client framework defaults. Add a filter that forces the token cookie to be written on requests that do not otherwise read it, since the token is loaded lazily. --- .../web/filters/CsrfCookieFilter.java | 30 +++++++++++++++++++ .../portal/spring/impl/WebSecurityConfig.java | 7 ++++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/wise/portal/presentation/web/filters/CsrfCookieFilter.java diff --git a/src/main/java/org/wise/portal/presentation/web/filters/CsrfCookieFilter.java b/src/main/java/org/wise/portal/presentation/web/filters/CsrfCookieFilter.java new file mode 100644 index 000000000..a36146a85 --- /dev/null +++ b/src/main/java/org/wise/portal/presentation/web/filters/CsrfCookieFilter.java @@ -0,0 +1,30 @@ +package org.wise.portal.presentation.web.filters; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Ensures the CSRF token cookie is sent to the browser. The token is loaded lazily, so on a + * request that does not otherwise read it the cookie would not be written, and the single page + * application would then have no token to send back on state-changing requests. Reading the + * token value here forces it to be generated and the cookie to be written. + */ +public class CsrfCookieFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (csrfToken != null) { + csrfToken.getToken(); + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/org/wise/portal/spring/impl/WebSecurityConfig.java b/src/main/java/org/wise/portal/spring/impl/WebSecurityConfig.java index b608d1272..3e3dd0f64 100644 --- a/src/main/java/org/wise/portal/spring/impl/WebSecurityConfig.java +++ b/src/main/java/org/wise/portal/spring/impl/WebSecurityConfig.java @@ -52,10 +52,13 @@ import org.springframework.security.web.authentication.logout.LogoutFilter; import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter; import org.springframework.security.web.session.HttpSessionEventPublisher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.session.Session; +import org.wise.portal.presentation.web.filters.CsrfCookieFilter; import org.wise.portal.presentation.web.filters.GoogleOpenIdConnectFilter; import org.wise.portal.presentation.web.filters.MicrosoftAuthenticationFailureHandler; import org.wise.portal.presentation.web.filters.MicrosoftOpenIdConnectFilter; @@ -79,7 +82,9 @@ public class WebSecurityConfig extends WebSecurityConfigurerA @Override protected void configure(HttpSecurity http) throws Exception { - http.csrf().disable() + http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .and() + .addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class) .addFilterAfter(openSessionInViewFilter(), SecurityContextHolderAwareRequestFilter.class) .addFilterAfter(oAuth2ClientContextFilter(), OpenSessionInViewFilter.class) .addFilterAfter(googleOpenIdConnectFilter(), OAuth2ClientContextFilter.class) From 97cd06d923e8bbaee2dd0b5157964203ce2861d7 Mon Sep 17 00:00:00 2001 From: Isaries Date: Mon, 6 Jul 2026 23:12:54 +0800 Subject: [PATCH 2/2] fix(security): send CSRF token from legacy portal pages Enabling CSRF protection also applies to the server rendered admin and teacher portal pages. Their jQuery POST requests and one plain HTML form did not send the token, so those actions (enabling or disabling users, managing roles, portal settings, deleting news, listing and importing projects, impersonating users) would be rejected with a 403. Add a shared handler in the common portal script that echoes the token cookie back in a request header on state changing same origin requests, include that script on the two pages that lacked it, and add the token field to the plain import form. Spring form tag pages already receive the token automatically. --- .../webapp/portal/admin/portal/manage.jsp | 1 + .../webapp/portal/admin/project/import.jsp | 1 + .../admin/project/manageallprojects.jsp | 1 + src/main/webapp/portal/javascript/general.js | 24 +++++++++++++++++++ 4 files changed, 27 insertions(+) diff --git a/src/main/webapp/portal/admin/portal/manage.jsp b/src/main/webapp/portal/admin/portal/manage.jsp index cdf9e9806..95f6446ce 100644 --- a/src/main/webapp/portal/admin/portal/manage.jsp +++ b/src/main/webapp/portal/admin/portal/manage.jsp @@ -5,6 +5,7 @@ <%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> + " /> + diff --git a/src/main/webapp/portal/javascript/general.js b/src/main/webapp/portal/javascript/general.js index 12c57aed4..ad18ad685 100644 --- a/src/main/webapp/portal/javascript/general.js +++ b/src/main/webapp/portal/javascript/general.js @@ -1,3 +1,27 @@ +// Attach the CSRF token to state changing AJAX requests sent from these portal pages. +// The server stores the token in a cookie that is readable by this script and expects +// it to be echoed back in a request header, which is how it confirms the request was +// made by the page and not forged by another site. Without this, jQuery POST requests +// on these pages would be rejected once CSRF protection is enabled. +(function () { + function getCookie(name) { + var match = document.cookie.match(new RegExp('(^|;\\s*)' + name + '=([^;]*)')); + return match ? decodeURIComponent(match[2]) : null; + } + $(document).ajaxSend(function (event, jqxhr, settings) { + var method = (settings.type || 'GET').toUpperCase(); + var isStateChanging = + method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && method !== 'TRACE'; + if (!isStateChanging || settings.crossDomain) { + return; + } + var token = getCookie('XSRF-TOKEN'); + if (token) { + jqxhr.setRequestHeader('X-XSRF-TOKEN', token); + } + }); +})(); + function impersonateUser(username, userType) { $.post( "/api/login/impersonate", {username: username}).always(function( data ) { window.location.href = `/${userType}`;