Skip to content

Commit 4ab0ea2

Browse files
authored
Support report-to CSP directive and Reporting-Endpoints header (#7374) (#7380)
1 parent 25ed9d2 commit 4ab0ea2

6 files changed

Lines changed: 205 additions & 65 deletions

File tree

api/src/org/labkey/api/action/BaseApiAction.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ private FormAndErrors<FORM> populateForm() throws Exception
312312
if (null != contentType)
313313
{
314314
if (MimeMap.DEFAULT.isJsonContentTypeHeader(contentType))
315-
{
315+
{
316316
_reqFormat = ApiResponseWriter.Format.JSON;
317317
return populateJsonForm();
318318
}

api/src/org/labkey/api/admin/AdminUrls.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ public interface AdminUrls extends UrlProvider
6565
ActionURL getSessionLoggingURL();
6666
ActionURL getTrackedAllocationsViewerURL();
6767
ActionURL getSystemMaintenanceURL();
68+
ActionURL getCspReportToURL(String cspVersion);
6869

6970
/**
7071
* Simply adds an "Admin Console" link to nav trail if invoked in the root container. Otherwise, root is unchanged.

api/src/org/labkey/api/security/roles/ImpersonatingTroubleshooterRole.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,10 @@ public boolean isPrivileged()
2626
{
2727
return true;
2828
}
29+
30+
@Override
31+
public boolean isAvailableEverywhere()
32+
{
33+
return false;
34+
}
2935
}

api/src/org/labkey/api/util/MimeMap.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,14 @@ public int hashCode()
125125
public static final MimeType XML = new MimeType("text/xml");
126126
public static final MimeType JSON = new MimeType("application/json", false, true);
127127
public static final MimeType TEXT_JSON = new MimeType("text/json", false, true);
128-
public static final MimeType CSP = new MimeType("application/csp-report", false, true);
128+
public static final MimeType CSP_REPORT_URI_JSON = new MimeType("application/csp-report", false, true);
129+
public static final MimeType CSP_REPORT_TO_JSON = new MimeType("application/reports+json", false, true);
129130
}
130131

131132
static
132133
{
133134
for (MimeType mt : Arrays.asList(MimeType.GIF, MimeType.JPEG, MimeType.PDF, MimeType.PNG, MimeType.SVG, MimeType.HTML, MimeType.PLAIN, MimeType.XML,
134-
MimeType.TEXT_JSON, MimeType.JSON, MimeType.CSP))
135+
MimeType.TEXT_JSON, MimeType.JSON, MimeType.CSP_REPORT_URI_JSON, MimeType.CSP_REPORT_TO_JSON))
135136
{
136137
mimeTypeMap.put(mt.getContentType(), mt);
137138
}

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

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@
1111
import org.apache.commons.collections4.SetValuedMap;
1212
import org.apache.commons.collections4.multimap.HashSetValuedHashMap;
1313
import org.apache.commons.lang3.StringUtils;
14+
import org.apache.commons.lang3.Strings;
1415
import org.apache.logging.log4j.Logger;
16+
import org.jetbrains.annotations.NotNull;
1517
import org.junit.Assert;
1618
import org.junit.Test;
19+
import org.labkey.api.admin.AdminUrls;
1720
import org.labkey.api.collections.CopyOnWriteHashMap;
1821
import org.labkey.api.collections.LabKeyCollectors;
1922
import org.labkey.api.security.Directive;
@@ -36,6 +39,7 @@
3639
import java.util.HashMap;
3740
import java.util.List;
3841
import java.util.Map;
42+
import java.util.Objects;
3943
import java.util.Set;
4044
import 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

Comments
 (0)