Skip to content

Commit ed916c4

Browse files
authored
Encapsulate all mutable CSP filter settings in a single object (#7391)
1 parent 80adc71 commit ed916c4

1 file changed

Lines changed: 132 additions & 72 deletions

File tree

api/src/org/labkey/filters/ContentSecurityPolicyFilter.java

Lines changed: 132 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import org.apache.commons.lang3.Strings;
1515
import org.apache.logging.log4j.Logger;
1616
import org.jetbrains.annotations.NotNull;
17+
import org.jetbrains.annotations.Nullable;
1718
import org.junit.Assert;
1819
import org.junit.Test;
1920
import org.labkey.api.admin.AdminUrls;
@@ -49,8 +50,7 @@
4950
*/
5051
public 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

Comments
 (0)