1010import jakarta .servlet .http .HttpServletResponse ;
1111import org .apache .commons .collections4 .SetValuedMap ;
1212import org .apache .commons .collections4 .multimap .HashSetValuedHashMap ;
13+ import org .apache .commons .lang3 .EnumUtils ;
1314import org .apache .commons .lang3 .StringUtils ;
1415import org .apache .commons .lang3 .Strings ;
1516import org .apache .logging .log4j .Logger ;
1920import org .junit .Test ;
2021import org .labkey .api .admin .AdminUrls ;
2122import org .labkey .api .collections .CopyOnWriteHashMap ;
22- import org .labkey .api .collections .LabKeyCollectors ;
2323import org .labkey .api .security .Directive ;
2424import org .labkey .api .settings .AppProps ;
2525import org .labkey .api .settings .OptionalFeatureService ;
4242import java .util .Map ;
4343import java .util .Objects ;
4444import java .util .Set ;
45+ import java .util .regex .Matcher ;
46+ import java .util .regex .Pattern ;
4547import java .util .stream .Collectors ;
4648
4749
@@ -95,6 +97,11 @@ public String getHeaderName()
9597 {
9698 return _headerName ;
9799 }
100+
101+ private static @ Nullable ContentSecurityPolicyType get (String disposition )
102+ {
103+ return EnumUtils .getEnumIgnoreCase (ContentSecurityPolicyType .class , disposition );
104+ }
98105 }
99106
100107 static
@@ -119,8 +126,9 @@ public void init(FilterConfig filterConfig) throws ServletException
119126 String paramValue = filterConfig .getInitParameter (paramName );
120127 if ("policy" .equalsIgnoreCase (paramName ))
121128 {
129+ // Extract before filtering since CSP version is in a comment
130+ extractCspVersion (paramValue );
122131 _stashedTemplate = filterPolicy (paramValue );
123- extractCspVersion (_stashedTemplate );
124132 }
125133 else if ("disposition" .equalsIgnoreCase (paramName ))
126134 {
@@ -139,12 +147,12 @@ else if ("disposition".equalsIgnoreCase(paramName))
139147 if (CSP_FILTERS .put (getType (), this ) != null )
140148 throw new ServletException ("ContentSecurityPolicyFilter is misconfigured, duplicate policies of type: " + getType ());
141149
142- // configure a different endpoint for each type to convey the correct csp version (eXX vs. rXX)
150+ // configure a different endpoint for each type. TODO: We only need one CSP violation reporting endpoint now, so one header would do
143151 _reportToEndpointName = "csp-" + getType ().name ().toLowerCase ();
144152 }
145153
146154 /** Filter out block comments and replace special characters in the provided policy */
147- public static String filterPolicy (String policy )
155+ private static String filterPolicy (String policy )
148156 {
149157 String s = policy .trim ();
150158 s = s .replace ( '\n' , ' ' );
@@ -164,40 +172,24 @@ public static String filterPolicy(String policy)
164172 return s ;
165173 }
166174
175+ private static final Pattern CSP_VERSION_PATTERN = Pattern .compile ("cspVersion\\ s*=\\ s*(\\ w+)" );
176+
167177 /**
168- * Extract the cspVersion parameter value from the report-uri directive , if possible . Otherwise, cspVersion is left
169- * as "Unknown". This value is reported as part of usage metrics.
178+ * Extract the cspVersion value from a comment in the CSP , if it exists . Otherwise, cspVersion is left as "Unknown".
179+ * This value is reported as part of usage metrics and included in violation reports that are logged and forwarded .
170180 */
171181 private void extractCspVersion (String s )
172182 {
173- // Simple parser that should be compliant with https://www.w3.org/TR/CSP3/#parse-serialized-policy
174- Map <String , String > cspMap = Arrays .stream (s .split (";" ))
175- .map (String ::trim )
176- .filter (line -> !line .isEmpty ())
177- .map (line -> line .split ("\\ s+" , 2 ))
178- .filter (parts -> parts .length == 2 )
179- .collect (LabKeyCollectors .toCaseInsensitiveLinkedMap (parts -> parts [0 ], parts -> parts [1 ]));
180-
181- String directive = "report-uri" ;
182- String reportUri = cspMap .get (directive );
183-
184- if (reportUri != null )
183+ Matcher matcher = CSP_VERSION_PATTERN .matcher (s );
184+ if (matcher .find ())
185185 {
186- try
187- {
188- ActionURL reportUrl = new ActionURL (reportUri );
189- String cspVersion = reportUrl .getParameter ("cspVersion" );
186+ _cspVersion = matcher .group (1 );
190187
191- if (null != cspVersion )
192- _cspVersion = cspVersion ;
193- }
194- catch (IllegalArgumentException e )
195- {
196- LOG .warn ("Unable to parse {} URI" , directive , e );
197- }
198- }
188+ if (matcher .find ())
189+ LOG .warn ("More than one cspVersion=XX assignment found; using the first one." );
199190
200- LOG .debug ("CspVersion: {}" , getCspVersion ());
191+ LOG .debug ("CspVersion: {}" , getCspVersion ());
192+ }
201193 }
202194
203195 @ Override
@@ -277,7 +269,7 @@ private CspFilterSettings(ContentSecurityPolicyFilter filter, String baseServerU
277269 {
278270 // Each filter adds its own "Reporting-Endpoints" header since we want to convey the correct version (eXX vs. rXX)
279271 @ SuppressWarnings ("DataFlowIssue" )
280- ActionURL violationUrl = PageFlowUtil .urlProvider (AdminUrls .class ).getCspReportToURL (filter . getCspVersion () );
272+ ActionURL violationUrl = PageFlowUtil .urlProvider (AdminUrls .class ).getCspReportToURL ();
281273 // Use an absolute URL so we always post to https:, even if the violating request uses http:
282274 _reportingEndpointsHeaderValue = filter .getReportToEndpointName () + "=\" " + violationUrl .getURIString () + "\" " ;
283275
@@ -406,6 +398,34 @@ public static boolean hasCsp(ContentSecurityPolicyType type)
406398 return CSP_FILTERS .get (type ) != null ;
407399 }
408400
401+ public static @ NotNull String getCspVersion (@ Nullable String disposition )
402+ {
403+ if (disposition != null )
404+ {
405+ ContentSecurityPolicyType type = ContentSecurityPolicyType .get (disposition );
406+
407+ if (type != null )
408+ {
409+ var filter = CSP_FILTERS .get (type );
410+
411+ if (null != filter )
412+ {
413+ return filter .getCspVersion ();
414+ }
415+ else
416+ {
417+ LOG .error ("Disposition {} doesn't match a configured CSP filter" , disposition );
418+ }
419+ }
420+ else
421+ {
422+ LOG .error ("Bad disposition: {}" , disposition );
423+ }
424+ }
425+
426+ return "Unknown" ;
427+ }
428+
409429 public static List <String > getMissingSubstitutions (ContentSecurityPolicyType type )
410430 {
411431 ContentSecurityPolicyFilter filter = CSP_FILTERS .get (type );
@@ -442,6 +462,32 @@ public static void registerMetricsProvider()
442462
443463 public static class TestCase extends Assert
444464 {
465+ @ Test
466+ public void testCspVersionExtraction ()
467+ {
468+ testCspExtract ("e14" , "/* cspVersion=e14 */" );
469+ testCspExtract ("r14" , "/*cspVersion=r14 */" );
470+ testCspExtract ("e15" , "/* cspVersion = e15 */" );
471+ testCspExtract ("r15" , "/* cspVersion=r15*/" );
472+ testCspExtract ("e15" , "/* cspVersion = e15*/" );
473+ testCspExtract ("e15" , "/* cspVersion = e15*/ /* cspVersion=XXX */" );
474+
475+ testCspExtract ("Unknown" , "" );
476+ testCspExtract ("Unknown" , " " );
477+ testCspExtract ("Unknown" , "/* cspVersin=e14 */" );
478+ testCspExtract ("Unknown" , "/* cspVersion */" );
479+ testCspExtract ("Unknown" , "/* cspVersion= */" );
480+ testCspExtract ("Unknown" , "/* cspVersion=*/" );
481+ testCspExtract ("Unknown" , "/* cspVersion== */" );
482+ }
483+
484+ private void testCspExtract (String expected , String csp )
485+ {
486+ ContentSecurityPolicyFilter filter = new ContentSecurityPolicyFilter ();
487+ filter .extractCspVersion (csp );
488+ assertEquals (expected , filter .getCspVersion ());
489+ }
490+
445491 @ Test
446492 public void testPolicyFiltering ()
447493 {
@@ -461,7 +507,7 @@ public void testPolicyFiltering()
461507 report-uri /* Whoa! */ /admin-contentsecuritypolicyreport.api?${CSP.REPORT.PARAMS} https://*;
462508 """ ;
463509
464- // Multi-line for readability, but notice that newlines are replaced before assignment
510+ // Multi-line for readability, but notice that newlines are replaced when constructing the expected string
465511 String expected = """
466512 default-src 'self' https: http: ;
467513 connect-src 'self' http://www.labkey.org localhost:* ws: ${LABKEY.ALLOWED.CONNECTIONS} ;
0 commit comments