1414import org .apache .commons .lang3 .Strings ;
1515import org .apache .logging .log4j .Logger ;
1616import org .jetbrains .annotations .NotNull ;
17+ import org .jetbrains .annotations .Nullable ;
1718import org .junit .Assert ;
1819import org .junit .Test ;
1920import org .labkey .api .admin .AdminUrls ;
4950 */
5051public class ContentSecurityPolicyFilter implements Filter
5152{
52- public static final String FEATURE_FLAG_DISABLE_ENFORCE_CSP = "disableEnforceCsp" ;
53- public static final String FEATURE_FLAG_FORWARD_CSP_REPORTS = "forwardCspReports" ;
53+ private static final Logger LOG = LogHelper .getLogger (ContentSecurityPolicyFilter .class , "Register/unregister allowed resource hosts" );
5454
5555 private static final String NONCE_SUBST = "REQUEST.SCRIPT.NONCE" ;
5656 private static final String REPORT_PARAMETER_SUBSTITUTION = "CSP.REPORT.PARAMS" ;
@@ -62,25 +62,24 @@ public class ContentSecurityPolicyFilter implements Filter
6262 // Lock that protects the static data structures below
6363 private static final Object SUBSTITUTION_LOCK = new Object ();
6464 private static final Map <Directive , SetValuedMap <String , String >> ALLOWED_SOURCES = new HashMap <>();
65+
66+ public static final String FEATURE_FLAG_DISABLE_ENFORCE_CSP = "disableEnforceCsp" ;
67+ public static final String FEATURE_FLAG_FORWARD_CSP_REPORTS = "forwardCspReports" ;
68+
6569 // Regenerate and stash on every "allowed source" change as a convenience (so every filter doesn't need to recalculate
6670 // it on every init() and change)
6771 private static Map <String , String > SUBSTITUTION_MAP = Collections .emptyMap ();
6872
6973 // Per-filter-instance parameters that are set in init() and never changed
7074 private ContentSecurityPolicyType _type = ContentSecurityPolicyType .Enforce ;
7175 private @ NotNull String _cspVersion = "Unknown" ;
76+ // These two are effectively @NotNull since they are set to non-null values in init() and never changed
7277 private String _stashedTemplate = null ;
7378 private String _reportToEndpointName = null ;
7479
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 ;
79-
80- // Updated after every change to "allowed sources"
81- private StringExpression _policyExpression = null ;
82-
83- private static final Logger LOG = LogHelper .getLogger (ContentSecurityPolicyFilter .class , "Register/unregister allowed resource hosts" );
80+ // Per-filter-instance settings are initialized on first request and reset when base server URL or allowed sources
81+ // change. Don't reference this directly; always use ensureSettings().
82+ private volatile @ Nullable CspFilterSettings _settings = null ;
8483
8584 public enum ContentSecurityPolicyType
8685 {
@@ -126,7 +125,7 @@ public void init(FilterConfig filterConfig) throws ServletException
126125 // Replace REPORT_PARAMETER_SUBSTITUTION now since its value is static
127126 s = substituteReportParams (s );
128127
129- _policyTemplate = _stashedTemplate = s ;
128+ _stashedTemplate = s ;
130129
131130 extractCspVersion (s );
132131 }
@@ -144,13 +143,11 @@ else if ("disposition".equalsIgnoreCase(paramName))
144143 }
145144 }
146145
147- if (CSP_FILTERS .put (_type , this ) != null )
148- throw new ServletException ("ContentSecurityPolicyFilter is misconfigured, duplicate policies of type: " + _type );
146+ if (CSP_FILTERS .put (getType () , this ) != null )
147+ throw new ServletException ("ContentSecurityPolicyFilter is misconfigured, duplicate policies of type: " + getType () );
149148
150149 // configure a different endpoint for each type to convey the correct csp version (eXX vs. rXX)
151- _reportToEndpointName = "csp-" + _type .name ().toLowerCase ();
152-
153- regeneratePolicyExpression ();
150+ _reportToEndpointName = "csp-" + getType ().name ().toLowerCase ();
154151 }
155152
156153 private String substituteReportParams (String expression )
@@ -213,78 +210,130 @@ private void extractCspVersion(String s)
213210 }
214211 }
215212
216- LOG .debug ("CspVersion: {}" , _cspVersion );
217- }
218-
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,
221- // the only substitution needed on a per-request basis is the nonce value.
222- private void regeneratePolicyExpression ()
223- {
224- final String allowSubstitutedPolicy ;
225-
226- synchronized (SUBSTITUTION_LOCK )
227- {
228- allowSubstitutedPolicy = StringExpressionFactory .create (_policyTemplate , false , NullValueBehavior .KeepSubstitution )
229- .eval (SUBSTITUTION_MAP );
230- }
231-
232- _policyExpression = StringExpressionFactory .create (allowSubstitutedPolicy , false , NullValueBehavior .ReplaceNullAndMissingWithBlank );
213+ LOG .debug ("CspVersion: {}" , getCspVersion ());
233214 }
234215
235216 @ Override
236217 public void doFilter (ServletRequest request , ServletResponse response , FilterChain chain ) throws IOException , ServletException
237218 {
238- if (request instanceof HttpServletRequest req && response instanceof HttpServletResponse resp && null != _policyExpression )
219+ if (request instanceof HttpServletRequest req && response instanceof HttpServletResponse resp )
239220 {
240- ensurePolicy ();
221+ CspFilterSettings settings = ensureSettings ();
241222
242- if (_type != ContentSecurityPolicyType .Enforce || !OptionalFeatureService .get ().isFeatureEnabled (FEATURE_FLAG_DISABLE_ENFORCE_CSP ))
223+ if (getType () != ContentSecurityPolicyType .Enforce || !OptionalFeatureService .get ().isFeatureEnabled (FEATURE_FLAG_DISABLE_ENFORCE_CSP ))
243224 {
244225 Map <String , String > map = Map .of (NONCE_SUBST , getScriptNonceHeader (req ));
245- var csp = _policyExpression .eval (map );
246- resp .setHeader (_type .getHeaderName (), csp );
226+ var csp = settings . getPolicyExpression () .eval (map );
227+ resp .setHeader (getType () .getHeaderName (), csp );
247228
248229 // null if https: is not configured on this server
249- if (_reportingEndpointsHeaderValue != null )
250- resp .addHeader ("Reporting-Endpoints" , _reportingEndpointsHeaderValue );
230+ String reportingEndpointsHeaderValue = settings .getReportingEndpointsHeaderValue ();
231+ if (reportingEndpointsHeaderValue != null )
232+ resp .addHeader ("Reporting-Endpoints" , reportingEndpointsHeaderValue );
251233 }
252234 }
253235 chain .doFilter (request , response );
254236 }
255237
256- private void ensurePolicy ()
238+ public ContentSecurityPolicyType getType ()
239+ {
240+ return _type ;
241+ }
242+
243+ public @ NotNull String getCspVersion ()
244+ {
245+ return _cspVersion ;
246+ }
247+
248+ public String getStashedTemplate ()
249+ {
250+ return _stashedTemplate ;
251+ }
252+
253+ public String getReportToEndpointName ()
254+ {
255+ return _reportToEndpointName ;
256+ }
257+
258+ private void clearSettings ()
259+ {
260+ _settings = null ;
261+ }
262+
263+ private @ NotNull CspFilterSettings ensureSettings ()
257264 {
258265 String baseServerUrl = AppProps .getInstance ().getBaseServerUrl ();
266+ CspFilterSettings settings = _settings ; // Stash a local copy to ensure consistency in the checks below
259267
260- // Reconsider "report-to" directive and "Reporting-Endpoints" header if base server URL has changed
261- if (!Objects .equals (baseServerUrl , _previousBaseServerUrl ))
268+ // Reset settings if null or if base server URL has changed
269+ if (null == settings || !Objects .equals (baseServerUrl , settings . getPreviousBaseServerUrl () ))
262270 {
263- synchronized (SUBSTITUTION_LOCK )
271+ settings = _settings = new CspFilterSettings (this , baseServerUrl );
272+ }
273+
274+ return settings ;
275+ }
276+
277+ // Hold all the mutable per-filter settings in a single object so they can be set atomically
278+ private static class CspFilterSettings
279+ {
280+ private final String _policyTemplate ;
281+ private final String _reportingEndpointsHeaderValue ;
282+ private final String _previousBaseServerUrl ;
283+ private final StringExpression _policyExpression ;
284+
285+ private CspFilterSettings (ContentSecurityPolicyFilter filter , String baseServerUrl )
286+ {
287+ // Add "Reporting-Endpoints" header and "report-to" directive only if https: is configured on this
288+ // server. This ensures that browsers fall-back on report-uri if https: isn't configured.
289+ if (Strings .CI .startsWith (baseServerUrl , "https://" ))
264290 {
265- _previousBaseServerUrl = baseServerUrl ;
291+ // Each filter adds its own "Reporting-Endpoints" header since we want to convey the correct version (eXX vs. rXX)
292+ @ SuppressWarnings ("DataFlowIssue" )
293+ ActionURL violationUrl = PageFlowUtil .urlProvider (AdminUrls .class ).getCspReportToURL (filter .getCspVersion ());
294+ // Use an absolute URL so we always post to https:, even if the violating request uses http:
295+ _reportingEndpointsHeaderValue = filter .getReportToEndpointName () + "=\" " + filter .substituteReportParams (violationUrl .getURIString () + "&${CSP.REPORT.PARAMS}" ) + "\" " ;
296+
297+ // Add "report-to" directive to the policy
298+ _policyTemplate = filter .getStashedTemplate () + " report-to " + filter .getReportToEndpointName () + " ;" ;
299+ }
300+ else
301+ {
302+ _policyTemplate = filter .getStashedTemplate ();
303+ _reportingEndpointsHeaderValue = null ;
304+ }
266305
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- }
306+ _previousBaseServerUrl = baseServerUrl ;
307+
308+ final String allowSubstitutedPolicy ;
285309
286- regeneratePolicyExpression ();
310+ synchronized (SUBSTITUTION_LOCK )
311+ {
312+ allowSubstitutedPolicy = StringExpressionFactory .create (_policyTemplate , false , NullValueBehavior .KeepSubstitution )
313+ .eval (SUBSTITUTION_MAP );
287314 }
315+
316+ _policyExpression = StringExpressionFactory .create (allowSubstitutedPolicy , false , NullValueBehavior .ReplaceNullAndMissingWithBlank );
317+ }
318+
319+ public String getPolicyTemplate ()
320+ {
321+ return _policyTemplate ;
322+ }
323+
324+ public String getReportingEndpointsHeaderValue ()
325+ {
326+ return _reportingEndpointsHeaderValue ;
327+ }
328+
329+ public String getPreviousBaseServerUrl ()
330+ {
331+ return _previousBaseServerUrl ;
332+ }
333+
334+ public StringExpression getPolicyExpression ()
335+ {
336+ return _policyExpression ;
288337 }
289338 }
290339
@@ -332,7 +381,10 @@ public static void unregisterAllowedSources(String key, Directive directive)
332381 }
333382 }
334383
335- // Regenerate the substitution map and all policy expressions on every register/unregister
384+ /**
385+ * Regenerate the substitution map on every register/unregister. The policy expression will be regenerated on the
386+ * next request (see {@link #ensureSettings()}).
387+ */
336388 public static void regenerateSubstitutionMap ()
337389 {
338390 synchronized (SUBSTITUTION_LOCK )
@@ -356,8 +408,9 @@ public static void regenerateSubstitutionMap()
356408
357409 SUBSTITUTION_MAP .put (UPGRADE_INSECURE_REQUESTS_SUBSTITUTION , AppProps .getInstance ().isSSLRequired () ? "upgrade-insecure-requests;" : "" );
358410
359- // Tell each registered ContentSecurityPolicyFilter to refresh its policy template based on the new substitution map
360- CSP_FILTERS .values ().forEach (ContentSecurityPolicyFilter ::regeneratePolicyExpression );
411+ // Tell each registered ContentSecurityPolicyFilter to clear its settings so the next request recreates them
412+ // using the new substitution map
413+ CSP_FILTERS .values ().forEach (ContentSecurityPolicyFilter ::clearSettings );
361414 }
362415 }
363416
@@ -376,7 +429,7 @@ public static List<String> getMissingSubstitutions(ContentSecurityPolicyType typ
376429 }
377430 else
378431 {
379- String template = filter ._policyTemplate ;
432+ String template = filter .ensureSettings (). getPolicyTemplate () ;
380433 ret = Arrays .stream (Directive .values ())
381434 .map (dir -> "${" + dir .getSubstitutionKey () + "}" )
382435 .filter (key -> !template .contains (key ))
@@ -389,8 +442,15 @@ public static List<String> getMissingSubstitutions(ContentSecurityPolicyType typ
389442 public static void registerMetricsProvider ()
390443 {
391444 UsageMetricsService .get ().registerUsageMetrics ("API" , () -> Map .of ("cspFilters" , CSP_FILTERS .values ().stream ()
392- .collect (Collectors .toMap (filter -> filter ._type ,
393- filter -> Map .of ("version" , filter ._cspVersion , "csp" , filter ._policyTemplate , "cspSubstituted" , filter ._policyExpression .getSource ())))));
445+ .collect (Collectors .toMap (ContentSecurityPolicyFilter ::getType ,
446+ filter -> {
447+ CspFilterSettings settings = filter .ensureSettings ();
448+ return Map .of (
449+ "version" , filter .getCspVersion (),
450+ "csp" , settings .getPolicyTemplate (),
451+ "cspSubstituted" , settings .getPolicyExpression ().getSource ()
452+ );
453+ }))));
394454 }
395455
396456 public static class TestCase extends Assert
@@ -537,7 +597,7 @@ public void testSubstitutionMap()
537597 private void verifySubstitutionInPolicyExpressions (String value , int expectedCount )
538598 {
539599 List <String > failures = CSP_FILTERS .values ().stream ()
540- .map (filter -> filter ._policyExpression .eval (Map .of ()))
600+ .map (filter -> filter .ensureSettings (). getPolicyExpression () .eval (Map .of ()))
541601 .filter (policy -> StringUtils .countMatches (policy , value ) != expectedCount )
542602 .toList ();
543603
0 commit comments