1111import org .apache .commons .collections4 .SetValuedMap ;
1212import org .apache .commons .collections4 .multimap .HashSetValuedHashMap ;
1313import org .apache .commons .lang3 .StringUtils ;
14+ import org .apache .commons .lang3 .Strings ;
1415import org .apache .logging .log4j .Logger ;
16+ import org .jetbrains .annotations .NotNull ;
1517import org .junit .Assert ;
1618import org .junit .Test ;
19+ import org .labkey .api .admin .AdminUrls ;
1720import org .labkey .api .collections .CopyOnWriteHashMap ;
1821import org .labkey .api .collections .LabKeyCollectors ;
1922import org .labkey .api .security .Directive ;
3639import java .util .HashMap ;
3740import java .util .List ;
3841import java .util .Map ;
42+ import java .util .Objects ;
3943import java .util .Set ;
4044import java .util .stream .Collectors ;
4145
@@ -64,8 +68,14 @@ public class ContentSecurityPolicyFilter implements Filter
6468
6569 // Per-filter-instance parameters that are set in init() and never changed
6670 private ContentSecurityPolicyType _type = ContentSecurityPolicyType .Enforce ;
67- private String _policyTemplate = null ;
68- private String _cspVersion = "Unknown" ;
71+ private @ NotNull String _cspVersion = "Unknown" ;
72+ private String _stashedTemplate = null ;
73+ private String _reportToEndpointName = null ;
74+
75+ // Per-filter-instance parameters that are set at first request and reset if base server URL changes
76+ private volatile String _previousBaseServerUrl = null ;
77+ private volatile String _policyTemplate = null ;
78+ private volatile String _reportingEndpointsHeaderValue = null ;
6979
7080 // Updated after every change to "allowed sources"
7181 private StringExpression _policyExpression = null ;
@@ -104,7 +114,6 @@ public String getHeaderName()
104114 public void init (FilterConfig filterConfig ) throws ServletException
105115 {
106116 LogHelper .getLogger (ContentSecurityPolicyFilter .class , "CSP filter initialization" ).info ("Initializing {}" , filterConfig .getFilterName ());
107-
108117 Enumeration <String > paramNames = filterConfig .getInitParameterNames ();
109118 while (paramNames .hasMoreElements ())
110119 {
@@ -115,10 +124,9 @@ public void init(FilterConfig filterConfig) throws ServletException
115124 String s = filterPolicy (paramValue );
116125
117126 // Replace REPORT_PARAMETER_SUBSTITUTION now since its value is static
118- s = StringExpressionFactory .create (s , false , NullValueBehavior .KeepSubstitution )
119- .eval (Map .of (REPORT_PARAMETER_SUBSTITUTION , "labkeyVersion=" + PageFlowUtil .encodeURIComponent (AppProps .getInstance ().getReleaseVersion ())));
127+ s = substituteReportParams (s );
120128
121- _policyTemplate = s ;
129+ _policyTemplate = _stashedTemplate = s ;
122130
123131 extractCspVersion (s );
124132 }
@@ -139,9 +147,18 @@ else if ("disposition".equalsIgnoreCase(paramName))
139147 if (CSP_FILTERS .put (_type , this ) != null )
140148 throw new ServletException ("ContentSecurityPolicyFilter is misconfigured, duplicate policies of type: " + _type );
141149
150+ // configure a different endpoint for each type to convey the correct csp version (eXX vs. rXX)
151+ _reportToEndpointName = "csp-" + _type .name ().toLowerCase ();
152+
142153 regeneratePolicyExpression ();
143154 }
144155
156+ private String substituteReportParams (String expression )
157+ {
158+ return StringExpressionFactory .create (expression , false , NullValueBehavior .KeepSubstitution )
159+ .eval (Map .of (REPORT_PARAMETER_SUBSTITUTION , "labkeyVersion=" + PageFlowUtil .encodeURIComponent (AppProps .getInstance ().getReleaseVersion ())));
160+ }
161+
145162 /** Filter out block comments and replace special characters in the provided policy */
146163 public static String filterPolicy (String policy )
147164 {
@@ -199,7 +216,8 @@ private void extractCspVersion(String s)
199216 LOG .debug ("CspVersion: {}" , _cspVersion );
200217 }
201218
202- // Make all the "allowed sources" substitutions at init() and whenever the allowed sources map changes. With this,
219+ // Make all the "allowed sources" substitutions at init(), whenever the allowed sources map changes, or whenever the
220+ // policy template changes (e.g., base server URL change that causes report-to to be added or removed). With this,
203221 // the only substitution needed on a per-request basis is the nonce value.
204222 private void regeneratePolicyExpression ()
205223 {
@@ -219,16 +237,57 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
219237 {
220238 if (request instanceof HttpServletRequest req && response instanceof HttpServletResponse resp && null != _policyExpression )
221239 {
240+ ensurePolicy ();
241+
222242 if (_type != ContentSecurityPolicyType .Enforce || !OptionalFeatureService .get ().isFeatureEnabled (FEATURE_FLAG_DISABLE_ENFORCE_CSP ))
223243 {
224244 Map <String , String > map = Map .of (NONCE_SUBST , getScriptNonceHeader (req ));
225245 var csp = _policyExpression .eval (map );
226246 resp .setHeader (_type .getHeaderName (), csp );
247+
248+ // null if https: is not configured on this server
249+ if (_reportingEndpointsHeaderValue != null )
250+ resp .addHeader ("Reporting-Endpoints" , _reportingEndpointsHeaderValue );
227251 }
228252 }
229253 chain .doFilter (request , response );
230254 }
231255
256+ private void ensurePolicy ()
257+ {
258+ String baseServerUrl = AppProps .getInstance ().getBaseServerUrl ();
259+
260+ // Reconsider "report-to" directive and "Reporting-Endpoints" header if base server URL has changed
261+ if (!Objects .equals (baseServerUrl , _previousBaseServerUrl ))
262+ {
263+ synchronized (SUBSTITUTION_LOCK )
264+ {
265+ _previousBaseServerUrl = baseServerUrl ;
266+
267+ // Add "Reporting-Endpoints" header and "report-to" directive only if https: is configured on this
268+ // server. This ensures that browsers fall-back on report-uri if https: isn't configured.
269+ if (Strings .CI .startsWith (baseServerUrl , "https://" ))
270+ {
271+ // Each filter adds its own "Reporting-Endpoints" header since we want to convey the correct version (eXX vs. rXX)
272+ @ SuppressWarnings ("DataFlowIssue" )
273+ ActionURL violationUrl = PageFlowUtil .urlProvider (AdminUrls .class ).getCspReportToURL (_cspVersion );
274+ // Use an absolute URL so we always post to https:, even if the violating request uses http:
275+ _reportingEndpointsHeaderValue = _reportToEndpointName + "=\" " + substituteReportParams (violationUrl .getURIString () + "&${CSP.REPORT.PARAMS}" ) + "\" " ;
276+
277+ // Add "report-to" directive to the policy
278+ _policyTemplate = _stashedTemplate + " report-to " + _reportToEndpointName + " ;" ;
279+ }
280+ else
281+ {
282+ _reportingEndpointsHeaderValue = null ;
283+ _policyTemplate = _stashedTemplate ;
284+ }
285+
286+ regeneratePolicyExpression ();
287+ }
288+ }
289+ }
290+
232291 public static String getScriptNonceHeader (HttpServletRequest request )
233292 {
234293 String nonce = (String )request .getAttribute (HEADER_NONCE );
0 commit comments