diff --git a/core/src/main/java/hudson/model/DirectoryBrowserSupport.java b/core/src/main/java/hudson/model/DirectoryBrowserSupport.java index aa92c9c1e83e..2ee1c55fd80c 100644 --- a/core/src/main/java/hudson/model/DirectoryBrowserSupport.java +++ b/core/src/main/java/hudson/model/DirectoryBrowserSupport.java @@ -24,6 +24,7 @@ package hudson.model; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import hudson.ExtensionList; import hudson.FilePath; import hudson.Util; import java.io.IOException; @@ -54,6 +55,8 @@ import javax.servlet.http.HttpServletResponse; import jenkins.model.Jenkins; import jenkins.security.MasterToSlaveCallable; +import jenkins.security.ResourceDomainConfiguration; +import jenkins.security.ResourceDomainRootAction; import jenkins.util.SystemProperties; import jenkins.util.VirtualFile; import org.apache.commons.io.IOUtils; @@ -88,6 +91,14 @@ public final class DirectoryBrowserSupport implements HttpResponse { private final boolean serveDirIndex; private String indexFileName = "index.html"; + @Restricted(NoExternalUse.class) + public static final String CSP_PROPERTY_NAME = DirectoryBrowserSupport.class.getName() + ".CSP"; + + /** + * Keeps track of whether this has been registered from use via {@link ResourceDomainRootAction}. + */ + private ResourceDomainRootAction.Token resourceToken; + /** * @deprecated as of 1.297 * Use {@link #DirectoryBrowserSupport(ModelObject, FilePath, String, String, boolean)} @@ -137,6 +148,10 @@ public DirectoryBrowserSupport(ModelObject owner, VirtualFile base, String title } public void generateResponse(StaplerRequest req, StaplerResponse rsp, Object node) throws IOException, ServletException { + if (!ResourceDomainConfiguration.isResourceRequest(req) && ResourceDomainConfiguration.isResourceDomainConfigured()) { + resourceToken = ResourceDomainRootAction.get().getToken(this, req); + } + try { serveFile(req,rsp,base,icon,serveDirIndex); } catch (InterruptedException e) { @@ -290,7 +305,11 @@ private void serveFile(StaplerRequest req, StaplerResponse rsp, VirtualFile root req.setAttribute("path", path); req.setAttribute("pattern",rest); req.setAttribute("dir", baseFile); - req.getView(this,"dir.jelly").forward(req, rsp); + if (ResourceDomainConfiguration.isResourceRequest(req)) { + req.getView(this, "plaindir.jelly").forward(req, rsp); + } else { + req.getView(this, "dir.jelly").forward(req, rsp); + } return; } @@ -339,17 +358,25 @@ private void serveFile(StaplerRequest req, StaplerResponse rsp, VirtualFile root // pseudo file name to let the Stapler set text/plain rsp.serveFile(req, in, lastModified, -1, length, "plain.txt"); } else { - String csp = SystemProperties.getString(DirectoryBrowserSupport.class.getName() + ".CSP", DEFAULT_CSP_VALUE); - if (!csp.trim().equals("")) { - // allow users to prevent sending this header by setting empty system property - for (String header : new String[]{"Content-Security-Policy", "X-WebKit-CSP", "X-Content-Security-Policy"}) { - rsp.setHeader(header, csp); + if (resourceToken != null) { + // redirect to second domain + rsp.sendRedirect(302, ResourceDomainRootAction.get().getRedirectUrl(resourceToken, req.getRestOfPath())); + } else { + if (!ResourceDomainConfiguration.isResourceRequest(req)) { + // if we're serving this from the main domain, set CSP headers + String csp = SystemProperties.getString(CSP_PROPERTY_NAME, DEFAULT_CSP_VALUE); + if (!csp.trim().equals("")) { + // allow users to prevent sending this header by setting empty system property + for (String header : new String[]{"Content-Security-Policy", "X-WebKit-CSP", "X-Content-Security-Policy"}) { + rsp.setHeader(header, csp); + } + } } + rsp.serveFile(req, in, lastModified, -1, length, baseFile.getName()); } - rsp.serveFile(req, in, lastModified, -1, length, baseFile.getName() ); } } - + private List> keepReadabilityOnlyOnDescendants(VirtualFile root, boolean patternUsed, List> pathFragmentsList){ Stream> pathFragmentsStream = pathFragmentsList.stream().map((List pathFragments) -> { List mappedFragments = new ArrayList<>(pathFragments.size()); diff --git a/core/src/main/java/jenkins/model/JenkinsLocationConfiguration.java b/core/src/main/java/jenkins/model/JenkinsLocationConfiguration.java index f13f497a0ebc..e6664df305f5 100644 --- a/core/src/main/java/jenkins/model/JenkinsLocationConfiguration.java +++ b/core/src/main/java/jenkins/model/JenkinsLocationConfiguration.java @@ -36,7 +36,8 @@ * @author Kohsuke Kawaguchi * @since 1.494 */ -@Extension @Symbol("location") +@Extension(ordinal = JenkinsLocationConfiguration.ORDINAL) +@Symbol("location") public class JenkinsLocationConfiguration extends GlobalConfiguration implements PersistentDescriptor { /** @@ -50,6 +51,9 @@ public class JenkinsLocationConfiguration extends GlobalConfiguration implements public static /* not final */ boolean DISABLE_URL_VALIDATION = SystemProperties.getBoolean(JenkinsLocationConfiguration.class.getName() + ".disableUrlValidation"); + @Restricted(NoExternalUse.class) + public static final int ORDINAL = 200; + /** * @deprecated replaced by {@link #jenkinsUrl} */ diff --git a/core/src/main/java/jenkins/security/ResourceDomainConfiguration.java b/core/src/main/java/jenkins/security/ResourceDomainConfiguration.java new file mode 100644 index 000000000000..7edd20276dde --- /dev/null +++ b/core/src/main/java/jenkins/security/ResourceDomainConfiguration.java @@ -0,0 +1,257 @@ +/* + * The MIT License + * + * Copyright 2019 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package jenkins.security; + +import hudson.Extension; +import hudson.ExtensionList; +import hudson.Util; +import hudson.util.FormValidation; +import jenkins.diagnostics.RootUrlNotSetMonitor; +import jenkins.model.GlobalConfiguration; +import jenkins.model.Jenkins; +import jenkins.model.JenkinsLocationConfiguration; +import jenkins.model.identity.InstanceIdentityProvider; +import jenkins.util.UrlHelper; +import org.apache.commons.codec.binary.Base64; +import org.jenkinsci.Symbol; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.Stapler; +import org.kohsuke.stapler.StaplerRequest; +import org.kohsuke.stapler.verb.POST; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; +import java.security.interfaces.RSAPublicKey; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static jenkins.security.ResourceDomainFilter.ERROR_RESPONSE; + +/** + * Configure the resource root URL, an alternative root URL to serve resources from + * to not need Content-Security-Policy headers, which mess with desired complex output. + * + * @see ResourceDomainFilter + * @see ResourceDomainRootAction + * + * @since TODO + */ +@Extension(ordinal = JenkinsLocationConfiguration.ORDINAL-1) // sort just below the regular location config +@Restricted(NoExternalUse.class) +@Symbol("resourceRoot") +public class ResourceDomainConfiguration extends GlobalConfiguration { + + private static final Logger LOGGER = Logger.getLogger(ResourceDomainConfiguration.class.getName()); + + private String url; + + public ResourceDomainConfiguration() { + load(); + } + + @POST + public FormValidation doCheckUrl(@QueryParameter("url") String resourceRootUrlString) { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + + return checkUrl(resourceRootUrlString, true); + } + + private FormValidation checkUrl(String resourceRootUrlString, boolean allowOnlineIdentityCheck) { + String jenkinsRootUrlString = JenkinsLocationConfiguration.get().getUrl(); + if (ExtensionList.lookupSingleton(RootUrlNotSetMonitor.class).isActivated() || jenkinsRootUrlString == null) { + // This is needed to round-trip expired resource URLs through regular URLs to refresh them, + // so while it's not required in the strictest sense, it is required. + return FormValidation.warning(Messages.ResourceDomainConfiguration_NeedsRootURL()); + } + + resourceRootUrlString = Util.fixEmptyAndTrim(resourceRootUrlString); + if (resourceRootUrlString == null) { + return FormValidation.ok(Messages.ResourceDomainConfiguration_Empty()); + } + + if (!UrlHelper.isValidRootUrl(resourceRootUrlString)) { + return FormValidation.error(Messages.ResourceDomainConfiguration_Invalid()); + } + + if (!resourceRootUrlString.endsWith("/")) { + resourceRootUrlString += '/'; + } + + URL resourceRootUrl; + try { + resourceRootUrl = new URL(resourceRootUrlString); + } catch (MalformedURLException ex) { + return FormValidation.error(Messages.ResourceDomainConfiguration_Invalid()); + } + + String resourceRootUrlHost = resourceRootUrl.getHost(); + try { + String jenkinsRootUrlHost = new URL(jenkinsRootUrlString).getHost(); + if (jenkinsRootUrlHost.equals(resourceRootUrlHost)) { + // We do not allow the same host for Jenkins and resource root URLs even if there's some other difference. + // This is a conservative choice and prohibits same host/different proto/different port/different path: + // - Different path still counts as the same origin for same-origin policy + // - Cookies are shared across ports, and non-Secure cookies get sent to HTTPS sites + return FormValidation.error(Messages.ResourceDomainConfiguration_SameAsJenkinsRoot()); + } + } catch (Exception ex) { + LOGGER.log(Level.CONFIG, "Failed to create URL from the existing Jenkins root URL", ex); + return FormValidation.error(Messages.ResourceDomainConfiguration_InvalidRootURL(ex.getMessage())); + } + + StaplerRequest currentRequest = Stapler.getCurrentRequest(); + if (currentRequest != null) { + String currentRequestHost = currentRequest.getHeader("Host"); + + if (currentRequestHost.equals(resourceRootUrlHost)) { + return FormValidation.error(Messages.ResourceDomainConfiguration_SameAsCurrent()); + } + } + + if (!allowOnlineIdentityCheck) { + return FormValidation.ok(); + } + + // Send a request to /instance-identity/ at the resource root URL and check whether it is this Jenkins + try { + URLConnection urlConnection = new URL(resourceRootUrlString + "instance-identity/").openConnection(); + if (urlConnection instanceof HttpURLConnection) { + HttpURLConnection httpURLConnection = (HttpURLConnection) urlConnection; + int responseCode = httpURLConnection.getResponseCode(); + + if (responseCode == 200) { + String identityHeader = urlConnection.getHeaderField("X-Instance-Identity"); + if (identityHeader == null) { + return FormValidation.warning(Messages.ResourceDomainConfiguration_NotJenkins()); + } + // URL points to a Jenkins instance + RSAPublicKey publicKey = InstanceIdentityProvider.RSA.getPublicKey(); + if (publicKey != null) { + String identity = Base64.encodeBase64String(publicKey.getEncoded()); + if (identity.equals(identityHeader)) { + return FormValidation.ok(Messages.ResourceDomainConfiguration_ThisJenkins()); + } + return FormValidation.warning(Messages.ResourceDomainConfiguration_OtherJenkins()); + } // the current instance has no public key + return FormValidation.warning(Messages.ResourceDomainConfiguration_SomeJenkins()); + } + // response is error + String responseMessage = httpURLConnection.getResponseMessage(); + if (responseCode == 404 && responseMessage.equals(ERROR_RESPONSE)) { + return FormValidation.ok(Messages.ResourceDomainConfiguration_ResourceResponse()); + } + return FormValidation.error(Messages.ResourceDomainConfiguration_FailedIdentityCheck(responseCode, responseMessage)); + } + return FormValidation.error(Messages.ResourceDomainConfiguration_Invalid()); // unlikely to ever be hit + } catch (MalformedURLException ex) { + // Not expected to be hit + LOGGER.log(Level.FINE, "MalformedURLException occurred during instance identity check for " + resourceRootUrlString, ex); + return FormValidation.error(Messages.ResourceDomainConfiguration_Exception(ex.getMessage())); + } catch (IOException ex) { + LOGGER.log(Level.FINE, "IOException occurred during instance identity check for " + resourceRootUrlString, ex); + return FormValidation.warning(Messages.ResourceDomainConfiguration_IOException(ex.getMessage())); + } + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + if (checkUrl(url, false).kind == FormValidation.Kind.OK) { + // only accept valid configurations, both with and without URL, but allow for networking issues + url = Util.fixEmpty(url); + if (url != null && !url.endsWith("/")) { + url += "/"; + } + this.url = url; + save(); + } + } + + /** + * Returns true if and only if this is a request to URLs under the resource root URL. + * + * For this to be the case, the requested host and port (from the Host HTTP request header) must match what is + * configured for the resource root URL. + * + * @param req the request to check + * @return whether the request is a resource URL request + */ + public static boolean isResourceRequest(HttpServletRequest req) { + if (!isResourceDomainConfigured()) { + return false; + } + String resourceRootUrl = get().getUrl(); + try { + URL url = new URL(resourceRootUrl); + + String resourceRootHost = url.getHost(); + if (!resourceRootHost.equalsIgnoreCase(req.getServerName())) { + return false; + } + + int resourceRootPort = url.getPort(); + if (resourceRootPort == -1) { + resourceRootPort = url.getDefaultPort(); + } + + // let's hope this gives the default port if the Host header exists but doesn't specify a port + int requestedPort = req.getServerPort(); + + if (requestedPort != resourceRootPort) { + return false; + } + } catch (MalformedURLException ex) { + // the URL here cannot be so broken that we cannot call `new URL(String)` on it... + return false; + } + return true; + } + + /** + * Returns true if and only if a domain has been configured to serve resource URLs from + * + * @return whether a domain has been configured + */ + public static boolean isResourceDomainConfigured() { + String resourceRootUrl = get().getUrl(); + if (resourceRootUrl == null || resourceRootUrl.isEmpty()) { + return false; + } + + // effectively not configured when the location configuration is empty + return Util.nullify(JenkinsLocationConfiguration.get().getUrl()) != null; + } + + public static ResourceDomainConfiguration get() { + return ExtensionList.lookupSingleton(ResourceDomainConfiguration.class); + } +} diff --git a/core/src/main/java/jenkins/security/ResourceDomainFilter.java b/core/src/main/java/jenkins/security/ResourceDomainFilter.java new file mode 100644 index 000000000000..7b7dceeee874 --- /dev/null +++ b/core/src/main/java/jenkins/security/ResourceDomainFilter.java @@ -0,0 +1,88 @@ +/* + * The MIT License + * + * Copyright 2019 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package jenkins.security; + +import hudson.init.InitMilestone; +import hudson.init.Initializer; +import hudson.util.PluginServletFilter; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.logging.Logger; + +/** + * Prohibit requests to Jenkins coming through a resource domain URL configured with + * {@link ResourceDomainConfiguration}, except anything going to {@link ResourceDomainRootAction}. + * + * @since TODO + */ +@Restricted(NoExternalUse.class) +public class ResourceDomainFilter implements Filter { + + private static final Logger LOGGER = Logger.getLogger(ResourceDomainFilter.class.getName()); + + private static final Set ALLOWED_PATHS = new HashSet<>(Arrays.asList("/" + ResourceDomainRootAction.URL, "/favicon.ico", "/robots.txt")); + public static final String ERROR_RESPONSE = "Jenkins serves only static files on this domain."; + + @Initializer(after = InitMilestone.EXTENSIONS_AUGMENTED) + public static void init() throws ServletException { + PluginServletFilter.addFilter(new ResourceDomainFilter()); + } + + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) + throws IOException, ServletException { + if (servletRequest instanceof HttpServletRequest) { + HttpServletRequest httpServletRequest = (HttpServletRequest)servletRequest; + HttpServletResponse httpServletResponse = (HttpServletResponse)servletResponse; + if (ResourceDomainConfiguration.isResourceRequest(httpServletRequest)) { + String path = httpServletRequest.getPathInfo(); + if (!path.startsWith("/" + ResourceDomainRootAction.URL + "/") && !ALLOWED_PATHS.contains(path)) { + LOGGER.fine(() -> "Rejecting request to " + httpServletRequest.getRequestURL() + " from " + httpServletRequest.getRemoteAddr() + " on resource domain"); + httpServletResponse.sendError(404, ERROR_RESPONSE); + return; + } + LOGGER.finer(() -> "Accepting request to " + httpServletRequest.getRequestURL() + " from " + httpServletRequest.getRemoteAddr() + " on resource domain"); + } + } + filterChain.doFilter(servletRequest, servletResponse); + } + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + + } + + @Override + public void destroy() { + + } +} diff --git a/core/src/main/java/jenkins/security/ResourceDomainRecommendation.java b/core/src/main/java/jenkins/security/ResourceDomainRecommendation.java new file mode 100644 index 000000000000..0e4e9c262424 --- /dev/null +++ b/core/src/main/java/jenkins/security/ResourceDomainRecommendation.java @@ -0,0 +1,75 @@ +/* + * The MIT License + * + * Copyright 2019 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package jenkins.security; + +import hudson.Extension; +import hudson.model.AdministrativeMonitor; +import hudson.model.DirectoryBrowserSupport; +import hudson.util.HttpResponses; +import jenkins.util.SystemProperties; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.HttpResponse; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.interceptor.RequirePOST; + +import java.io.IOException; + +/** + * Recommend use of {@link ResourceDomainConfiguration} to users with the system property + * {@code hudson.model.DirectoryBrowserSupport.CSP} set to override + * {@link DirectoryBrowserSupport#DEFAULT_CSP_VALUE}. + * + * @see ResourceDomainConfiguration + * + * @since TODO + */ +@Extension +@Restricted(NoExternalUse.class) +public class ResourceDomainRecommendation extends AdministrativeMonitor { + + @Override + public String getDisplayName() { + return Messages.ResourceDomainConfiguration_DisplayName(); + } + + @Override + public boolean isActivated() { + boolean isResourceRootUrlSet = ResourceDomainConfiguration.isResourceDomainConfigured(); + boolean isOverriddenCSP = SystemProperties.getString(DirectoryBrowserSupport.CSP_PROPERTY_NAME) != null; + return isOverriddenCSP && !isResourceRootUrlSet; + } + + @RequirePOST + public HttpResponse doAct(@QueryParameter String redirect, @QueryParameter String dismiss) throws IOException { + if (dismiss != null) { + disable(true); + return HttpResponses.redirectViaContextPath("manage"); + } + if (redirect != null) { + return HttpResponses.redirectViaContextPath("configure"); + } + return HttpResponses.forwardToPreviousPage(); + } +} diff --git a/core/src/main/java/jenkins/security/ResourceDomainRootAction.java b/core/src/main/java/jenkins/security/ResourceDomainRootAction.java new file mode 100644 index 000000000000..5cb8d2b41e5e --- /dev/null +++ b/core/src/main/java/jenkins/security/ResourceDomainRootAction.java @@ -0,0 +1,307 @@ +/* + * The MIT License + * + * Copyright 2019 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package jenkins.security; + +import hudson.Extension; +import hudson.ExtensionList; +import hudson.Util; +import hudson.model.DirectoryBrowserSupport; +import hudson.model.UnprotectedRootAction; +import hudson.model.User; +import hudson.security.ACL; +import hudson.security.ACLContext; +import jenkins.model.Jenkins; +import jenkins.util.SystemProperties; +import org.acegisecurity.AccessDeniedException; +import org.acegisecurity.Authentication; +import org.acegisecurity.userdetails.UsernameNotFoundException; +import org.apache.commons.lang.ArrayUtils; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.*; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static java.time.Instant.*; +import static java.time.temporal.ChronoUnit.MINUTES; + +/** + * Root action serving {@link DirectoryBrowserSupport} instances + * on random URLs to support resource URLs (second domain). + * + * @see ResourceDomainConfiguration + * @see ResourceDomainFilter + * + * @since TODO + */ +@Extension +@Restricted(NoExternalUse.class) +public class ResourceDomainRootAction implements UnprotectedRootAction { + + private static final Logger LOGGER = Logger.getLogger(ResourceDomainRootAction.class.getName()); + + public static final String URL = "static-files"; + + @CheckForNull + @Override + public String getIconFileName() { + return null; + } + + @CheckForNull + @Override + public String getDisplayName() { + return null; + } + + @CheckForNull + @Override + public String getUrlName() { + return URL; + } + + public static ResourceDomainRootAction get() { + return ExtensionList.lookupSingleton(ResourceDomainRootAction.class); + } + + public void doIndex(StaplerRequest req, StaplerResponse rsp) throws IOException { + if (ResourceDomainConfiguration.isResourceRequest(req)) { + rsp.sendError(404, ResourceDomainFilter.ERROR_RESPONSE); + } else { + rsp.sendError(404, "Cannot handle requests to this URL unless on Jenkins resource URL."); + } + } + + public Object getDynamic(String id, StaplerRequest req, StaplerResponse rsp) throws Exception { + if (!ResourceDomainConfiguration.isResourceRequest(req)) { + rsp.sendError(404, "Cannot handle requests to this URL unless on Jenkins resource URL."); + return null; + } + + Token token = Token.decode(id); + if (token == null) { + rsp.sendError(404, ResourceDomainFilter.ERROR_RESPONSE); + return null; + } + + String authenticationName = token.username; + String browserUrl = token.path; + + + if (token.timestamp.plus(VALID_FOR_MINUTES, MINUTES).isAfter(now()) && token.timestamp.isBefore(now())) { + return new InternalResourceRequest(browserUrl, authenticationName); + } + + // too old, so redirect to the real file first + return new Redirection(browserUrl); + } + + private static class Redirection { + private final String url; + + private Redirection(String url) { + this.url = url; + } + + public void doDynamic(StaplerRequest req, StaplerResponse rsp) throws IOException { + String restOfPath = req.getRestOfPath(); + + String url = Jenkins.get().getRootUrl() + this.url + restOfPath; + rsp.sendRedirect(302, url); + } + } + + public String getRedirectUrl(@Nonnull Token token, @Nonnull String restOfPath) { + String resourceRootUrl = getResourceRootUrl(); + if (!restOfPath.startsWith("/")) { + // Unsure whether this can happen -- just be safe here + restOfPath = "/" + restOfPath; + } + return resourceRootUrl + getUrlName() + "/" + token.encode() + restOfPath; + } + + private static String getResourceRootUrl() { + return ResourceDomainConfiguration.get().getUrl(); + } + + /** + * Called from {@link DirectoryBrowserSupport#generateResponse(StaplerRequest, StaplerResponse, Object)} to obtain + * a token to use when rendering a response. + * + * @param dbs the {@link DirectoryBrowserSupport} instance requesting the token + * @param req the current request + * @return a token that can be used to redirect users to the {@link ResourceDomainRootAction}. + */ + @CheckForNull + public Token getToken(@Nonnull DirectoryBrowserSupport dbs, @Nonnull StaplerRequest req) { + // This is the "restOfPath" of the DirectoryBrowserSupport, i.e. the directory/file/pattern "inside" the DBS. + final String dbsFile = req.getRestOfPath(); + + // Now get the 'restOfUrl' after the top-level ancestor (which is the Jenkins singleton). + // In other words, this is the complete URL after Jenkins handled the top-level request. + final String completeUrl = req.getAncestors().get(0).getRestOfUrl(); + + // And finally, remove the 'restOfPath' suffix from the complete URL, as that's the path from Jenkins to the DBS. + String dbsUrl = completeUrl.substring(0, completeUrl.length() - dbsFile.length()); + LOGGER.fine(() -> "Determined DBS URL: " + dbsUrl + " from restOfUrl: " + completeUrl + " and restOfPath: " + dbsFile); + + Authentication authentication = Jenkins.getAuthentication(); + String authenticationName = authentication == Jenkins.ANONYMOUS ? "" : authentication.getName(); + + try { + return new Token(dbsUrl, authenticationName, Instant.now()); + } catch (Exception ex) { + LOGGER.log(Level.WARNING, "Failed to encode token for URL: " + dbsUrl + " user: " + authenticationName, ex); + } + return null; + } + + /** + * Implements the browsing support for a specific {@link DirectoryBrowserSupport} like permission check. + */ + private static class InternalResourceRequest { + private final String authenticationName; + private final String browserUrl; + + InternalResourceRequest(@Nonnull String browserUrl, String authenticationName) { + this.browserUrl = browserUrl; + this.authenticationName = authenticationName; + } + + public void doDynamic(StaplerRequest req, StaplerResponse rsp) throws IOException { + String restOfPath = req.getRestOfPath(); + + String requestUrlSuffix = this.browserUrl; + + LOGGER.fine(() -> "Performing a request as authentication: " + authenticationName + " and restOfUrl: " + requestUrlSuffix + " and restOfPath: " + restOfPath); + + Authentication auth = Jenkins.ANONYMOUS; + if (authenticationName != null) { + User user = User.getById(authenticationName, false); + if (user != null) { + try { + auth = user.impersonate(); + LOGGER.fine(() -> "Successfully impersonated " + authenticationName); + } catch (UsernameNotFoundException ex) { + LOGGER.log(Level.FINE, "Failed to impersonate " + authenticationName, ex); + rsp.sendError(403, "No such user: " + authenticationName); + return; + } + } + } + + try (ACLContext ignored = ACL.as(auth)) { + try { + Stapler.getCurrent().invoke(req, rsp, Jenkins.get(), requestUrlSuffix + restOfPath); + } catch (Exception ex) { + // cf. UnwrapSecurityExceptionFilter + Throwable cause = ex.getCause(); + while (cause != null) { + if (cause instanceof AccessDeniedException) { + throw (AccessDeniedException) cause; + } + cause = cause.getCause(); + } + throw ex; + } + /* + While we could just redirect below to the real URL like we do for expired resource URLs, the question + is whether we'd end up in a redirect loop if the exception is specific to this mode (and the "normal" + URLs redirect to resource URLs). That seems even worse than an error here. + */ + } catch (AccessDeniedException ade) { + /* This is expected to be fairly common, as permission issues are thrown up as exceptions */ + LOGGER.log(Level.FINE, "Failed permission check for resource URL access", ade); + rsp.sendError(403, "Failed permission check: " + ade.getMessage()); + } catch (Exception e) { + /* + This should be fairly uncommon -- it's basically the 'rage' butler response. Notably, lack of access + to a job (permissions/deleted/renamed/...) would not throw an exception, but just sends a 404 response. + */ + LOGGER.log(Level.FINE, "Something else failed for resource URL access", e); + rsp.sendError(404); + } + } + + @Override + public String toString() { + return "[" + super.toString() + ", authentication=" + authenticationName + "; key=" + browserUrl + "]"; + } + } + + public static class Token { + private String path; + private String username; + private Instant timestamp; + private Token (String path, @Nullable String username, Instant timestamp) { + this.path = path; + this.username = Util.fixNull(username); + this.timestamp = timestamp; + } + + private String encode() { + String value = username + ":" + timestamp.toEpochMilli() + ":" + path; + byte[] valueBytes = value.getBytes(StandardCharsets.UTF_8); + byte[] byteValue = ArrayUtils.addAll(KEY.mac(valueBytes), valueBytes); + return Base64.getUrlEncoder().encodeToString(byteValue); + } + + private static Token decode(String value) { + byte[] byteValue = Base64.getUrlDecoder().decode(value); + try { + byte[] mac = Arrays.copyOf(byteValue, 32); + byte[] restBytes = Arrays.copyOfRange(byteValue, 32, byteValue.length); + String rest = new String(restBytes, StandardCharsets.UTF_8); + if (!KEY.checkMac(restBytes, mac)) { + throw new IllegalArgumentException("Failed mac check for " + rest); + } + + String[] splits = rest.split(":", 3); + String authenticationName = Util.fixEmpty(splits[0]); + String epoch = splits[1]; + String browserUrl = splits[2]; + return new Token(browserUrl, authenticationName, ofEpochMilli(Long.parseLong(epoch))); + } catch (Exception ex) { + // Choose log level that hides people messing with the URLs + LOGGER.log(Level.FINE, "Failure decoding", ex); + return null; + } + } + + } + + private static HMACConfidentialKey KEY = new HMACConfidentialKey(ResourceDomainRootAction.class, "key"); + + // Not @Restricted because the entire class is + public static /* not final for Groovy */ int VALID_FOR_MINUTES = SystemProperties.getInteger(ResourceDomainRootAction.class.getName() + ".validForMinutes", 30); +} diff --git a/core/src/main/resources/hudson/model/DirectoryBrowserSupport/plaindir.jelly b/core/src/main/resources/hudson/model/DirectoryBrowserSupport/plaindir.jelly new file mode 100644 index 000000000000..547bbe1acb50 --- /dev/null +++ b/core/src/main/resources/hudson/model/DirectoryBrowserSupport/plaindir.jelly @@ -0,0 +1,56 @@ + + + + + + + + + + ..
+
+ +

${%No files in directory}

+
+ + + + + + + + ${t.title} + + + ${t.title} + + + + / + +
+
+
+
+ + +
+
diff --git a/core/src/main/resources/jenkins/security/Messages.properties b/core/src/main/resources/jenkins/security/Messages.properties index e9a868547763..5ff4e1274b1c 100644 --- a/core/src/main/resources/jenkins/security/Messages.properties +++ b/core/src/main/resources/jenkins/security/Messages.properties @@ -31,3 +31,19 @@ RekeySecretAdminMonitor.DisplayName=Re-keying UpdateSiteWarningsMonitor.DisplayName=Update Site Warnings QueueItemAuthenticatorMonitor.DisplayName=Access Control for Builds Token.Created.on=Token created on {0} + +ResourceDomainConfiguration.DisplayName=Resource Domain Recommendation +ResourceDomainConfiguration.NeedsRootURL=Can only set resource root URL if regular root URL is set. +ResourceDomainConfiguration.InvalidRootURL=Jenkins root URL is set to an invalid value, please report a bug: {0} +ResourceDomainConfiguration.Empty=Without a resource root URL, resources will be served from the main domain with Content-Security-Policy set. +ResourceDomainConfiguration.NotJenkins=The specified URL does not appear to point to a Jenkins instance. +ResourceDomainConfiguration.ThisJenkins=The specified URL is a valid resource root URL candidate. +ResourceDomainConfiguration.OtherJenkins=The specified URL points to a different Jenkins instance. +ResourceDomainConfiguration.SomeJenkins=The specified URL points to a Jenkins instance, but failed to determine whether it is this or another instance. +ResourceDomainConfiguration.ResourceResponse=The specified URL points to a previously set up Jenkins resource URL. +ResourceDomainConfiguration.FailedIdentityCheck=An error occurred when checking the instance identity at that URL: {0} {1} +ResourceDomainConfiguration.Exception=An exception occurred with the URL: {0} +ResourceDomainConfiguration.IOException=Failed to connect: {0} +ResourceDomainConfiguration.Invalid=Not a valid URL. +ResourceDomainConfiguration.SameAsJenkinsRoot=Cannot use the same host name for both Jenkins root URL and resource root URL. +ResourceDomainConfiguration.SameAsCurrent=You are currently accessing Jenkins through a URL similar to the proposed resource root URL. Saving this URL might remove your access to Jenkins. diff --git a/core/src/main/resources/jenkins/security/ResourceDomainConfiguration/config.jelly b/core/src/main/resources/jenkins/security/ResourceDomainConfiguration/config.jelly new file mode 100644 index 000000000000..a28d4dc3c2ee --- /dev/null +++ b/core/src/main/resources/jenkins/security/ResourceDomainConfiguration/config.jelly @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/core/src/main/resources/jenkins/security/ResourceDomainConfiguration/help-url.html b/core/src/main/resources/jenkins/security/ResourceDomainConfiguration/help-url.html new file mode 100644 index 000000000000..105e75fb7dd9 --- /dev/null +++ b/core/src/main/resources/jenkins/security/ResourceDomainConfiguration/help-url.html @@ -0,0 +1,51 @@ +

+ Jenkins serves many files that are potentially created by untrusted users, such as files in project workspaces or archived artifacts. + When no resource root URL is defined, Jenkins will serve these files with the HTTP header Content-Security-Policy ("CSP"). + By default it is set to a value that disables many modern web features to prevent cross-site scripting (XSS) and other attacks on Jenkins users accessing these files. + While the specific value for the CSP header is user configurable (and can even be disabled), doing so is a trade-off between security and functionality. +

+

+ If the resource root URL is defined, Jenkins will instead redirect requests for user-created resource files to URLs starting with the URL configured here. + These URLs will not set the CSP header, allowing Javascript and similar features to work. + For this option to work as expected, the following constraints and considerations apply: +

+
    +
  • The resource root URL must be a valid alternative choice for the Jenkins root URL for requests to be processed correctly.
  • +
  • The Jenkins root URL must be set and it must be different from this resource root URL (in fact, a different host name is required).
  • +
  • + Once set, Jenkins will only serve resource URL requests via the resource root URL. + All other requests will get HTTP 404 Not Found responses. +
  • +
+

+ Once this URL has been set up correctly, Jenkins will redirect requests to workspaces, archived artifacts, and similar collections of usually user-generated content to URLs starting with the resource root URL. + Instead of a path like job/name_here/ws, resource URLs will contain a token encoding that path, the user for which the URL was created, and when it was created. + These resource URLs access static files as if the user for which they were created would access them: + If the user’s permission to access these files is removed, the corresponding resource URLs will not work anymore either. + These URLs are accessible to anyone without authentication until they expire, so sharing these URLs is akin to sharing the files directly. +

+

Security considerations

+

Authentication

+

+ Resource URLs do not require authentication (users will not have a valid session for the resource root URL). + Sharing a resource URL with another user, even one lacking Overall/Read permission for Jenkins, will grant that user access to these files until the URLs expire. +

+

Expiration

+

+ Resource URLs expire after 30 minutes by default. + Expired resource URLs will redirect users to their equivalent Jenkins URLs, so that the user can reauthenticate, if necessary, and then be redirected back to a new resource URL that will be valid for another 30 minutes. + This will generally be transparent to the user if they have a valid Jenkins session. + Otherwise, they will need to authenticate with Jenkins again. + However, when browsing pages with HTML frames, like Javadoc sites, the login form cannot appear in a frame. + In these cases, users will need to reload the top-level frame to make the login form appear. +

+

+ To change how quickly resource URLs expire, set the system property jenkins.security.ResourceDomainRootAction.validForMinutes to the desired value in minutes. + Earlier expiration might make it harder to use these URLs, while later expiration increases the likelihood of unauthorized users gaining access through URLs shared with them by authorized users. +

+

Authenticity

+

+ Resource URLs encode the URL, the user for which they were created, and their creation timestamp. + Additionally, this string contains an HMAC to ensure the authenticity of the URL. + This prevents attackers from forging URLs that would grant them access to resource files as if they were another user. +

\ No newline at end of file diff --git a/core/src/main/resources/jenkins/security/ResourceDomainRecommendation/message.groovy b/core/src/main/resources/jenkins/security/ResourceDomainRecommendation/message.groovy new file mode 100644 index 000000000000..1d7422033bd4 --- /dev/null +++ b/core/src/main/resources/jenkins/security/ResourceDomainRecommendation/message.groovy @@ -0,0 +1,38 @@ +/* + * The MIT License + * + * Copyright 2019 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package jenkins.security.ResourceDomainRecommendation + +def f = namespace(lib.FormTagLib) + +dl { + div(class: "alert alert-info") { + a(name: "resource-root-url") + form(method: "post", action: "${rootURL}/${my.url}/act") { + f.submit(name: 'redirect', value: _("Go to resource root URL configuration")) + f.submit(name: 'dismiss', value: _("Dismiss")) + } + + raw(_("blurb")) + } +} diff --git a/core/src/main/resources/jenkins/security/ResourceDomainRecommendation/message.properties b/core/src/main/resources/jenkins/security/ResourceDomainRecommendation/message.properties new file mode 100644 index 000000000000..8a237ecc72ca --- /dev/null +++ b/core/src/main/resources/jenkins/security/ResourceDomainRecommendation/message.properties @@ -0,0 +1,2 @@ +blurb = The default Content-Security-Policy is currently overridden using the hudson.model.DirectoryBrowserSupport.CSP system property, which is a potential security issue when browsing untrusted files. \ + As an alternative, you can set up a Resource Root URL that Jenkins will use to serve some static files without adding Content-Security-Policy headers. diff --git a/test/src/test/java/jenkins/security/ResourceDomainTest.java b/test/src/test/java/jenkins/security/ResourceDomainTest.java new file mode 100644 index 000000000000..86333d23d75f --- /dev/null +++ b/test/src/test/java/jenkins/security/ResourceDomainTest.java @@ -0,0 +1,282 @@ +package jenkins.security; + +import com.gargoylesoftware.htmlunit.Page; +import com.gargoylesoftware.htmlunit.html.HtmlPage; +import hudson.ExtensionList; +import hudson.model.DirectoryBrowserSupport; +import hudson.model.FreeStyleProject; +import hudson.model.Item; +import jenkins.model.Jenkins; +import jenkins.model.JenkinsLocationConfiguration; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.CreateFileBuilder; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.MockAuthorizationStrategy; + +import java.net.URL; +import java.util.UUID; + +@Issue("JENKINS-41891") +@For({ ResourceDomainRootAction.class, ResourceDomainFilter.class, ResourceDomainConfiguration.class }) +public class ResourceDomainTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + private static final String RESOURCE_DOMAIN = "127.0.0.1"; + + @Before + public void prepare() throws Exception { + String resourceRoot; + URL root = j.getURL(); // which always will use "localhost", see JenkinsRule#getURL() + Assert.assertTrue(root.toString().contains("localhost")); // to be safe + + resourceRoot = root.toString().replace("localhost", RESOURCE_DOMAIN); + ResourceDomainConfiguration configuration = ExtensionList.lookupSingleton(ResourceDomainConfiguration.class); + configuration.setUrl(resourceRoot); + } + + @Test + public void secondDomainBasics() throws Exception { + JenkinsRule.WebClient webClient = j.createWebClient(); + + { // DBS directory listing is shown as always + Page page = webClient.goTo("userContent"); + Assert.assertEquals("successful request", 200, page.getWebResponse().getStatusCode()); + Assert.assertTrue("still on the original URL", page.getUrl().toString().contains("/userContent")); + Assert.assertTrue("web page", page.isHtmlPage()); + Assert.assertTrue("complex web page", page.getWebResponse().getContentAsString().contains("javascript")); + } + + String resourceResponseUrl; + { // DBS on primary domain forwards to second domain when trying to access a file URL + webClient.setRedirectEnabled(true); + Page page = webClient.goTo("userContent/readme.txt", "text/plain"); + resourceResponseUrl = page.getUrl().toString(); + Assert.assertEquals("resource response success", 200, page.getWebResponse().getStatusCode()); + Assert.assertNull("no CSP headers", page.getWebResponse().getResponseHeaderValue("Content-Security-Policy")); + Assert.assertTrue("Served from resource domain", resourceResponseUrl.contains(RESOURCE_DOMAIN)); + Assert.assertTrue("Served from resource action", resourceResponseUrl.contains("static-files")); + } + + { // direct access to resource URL works + Page page = webClient.getPage(resourceResponseUrl); + resourceResponseUrl = page.getUrl().toString(); + Assert.assertEquals("resource response success", 200, page.getWebResponse().getStatusCode()); + Assert.assertNull("no CSP headers", page.getWebResponse().getResponseHeaderValue("Content-Security-Policy")); + Assert.assertTrue("Served from resource domain", resourceResponseUrl.contains(RESOURCE_DOMAIN)); + Assert.assertTrue("Served from resource action", resourceResponseUrl.contains("static-files")); + } + + { // show directory index + webClient.setRedirectEnabled(false); + webClient.setThrowExceptionOnFailingStatusCode(false); + Page page = webClient.getPage(resourceResponseUrl.replace("readme.txt", "")); + Assert.assertEquals("directory listing response", 200, page.getWebResponse().getStatusCode()); + String responseContent = page.getWebResponse().getContentAsString(); + Assert.assertTrue("directory listing shown", responseContent.contains("readme.txt")); + Assert.assertTrue("is HTML", responseContent.contains("href=")); + } + + String resourceRootUrl = ResourceDomainConfiguration.get().getUrl(); + { + webClient.setThrowExceptionOnFailingStatusCode(false); + Page page = webClient.getPage(resourceRootUrl); + Assert.assertEquals("resource root URL response is 404", 404, page.getWebResponse().getStatusCode()); + } + + { + webClient.setThrowExceptionOnFailingStatusCode(false); + Page page = webClient.getPage(resourceRootUrl + "/static-files"); + Assert.assertEquals("resource action index page response is 404", 404, page.getWebResponse().getStatusCode()); + } + + { // second domain invalid URL gets 404 + webClient.setThrowExceptionOnFailingStatusCode(false); + String uuid = UUID.randomUUID().toString(); + Page page = webClient.getPage(resourceRootUrl + "static-files/" + uuid); + Assert.assertEquals("resource response is 404", 404, page.getWebResponse().getStatusCode()); + Assert.assertTrue("response URL is still the same", page.getUrl().toString().contains(uuid)); + } + + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + MockAuthorizationStrategy a = new MockAuthorizationStrategy(); + j.jenkins.setAuthorizationStrategy(a); + + { // fails without Overall/Read + webClient.withRedirectEnabled(false).withThrowExceptionOnFailingStatusCode(false); + Page page = webClient.getPage(resourceResponseUrl); + resourceResponseUrl = page.getUrl().toString(); + Assert.assertEquals("resource response failed", 403, page.getWebResponse().getStatusCode()); + Assert.assertNull("no CSP headers", page.getWebResponse().getResponseHeaderValue("Content-Security-Policy")); + Assert.assertTrue("Served from resource domain", resourceResponseUrl.contains(RESOURCE_DOMAIN)); + } + + a.grant(Jenkins.READ).onRoot().to("anonymous"); + + { // now it works again + Page page = webClient.getPage(resourceResponseUrl); + resourceResponseUrl = page.getUrl().toString(); + Assert.assertEquals("resource response success", 200, page.getWebResponse().getStatusCode()); + Assert.assertNull("no CSP headers", page.getWebResponse().getResponseHeaderValue("Content-Security-Policy")); + Assert.assertTrue("Served from resource domain", resourceResponseUrl.contains(RESOURCE_DOMAIN)); + Assert.assertTrue("Served from resource action", resourceResponseUrl.contains("static-files")); + } + } + + @Test + public void clearRootUrl() throws Exception { + JenkinsLocationConfiguration.get().setUrl(null); + + JenkinsRule.WebClient webClient = j.createWebClient(); + + String resourceResponseUrl; + { + webClient.setRedirectEnabled(true); + Page page = webClient.goTo("userContent/readme.txt", "text/plain"); + resourceResponseUrl = page.getUrl().toString(); + Assert.assertEquals("resource response success", 200, page.getWebResponse().getStatusCode()); + Assert.assertNotNull("CSP headers set", page.getWebResponse().getResponseHeaderValue("Content-Security-Policy")); + Assert.assertFalse("Not served from resource domain", resourceResponseUrl.contains(RESOURCE_DOMAIN)); + Assert.assertFalse("Not served from resource action", resourceResponseUrl.contains("static-files")); + Assert.assertTrue("Original URL", resourceResponseUrl.contains("userContent/readme.txt")); + } + + } + + @Test + public void secondDomainCannotBeFaked() throws Exception { + JenkinsRule.WebClient webClient = j.createWebClient(); + + String resourceResponseUrl; + { // first, obtain a resource response URL + webClient.setRedirectEnabled(true); + webClient.setThrowExceptionOnFailingStatusCode(false); + Page page = webClient.goTo("userContent/readme.txt", "text/plain"); + resourceResponseUrl = page.getUrl().toString(); + Assert.assertEquals("resource response success", 200, page.getWebResponse().getStatusCode()); + Assert.assertNull("no CSP headers", page.getWebResponse().getResponseHeaderValue("Content-Security-Policy")); + Assert.assertTrue("Served from resource domain", resourceResponseUrl.contains(RESOURCE_DOMAIN)); + Assert.assertTrue("Served from resource action", resourceResponseUrl.contains("static-files")); + } + + { + // now, modify its prefix to have an invalid HMAC + String modifiedUrl = resourceResponseUrl.replaceAll("static[-]files[/]....", "static-files/aaaa"); + Page page = webClient.getPage(modifiedUrl); + Assert.assertEquals("resource not found", 404, page.getWebResponse().getStatusCode()); + Assert.assertEquals("resource not found", ResourceDomainFilter.ERROR_RESPONSE, page.getWebResponse().getStatusMessage()); + } + + + } + + @Test + public void missingPermissionsCause403() throws Exception { + // setup: A job that creates a file in its workspace + FreeStyleProject project = j.createFreeStyleProject(); + project.getBuildersList().add(new CreateFileBuilder("file.html", "the content")); + project.save(); + + // setup: Everyone has permission to Jenkins and the job + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + MockAuthorizationStrategy a = new MockAuthorizationStrategy(); + a.grant(Jenkins.READ).everywhere().toEveryone(); + a.grant(Item.READ, Item.WORKSPACE).onItems(project).toEveryone(); + j.jenkins.setAuthorizationStrategy(a); + + j.buildAndAssertSuccess(project); + + JenkinsRule.WebClient webClient = j.createWebClient(); + webClient.setThrowExceptionOnFailingStatusCode(false); + webClient.setRedirectEnabled(true); + + // basics work + HtmlPage page = webClient.getPage(project, "ws/file.html"); + Assert.assertEquals("page is found", 200, page.getWebResponse().getStatusCode()); + Assert.assertTrue("page content is as expected", page.getWebResponse().getContentAsString().contains("the content")); + + URL anonUrl = page.getUrl(); + Assert.assertTrue("page is served by resource domain", anonUrl.toString().contains("/static-files/")); + + // now remove workspace permission from all users + a = new MockAuthorizationStrategy(); + a.grant(Jenkins.READ).everywhere().toEveryone(); + a.grant(Item.READ).onItems(project).toEveryone(); + j.jenkins.setAuthorizationStrategy(a); + + // and we get a 403 response + page = webClient.getPage(anonUrl); + Assert.assertEquals("page is not found", 403, page.getWebResponse().getStatusCode()); + Assert.assertTrue("Response mentions workspace permission", page.getWebResponse().getStatusMessage().contains("Failed permission check: anonymous is missing the Job/Workspace permission")); + + // now remove Job/Read permission from all users (but grant Discover) + a = new MockAuthorizationStrategy(); + a.grant(Jenkins.READ).everywhere().toEveryone(); + a.grant(Item.DISCOVER).onItems(project).toEveryone(); + j.jenkins.setAuthorizationStrategy(a); + + // and we get a 403 response asking to log in (Job/Discover is basically meant to be granted to anonymous only) + page = webClient.getPage(anonUrl); + Assert.assertEquals("page is not found", 403, page.getWebResponse().getStatusCode()); + Assert.assertTrue("Response mentions workspace permission", page.getWebResponse().getStatusMessage().contains("Failed permission check: Please login to access job")); + } + + @Test + public void projectWasRenamedCauses404() throws Exception { + // setup: A job that creates a file in its workspace + FreeStyleProject project = j.createFreeStyleProject(); + project.getBuildersList().add(new CreateFileBuilder("file.html", "the content")); + project.save(); + + // setup: Everyone has permission to Jenkins and the job + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + MockAuthorizationStrategy a = new MockAuthorizationStrategy(); + a.grant(Jenkins.READ, Item.READ, Item.WORKSPACE).everywhere().toEveryone(); + j.jenkins.setAuthorizationStrategy(a); + + j.buildAndAssertSuccess(project); + + JenkinsRule.WebClient webClient = j.createWebClient(); + webClient.setThrowExceptionOnFailingStatusCode(false); + webClient.setRedirectEnabled(true); + + HtmlPage page = webClient.getPage(project, "ws/file.html"); + Assert.assertEquals("page is found", 200, page.getWebResponse().getStatusCode()); + Assert.assertTrue("page content is as expected", page.getWebResponse().getContentAsString().contains("the content")); + + URL url = page.getUrl(); + Assert.assertTrue("page is served by resource domain", url.toString().contains("/static-files/")); + + project.renameTo("new-job-name"); // or delete, doesn't really matter + + Page failedPage = webClient.getPage(url); + Assert.assertEquals("page is not found", 404, failedPage.getWebResponse().getStatusCode()); + Assert.assertEquals("page is not found", "Not Found", failedPage.getWebResponse().getStatusMessage()); // TODO Is this not done through our exception handler? + } + +// @Test + public void indexFileIsUsedIfDefined() throws Exception { + // TODO Test with DBS with and without directory index file + } + + @Test + public void adminMonitorShowsUpWithOverriddenCSP() throws Exception { + ResourceDomainRecommendation monitor = ExtensionList.lookupSingleton(ResourceDomainRecommendation.class); + Assert.assertFalse(monitor.isActivated()); + System.setProperty(DirectoryBrowserSupport.class.getName() + ".CSP", ""); + try { + Assert.assertFalse(monitor.isActivated()); + ResourceDomainConfiguration.get().setUrl(null); + Assert.assertTrue(monitor.isActivated()); + } finally { + System.clearProperty(DirectoryBrowserSupport.class.getName() + ".CSP"); + } + Assert.assertFalse(monitor.isActivated()); + } +}