1212import org .apache .commons .collections4 .multimap .HashSetValuedHashMap ;
1313import org .apache .commons .lang3 .EnumUtils ;
1414import org .apache .commons .lang3 .StringUtils ;
15- import org .apache .commons .lang3 .Strings ;
1615import org .apache .logging .log4j .Logger ;
1716import org .jetbrains .annotations .NotNull ;
1817import org .jetbrains .annotations .Nullable ;
3029import org .labkey .api .util .StringExpressionFactory ;
3130import org .labkey .api .util .StringExpressionFactory .AbstractStringExpression .NullValueBehavior ;
3231import org .labkey .api .util .logging .LogHelper ;
33- import org .labkey .api .view .ActionURL ;
3432
3533import java .io .IOException ;
3634import java .security .SecureRandom ;
4038import java .util .HashMap ;
4139import java .util .List ;
4240import java .util .Map ;
43- import java .util .Objects ;
4441import java .util .Set ;
4542import java .util .regex .Matcher ;
4643import java .util .regex .Pattern ;
4744import java .util .stream .Collectors ;
4845
49-
5046/**
5147 * Content Security Policies (CSPs) are loaded from the csp.enforce and csp.report properties in application.properties.
5248 */
@@ -57,6 +53,9 @@ public class ContentSecurityPolicyFilter implements Filter
5753 private static final String NONCE_SUBST = "REQUEST.SCRIPT.NONCE" ;
5854 private static final String UPGRADE_INSECURE_REQUESTS_SUBSTITUTION = "UPGRADE.INSECURE.REQUESTS" ;
5955 private static final String HEADER_NONCE = "org.labkey.filters.ContentSecurityPolicyFilter#NONCE" ; // needs to match PageConfig.HEADER_NONCE
56+ private static final String REPORTING_ENDPOINTS_HEADER = "Reporting-Endpoints" ;
57+ @ SuppressWarnings ("DataFlowIssue" )
58+ private static final String REPORTING_ENDPOINTS_HEADER_VALUE = "csp-report=\" " + PageFlowUtil .urlProvider (AdminUrls .class ).getCspReportToURL ().getLocalURIString () + "\" " ;;
6059
6160 private static final Map <ContentSecurityPolicyType , ContentSecurityPolicyFilter > CSP_FILTERS = new CopyOnWriteHashMap <>();
6261
@@ -76,11 +75,10 @@ public class ContentSecurityPolicyFilter implements Filter
7675 private @ NotNull String _cspVersion = "Unknown" ;
7776 // These two are effectively @NotNull since they are set to non-null values in init() and never changed
7877 private String _stashedTemplate = null ;
79- private String _reportToEndpointName = null ;
8078
81- // Per-filter-instance settings are initialized on first request and reset when base server URL or allowed sources
82- // change. Don't reference this directly; always use ensureSettings ().
83- private volatile @ Nullable CspFilterSettings _settings = null ;
79+ // Initialized on first request and reset when allowed sources change. Don't reference this directly; always use
80+ // ensurePolicyExpression ().
81+ private volatile StringExpression _policyExpression ;
8482
8583 public enum ContentSecurityPolicyType
8684 {
@@ -118,7 +116,7 @@ public String getHeaderName()
118116 @ Override
119117 public void init (FilterConfig filterConfig ) throws ServletException
120118 {
121- LogHelper . getLogger ( ContentSecurityPolicyFilter . class , "CSP filter initialization" ) .info ("Initializing {}" , filterConfig .getFilterName ());
119+ LOG .info ("Initializing {}" , filterConfig .getFilterName ());
122120 Enumeration <String > paramNames = filterConfig .getInitParameterNames ();
123121 while (paramNames .hasMoreElements ())
124122 {
@@ -146,9 +144,6 @@ else if ("disposition".equalsIgnoreCase(paramName))
146144
147145 if (CSP_FILTERS .put (getType (), this ) != null )
148146 throw new ServletException ("ContentSecurityPolicyFilter is misconfigured, duplicate policies of type: " + getType ());
149-
150- // configure a different endpoint for each type. TODO: We only need one CSP violation reporting endpoint now, so one header would do
151- _reportToEndpointName = "csp-" + getType ().name ().toLowerCase ();
152147 }
153148
154149 /** Filter out block comments and replace special characters in the provided policy */
@@ -197,18 +192,21 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
197192 {
198193 if (request instanceof HttpServletRequest req && response instanceof HttpServletResponse resp )
199194 {
200- CspFilterSettings settings = ensureSettings ();
195+ StringExpression expression = ensurePolicyExpression ();
201196
202197 if (getType () != ContentSecurityPolicyType .Enforce || !OptionalFeatureService .get ().isFeatureEnabled (FEATURE_FLAG_DISABLE_ENFORCE_CSP ))
203198 {
204199 Map <String , String > map = Map .of (NONCE_SUBST , getScriptNonceHeader (req ));
205- var csp = settings .getPolicyExpression ().eval (map );
206- resp .setHeader (getType ().getHeaderName (), csp );
200+ String csp = expression .eval (map );
201+
202+ if ("https" .equals (req .getScheme ()))
203+ {
204+ if (resp .getHeader (REPORTING_ENDPOINTS_HEADER ) == null )
205+ resp .addHeader (REPORTING_ENDPOINTS_HEADER , REPORTING_ENDPOINTS_HEADER_VALUE );
206+ csp = csp + " report-to csp-report ;" ;
207+ }
207208
208- // null if https: is not configured on this server
209- String reportingEndpointsHeaderValue = settings .getReportingEndpointsHeaderValue ();
210- if (reportingEndpointsHeaderValue != null )
211- resp .addHeader ("Reporting-Endpoints" , reportingEndpointsHeaderValue );
209+ resp .setHeader (getType ().getHeaderName (), csp );
212210 }
213211 }
214212 chain .doFilter (request , response );
@@ -229,91 +227,26 @@ public String getStashedTemplate()
229227 return _stashedTemplate ;
230228 }
231229
232- public String getReportToEndpointName ()
233- {
234- return _reportToEndpointName ;
235- }
236-
237- private void clearSettings ()
230+ private void clearPolicyExpression ()
238231 {
239- _settings = null ;
232+ _policyExpression = null ;
240233 }
241234
242- private @ NotNull CspFilterSettings ensureSettings ()
235+ private @ NotNull StringExpression ensurePolicyExpression ()
243236 {
244- String baseServerUrl = AppProps .getInstance ().getBaseServerUrl ();
245- CspFilterSettings settings = _settings ; // Stash a local copy to ensure consistency in the checks below
237+ StringExpression expression = _policyExpression ;
246238
247- // Reset settings if null or if base server URL has changed
248- if (null == settings || !Objects .equals (baseServerUrl , settings .getPreviousBaseServerUrl ()))
239+ if (expression == null )
249240 {
250- settings = _settings = new CspFilterSettings (this , baseServerUrl );
251- }
252-
253- return settings ;
254- }
255-
256- // Hold all the mutable per-filter settings in a single object so they can be set atomically
257- private static class CspFilterSettings
258- {
259- private final String _policyTemplate ;
260- private final String _reportingEndpointsHeaderValue ;
261- private final String _previousBaseServerUrl ;
262- private final StringExpression _policyExpression ;
263-
264- private CspFilterSettings (ContentSecurityPolicyFilter filter , String baseServerUrl )
265- {
266- // Add "Reporting-Endpoints" header and "report-to" directive only if https: is configured on this
267- // server. This ensures that browsers fall-back on report-uri if https: isn't configured.
268- if (Strings .CI .startsWith (baseServerUrl , "https://" ))
269- {
270- // Each filter adds its own "Reporting-Endpoints" header since we want to convey the correct version (eXX vs. rXX)
271- @ SuppressWarnings ("DataFlowIssue" )
272- ActionURL violationUrl = PageFlowUtil .urlProvider (AdminUrls .class ).getCspReportToURL ();
273- // Use an absolute URL so we always post to https:, even if the violating request uses http:
274- _reportingEndpointsHeaderValue = filter .getReportToEndpointName () + "=\" " + violationUrl .getURIString () + "\" " ;
275-
276- // Add "report-to" directive to the policy
277- _policyTemplate = filter .getStashedTemplate () + " report-to " + filter .getReportToEndpointName () + " ;" ;
278- }
279- else
280- {
281- _policyTemplate = filter .getStashedTemplate ();
282- _reportingEndpointsHeaderValue = null ;
283- }
284-
285- _previousBaseServerUrl = baseServerUrl ;
286-
287- final String substitutedPolicy ;
288-
289241 synchronized (SUBSTITUTION_LOCK )
290242 {
291- substitutedPolicy = StringExpressionFactory .create (_policyTemplate , false , NullValueBehavior .KeepSubstitution )
243+ var substitutedPolicy = StringExpressionFactory .create (getStashedTemplate () , false , NullValueBehavior .KeepSubstitution )
292244 .eval (SUBSTITUTION_MAP );
245+ expression = _policyExpression = StringExpressionFactory .create (substitutedPolicy , false , NullValueBehavior .ReplaceNullAndMissingWithBlank );
293246 }
294-
295- _policyExpression = StringExpressionFactory .create (substitutedPolicy , false , NullValueBehavior .ReplaceNullAndMissingWithBlank );
296- }
297-
298- public String getPolicyTemplate ()
299- {
300- return _policyTemplate ;
301247 }
302248
303- public String getReportingEndpointsHeaderValue ()
304- {
305- return _reportingEndpointsHeaderValue ;
306- }
307-
308- public String getPreviousBaseServerUrl ()
309- {
310- return _previousBaseServerUrl ;
311- }
312-
313- public StringExpression getPolicyExpression ()
314- {
315- return _policyExpression ;
316- }
249+ return expression ;
317250 }
318251
319252 public static String getScriptNonceHeader (HttpServletRequest request )
@@ -362,7 +295,7 @@ public static void unregisterAllowedSources(String key, Directive directive)
362295
363296 /**
364297 * Regenerate the substitution map on every register/unregister. The policy expression will be regenerated on the
365- * next request (see {@link #ensureSettings ()}).
298+ * next request (see {@link #ensurePolicyExpression ()}).
366299 */
367300 public static void regenerateSubstitutionMap ()
368301 {
@@ -389,7 +322,7 @@ public static void regenerateSubstitutionMap()
389322
390323 // Tell each registered ContentSecurityPolicyFilter to clear its settings so the next request recreates them
391324 // using the new substitution map
392- CSP_FILTERS .values ().forEach (ContentSecurityPolicyFilter ::clearSettings );
325+ CSP_FILTERS .values ().forEach (ContentSecurityPolicyFilter ::clearPolicyExpression );
393326 }
394327 }
395328
@@ -436,7 +369,7 @@ public static List<String> getMissingSubstitutions(ContentSecurityPolicyType typ
436369 }
437370 else
438371 {
439- String template = filter .ensureSettings (). getPolicyTemplate ();
372+ String template = filter .getStashedTemplate ();
440373 ret = Arrays .stream (Directive .values ())
441374 .map (dir -> "${" + dir .getSubstitutionKey () + "}" )
442375 .filter (key -> !template .contains (key ))
@@ -451,13 +384,15 @@ public static void registerMetricsProvider()
451384 UsageMetricsService .get ().registerUsageMetrics ("API" , () -> Map .of ("cspFilters" , CSP_FILTERS .values ().stream ()
452385 .collect (Collectors .toMap (ContentSecurityPolicyFilter ::getType ,
453386 filter -> {
454- CspFilterSettings settings = filter .ensureSettings ();
387+ StringExpression expression = filter .ensurePolicyExpression ();
455388 return Map .of (
456389 "version" , filter .getCspVersion (),
457- "csp" , settings . getPolicyTemplate (),
458- "cspSubstituted" , settings . getPolicyExpression () .getSource ()
390+ "csp" , filter . getStashedTemplate (),
391+ "cspSubstituted" , expression .getSource ()
459392 );
460- }))));
393+ }))
394+ )
395+ );
461396 }
462397
463398 public static class TestCase extends Assert
@@ -630,7 +565,7 @@ public void testSubstitutionMap()
630565 private void verifySubstitutionInPolicyExpressions (String value , int expectedCount )
631566 {
632567 List <String > failures = CSP_FILTERS .values ().stream ()
633- .map (filter -> filter .ensureSettings (). getPolicyExpression ().eval (Map .of ()))
568+ .map (filter -> filter .ensurePolicyExpression ().eval (Map .of ()))
634569 .filter (policy -> StringUtils .countMatches (policy , value ) != expectedCount )
635570 .toList ();
636571
0 commit comments