diff --git a/api/src/org/labkey/api/action/BaseApiAction.java b/api/src/org/labkey/api/action/BaseApiAction.java index e4a6809cede..d0944264647 100644 --- a/api/src/org/labkey/api/action/BaseApiAction.java +++ b/api/src/org/labkey/api/action/BaseApiAction.java @@ -312,7 +312,7 @@ private FormAndErrors
populateForm() throws Exception if (null != contentType) { if (MimeMap.DEFAULT.isJsonContentTypeHeader(contentType)) - { + { _reqFormat = ApiResponseWriter.Format.JSON; return populateJsonForm(); } diff --git a/api/src/org/labkey/api/admin/AdminUrls.java b/api/src/org/labkey/api/admin/AdminUrls.java index f7a46e6ffff..4f2e30826d5 100644 --- a/api/src/org/labkey/api/admin/AdminUrls.java +++ b/api/src/org/labkey/api/admin/AdminUrls.java @@ -65,6 +65,7 @@ public interface AdminUrls extends UrlProvider ActionURL getSessionLoggingURL(); ActionURL getTrackedAllocationsViewerURL(); ActionURL getSystemMaintenanceURL(); + ActionURL getCspReportToURL(String cspVersion); /** * Simply adds an "Admin Console" link to nav trail if invoked in the root container. Otherwise, root is unchanged. diff --git a/api/src/org/labkey/api/security/roles/ImpersonatingTroubleshooterRole.java b/api/src/org/labkey/api/security/roles/ImpersonatingTroubleshooterRole.java index b0a8d1ffddf..f6bdfcad095 100644 --- a/api/src/org/labkey/api/security/roles/ImpersonatingTroubleshooterRole.java +++ b/api/src/org/labkey/api/security/roles/ImpersonatingTroubleshooterRole.java @@ -26,4 +26,10 @@ public boolean isPrivileged() { return true; } + + @Override + public boolean isAvailableEverywhere() + { + return false; + } } diff --git a/api/src/org/labkey/api/util/MimeMap.java b/api/src/org/labkey/api/util/MimeMap.java index fc04a1f0c47..f0341ee6397 100644 --- a/api/src/org/labkey/api/util/MimeMap.java +++ b/api/src/org/labkey/api/util/MimeMap.java @@ -125,13 +125,14 @@ public int hashCode() public static final MimeType XML = new MimeType("text/xml"); public static final MimeType JSON = new MimeType("application/json", false, true); public static final MimeType TEXT_JSON = new MimeType("text/json", false, true); - public static final MimeType CSP = new MimeType("application/csp-report", false, true); + public static final MimeType CSP_REPORT_URI_JSON = new MimeType("application/csp-report", false, true); + public static final MimeType CSP_REPORT_TO_JSON = new MimeType("application/reports+json", false, true); } static { for (MimeType mt : Arrays.asList(MimeType.GIF, MimeType.JPEG, MimeType.PDF, MimeType.PNG, MimeType.SVG, MimeType.HTML, MimeType.PLAIN, MimeType.XML, - MimeType.TEXT_JSON, MimeType.JSON, MimeType.CSP)) + MimeType.TEXT_JSON, MimeType.JSON, MimeType.CSP_REPORT_URI_JSON, MimeType.CSP_REPORT_TO_JSON)) { mimeTypeMap.put(mt.getContentType(), mt); } diff --git a/api/src/org/labkey/filters/ContentSecurityPolicyFilter.java b/api/src/org/labkey/filters/ContentSecurityPolicyFilter.java index ae58e441059..71840b510a7 100644 --- a/api/src/org/labkey/filters/ContentSecurityPolicyFilter.java +++ b/api/src/org/labkey/filters/ContentSecurityPolicyFilter.java @@ -11,9 +11,12 @@ import org.apache.commons.collections4.SetValuedMap; import org.apache.commons.collections4.multimap.HashSetValuedHashMap; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; import org.junit.Assert; import org.junit.Test; +import org.labkey.api.admin.AdminUrls; import org.labkey.api.collections.CopyOnWriteHashMap; import org.labkey.api.collections.LabKeyCollectors; import org.labkey.api.security.Directive; @@ -36,6 +39,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -64,8 +68,14 @@ public class ContentSecurityPolicyFilter implements Filter // Per-filter-instance parameters that are set in init() and never changed private ContentSecurityPolicyType _type = ContentSecurityPolicyType.Enforce; - private String _policyTemplate = null; - private String _cspVersion = "Unknown"; + private @NotNull String _cspVersion = "Unknown"; + private String _stashedTemplate = null; + private String _reportToEndpointName = null; + + // Per-filter-instance parameters that are set at first request and reset if base server URL changes + private volatile String _previousBaseServerUrl = null; + private volatile String _policyTemplate = null; + private volatile String _reportingEndpointsHeaderValue = null; // Updated after every change to "allowed sources" private StringExpression _policyExpression = null; @@ -104,7 +114,6 @@ public String getHeaderName() public void init(FilterConfig filterConfig) throws ServletException { LogHelper.getLogger(ContentSecurityPolicyFilter.class, "CSP filter initialization").info("Initializing {}", filterConfig.getFilterName()); - Enumeration paramNames = filterConfig.getInitParameterNames(); while (paramNames.hasMoreElements()) { @@ -115,10 +124,9 @@ public void init(FilterConfig filterConfig) throws ServletException String s = filterPolicy(paramValue); // Replace REPORT_PARAMETER_SUBSTITUTION now since its value is static - s = StringExpressionFactory.create(s, false, NullValueBehavior.KeepSubstitution) - .eval(Map.of(REPORT_PARAMETER_SUBSTITUTION, "labkeyVersion=" + PageFlowUtil.encodeURIComponent(AppProps.getInstance().getReleaseVersion()))); + s = substituteReportParams(s); - _policyTemplate = s; + _policyTemplate = _stashedTemplate = s; extractCspVersion(s); } @@ -139,9 +147,18 @@ else if ("disposition".equalsIgnoreCase(paramName)) if (CSP_FILTERS.put(_type, this) != null) throw new ServletException("ContentSecurityPolicyFilter is misconfigured, duplicate policies of type: " + _type); + // configure a different endpoint for each type to convey the correct csp version (eXX vs. rXX) + _reportToEndpointName = "csp-" + _type.name().toLowerCase(); + regeneratePolicyExpression(); } + private String substituteReportParams(String expression) + { + return StringExpressionFactory.create(expression, false, NullValueBehavior.KeepSubstitution) + .eval(Map.of(REPORT_PARAMETER_SUBSTITUTION, "labkeyVersion=" + PageFlowUtil.encodeURIComponent(AppProps.getInstance().getReleaseVersion()))); + } + /** Filter out block comments and replace special characters in the provided policy */ public static String filterPolicy(String policy) { @@ -199,7 +216,8 @@ private void extractCspVersion(String s) LOG.debug("CspVersion: {}", _cspVersion); } - // Make all the "allowed sources" substitutions at init() and whenever the allowed sources map changes. With this, + // Make all the "allowed sources" substitutions at init(), whenever the allowed sources map changes, or whenever the + // policy template changes (e.g., base server URL change that causes report-to to be added or removed). With this, // the only substitution needed on a per-request basis is the nonce value. private void regeneratePolicyExpression() { @@ -219,16 +237,57 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha { if (request instanceof HttpServletRequest req && response instanceof HttpServletResponse resp && null != _policyExpression) { + ensurePolicy(); + if (_type != ContentSecurityPolicyType.Enforce || !OptionalFeatureService.get().isFeatureEnabled(FEATURE_FLAG_DISABLE_ENFORCE_CSP)) { Map map = Map.of(NONCE_SUBST, getScriptNonceHeader(req)); var csp = _policyExpression.eval(map); resp.setHeader(_type.getHeaderName(), csp); + + // null if https: is not configured on this server + if (_reportingEndpointsHeaderValue != null) + resp.addHeader("Reporting-Endpoints", _reportingEndpointsHeaderValue); } } chain.doFilter(request, response); } + private void ensurePolicy() + { + String baseServerUrl = AppProps.getInstance().getBaseServerUrl(); + + // Reconsider "report-to" directive and "Reporting-Endpoints" header if base server URL has changed + if (!Objects.equals(baseServerUrl, _previousBaseServerUrl)) + { + synchronized (SUBSTITUTION_LOCK) + { + _previousBaseServerUrl = baseServerUrl; + + // Add "Reporting-Endpoints" header and "report-to" directive only if https: is configured on this + // server. This ensures that browsers fall-back on report-uri if https: isn't configured. + if (Strings.CI.startsWith(baseServerUrl, "https://")) + { + // Each filter adds its own "Reporting-Endpoints" header since we want to convey the correct version (eXX vs. rXX) + @SuppressWarnings("DataFlowIssue") + ActionURL violationUrl = PageFlowUtil.urlProvider(AdminUrls.class).getCspReportToURL(_cspVersion); + // Use an absolute URL so we always post to https:, even if the violating request uses http: + _reportingEndpointsHeaderValue = _reportToEndpointName + "=\"" + substituteReportParams(violationUrl.getURIString() + "&${CSP.REPORT.PARAMS}") + "\""; + + // Add "report-to" directive to the policy + _policyTemplate = _stashedTemplate + " report-to " + _reportToEndpointName + " ;"; + } + else + { + _reportingEndpointsHeaderValue = null; + _policyTemplate = _stashedTemplate; + } + + regeneratePolicyExpression(); + } + } + } + public static String getScriptNonceHeader(HttpServletRequest request) { String nonce = (String)request.getAttribute(HEADER_NONCE); diff --git a/core/src/org/labkey/core/admin/AdminController.java b/core/src/org/labkey/core/admin/AdminController.java index 3d375a0b6da..b2535b0b688 100644 --- a/core/src/org/labkey/core/admin/AdminController.java +++ b/core/src/org/labkey/core/admin/AdminController.java @@ -44,6 +44,7 @@ import org.jfree.chart.JFreeChart; import org.jfree.chart.plot.PlotOrientation; import org.jfree.data.category.DefaultCategoryDataset; +import org.json.JSONArray; import org.json.JSONObject; import org.junit.Assert; import org.junit.Test; @@ -190,7 +191,6 @@ import org.labkey.api.security.AdminConsoleAction; import org.labkey.api.security.CSRF; import org.labkey.api.security.Directive; -import org.labkey.api.security.ElevatedUser; import org.labkey.api.security.Group; import org.labkey.api.security.GroupManager; import org.labkey.api.security.IgnoresTermsOfUse; @@ -883,6 +883,13 @@ public ActionURL getSystemMaintenanceURL() return new ActionURL(ConfigureSystemMaintenanceAction.class, ContainerManager.getRoot()); } + @Override + public ActionURL getCspReportToURL(@NotNull String cspVersion) + { + return new ActionURL(ContentSecurityPolicyReportToAction.class, ContainerManager.getRoot()) + .addParameter("cspVersion", cspVersion); + } + public static ActionURL getDeprecatedFeaturesURL() { return new ActionURL(OptionalFeaturesAction.class, ContainerManager.getRoot()).addParameter("type", FeatureType.Deprecated.name()); @@ -11974,16 +11981,54 @@ public void addNavTrail(NavTree root) } } - private static final URI LABKEY_ORG_REPORT_ACTION; + private static final URI LABKEY_ORG_REPORT_URI_ACTION; + private static final URI LABKEY_ORG_REPORT_TO_ACTION; static { - LABKEY_ORG_REPORT_ACTION = URI.create("https://www.labkey.org/admin-contentSecurityPolicyReport.api"); + LABKEY_ORG_REPORT_URI_ACTION = URI.create("https://www.labkey.org/admin-contentSecurityPolicyReport.api"); + LABKEY_ORG_REPORT_TO_ACTION = URI.create("https://www.labkey.org/admin-contentSecurityPolicyReportTo.api"); + } + + // report-to endpoints get sent a JSON array of reports. Use Jackson to deserialize these into a List. + public static class ReportToJsonObjects extends ArrayList + { } @RequiresNoPermission @CSRF(CSRF.Method.NONE) - public static class ContentSecurityPolicyReportAction extends ReadOnlyApiAction + @Marshal(Marshaller.Jackson) + public static class ContentSecurityPolicyReportToAction extends BaseContentSecurityPolicyReportAction + { + @Override + public void handleReports(ReportToJsonObjects jsonObjects, HttpServletRequest request, String userAgent) throws IOException, InterruptedException + { + JSONArray reportsToForward = new JSONArray(); + + jsonObjects.forEach(jsonObject -> { + if (handleOneReport(jsonObject, request, userAgent, "body", "blockedURL", "documentURL")) + reportsToForward.put(jsonObject); + }); + + if (!reportsToForward.isEmpty()) + forwardReports(LABKEY_ORG_REPORT_TO_ACTION, request, reportsToForward.toString(2)); + } + } + + @RequiresNoPermission + @CSRF(CSRF.Method.NONE) + public static class ContentSecurityPolicyReportAction extends BaseContentSecurityPolicyReportAction + { + @Override + public void handleReports(SimpleApiJsonForm form, HttpServletRequest request, String userAgent) throws IOException, InterruptedException + { + JSONObject jsonObject = form.getJsonObject(); + if (handleOneReport(jsonObject, request, userAgent, "csp-report", "blocked-uri", "document-uri")) + forwardReports(LABKEY_ORG_REPORT_URI_ACTION, request, jsonObject.toString(2)); + } + } + + protected abstract static class BaseContentSecurityPolicyReportAction extends ReadOnlyApiAction { private static final Logger _log = LogHelper.getLogger(ContentSecurityPolicyReportAction.class, "CSP warnings"); @@ -11991,7 +12036,15 @@ public static class ContentSecurityPolicyReportAction extends ReadOnlyApiAction< private static final Map reports = Collections.synchronizedMap(new LRUMap<>(20)); @Override - public Object execute(SimpleApiJsonForm form, BindException errors) throws Exception + protected String getCommandClassMethodName() + { + return "handleReports"; + } + + abstract public void handleReports(FORM form, HttpServletRequest request, String userAgent) throws IOException, InterruptedException; + + @Override + public Object execute(FORM form, BindException errors) throws Exception { var ret = new JSONObject().put("success", true); @@ -12006,35 +12059,50 @@ public Object execute(SimpleApiJsonForm form, BindException errors) throws Excep if (PageFlowUtil.isRobotUserAgent(userAgent) && !_log.isDebugEnabled()) return ret; - // NOTE User may be "guest", and will always be guest if being relayed to labkey.org - var jsonObj = form.getJsonObject(); + handleReports(form, request, userAgent); + + return ret; + } + + // Returns true if the report should be forwarded + protected boolean handleOneReport(JSONObject jsonObj, HttpServletRequest request, String userAgent, String bodyKey, String blockedUrlKey, String documentUrlKey) + { if (null != jsonObj) { - JSONObject cspReport = jsonObj.optJSONObject("csp-report"); + JSONObject cspReport = jsonObj.optJSONObject(bodyKey); if (cspReport != null) { - String blockedUri = cspReport.optString("blocked-uri", null); + String blockedUrl = cspReport.optString(blockedUrlKey, null); // Issue 52933 - suppress base-uri problems from a crawler or bot on labkey.org - if (blockedUri != null && - blockedUri.startsWith("https://labkey.org%2C") && - blockedUri.endsWith("undefined") && - !_log.isDebugEnabled()) + if (blockedUrl != null && + blockedUrl.startsWith("https://labkey.org%2C") && + blockedUrl.endsWith("undefined") && + !_log.isDebugEnabled()) { - return ret; + return false; } - String urlString = cspReport.optString("document-uri", null); - if (urlString != null) + String documentUrl = cspReport.optString(documentUrlKey, null); + if (documentUrl != null) { - URLHelper urlHelper = new URLHelper(urlString); + URLHelper documentUrlHelper; + try + { + documentUrlHelper = new URLHelper(documentUrl); + } + catch (URISyntaxException e) + { + throw new RuntimeException(e); + } + // URL parameter that tells us to bypass suppression of redundant logging // Used to make sure that tests of CSP logging are deterministic and convenient - boolean bypassCspDedupe = "true".equals(urlHelper.getParameter("bypassCspDedupe")); - String path = urlHelper.deleteParameters().getURIString(); + boolean bypassCspDedupe = "true".equals(documentUrlHelper.getParameter("bypassCspDedupe")); + String path = documentUrlHelper.deleteParameters().getURIString(); if (null == reports.put(path, Boolean.TRUE) || _log.isDebugEnabled() || bypassCspDedupe) { - // Don't modify forwarded reports; they already have user, ip, user-agent, etc. from the forwarding server. + // Don't modify forwarded reports; they already have user, ip, user_agent, etc. from the forwarding server. boolean forwarded = jsonObj.optBoolean("forwarded", false); if (!forwarded) { @@ -12042,7 +12110,7 @@ public Object execute(SimpleApiJsonForm form, BindException errors) throws Excep String email = null; // If the user is not logged in, we may still be able to snag the email address from our cookie if (user.isGuest()) - email = LoginController.getEmailFromCookie(getViewContext().getRequest()); + email = LoginController.getEmailFromCookie(request); if (null == email) email = user.getEmail(); jsonObj.put("user", email); @@ -12050,8 +12118,8 @@ public Object execute(SimpleApiJsonForm form, BindException errors) throws Excep if (ipAddress == null) ipAddress = request.getRemoteAddr(); jsonObj.put("ip", ipAddress); - if (isNotBlank(userAgent)) - jsonObj.put("user-agent", userAgent); + if (isNotBlank(userAgent) && !jsonObj.has("user_agent")) + jsonObj.put("user_agent", userAgent); String labkeyVersion = request.getParameter("labkeyVersion"); if (null != labkeyVersion) jsonObj.put("labkeyVersion", labkeyVersion); @@ -12061,50 +12129,55 @@ public Object execute(SimpleApiJsonForm form, BindException errors) throws Excep } var jsonStr = jsonObj.toString(2); - _log.warn("ContentSecurityPolicy warning on page: {}\n{}", urlString, jsonStr); + _log.warn("ContentSecurityPolicy warning on page: {}\n{}", documentUrl, jsonStr); - if (!forwarded && OptionalFeatureService.get().isFeatureEnabled(ContentSecurityPolicyFilter.FEATURE_FLAG_FORWARD_CSP_REPORTS)) - { + boolean shouldForward = !forwarded && OptionalFeatureService.get().isFeatureEnabled(ContentSecurityPolicyFilter.FEATURE_FLAG_FORWARD_CSP_REPORTS); + if (shouldForward) jsonObj.put("forwarded", true); - // Create an HttpClient - HttpClient client = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(); + return shouldForward; + } + } + } + } - // Create the POST request - HttpRequest remoteRequest = HttpRequest.newBuilder() - .uri(LABKEY_ORG_REPORT_ACTION) - .header("Content-Type", request.getContentType()) // Use whatever the browser set - .POST(HttpRequest.BodyPublishers.ofString(jsonObj.toString(2))) - .build(); + return false; + } + + protected void forwardReports(URI destination, HttpServletRequest request, String content) throws IOException, InterruptedException + { + // Create an HttpClient + try (HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build()) + { + // Create the POST request + HttpRequest remoteRequest = HttpRequest.newBuilder() + .uri(destination) + .header("Content-Type", request.getContentType()) // Use whatever the browser set + .POST(HttpRequest.BodyPublishers.ofString(content)) + .build(); - // Send the request and get the response - HttpResponse response = client.send(remoteRequest, HttpResponse.BodyHandlers.ofString()); + // Send the request and get the response + HttpResponse response = client.send(remoteRequest, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 200) - { - _log.error("ContentSecurityPolicy report forwarding to https://www.labkey.org failed: {}\n{}", response.statusCode(), response.body()); - } - else - { - JSONObject jsonResponse = new JSONObject(response.body()); - boolean success = jsonResponse.optBoolean("success", false); - if (!success) - { - _log.error("ContentSecurityPolicy report forwarding to https://www.labkey.org failed: {}", jsonResponse); - } - } - } - } + if (response.statusCode() != 200) + { + _log.error("ContentSecurityPolicy report forwarding to https://www.labkey.org failed: {}\n{}", response.statusCode(), response.body()); + } + else + { + JSONObject jsonResponse = new JSONObject(response.body()); + boolean success = jsonResponse.optBoolean("success", false); + if (!success) + { + _log.error("ContentSecurityPolicy report forwarding to https://www.labkey.org failed: {}", jsonResponse); } } } - return ret; } } - public static class TestCase extends AbstractActionPermissionTest { @Override