Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 on lines 128 to +129
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider CSP_V1 and CSP_V3 or similar, as they both seems like CSP reports.

}

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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this work when bootstrapping a server? And what happens if the base URL is changed after startup?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll check. Can probably switch to populating this lazily and any time the base server URL changes.

// 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}");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this use ActionURL, at least for the parts other than the substitution syntax? It could add the CSP version too, which could theoretically need URI encoding

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried, but it's quite awkward given the substitution has to be in the middle of the URL. I took another stab. I guess it's okay.

if (_cspVersion != null)
violationEndpoint += "&cspVersion=" + _cspVersion;
_reportingEndpoints = "csp-endpoint=\"" + violationEndpoint + "\"";
_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