diff --git a/portal-ui/src/main/java/org/entando/entando/aps/servlet/ControllerServlet.java b/portal-ui/src/main/java/org/entando/entando/aps/servlet/ControllerServlet.java
index af1af32f0c..90cd5dbbd6 100644
--- a/portal-ui/src/main/java/org/entando/entando/aps/servlet/ControllerServlet.java
+++ b/portal-ui/src/main/java/org/entando/entando/aps/servlet/ControllerServlet.java
@@ -32,7 +32,6 @@
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
-import java.security.SecureRandom;
import java.util.List;
import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletException;
@@ -108,15 +107,7 @@ protected RequestContext initRequestContext(HttpServletRequest request,
}
public String createSecureRandomString() {
- int leftLimit = 48;
- int rightLimit = 122;
- int targetStringLength = 64;
- SecureRandom rand = new SecureRandom();
- return rand.ints(leftLimit, rightLimit + 1)
- .filter(i -> (i <= 57 || i >= 65) && (i <= 90 || i >= 97))
- .limit(targetStringLength)
- .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
- .toString();
+ return CspNonceGenerator.createNonce();
}
protected int controlRequest(HttpServletRequest request,
diff --git a/portal-ui/src/main/java/org/entando/entando/aps/servlet/CspHeaderFilter.java b/portal-ui/src/main/java/org/entando/entando/aps/servlet/CspHeaderFilter.java
new file mode 100644
index 0000000000..d9f28d908e
--- /dev/null
+++ b/portal-ui/src/main/java/org/entando/entando/aps/servlet/CspHeaderFilter.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2026-Present Entando Inc. (http://www.entando.com) All rights reserved.
+ *
+ * This library is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation; either version 2.1 of the License, or (at your option)
+ * any later version.
+ *
+ * This library is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ */
+package org.entando.entando.aps.servlet;
+
+import com.agiletec.aps.system.SystemConstants;
+import com.agiletec.aps.system.services.baseconfig.ConfigInterface;
+import com.agiletec.aps.util.ApsWebApplicationUtils;
+import java.io.IOException;
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * Applies the Content-Security-Policy header to back-office requests
+ * (/do/*, /struts/*, /ExtStr2/do/*), which do not route through the
+ * {@link ControllerServlet} and would otherwise get no CSP at all.
+ *
+ * The policy is read from the "cspBackofficeConfig" system parameter and honors
+ * the same "cspEnabled" switch used for the portal. An optional "{nonce}"
+ * placeholder in the policy is replaced with a per-request secure random token,
+ * also exposed as the "cspNonceToken" request attribute for use in JSPs.
+ * When the "cspBackofficeReportOnly" parameter is true, the policy is sent as
+ * Content-Security-Policy-Report-Only, allowing violation monitoring without
+ * enforcement during rollout.
+ */
+public class CspHeaderFilter implements Filter {
+
+ public static final String CSP_HEADER_NAME = "Content-Security-Policy";
+ public static final String CSP_REPORT_ONLY_HEADER_NAME = "Content-Security-Policy-Report-Only";
+ public static final String NONCE_PLACEHOLDER = "{nonce}";
+ public static final String REQUEST_ATTR_CSP_NONCE = "cspNonceToken";
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+ if (request instanceof HttpServletRequest && response instanceof HttpServletResponse) {
+ this.addCspHeader((HttpServletRequest) request, (HttpServletResponse) response);
+ }
+ chain.doFilter(request, response);
+ }
+
+ protected void addCspHeader(HttpServletRequest request, HttpServletResponse response) {
+ ConfigInterface configManager = this.getConfigManager(request);
+ if (!Boolean.parseBoolean(configManager.getParam(SystemConstants.PAR_CSP_ENABLED))) {
+ return;
+ }
+ String policy = configManager.getParam(SystemConstants.PAR_CSP_BACKOFFICE_CONFIG);
+ if (StringUtils.isBlank(policy)) {
+ return;
+ }
+ if (policy.contains(NONCE_PLACEHOLDER)) {
+ String nonce = CspNonceGenerator.createNonce();
+ request.setAttribute(REQUEST_ATTR_CSP_NONCE, nonce);
+ policy = policy.replace(NONCE_PLACEHOLDER, nonce);
+ }
+ boolean reportOnly = Boolean.parseBoolean(configManager.getParam(SystemConstants.PAR_CSP_BACKOFFICE_REPORT_ONLY));
+ response.setHeader(reportOnly ? CSP_REPORT_ONLY_HEADER_NAME : CSP_HEADER_NAME, policy);
+ }
+
+ protected ConfigInterface getConfigManager(HttpServletRequest request) {
+ return (ConfigInterface) ApsWebApplicationUtils.getBean(SystemConstants.BASE_CONFIG_MANAGER, request);
+ }
+
+}
diff --git a/portal-ui/src/main/java/org/entando/entando/aps/servlet/CspNonceGenerator.java b/portal-ui/src/main/java/org/entando/entando/aps/servlet/CspNonceGenerator.java
new file mode 100644
index 0000000000..7093dfe5b0
--- /dev/null
+++ b/portal-ui/src/main/java/org/entando/entando/aps/servlet/CspNonceGenerator.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2026-Present Entando Inc. (http://www.entando.com) All rights reserved.
+ *
+ * This library is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation; either version 2.1 of the License, or (at your option)
+ * any later version.
+ *
+ * This library is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ */
+package org.entando.entando.aps.servlet;
+
+import java.security.SecureRandom;
+
+/**
+ * Generates the per-request secure random tokens used as Content-Security-Policy nonces,
+ * shared between the portal front-controller and the back-office CSP filter.
+ */
+public final class CspNonceGenerator {
+
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ private CspNonceGenerator() {
+ // utility class
+ }
+
+ public static String createNonce() {
+ int leftLimit = 48;
+ int rightLimit = 122;
+ int targetStringLength = 64;
+ return RANDOM.ints(leftLimit, rightLimit + 1)
+ .filter(i -> (i <= 57 || i >= 65) && (i <= 90 || i >= 97))
+ .limit(targetStringLength)
+ .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
+ .toString();
+ }
+
+}
diff --git a/portal-ui/src/main/webapp/WEB-INF/aps/jsp/internalservlet/user/inc/attributes/front_dateAttribute.jsp b/portal-ui/src/main/webapp/WEB-INF/aps/jsp/internalservlet/user/inc/attributes/front_dateAttribute.jsp
index 7aca86e398..31ea2e3fdb 100644
--- a/portal-ui/src/main/webapp/WEB-INF/aps/jsp/internalservlet/user/inc/attributes/front_dateAttribute.jsp
+++ b/portal-ui/src/main/webapp/WEB-INF/aps/jsp/internalservlet/user/inc/attributes/front_dateAttribute.jsp
@@ -58,6 +58,6 @@
});
-
-
+
+
diff --git a/portal-ui/src/test/java/org/entando/entando/aps/servlet/CspHeaderFilterTest.java b/portal-ui/src/test/java/org/entando/entando/aps/servlet/CspHeaderFilterTest.java
new file mode 100644
index 0000000000..39580bd8a8
--- /dev/null
+++ b/portal-ui/src/test/java/org/entando/entando/aps/servlet/CspHeaderFilterTest.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright 2026-Present Entando Inc. (http://www.entando.com) All rights reserved.
+ *
+ * This library is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU Lesser General Public License as published by the Free
+ * Software Foundation; either version 2.1 of the License, or (at your option)
+ * any later version.
+ *
+ * This library is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+ * details.
+ */
+package org.entando.entando.aps.servlet;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+
+import com.agiletec.aps.system.SystemConstants;
+import com.agiletec.aps.system.services.baseconfig.ConfigInterface;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class CspHeaderFilterTest {
+
+ private static final String POLICY = "default-src 'self'; object-src 'none'";
+
+ @Mock
+ private ConfigInterface configManager;
+
+ @Mock
+ private HttpServletRequest request;
+
+ @Mock
+ private HttpServletResponse response;
+
+ @Mock
+ private FilterChain filterChain;
+
+ private CspHeaderFilter filter;
+
+ @BeforeEach
+ void setUp() {
+ this.filter = new CspHeaderFilter() {
+ @Override
+ protected ConfigInterface getConfigManager(HttpServletRequest request) {
+ return configManager;
+ }
+ };
+ }
+
+ @Test
+ void shouldSetEnforcedHeaderWhenCspIsEnabled() throws Exception {
+ Mockito.when(configManager.getParam(SystemConstants.PAR_CSP_ENABLED)).thenReturn("true");
+ Mockito.when(configManager.getParam(SystemConstants.PAR_CSP_BACKOFFICE_CONFIG)).thenReturn(POLICY);
+ Mockito.when(configManager.getParam(SystemConstants.PAR_CSP_BACKOFFICE_REPORT_ONLY)).thenReturn("false");
+ filter.doFilter(request, response, filterChain);
+ Mockito.verify(response).setHeader(CspHeaderFilter.CSP_HEADER_NAME, POLICY);
+ Mockito.verify(filterChain).doFilter(request, response);
+ }
+
+ @Test
+ void shouldSetReportOnlyHeaderWhenConfigured() throws Exception {
+ Mockito.when(configManager.getParam(SystemConstants.PAR_CSP_ENABLED)).thenReturn("true");
+ Mockito.when(configManager.getParam(SystemConstants.PAR_CSP_BACKOFFICE_CONFIG)).thenReturn(POLICY);
+ Mockito.when(configManager.getParam(SystemConstants.PAR_CSP_BACKOFFICE_REPORT_ONLY)).thenReturn("true");
+ filter.doFilter(request, response, filterChain);
+ Mockito.verify(response).setHeader(CspHeaderFilter.CSP_REPORT_ONLY_HEADER_NAME, POLICY);
+ Mockito.verify(response, never()).setHeader(eq(CspHeaderFilter.CSP_HEADER_NAME), anyString());
+ Mockito.verify(filterChain).doFilter(request, response);
+ }
+
+ @Test
+ void shouldNotSetHeaderWhenCspIsDisabled() throws Exception {
+ Mockito.when(configManager.getParam(SystemConstants.PAR_CSP_ENABLED)).thenReturn("false");
+ filter.doFilter(request, response, filterChain);
+ Mockito.verify(response, never()).setHeader(anyString(), anyString());
+ Mockito.verify(filterChain).doFilter(request, response);
+ }
+
+ @Test
+ void shouldNotSetHeaderWhenPolicyIsBlank() throws Exception {
+ Mockito.when(configManager.getParam(SystemConstants.PAR_CSP_ENABLED)).thenReturn("true");
+ Mockito.when(configManager.getParam(SystemConstants.PAR_CSP_BACKOFFICE_CONFIG)).thenReturn(" ");
+ filter.doFilter(request, response, filterChain);
+ Mockito.verify(response, never()).setHeader(anyString(), anyString());
+ Mockito.verify(filterChain).doFilter(request, response);
+ }
+
+ @Test
+ void shouldReplaceNoncePlaceholderAndExposeRequestAttribute() throws Exception {
+ Mockito.when(configManager.getParam(SystemConstants.PAR_CSP_ENABLED)).thenReturn("true");
+ Mockito.when(configManager.getParam(SystemConstants.PAR_CSP_BACKOFFICE_CONFIG))
+ .thenReturn("script-src 'nonce-{nonce}'; object-src 'none'");
+ Mockito.when(configManager.getParam(SystemConstants.PAR_CSP_BACKOFFICE_REPORT_ONLY)).thenReturn("false");
+ filter.doFilter(request, response, filterChain);
+ ArgumentCaptor nonceCaptor = ArgumentCaptor.forClass(String.class);
+ Mockito.verify(request).setAttribute(eq(CspHeaderFilter.REQUEST_ATTR_CSP_NONCE), nonceCaptor.capture());
+ String nonce = nonceCaptor.getValue();
+ Assertions.assertEquals(64, nonce.length());
+ Mockito.verify(response).setHeader(CspHeaderFilter.CSP_HEADER_NAME,
+ "script-src 'nonce-" + nonce + "'; object-src 'none'");
+ }
+
+ @Test
+ void shouldGenerateDifferentNoncesPerRequest() {
+ String first = CspNonceGenerator.createNonce();
+ String second = CspNonceGenerator.createNonce();
+ Assertions.assertNotEquals(first, second);
+ Assertions.assertTrue(first.matches("[0-9A-Za-z]{64}"));
+ }
+
+}
diff --git a/webapp/src/main/conf/systemParams.properties b/webapp/src/main/conf/systemParams.properties
index d380177547..e1a95d436f 100644
--- a/webapp/src/main/conf/systemParams.properties
+++ b/webapp/src/main/conf/systemParams.properties
@@ -52,7 +52,17 @@ widgets.internalServlet=formAction
## Security configuration
csp.header.enabled=${CSP_HEADER_ENABLED:true}
-csp.header.extraConfig=${CSP_HEADER_EXTRACONFIG:'strict-dynamic' https: 'self'; object-src 'none'; base-uri 'self'}
+# Appended to "script-src 'nonce-...'" on portal (front-end) responses: the first tokens continue
+# the script-src directive, then ";" starts the other directives.
+# Prefer the CSP_HEADER_PORTAL_EXTRACONFIG environment variable, which makes it explicit that this
+# policy targets portal pages only (the back office is configured by CSP_HEADER_BACKOFFICE_CONFIG).
+# CSP_HEADER_EXTRACONFIG is deprecated and kept as a fallback for backward compatibility.
+csp.header.extraConfig=${CSP_HEADER_PORTAL_EXTRACONFIG:${CSP_HEADER_EXTRACONFIG:'strict-dynamic' https: 'self'; default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; worker-src 'self'; child-src 'self' https:; frame-src 'self' https:; report-to default}}
+# Full CSP applied by CspHeaderFilter to back-office responses (/do/*, /struts/*, /ExtStr2/do/*).
+# An optional "{nonce}" placeholder is replaced with a per-request token (see CspHeaderFilter).
+csp.header.backoffice.config=${CSP_HEADER_BACKOFFICE_CONFIG:default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://ajax.googleapis.com/ https://code.jquery.com; style-src 'self' 'unsafe-inline' https://code.jquery.com; img-src 'self' data: https:; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; connect-src 'self' https:; frame-src 'self' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/}
+# When true, the back-office policy is sent as Content-Security-Policy-Report-Only (safe rollout mode).
+csp.header.backoffice.reportOnly=${CSP_HEADER_BACKOFFICE_REPORT_ONLY:false}
##----------------------
## CMS configuration
diff --git a/webapp/src/main/webapp/WEB-INF/aps/jsp/models/home.jsp b/webapp/src/main/webapp/WEB-INF/aps/jsp/models/home.jsp
index 077e276430..5fe1fa266e 100644
--- a/webapp/src/main/webapp/WEB-INF/aps/jsp/models/home.jsp
+++ b/webapp/src/main/webapp/WEB-INF/aps/jsp/models/home.jsp
@@ -58,8 +58,8 @@
Copyright <%= new java.text.SimpleDateFormat("yyyy").format(new java.util.Date()) %> Entando
diff --git a/webapp/src/main/webapp/WEB-INF/web.xml b/webapp/src/main/webapp/WEB-INF/web.xml
index db0b3ddc62..5b84510c3e 100644
--- a/webapp/src/main/webapp/WEB-INF/web.xml
+++ b/webapp/src/main/webapp/WEB-INF/web.xml
@@ -57,6 +57,10 @@
XSSFilter
org.entando.entando.aps.servlet.XSSFilter
+
+ CspHeaderFilter
+ org.entando.entando.aps.servlet.CspHeaderFilter
+
CharacterEncodingFilter
org.springframework.web.filter.CharacterEncodingFilter
@@ -87,6 +91,48 @@
mdcUserFilter
/*
+
+ CspHeaderFilter
+ /do/*
+ REQUEST
+ FORWARD
+ ERROR
+
+
+ CspHeaderFilter
+ /struts/*
+ REQUEST
+ FORWARD
+ ERROR
+
+
+ CspHeaderFilter
+ /ExtStr2/do/*
+ REQUEST
+ FORWARD
+ ERROR
+
+
+ CspHeaderFilter
+ /error
+ REQUEST
+ FORWARD
+ ERROR
+
+
+ CspHeaderFilter
+ /404.jsp
+ REQUEST
+ FORWARD
+ ERROR
+
+
+ CspHeaderFilter
+ /error.jsp
+ REQUEST
+ FORWARD
+ ERROR
+
struts2
/do/*
@@ -216,6 +262,10 @@
index.jsp
adminindex.jsp
+
+ cjs
+ text/javascript
+
404
/404.jsp
diff --git a/webdynamicform-plugin/src/main/webapp/WEB-INF/plugins/jpwebdynamicform/aps/jsp/internalservlet/message/modules/edit/dateAttribute.jsp b/webdynamicform-plugin/src/main/webapp/WEB-INF/plugins/jpwebdynamicform/aps/jsp/internalservlet/message/modules/edit/dateAttribute.jsp
index 3c1c0d9787..625bd4ecf7 100644
--- a/webdynamicform-plugin/src/main/webapp/WEB-INF/plugins/jpwebdynamicform/aps/jsp/internalservlet/message/modules/edit/dateAttribute.jsp
+++ b/webdynamicform-plugin/src/main/webapp/WEB-INF/plugins/jpwebdynamicform/aps/jsp/internalservlet/message/modules/edit/dateAttribute.jsp
@@ -58,8 +58,8 @@
});
-
-
+
+
\ No newline at end of file