Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/src/org/labkey/api/action/BaseApiAction.java
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ private FormAndErrors<FORM> populateForm() throws Exception
if (null != contentType)
{
if (MimeMap.DEFAULT.isJsonContentTypeHeader(contentType))
{
{
_reqFormat = ApiResponseWriter.Format.JSON;
return populateJsonForm();
}
Expand Down
3 changes: 2 additions & 1 deletion api/src/org/labkey/api/util/MimeMap.java
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,13 @@ public int hashCode()
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 = new MimeType("application/reports+json", false, true);
Comment thread
labkey-adam marked this conversation as resolved.
Outdated
}

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, MimeType.CSP_REPORT))
{
mimeTypeMap.put(mt.getContentType(), mt);
}
Expand Down
30 changes: 27 additions & 3 deletions api/src/org/labkey/filters/ContentSecurityPolicyFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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.junit.Assert;
import org.junit.Test;
Expand Down Expand Up @@ -66,6 +67,7 @@ public class ContentSecurityPolicyFilter implements Filter
private ContentSecurityPolicyType _type = ContentSecurityPolicyType.Enforce;
private String _policyTemplate = null;
private String _cspVersion = "Unknown";
private String _reportingEndpoints = null;

// Updated after every change to "allowed sources"
private StringExpression _policyExpression = null;
Expand Down Expand Up @@ -104,7 +106,6 @@ public String getHeaderName()
public void init(FilterConfig filterConfig) throws ServletException
{
LogHelper.getLogger(ContentSecurityPolicyFilter.class, "CSP filter initialization").info("Initializing {}", filterConfig.getFilterName());

Enumeration<String> paramNames = filterConfig.getInitParameterNames();
while (paramNames.hasMoreElements())
{
Expand All @@ -115,8 +116,7 @@ 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;

Expand All @@ -136,12 +136,32 @@ else if ("disposition".equalsIgnoreCase(paramName))
}
}

String baseServerUrl = AppProps.getInstance().getBaseServerUrl();
Comment thread
labkey-adam marked this conversation as resolved.
Outdated
// 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://"))
{
// Generate the Reporting-Endpoints header value now since its value is static. Use an absolute URL so we
// always post reports to https:, even when the violating request happens to be http:
String violationEndpoint = substituteReportParams(baseServerUrl + "/admin-contentSecurityPolicyReportTo.api?${CSP.REPORT.PARAMS}");
Comment thread
labkey-adam marked this conversation as resolved.
Outdated
if (_cspVersion != null)
violationEndpoint += "&cspVersion=" + _cspVersion;
_reportingEndpoints = "csp-endpoint=\"" + violationEndpoint + "\"";
Comment thread
labkey-adam marked this conversation as resolved.
Outdated
_policyTemplate = _policyTemplate + " report-to csp-endpoint ;";
}

if (CSP_FILTERS.put(_type, this) != null)
throw new ServletException("ContentSecurityPolicyFilter is misconfigured, duplicate policies of type: " + _type);

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)
{
Expand Down Expand Up @@ -224,6 +244,10 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
Map<String, String> map = Map.of(NONCE_SUBST, getScriptNonceHeader(req));
var csp = _policyExpression.eval(map);
resp.setHeader(_type.getHeaderName(), csp);

// non-null if https: is configured on this server
if (_reportingEndpoints != null)
resp.setHeader("Reporting-Endpoints", _reportingEndpoints);
}
}
chain.doFilter(request, response);
Expand Down
176 changes: 121 additions & 55 deletions core/src/org/labkey/core/admin/AdminController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -11974,24 +11974,70 @@ 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<JSONObject>.
public static class ReportToJsonObjects extends ArrayList<JSONObject>
{
}

@RequiresNoPermission
@CSRF(CSRF.Method.NONE)
public static class ContentSecurityPolicyReportAction extends ReadOnlyApiAction<SimpleApiJsonForm>
@Marshal(Marshaller.Jackson)
public static class ContentSecurityPolicyReportToAction extends BaseContentSecurityPolicyReportAction<ReportToJsonObjects>
{
@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<SimpleApiJsonForm>
{
@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<FORM> extends ReadOnlyApiAction<FORM>
{
private static final Logger _log = LogHelper.getLogger(ContentSecurityPolicyReportAction.class, "CSP warnings");

// recent reports, to help avoid log spam
private static final Map<String, Boolean> 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);

Expand All @@ -12006,52 +12052,67 @@ 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)
{
User user = getUser();
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);
String ipAddress = request.getHeader("X-FORWARDED-FOR");
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);
Expand All @@ -12061,50 +12122,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;
}

// Send the request and get the response
HttpResponse<String> response = client.send(remoteRequest, HttpResponse.BodyHandlers.ofString());
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();

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);
}
}
}
}
// Send the request and get the response
HttpResponse<String> 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);
}
}
}
return ret;
}
}


public static class TestCase extends AbstractActionPermissionTest
{
@Override
Expand Down