diff --git a/core/pom.xml b/core/pom.xml index f1d5ebe6bea5ed582f5a9469dc1ce856b29d4e45..fb832ea0a32437aee69ee3b7e67fcca8363fa673 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -39,7 +39,7 @@ THE SOFTWARE. true - 1.254.2 + 1.254.3 2.5.6.SEC03 2.4.11 @@ -179,6 +179,11 @@ THE SOFTWARE. tests test + + io.jenkins.stapler + jenkins-stapler-support + 1.0 + org.hamcrest hamcrest-library diff --git a/core/src/main/java/hudson/ProxyConfiguration.java b/core/src/main/java/hudson/ProxyConfiguration.java index d8c0d032aff37ef8ffc2d184958c3cc79f71b4b0..c93703b4528bd3cad90a65a5bacc7d090dec1dec 100644 --- a/core/src/main/java/hudson/ProxyConfiguration.java +++ b/core/src/main/java/hudson/ProxyConfiguration.java @@ -50,6 +50,7 @@ import java.util.List; import java.util.regex.Pattern; import javax.annotation.CheckForNull; import jenkins.model.Jenkins; +import jenkins.security.stapler.StaplerAccessibleType; import jenkins.util.JenkinsJVM; import jenkins.util.SystemProperties; import org.apache.commons.httpclient.Credentials; @@ -78,6 +79,7 @@ import org.kohsuke.stapler.interceptor.RequirePOST; * * @see jenkins.model.Jenkins#proxy */ +@StaplerAccessibleType public final class ProxyConfiguration extends AbstractDescribableImpl implements Saveable, Serializable { /** * Holds a default TCP connect timeout set on all connections returned from this class, diff --git a/core/src/main/java/hudson/TcpSlaveAgentListener.java b/core/src/main/java/hudson/TcpSlaveAgentListener.java index f0a9c037002dbbce2b2dabfaaf7ae07283b032e6..053a2a57022a1031cce47eee73ca46cd8c0a42c4 100644 --- a/core/src/main/java/hudson/TcpSlaveAgentListener.java +++ b/core/src/main/java/hudson/TcpSlaveAgentListener.java @@ -34,6 +34,7 @@ import javax.annotation.Nullable; import hudson.model.AperiodicWork; import jenkins.model.Jenkins; import jenkins.model.identity.InstanceIdentityProvider; +import jenkins.security.stapler.StaplerAccessibleType; import jenkins.slaves.RemotingVersionInfo; import jenkins.util.SystemProperties; import hudson.slaves.OfflineCause; @@ -82,6 +83,7 @@ import org.kohsuke.accmod.restrictions.NoExternalUse; * @author Kohsuke Kawaguchi * @see AgentProtocol */ +@StaplerAccessibleType public final class TcpSlaveAgentListener extends Thread { private final ServerSocketChannel serverSocket; diff --git a/core/src/main/java/hudson/diagnosis/ReverseProxySetupMonitor.java b/core/src/main/java/hudson/diagnosis/ReverseProxySetupMonitor.java index 38a9215c22aebd50389331a6ac5a6b6685f6b5fe..7f2287ad0ae49b76de65d26466413495b4f3e86b 100644 --- a/core/src/main/java/hudson/diagnosis/ReverseProxySetupMonitor.java +++ b/core/src/main/java/hudson/diagnosis/ReverseProxySetupMonitor.java @@ -26,6 +26,7 @@ package hudson.diagnosis; import hudson.Extension; import hudson.Util; import hudson.model.AdministrativeMonitor; +import jenkins.security.stapler.StaplerDispatchable; import org.jenkinsci.Symbol; import org.kohsuke.stapler.HttpRedirect; import org.kohsuke.stapler.HttpResponse; @@ -70,6 +71,7 @@ public class ReverseProxySetupMonitor extends AdministrativeMonitor { return new HttpRedirect(redirect); } + @StaplerDispatchable public void getTestForReverseProxySetup(String rest) { Jenkins j = Jenkins.getInstance(); String inferred = j.getRootUrlFromRequest() + "manage"; diff --git a/core/src/main/java/hudson/model/Computer.java b/core/src/main/java/hudson/model/Computer.java index 68a1e9e03bc7eaadefe04fa133d1c0b060d1f4f2..97e826fe1d8c923a4b80f5bb870b3fb9f14d8210 100644 --- a/core/src/main/java/hudson/model/Computer.java +++ b/core/src/main/java/hudson/model/Computer.java @@ -30,6 +30,7 @@ import hudson.EnvVars; import hudson.Extension; import hudson.Launcher.ProcStarter; import hudson.slaves.Cloud; +import jenkins.security.stapler.StaplerDispatchable; import jenkins.util.SystemProperties; import hudson.Util; import hudson.cli.declarative.CLIResolver; @@ -958,6 +959,7 @@ public /*transient*/ abstract class Computer extends Actionable implements Acces * Gets the read-only snapshot view of all {@link Executor}s. */ @Exported + @StaplerDispatchable public List getExecutors() { return new ArrayList(executors); } @@ -966,6 +968,7 @@ public /*transient*/ abstract class Computer extends Actionable implements Acces * Gets the read-only snapshot view of all {@link OneOffExecutor}s. */ @Exported + @StaplerDispatchable public List getOneOffExecutors() { return new ArrayList(oneOffExecutors); } diff --git a/core/src/main/java/hudson/model/ModelObject.java b/core/src/main/java/hudson/model/ModelObject.java index 5467b2a7a77e6e02ec2b073148d670ce55a92f3f..f66d0fbd1bbee40e9831e94df3013d906b61a5c4 100644 --- a/core/src/main/java/hudson/model/ModelObject.java +++ b/core/src/main/java/hudson/model/ModelObject.java @@ -23,6 +23,8 @@ */ package hudson.model; +import jenkins.security.stapler.StaplerAccessibleType; + /** * A model object has a human readable name. * @@ -32,6 +34,7 @@ package hudson.model; * * @author Kohsuke Kawaguchi */ +@StaplerAccessibleType public interface ModelObject { String getDisplayName(); } diff --git a/core/src/main/java/hudson/model/ParameterValue.java b/core/src/main/java/hudson/model/ParameterValue.java index 6cd1f46a190d322cb7cf8fbe0cbf6ccba4e3a66a..fd01e5019d4512974c1fb4d7a37ee1830add1d9f 100644 --- a/core/src/main/java/hudson/model/ParameterValue.java +++ b/core/src/main/java/hudson/model/ParameterValue.java @@ -39,6 +39,7 @@ import java.util.logging.Logger; import javax.annotation.CheckForNull; import jenkins.model.Jenkins; +import jenkins.security.stapler.StaplerAccessibleType; import net.sf.json.JSONObject; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.DoNotUse; @@ -75,6 +76,7 @@ import org.kohsuke.stapler.export.ExportedBean; * @see ParametersAction */ @ExportedBean(defaultVisibility=3) +@StaplerAccessibleType public abstract class ParameterValue implements Serializable { private static final Logger LOGGER = Logger.getLogger(ParameterValue.class.getName()); diff --git a/core/src/main/java/hudson/model/Queue.java b/core/src/main/java/hudson/model/Queue.java index a1b4225e9972fc9184fc714906cbd8af1e52dad7..8f1a024123a33af727f7e78b4d4641c7bedde631 100644 --- a/core/src/main/java/hudson/model/Queue.java +++ b/core/src/main/java/hudson/model/Queue.java @@ -69,6 +69,7 @@ import java.nio.file.Files; import hudson.util.Futures; import jenkins.security.QueueItemAuthenticatorProvider; +import jenkins.security.stapler.StaplerAccessibleType; import jenkins.util.SystemProperties; import jenkins.util.Timer; import hudson.triggers.SafeTimerTask; @@ -1993,6 +1994,7 @@ public class Queue extends ResourceController implements Saveable { * Implementation must have executorCell.jelly, which is * used to render the HTML that indicates this executable is executing. */ + @StaplerAccessibleType public interface Executable extends Runnable { /** * Task from which this executable was created. diff --git a/core/src/main/java/hudson/model/UpdateCenter.java b/core/src/main/java/hudson/model/UpdateCenter.java index 097b1d9b2eb5ec34bc331a21c27b53a9fe2753c6..b067f686f9574d90e25b3fcd047db163814631b6 100644 --- a/core/src/main/java/hudson/model/UpdateCenter.java +++ b/core/src/main/java/hudson/model/UpdateCenter.java @@ -33,6 +33,8 @@ import hudson.ProxyConfiguration; import hudson.security.ACLContext; import java.nio.file.Files; import java.nio.file.InvalidPathException; + +import jenkins.security.stapler.StaplerDispatchable; import jenkins.util.SystemProperties; import hudson.Util; import hudson.XmlFile; @@ -317,6 +319,7 @@ public class UpdateCenter extends AbstractModelObject implements Saveable, OnMas * can be empty but never null. Oldest entries first. */ @Exported + @StaplerDispatchable public List getJobs() { synchronized (jobs) { return new ArrayList(jobs); @@ -517,6 +520,7 @@ public class UpdateCenter extends AbstractModelObject implements Saveable, OnMas * @return * can be empty but never null. */ + @StaplerDispatchable // referenced by _api.jelly public PersistedList getSites() { return sites; } diff --git a/core/src/main/java/hudson/model/View.java b/core/src/main/java/hudson/model/View.java index 50228d0647233e13b14035fbb4587df57b6766e5..1a7dc1ae987141de376828d6edc0b3118eb7fa64 100644 --- a/core/src/main/java/hudson/model/View.java +++ b/core/src/main/java/hudson/model/View.java @@ -63,6 +63,7 @@ import jenkins.model.item_category.Categories; import jenkins.model.item_category.Category; import jenkins.model.item_category.ItemCategory; import jenkins.scm.RunWithSCM; +import jenkins.security.stapler.StaplerAccessibleType; import jenkins.util.ProgressiveRendering; import jenkins.util.xml.XMLUtils; @@ -700,6 +701,7 @@ public abstract class View extends AbstractModelObject implements AccessControll } @ExportedBean + @StaplerAccessibleType public static final class People { @Exported public final List users; diff --git a/core/src/main/java/hudson/security/AuthorizationStrategy.java b/core/src/main/java/hudson/security/AuthorizationStrategy.java index a273e984b201c96d3c33c86666bb04ac297354e1..4c246a40d4f63ed92cc400c3e97f586843855a68 100644 --- a/core/src/main/java/hudson/security/AuthorizationStrategy.java +++ b/core/src/main/java/hudson/security/AuthorizationStrategy.java @@ -36,6 +36,7 @@ import java.util.Collections; import javax.annotation.Nonnull; import jenkins.model.Jenkins; +import jenkins.security.stapler.StaplerAccessibleType; import net.sf.json.JSONObject; import org.acegisecurity.Authentication; @@ -62,6 +63,7 @@ import org.kohsuke.stapler.StaplerRequest; * @author Kohsuke Kawaguchi * @see SecurityRealm */ +@StaplerAccessibleType public abstract class AuthorizationStrategy extends AbstractDescribableImpl implements ExtensionPoint { /** * Returns the instance of {@link ACL} where all the other {@link ACL} instances diff --git a/core/src/main/java/hudson/security/csrf/CrumbIssuer.java b/core/src/main/java/hudson/security/csrf/CrumbIssuer.java index 1f3c68c743f91c1f56d75ad41befd48be996abf6..a16f48689a40f19ab6900837b267ddc7110b6603 100644 --- a/core/src/main/java/hudson/security/csrf/CrumbIssuer.java +++ b/core/src/main/java/hudson/security/csrf/CrumbIssuer.java @@ -9,6 +9,7 @@ import javax.servlet.ServletRequest; import hudson.init.Initializer; import jenkins.model.Jenkins; +import jenkins.security.stapler.StaplerAccessibleType; import org.kohsuke.stapler.Stapler; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.WebApp; @@ -40,6 +41,7 @@ import org.kohsuke.stapler.StaplerResponse; * @see Wikipedia: Cross site request forgery */ @ExportedBean +@StaplerAccessibleType public abstract class CrumbIssuer implements Describable, ExtensionPoint { private static final String CRUMB_ATTRIBUTE = CrumbIssuer.class.getName() + "_crumb"; diff --git a/core/src/main/java/jenkins/diagnosis/HsErrPidList.java b/core/src/main/java/jenkins/diagnosis/HsErrPidList.java index da7f0cde60ec1375e4f6a9ef6fb1d015bac9e2e3..c38bdb06f4ed4b10c39b204f319afaaf944fd8d3 100644 --- a/core/src/main/java/jenkins/diagnosis/HsErrPidList.java +++ b/core/src/main/java/jenkins/diagnosis/HsErrPidList.java @@ -11,6 +11,7 @@ import java.nio.file.InvalidPathException; import java.nio.file.OpenOption; import java.nio.file.StandardOpenOption; import jenkins.model.Jenkins; +import jenkins.security.stapler.StaplerDispatchable; import org.apache.tools.ant.DirectoryScanner; import org.apache.tools.ant.Project; import org.apache.tools.ant.types.FileSet; @@ -94,6 +95,7 @@ public class HsErrPidList extends AdministrativeMonitor { /** * Expose files to the URL. */ + @StaplerDispatchable public List getFiles() { return files; } diff --git a/core/src/main/java/jenkins/install/InstallState.java b/core/src/main/java/jenkins/install/InstallState.java index 939c45f828a9f3b4c412d541a2e9901044973c6f..c0fa6367b7e7b5557ab748aa2b5b4ff283ed830c 100644 --- a/core/src/main/java/jenkins/install/InstallState.java +++ b/core/src/main/java/jenkins/install/InstallState.java @@ -32,6 +32,7 @@ import hudson.ExtensionPoint; import java.util.logging.Level; import java.util.logging.Logger; import jenkins.model.Jenkins; +import jenkins.security.stapler.StaplerAccessibleType; import org.apache.commons.lang.StringUtils; /** * Jenkins install state. @@ -44,6 +45,7 @@ import org.apache.commons.lang.StringUtils; * * @author tom.fennelly@gmail.com */ +@StaplerAccessibleType public class InstallState implements ExtensionPoint { /** * Need InstallState != NEW for tests by default diff --git a/core/src/main/java/jenkins/model/Jenkins.java b/core/src/main/java/jenkins/model/Jenkins.java index 20f397d7bc94cf0d216f72244b8e13f158105412..ded0efaa2a6375319137d5bf733d75e699f06ddb 100644 --- a/core/src/main/java/jenkins/model/Jenkins.java +++ b/core/src/main/java/jenkins/model/Jenkins.java @@ -37,7 +37,11 @@ import hudson.*; import hudson.Launcher.LocalLauncher; import jenkins.AgentProtocol; import jenkins.diagnostics.URICheckEncodingMonitor; +import jenkins.security.stapler.DoActionFilter; +import jenkins.security.stapler.StaplerFilteredActionListener; +import jenkins.security.stapler.StaplerDispatchable; import jenkins.security.RedactSecretJsonInErrorMessageSanitizer; +import jenkins.security.stapler.TypedFilter; import jenkins.util.SystemProperties; import hudson.cli.declarative.CLIMethod; import hudson.cli.declarative.CLIResolver; @@ -895,6 +899,16 @@ public class Jenkins extends AbstractCIBase implements DirectlyModifiableTopLeve webApp.setClassLoader(pluginManager.uberClassLoader); webApp.setJsonInErrorMessageSanitizer(RedactSecretJsonInErrorMessageSanitizer.INSTANCE); + TypedFilter typedFilter = new TypedFilter(); + webApp.setFilterForGetMethods(typedFilter); + webApp.setFilterForFields(typedFilter); + webApp.setFilterForDoActions(new DoActionFilter()); + + StaplerFilteredActionListener actionListener = new StaplerFilteredActionListener(); + webApp.setFilteredGetterTriggerListener(actionListener); + webApp.setFilteredDoActionTriggerListener(actionListener); + webApp.setFilteredFieldTriggerListener(actionListener); + adjuncts = new AdjunctManager(servletContext, pluginManager.uberClassLoader,"adjuncts/"+SESSION_HASH, TimeUnit.DAYS.toMillis(365)); ClassFilterImpl.register(); @@ -1643,6 +1657,7 @@ public class Jenkins extends AbstractCIBase implements DirectlyModifiableTopLeve save(); } + @StaplerDispatchable public FederatedLoginService getFederatedLoginService(String name) { for (FederatedLoginService fls : FederatedLoginService.all()) { if (fls.getUrlName().equals(name)) @@ -2601,6 +2616,7 @@ public class Jenkins extends AbstractCIBase implements DirectlyModifiableTopLeve * * @since 1.349 */ + @StaplerDispatchable public ExtensionList getExtensionList(String extensionType) throws ClassNotFoundException { return getExtensionList(pluginManager.uberClassLoader.loadClass(extensionType)); } @@ -2970,6 +2986,7 @@ public class Jenkins extends AbstractCIBase implements DirectlyModifiableTopLeve } // if no finger print matches, display "not found page". + @StaplerDispatchable public Object getFingerprint( String md5sum ) throws IOException { Fingerprint r = fingerprintMap.get(md5sum); if(r==null) return new NoFingerprintMatch(md5sum); @@ -4040,6 +4057,7 @@ public class Jenkins extends AbstractCIBase implements DirectlyModifiableTopLeve * End point that intentionally throws an exception to test the error behaviour. * @since 1.467 */ + @StaplerDispatchable public void doException() { throw new RuntimeException(); } @@ -4588,6 +4606,7 @@ public class Jenkins extends AbstractCIBase implements DirectlyModifiableTopLeve * Plugins who wish to contribute boxes on the side panel can add widgets * by {@code getWidgets().add(new MyWidget())} from {@link Plugin#start()}. */ + @StaplerDispatchable // some plugins use this to add views to widgets public List getWidgets() { return widgets; } diff --git a/core/src/main/java/jenkins/security/stapler/DoActionFilter.java b/core/src/main/java/jenkins/security/stapler/DoActionFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..288398bcbaeb8c59b7feb0199eb14d64f161bbfc --- /dev/null +++ b/core/src/main/java/jenkins/security/stapler/DoActionFilter.java @@ -0,0 +1,133 @@ +/* + * The MIT License + * + * Copyright (c) 2018, 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.stapler; + +import hudson.ExtensionList; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.Function; +import org.kohsuke.stapler.FunctionList; +import org.kohsuke.stapler.HttpResponse; +import org.kohsuke.stapler.interceptor.InterceptorAnnotation; + +import javax.annotation.Nonnull; +import java.lang.annotation.Annotation; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +@Restricted(NoExternalUse.class) +public class DoActionFilter implements FunctionList.Filter { + private static final Logger LOGGER = Logger.getLogger(DoActionFilter.class.getName()); + + /** + * if a method has "do" as name (not possible in pure Java but doable in Groovy or other JVM languages) + * the new system does not consider it as a web method. + *

+ * Use @WebMethod(name="") or doIndex in such case. + */ + private static final Pattern DO_METHOD_REGEX = Pattern.compile("^do[^a-z].*"); + + public boolean keep(@Nonnull Function m) { + + if (m.getAnnotation(StaplerNotDispatchable.class) != null) { + return false; + } + + if (m.getAnnotation(StaplerDispatchable.class) != null) { + return true; + } + + String methodName = m.getName(); + String signature = m.getSignature(); + + // check whitelist + ExtensionList whitelistProviders = ExtensionList.lookup(RoutingDecisionProvider.class); + if (whitelistProviders.size() > 0) { + for (RoutingDecisionProvider provider : whitelistProviders) { + RoutingDecisionProvider.Decision methodDecision = provider.decide(signature); + if (methodDecision == RoutingDecisionProvider.Decision.ACCEPTED) { + LOGGER.log(Level.CONFIG, "Action " + signature + " is acceptable because it is whitelisted by " + provider); + return true; + } + if (methodDecision == RoutingDecisionProvider.Decision.REJECTED) { + LOGGER.log(Level.CONFIG, "Action " + signature + " is not acceptable because it is blacklisted by " + provider); + return false; + } + } + } + + if (methodName.equals("doDynamic")) { + // reject doDynamic because it's treated separately by Stapler. + return false; + } + + for (Annotation a : m.getAnnotations()) { + if (WebMethodConstants.WEB_METHOD_ANNOTATION_NAMES.contains(a.annotationType().getName())) { + return true; + } + if (a.annotationType().getAnnotation(InterceptorAnnotation.class) != null) { + // This is a Stapler interceptor annotation like RequirePOST or JsonResponse + return true; + } + } + + // there is rarely more than two annotations in a method signature + for (Annotation[] perParameterAnnotation : m.getParameterAnnotations()) { + for (Annotation annotation : perParameterAnnotation) { + if (WebMethodConstants.WEB_METHOD_PARAMETER_ANNOTATION_NAMES.contains(annotation.annotationType().getName())) { + return true; + } + } + } + + if (!DO_METHOD_REGEX.matcher(methodName).matches()) { + return false; + } + + // after the method name check to avoid allowing methods that are meant to be used by routable ones + // normally they should be private in such case + for (Class parameterType : m.getParameterTypes()) { + if (WebMethodConstants.WEB_METHOD_PARAMETERS_NAMES.contains(parameterType.getName())) { + return true; + } + } + + Class returnType = m.getReturnType(); + if (HttpResponse.class.isAssignableFrom(returnType)) { + return true; + } + + // as HttpResponseException inherits from RuntimeException, + // there is no requirement for the developer to explicitly checks it. + Class[] checkedExceptionTypes = m.getCheckedExceptionTypes(); + for (Class checkedExceptionType : checkedExceptionTypes) { + if (HttpResponse.class.isAssignableFrom(checkedExceptionType)) { + return true; + } + } + + return false; + } +} diff --git a/core/src/main/java/jenkins/security/stapler/RoutingDecisionProvider.java b/core/src/main/java/jenkins/security/stapler/RoutingDecisionProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..c7cab91839b76bbaa48843d11b1e0869fc64efe4 --- /dev/null +++ b/core/src/main/java/jenkins/security/stapler/RoutingDecisionProvider.java @@ -0,0 +1,38 @@ +/* + * The MIT License + * + * Copyright (c) 2018, 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.stapler; + +import hudson.ExtensionPoint; + +import javax.annotation.Nonnull; + +public abstract class RoutingDecisionProvider implements ExtensionPoint { + enum Decision { + ACCEPTED, + REJECTED, + UNKNOWN + } + + @Nonnull public abstract Decision decide(@Nonnull String signature); +} diff --git a/core/src/main/java/jenkins/security/stapler/StaplerFilteredActionListener.java b/core/src/main/java/jenkins/security/stapler/StaplerFilteredActionListener.java new file mode 100644 index 0000000000000000000000000000000000000000..bdc329775f6dbc328da9825a8baeeeffbeb2dfdd --- /dev/null +++ b/core/src/main/java/jenkins/security/stapler/StaplerFilteredActionListener.java @@ -0,0 +1,76 @@ +/* + * The MIT License + * + * Copyright (c) 2018, 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.stapler; + +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.Function; +import org.kohsuke.stapler.StaplerRequest; +import org.kohsuke.stapler.StaplerResponse; +import org.kohsuke.stapler.event.FilteredDoActionTriggerListener; +import org.kohsuke.stapler.event.FilteredFieldTriggerListener; +import org.kohsuke.stapler.event.FilteredGetterTriggerListener; +import org.kohsuke.stapler.lang.FieldRef; + +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Log a warning message when a "getter" or "doAction" function that was filtered out by SECURITY-400 new rules + */ +@Restricted(NoExternalUse.class) +public class StaplerFilteredActionListener implements FilteredDoActionTriggerListener, FilteredGetterTriggerListener, FilteredFieldTriggerListener { + private static final Logger LOGGER = Logger.getLogger(StaplerFilteredActionListener.class.getName()); + + private static final String LOG_MESSAGE = "New Stapler routing rules result in the URL \"{0}\" no longer being allowed. " + + "If you consider it safe to use, add the following to the whitelist: \"{1}\". " + + "Learn more: https://jenkins.io/redirect/stapler-routing"; + + @Override + public boolean onDoActionTrigger(Function f, StaplerRequest req, StaplerResponse rsp, Object node) { + LOGGER.log(Level.WARNING, LOG_MESSAGE, new Object[]{ + req.getPathInfo(), + f.getSignature() + }); + return false; + } + + @Override + public boolean onGetterTrigger(Function f, StaplerRequest req, StaplerResponse rsp, Object node, String expression) { + LOGGER.log(Level.WARNING, LOG_MESSAGE, new Object[]{ + req.getPathInfo(), + f.getSignature() + }); + return false; + } + + @Override + public boolean onFieldTrigger(FieldRef f, StaplerRequest req, StaplerResponse staplerResponse, Object node, String expression) { + LOGGER.log(Level.WARNING, LOG_MESSAGE, new Object[]{ + req.getPathInfo(), + f.getSignature() + }); + return false; + } +} diff --git a/core/src/main/java/jenkins/security/stapler/StaticRoutingDecisionProvider.java b/core/src/main/java/jenkins/security/stapler/StaticRoutingDecisionProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..cbb58760154073535915b02ce9e22ce9d7d8c322 --- /dev/null +++ b/core/src/main/java/jenkins/security/stapler/StaticRoutingDecisionProvider.java @@ -0,0 +1,266 @@ +/* + * The MIT License + * + * Copyright (c) 2018, 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.stapler; + +import com.google.common.annotations.VisibleForTesting; +import hudson.BulkChange; +import hudson.Extension; +import hudson.ExtensionList; +import hudson.model.Saveable; +import jenkins.model.Jenkins; +import jenkins.util.SystemProperties; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.Function; +import org.kohsuke.stapler.WebApp; +import org.kohsuke.stapler.lang.FieldRef; + +import javax.annotation.Nonnull; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Fill the list of getter methods that are whitelisted for Stapler + * Each item in the set are formatted to correspond exactly to what {@link Function#getDisplayName()} returns + */ +@Restricted(NoExternalUse.class) +@Extension +public class StaticRoutingDecisionProvider extends RoutingDecisionProvider implements Saveable { + private static final Logger LOGGER = Logger.getLogger(StaticRoutingDecisionProvider.class.getName()); + + private Set whitelistSignaturesFromFixedList; + private Set whitelistSignaturesFromUserControlledList; + + private Set blacklistSignaturesFromFixedList; + private Set blacklistSignaturesFromUserControlledList; + + public StaticRoutingDecisionProvider() { + reload(); + } + + /** + * Return the singleton instance of this class, typically for script console use + */ + public static StaticRoutingDecisionProvider get() { + return ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class); + } + + /** + * @see Function#getSignature() + * @see FieldRef#getSignature() + */ + @Nonnull + public synchronized Decision decide(@Nonnull String signature) { + if (whitelistSignaturesFromFixedList == null || whitelistSignaturesFromUserControlledList == null || + blacklistSignaturesFromFixedList == null || blacklistSignaturesFromUserControlledList == null) { + reload(); + } + + LOGGER.log(Level.CONFIG, "Checking whitelist for " + signature); + + // priority to blacklist + if (blacklistSignaturesFromFixedList.contains(signature) || blacklistSignaturesFromUserControlledList.contains(signature)) { + return Decision.REJECTED; + } + + if (whitelistSignaturesFromFixedList.contains(signature) || whitelistSignaturesFromUserControlledList.contains(signature)) { + return Decision.ACCEPTED; + } + + return Decision.UNKNOWN; + } + + public synchronized void reload() { + reloadFromDefault(); + reloadFromUserControlledList(); + + resetMetaClassCache(); + } + + @VisibleForTesting + synchronized void resetAndSave(){ + this.whitelistSignaturesFromFixedList = new HashSet<>(); + this.whitelistSignaturesFromUserControlledList = new HashSet<>(); + this.blacklistSignaturesFromFixedList = new HashSet<>(); + this.blacklistSignaturesFromUserControlledList = new HashSet<>(); + + this.save(); + } + + private void resetMetaClassCache() { + // to allow the change to be effective, i.e. rebuild the MetaClass using the new whitelist + WebApp.get(Jenkins.get().servletContext).clearMetaClassCache(); + } + + private synchronized void reloadFromDefault() { + try (InputStream is = StaticRoutingDecisionProvider.class.getResourceAsStream("default-whitelist.txt")) { + whitelistSignaturesFromFixedList = new HashSet<>(); + blacklistSignaturesFromFixedList = new HashSet<>(); + + parseFileIntoList( + IOUtils.readLines(is, StandardCharsets.UTF_8), + whitelistSignaturesFromFixedList, + blacklistSignaturesFromFixedList + ); + } catch (IOException e) { + throw new ExceptionInInitializerError(e); + } + + LOGGER.log(Level.FINE, "Found {0} getter in the standard whitelist", whitelistSignaturesFromFixedList.size()); + } + + public synchronized StaticRoutingDecisionProvider add(@Nonnull String signature) { + if (this.whitelistSignaturesFromUserControlledList.add(signature)) { + LOGGER.log(Level.INFO, "Signature [{0}] added to the whitelist", signature); + save(); + resetMetaClassCache(); + } else { + LOGGER.log(Level.INFO, "Signature [{0}] was already present in the whitelist", signature); + } + return this; + } + + public synchronized StaticRoutingDecisionProvider addBlacklistSignature(@Nonnull String signature) { + if (this.blacklistSignaturesFromUserControlledList.add(signature)) { + LOGGER.log(Level.INFO, "Signature [{0}] added to the blacklist", signature); + save(); + resetMetaClassCache(); + } else { + LOGGER.log(Level.INFO, "Signature [{0}] was already present in the blacklist", signature); + } + return this; + } + + public synchronized StaticRoutingDecisionProvider remove(@Nonnull String signature) { + if (this.whitelistSignaturesFromUserControlledList.remove(signature)) { + LOGGER.log(Level.INFO, "Signature [{0}] removed from the whitelist", signature); + save(); + resetMetaClassCache(); + } else { + LOGGER.log(Level.INFO, "Signature [{0}] was not present in the whitelist", signature); + } + return this; + } + + public synchronized StaticRoutingDecisionProvider removeBlacklistSignature(@Nonnull String signature) { + if (this.blacklistSignaturesFromUserControlledList.remove(signature)) { + LOGGER.log(Level.INFO, "Signature [{0}] removed from the blacklist", signature); + save(); + resetMetaClassCache(); + } else { + LOGGER.log(Level.INFO, "Signature [{0}] was not present in the blacklist", signature); + } + return this; + } + + /** + * Saves the configuration info to the disk. + */ + public synchronized void save() { + if (BulkChange.contains(this)) { + return; + } + + File file = getConfigFile(); + try { + List allSignatures = new ArrayList<>(whitelistSignaturesFromUserControlledList); + blacklistSignaturesFromUserControlledList.stream() + .map(signature -> "!" + signature) + .forEach(allSignatures::add); + + FileUtils.writeLines(file, allSignatures); + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Failed to save " + file.getAbsolutePath(), e); + } + } + + /** + * Loads the data from the disk into this object. + * + *

+ * The constructor of the derived class must call this method. + * (If we do that in the base class, the derived class won't + * get a chance to set default values.) + */ + private synchronized void reloadFromUserControlledList() { + File file = getConfigFile(); + if (!file.exists()) { + if ((whitelistSignaturesFromUserControlledList != null && whitelistSignaturesFromUserControlledList.isEmpty()) || + (blacklistSignaturesFromUserControlledList != null && blacklistSignaturesFromUserControlledList.isEmpty())) { + LOGGER.log(Level.INFO, "No whitelist source file found at " + file + " so resetting user-controlled whitelist"); + } + whitelistSignaturesFromUserControlledList = new HashSet<>(); + blacklistSignaturesFromUserControlledList = new HashSet<>(); + return; + } + + LOGGER.log(Level.INFO, "Whitelist source file found at " + file); + + try { + whitelistSignaturesFromUserControlledList = new HashSet<>(); + blacklistSignaturesFromUserControlledList = new HashSet<>(); + + parseFileIntoList( + FileUtils.readLines(file, StandardCharsets.UTF_8), + whitelistSignaturesFromUserControlledList, + blacklistSignaturesFromUserControlledList + ); + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Failed to load " + file.getAbsolutePath(), e); + } + } + + private File getConfigFile() { + return new File(WHITELIST_PATH == null ? new File(Jenkins.get().getRootDir(), "stapler-whitelist.txt").toString() : WHITELIST_PATH); + } + + private void parseFileIntoList(List lines, Set whitelist, Set blacklist){ + lines.stream() + .filter(line -> !line.matches("#.*|\\s*")) + .forEach(line -> { + if (line.startsWith("!")) { + String withoutExclamation = line.substring(1); + if (!withoutExclamation.isEmpty()) { + blacklist.add(withoutExclamation); + } + } else { + whitelist.add(line); + } + }); + } + + /** Allow script console access */ + public static String WHITELIST_PATH = SystemProperties.getString(StaticRoutingDecisionProvider.class.getName() + ".whitelist"); + +} diff --git a/core/src/main/java/jenkins/security/stapler/TypedFilter.java b/core/src/main/java/jenkins/security/stapler/TypedFilter.java new file mode 100644 index 0000000000000000000000000000000000000000..6f9bdb4019d887e932e48b0e69fd19f435f841a5 --- /dev/null +++ b/core/src/main/java/jenkins/security/stapler/TypedFilter.java @@ -0,0 +1,276 @@ +package jenkins.security.stapler; + +import hudson.ExtensionList; +import jenkins.util.SystemProperties; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.Function; +import org.kohsuke.stapler.FunctionList; +import org.kohsuke.stapler.StaplerFallback; +import org.kohsuke.stapler.StaplerOverridable; +import org.kohsuke.stapler.StaplerProxy; +import org.kohsuke.stapler.WebApp; +import org.kohsuke.stapler.interceptor.InterceptorAnnotation; +import org.kohsuke.stapler.lang.FieldRef; + +import javax.annotation.Nonnull; +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +@Restricted(NoExternalUse.class) +public class TypedFilter implements FieldRef.Filter, FunctionList.Filter { + private static final Logger LOGGER = Logger.getLogger(TypedFilter.class.getName()); + + private static final Map, Boolean> staplerCache = new HashMap<>(); + + private boolean isClassAcceptable(Class clazz) { + if (clazz.isArray()) { + // special case to allow klass.isArray() dispatcher + Class elementClazz = clazz.getComponentType(); + // does not seem possible to fall in an infinite loop since array cannot be recursively defined + if (isClassAcceptable(elementClazz)) { + LOGGER.log(Level.FINE, + "Class {0} is acceptable because it is an Array of acceptable elements {1}", + new Object[]{clazz.getName(), elementClazz.getName()} + ); + return true; + } else { + LOGGER.log(Level.FINE, + "Class {0} is not acceptable because it is an Array of non-acceptable elements {1}", + new Object[]{clazz.getName(), elementClazz.getName()} + ); + return false; + } + } + return SKIP_TYPE_CHECK || isStaplerRelevantCached(clazz); + } + + private static boolean isStaplerRelevantCached(@Nonnull Class clazz) { + if (staplerCache.containsKey(clazz)) { + return staplerCache.get(clazz); + } + boolean ret = isStaplerRelevant(clazz); + + staplerCache.put(clazz, ret); + return ret; + } + + @Restricted(NoExternalUse.class) + public static boolean isStaplerRelevant(@Nonnull Class clazz) { + return isSpecificClassStaplerRelevant(clazz) || isSuperTypesStaplerRelevant(clazz); + } + + private static boolean isSuperTypesStaplerRelevant(@Nonnull Class clazz) { + Class superclass = clazz.getSuperclass(); + if (superclass != null && isStaplerRelevantCached(superclass)) { + return true; + } + for (Class interfaceClass : clazz.getInterfaces()) { + if (isStaplerRelevantCached(interfaceClass)) { + return true; + } + } + return false; + } + + private static boolean isSpecificClassStaplerRelevant(@Nonnull Class clazz) { + if (clazz.isAnnotationPresent(StaplerAccessibleType.class)) { + return true; + } + + // Classes implementing these Stapler types can be considered routable + if (StaplerProxy.class.isAssignableFrom(clazz)) { + return true; + } + if (StaplerFallback.class.isAssignableFrom(clazz)) { + return true; + } + if (StaplerOverridable.class.isAssignableFrom(clazz)) { + return true; + } + + for (Method m : clazz.getMethods()) { + if (isRoutableMethod(m)) { + return true; + } + } + + return false; + } + + private static boolean isRoutableMethod(@Nonnull Method m) { + for (Annotation a : m.getDeclaredAnnotations()) { + if (WebMethodConstants.WEB_METHOD_ANNOTATION_NAMES.contains(a.annotationType().getName())) { + return true; + } + if (a.annotationType().isAnnotationPresent(InterceptorAnnotation.class)) { + // This is a Stapler interceptor annotation like RequirePOST or JsonResponse + return true; + } + } + + for (Annotation[] set : m.getParameterAnnotations()) { + for (Annotation a : set) { + if (WebMethodConstants.WEB_METHOD_PARAMETER_ANNOTATION_NAMES.contains(a.annotationType().getName())) { + return true; + } + } + } + + for (Class parameterType : m.getParameterTypes()) { + if (WebMethodConstants.WEB_METHOD_PARAMETERS_NAMES.contains(parameterType.getName())) { + return true; + } + } + + return WebApp.getCurrent().getFilterForDoActions().keep(new Function.InstanceFunction(m)); + } + + @Override + public boolean keep(@Nonnull FieldRef fieldRef) { + + if (fieldRef.getAnnotation(StaplerNotDispatchable.class) != null) { + // explicitly marked as an invalid field + return false; + } + + if (fieldRef.getAnnotation(StaplerDispatchable.class) != null) { + // explicitly marked as a valid field + return true; + } + + String signature = fieldRef.getSignature(); + + // check whitelist + ExtensionList decisionProviders = ExtensionList.lookup(RoutingDecisionProvider.class); + if (decisionProviders.size() > 0) { + for (RoutingDecisionProvider provider : decisionProviders) { + RoutingDecisionProvider.Decision fieldDecision = provider.decide(signature); + if (fieldDecision == RoutingDecisionProvider.Decision.ACCEPTED) { + LOGGER.log(Level.CONFIG, "Field {0} is acceptable because it is whitelisted by {1}", new Object[]{signature, provider}); + return true; + } + if (fieldDecision == RoutingDecisionProvider.Decision.REJECTED) { + LOGGER.log(Level.CONFIG, "Field {0} is not acceptable because it is blacklisted by {1}", new Object[]{signature, provider}); + return false; + } + Class type = fieldRef.getReturnType(); + if (type != null) { + String typeSignature = "class " + type.getCanonicalName(); + RoutingDecisionProvider.Decision fieldTypeDecision = provider.decide(typeSignature); + if (fieldTypeDecision == RoutingDecisionProvider.Decision.ACCEPTED) { + LOGGER.log(Level.CONFIG, "Field {0} is acceptable because its type is whitelisted by {1}", new Object[]{signature, provider}); + return true; + } + if (fieldTypeDecision == RoutingDecisionProvider.Decision.REJECTED) { + LOGGER.log(Level.CONFIG, "Field {0} is not acceptable because its type is blacklisted by {1}", new Object[]{signature, provider}); + return false; + } + } + } + } + + if (PROHIBIT_STATIC_ACCESS && fieldRef.isStatic()) { + // unless whitelisted or marked as routable, reject static fields + return false; + } + + + Class returnType = fieldRef.getReturnType(); + + boolean isOk = isClassAcceptable(returnType); + LOGGER.log(Level.FINE, "Field analyzed: {0} => {1}", new Object[]{fieldRef.getName(), isOk}); + return isOk; + } + + @Override + public boolean keep(@Nonnull Function function) { + + if (function.getAnnotation(StaplerNotDispatchable.class) != null) { + // explicitly marked as an invalid getter + return false; + } + + if (function.getAnnotation(StaplerDispatchable.class) != null) { + // explicitly marked as a valid getter + return true; + } + + String signature = function.getSignature(); + + // check whitelist + ExtensionList decision = ExtensionList.lookup(RoutingDecisionProvider.class); + if (decision.size() > 0) { + for (RoutingDecisionProvider provider : decision) { + RoutingDecisionProvider.Decision methodDecision = provider.decide(signature); + if (methodDecision == RoutingDecisionProvider.Decision.ACCEPTED) { + LOGGER.log(Level.CONFIG, "Function {0} is acceptable because it is whitelisted by {1}", new Object[]{signature, provider}); + return true; + } + if (methodDecision == RoutingDecisionProvider.Decision.REJECTED) { + LOGGER.log(Level.CONFIG, "Function {0} is not acceptable because it is blacklisted by {1}", new Object[]{signature, provider}); + return false; + } + + Class type = function.getReturnType(); + if (type != null) { + String typeSignature = "class " + type.getCanonicalName(); + RoutingDecisionProvider.Decision returnTypeDecision = provider.decide(typeSignature); + if (returnTypeDecision == RoutingDecisionProvider.Decision.ACCEPTED) { + LOGGER.log(Level.CONFIG, "Function {0} is acceptable because its type is whitelisted by {1}", new Object[]{signature, provider}); + return true; + } + if (returnTypeDecision == RoutingDecisionProvider.Decision.REJECTED) { + LOGGER.log(Level.CONFIG, "Function {0} is not acceptable because its type is blacklisted by {1}", new Object[]{signature, provider}); + return false; + } + } + } + } + + if (PROHIBIT_STATIC_ACCESS && function.isStatic()) { + // unless whitelisted or marked as routable, reject static methods + return false; + } + + if (function.getName().equals("getDynamic")) { + Class[] parameterTypes = function.getParameterTypes(); + if (parameterTypes.length > 0 && parameterTypes[0] == String.class) { + // While this is more general than what Stapler can invoke on these types, + // The above is the only criterion for Stapler to attempt dispatch. + // Therefore prohibit this as a regular getter. + return false; + } + } + + if (function.getName().equals("getStaplerFallback") && function.getParameterTypes().length == 0) { + // A parameter-less #getStaplerFallback() implements special fallback behavior for the + // StaplerFallback interface. We do not check for the presence of the interface on the current + // class, or the return type, as that could change since the implementing component was last built. + return false; + } + + if (function.getName().equals("getTarget") && function.getParameterTypes().length == 0) { + // A parameter-less #getTarget() implements special redirection behavior for the + // StaplerProxy interface. We do not check for the presence of the interface on the current + // class, or the return type, as that could change since the implementing component was last built. + return false; + } + + Class returnType = function.getReturnType(); + + boolean isOk = isClassAcceptable(returnType); + LOGGER.log(Level.FINE, "Function analyzed: {0} => {1}", new Object[]{signature, isOk}); + return isOk; + } + + @Restricted(NoExternalUse.class) + public static boolean SKIP_TYPE_CHECK = SystemProperties.getBoolean(TypedFilter.class.getName() + ".skipTypeCheck"); + + @Restricted(NoExternalUse.class) + public static boolean PROHIBIT_STATIC_ACCESS = SystemProperties.getBoolean(TypedFilter.class.getName() + ".prohibitStaticAccess", true); +} diff --git a/core/src/main/java/jenkins/security/stapler/WebMethodConstants.java b/core/src/main/java/jenkins/security/stapler/WebMethodConstants.java new file mode 100644 index 0000000000000000000000000000000000000000..d8f678e4b072b3747bece29870f4deff9d76980e --- /dev/null +++ b/core/src/main/java/jenkins/security/stapler/WebMethodConstants.java @@ -0,0 +1,101 @@ +/* + * The MIT License + * + * Copyright (c) 2018, 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.stapler; + +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.AncestorInPath; +import org.kohsuke.stapler.Header; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.StaplerRequest; +import org.kohsuke.stapler.StaplerResponse; +import org.kohsuke.stapler.WebMethod; +import org.kohsuke.stapler.bind.JavaScriptMethod; +import org.kohsuke.stapler.json.JsonBody; +import org.kohsuke.stapler.json.SubmittedForm; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.lang.annotation.Annotation; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Restricted(NoExternalUse.class) +final class WebMethodConstants { + /** + * If a method has at least one of those parameters, it is considered as an implicit web method + */ + private static final List> WEB_METHOD_PARAMETERS = Collections.unmodifiableList(Arrays.asList( + StaplerRequest.class, + HttpServletRequest.class, + StaplerResponse.class, + HttpServletResponse.class + )); + + static final Set WEB_METHOD_PARAMETERS_NAMES = Collections.unmodifiableSet( + WEB_METHOD_PARAMETERS.stream() + .map(Class::getName) + .collect(Collectors.toSet()) + ); + + /** + * If a method is annotated with one of those annotations, + * the method is considered as an explicit web method + */ + static final List> WEB_METHOD_ANNOTATIONS = Collections.singletonList( + WebMethod.class + // plus every annotation that's annotated with InterceptorAnnotation + // JavaScriptMethod.class not taken here because it's a special case + ); + + static final Set WEB_METHOD_ANNOTATION_NAMES; + static { + Set webMethodAnnotationNames = WEB_METHOD_ANNOTATIONS.stream() + .map(Class::getName) + .collect(Collectors.toSet()); + webMethodAnnotationNames.add(JavaScriptMethod.class.getName()); + WEB_METHOD_ANNOTATION_NAMES = Collections.unmodifiableSet(webMethodAnnotationNames); + } + + /** + * If at least one parameter of the method is annotated with one of those annotations, + * the method is considered as an implicit web method + */ + private static final List> WEB_METHOD_PARAMETER_ANNOTATIONS = Collections.unmodifiableList(Arrays.asList( + QueryParameter.class, + AncestorInPath.class, + Header.class, + JsonBody.class, + SubmittedForm.class + )); + + static final Set WEB_METHOD_PARAMETER_ANNOTATION_NAMES = Collections.unmodifiableSet( + WEB_METHOD_PARAMETER_ANNOTATIONS.stream() + .map(Class::getName) + .collect(Collectors.toSet()) + ); +} diff --git a/core/src/main/resources/jenkins/security/stapler/default-whitelist.txt b/core/src/main/resources/jenkins/security/stapler/default-whitelist.txt new file mode 100644 index 0000000000000000000000000000000000000000..0938363779c36001c40012550d73c3cc2bf884b6 --- /dev/null +++ b/core/src/main/resources/jenkins/security/stapler/default-whitelist.txt @@ -0,0 +1,177 @@ +# This file contains the built-in whitelist for Stapler request dispatching. +# It's a tool for retaining compatibility with unusual plugin behavior after introducing the SECURITY-595 security fix. +# To provide your own custom whitelist, create/edit $JENKINS_HOME/stapler-whitelist.txt + +# Determine the whitelist entries for methods in a known class from the script console: +# com.acme.package.ClassName.class.methods.each { +# println new org.kohsuke.stapler.Function.InstanceFunction(it).signature +# } +# com.acme.package.ClassName.class.fields.each { +# println org.kohsuke.stapler.lang.FieldRef.wrap(it).signature +# } +# return + +####################################################################################################################### +###################################################### Whitelist ###################################################### +####################################################################################################################### + + +###################### +# Credentials Plugin # +###################### +# Used where credentials are used (e.g. SCM config), without this, 'Add' button will break as its dialog is at: +# /descriptor/….CredentialsSelectHelper/resolver/….CredentialsSelectHelper$SystemContextResolver/provider/….SystemCredentialsProvider$ProviderImpl/context/jenkins/dialog +method com.cloudbees.plugins.credentials.CredentialsSelectHelper getResolver java.lang.String +method com.cloudbees.plugins.credentials.CredentialsSelectHelper$WrappedContextResolver getProvider java.lang.String +# Used by Credentials Plugin's FingerprintTest and Git Plugin's GitSCMTest, as well as others: +method com.cloudbees.plugins.credentials.CredentialsStoreAction$DomainWrapper getCredentials + + +################ +# JUnit Plugin # +################ +# Allow various #getHistory() as these only have resources and #getGraph() +class hudson.tasks.junit.History + + +################## +# Metrics Plugin # +################## +# Method returns Object for no clear reason +method jenkins.metrics.api.MetricsRootAction getCurrentUser + + +######################### +# Pipeline Plugin Suite # +######################### +# Used in the 'Pipeline Steps' UI, the Execution has Nodes but no UI of its own +method org.jenkinsci.plugins.workflow.job.WorkflowRun getExecution +# FlowGraphTable only has a Jelly view, and nothing else that would indicate Stapler-routability +method org.jenkinsci.plugins.workflow.job.views.FlowGraphTableAction getFlowGraph + + +############################ +# Maven Integration Plugin # +############################ +# Advertised in https://github.com/jenkinsci/maven-plugin/blob/7ac83fa85fda0c4d1d02663059644f0655823879/src/main/resources/hudson/maven/reporters/MavenArtifactRecord/_api.jelly#L31 +field hudson.maven.reporters.MavenArtifactRecord attachedArtifacts + + +########################################### +# Static Analysis Plugins (analysis-core) # +########################################### +# Methods return Object for no clear reason +method hudson.plugins.analysis.core.AbstractProjectAction getTrendGraph +method hudson.plugins.analysis.core.AbstractProjectAction getTrendDetails +method hudson.plugins.analysis.core.AbstractProjectAction getTrendDetails org.kohsuke.stapler.StaplerRequest org.kohsuke.stapler.StaplerResponse + + +########################### +# Blue Ocean Plugin Suite # +########################### +# Methods return Object for no clear reason +method io.jenkins.blueocean.service.embedded.rest.AbstractRunImpl getLog +method io.jenkins.blueocean.service.embedded.rest.QueuedBlueRun getLog +method io.jenkins.blueocean.rest.impl.pipeline.PipelineNodeImpl getLog +method io.jenkins.blueocean.rest.impl.pipeline.PipelineStepImpl getLog + + +########################## +# Promoted Builds Plugin # +########################## +# Only subtypes look Stapler-relevant +method hudson.plugins.promoted_builds.PromotionProcess getPromotionCondition java.lang.String + + +########################## +# Robot Framework Plugin # +########################## +# Unsure whether this is needed, but RobotSuiteResult is supposed to be reachable. +method hudson.plugins.robot.model.RobotResult getSuites + + +######################## +# Maven Invoker Plugin # +######################## +# MavenInvokerResult only has an index view, and nothing else that would indicate Stapler-routability +method org.jenkinsci.plugins.maveninvoker.MavenInvokerBuildAction getResult java.lang.String + + +########################### +# Cloud Statistics Plugin # +########################### +# Used via CloudStatistics#getUrl(ProvisioningActivity, PhaseExecution, PhaseExecutionAttachment) for attempts.groovy +method org.jenkinsci.plugins.cloudstats.CloudStatistics getActivity java.lang.String +method org.jenkinsci.plugins.cloudstats.ProvisioningActivity getPhase java.lang.String + + +#################### +# SLOCCount Plugin # +#################### +# Support various getters in the same type +class hudson.plugins.sloccount.SloccountResult + + +############################## +# Project Inheritance Plugin # +############################## +# do* methods with no indication they're supposed to be routable (return String, no args, no annotations) +method hudson.plugins.project_inheritance.projects.InheritanceProject doGetParamDefaultsAsXML +method hudson.plugins.project_inheritance.projects.InheritanceProject doGetParamExpansionsAsXML +method hudson.plugins.project_inheritance.projects.InheritanceProject doGetVersionsAsCompressedXML +method hudson.plugins.project_inheritance.projects.InheritanceProject doGetVersionsAsXML +method hudson.plugins.project_inheritance.projects.InheritanceProject doRenderSVGRelationGraph + + +############################################### +# Changes since last successfull build Plugin # (sic) +############################################### +# Only has an index.jelly view, so needs to be explicit +class com.cloudbees.jenkins.plugins.changelog.Changes + + +################### +# Fitnesse Plugin # +################### +# return hudson.plugins.fitnesse.History with only getters for hudson.util.Graph, like analysis-core +method hudson.plugins.fitnesse.FitnesseProjectAction getTrend + + +########################### +# Build Time Blame Plugin # +########################### +# Only has resources and a getter for a Graph, so needs whitelisting when not scanning +class org.jenkins.ci.plugins.buildtimeblame.analysis.BlameReport + + +########################## +# Azure VM Agents Plugin # +########################## +# Declared to return String, no args, and always returns null…? +method com.microsoft.azure.vmagent.AzureVMAgentTemplate$DescriptorImpl doFillImageReferenceTypeItems + + +################################ +# TestComplete support plug-in # +################################ +method com.smartbear.jenkins.plugins.testcomplete.TcSummaryAction getReports + + +##################### +# Job Cacher Plugin # +##################### +# UI linking to this is ArbitraryFileCache/cache-entry.jelly, used from CacheProjectAction/index.jelly, and ultimately handled by ArbitraryFileCache#doDynamic +method jenkins.plugins.jobcacher.CacheProjectAction getCaches + + +######################### +# Dimensions SCM Plugin # +######################### +# Needs whitelisting due to not following the usual naming scheme +method hudson.plugins.dimensionsscm.DimensionsSCM$DescriptorImpl domanadatoryFieldCheck org.kohsuke.stapler.StaplerRequest org.kohsuke.stapler.StaplerResponse + + +############################## +# Google Health Check Plugin # +############################## +method com.google.jenkins.plugins.health.lib.DerivedPageAction getZone java.lang.String diff --git a/core/src/test/java/jenkins/security/stapler/StaplerSignaturesTest.java b/core/src/test/java/jenkins/security/stapler/StaplerSignaturesTest.java new file mode 100644 index 0000000000000000000000000000000000000000..1e7a79156102a2f4cfac9092e73e7b40a347114a --- /dev/null +++ b/core/src/test/java/jenkins/security/stapler/StaplerSignaturesTest.java @@ -0,0 +1,116 @@ +package jenkins.security.stapler; + +import org.junit.Assert; +import org.junit.Test; +import org.kohsuke.stapler.Function; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.StaplerRequest; +import org.kohsuke.stapler.StaplerResponse; +import org.kohsuke.stapler.json.JsonResponse; +import org.kohsuke.stapler.lang.FieldRef; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public class StaplerSignaturesTest { + @Test + public void testSignaturesSimple() throws Exception { + Set methodSignatures = Arrays.stream(SomeClass.class.getMethods()).map(it -> new Function.InstanceFunction(it).getSignature()).collect(Collectors.toSet()); + Assert.assertEquals(SomeClass.METHOD_SIGNATURES, methodSignatures); + + Set fieldSignatures = Arrays.stream(SomeClass.class.getFields()).map(it -> FieldRef.wrap(it).getSignature()).collect(Collectors.toSet()); + Assert.assertEquals(SomeClass.FIELD_SIGNATURES, fieldSignatures); + } + + @Test + public void testSignaturesInheritance() throws Exception { + Set methodSignatures = Arrays.stream(SomeSubclass.class.getMethods()).map(it -> new Function.InstanceFunction(it).getSignature()).collect(Collectors.toSet()); + Assert.assertEquals(SomeSubclass.METHOD_SIGNATURES, methodSignatures); + + Set fieldSignatures = Arrays.stream(SomeSubclass.class.getFields()).map(it -> FieldRef.wrap(it).getSignature()).collect(Collectors.toSet()); + Assert.assertEquals(SomeSubclass.FIELD_SIGNATURES, fieldSignatures); + } + + public static class SomeClass { + static Set METHOD_SIGNATURES = new HashSet<>(Arrays.asList( + "method jenkins.security.stapler.StaplerSignaturesTest$SomeClass getFoo", + "method jenkins.security.stapler.StaplerSignaturesTest$SomeClass getFoo java.lang.String", + "staticMethod jenkins.security.stapler.StaplerSignaturesTest$SomeClass getFoo int", + "staticMethod jenkins.security.stapler.StaplerSignaturesTest$SomeClass getFoo long", + "method jenkins.security.stapler.StaplerSignaturesTest$SomeClass getFoo jenkins.security.stapler.StaplerSignaturesTest$SomeClass", + "method jenkins.security.stapler.StaplerSignaturesTest$SomeClass doFoo org.kohsuke.stapler.StaplerRequest org.kohsuke.stapler.StaplerResponse", + "method jenkins.security.stapler.StaplerSignaturesTest$SomeClass doWhatever java.lang.String", + "method java.lang.Object getClass", + "method java.lang.Object equals java.lang.Object", + "method java.lang.Object hashCode", + "method java.lang.Object notify", + "method java.lang.Object notifyAll", + "method java.lang.Object toString", + "method java.lang.Object wait long int", + "method java.lang.Object wait long", + "method java.lang.Object wait" + )); + public void getFoo() {} + public void getFoo(String arg) {} + public static void getFoo(int arg) {} + public static void getFoo(long arg) {} + public void getFoo(SomeClass arg) {} + public void doFoo(StaplerRequest req, StaplerResponse rsp) {} + @StaplerDispatchable @JsonResponse + public void doWhatever(@QueryParameter String arg) {} + + static Set FIELD_SIGNATURES = new HashSet<>(Arrays.asList( + "field jenkins.security.stapler.StaplerSignaturesTest$SomeClass whatever", + "field jenkins.security.stapler.StaplerSignaturesTest$SomeClass thing", + "staticField jenkins.security.stapler.StaplerSignaturesTest$SomeClass staticField", + "field jenkins.security.stapler.StaplerSignaturesTest$SomeClass stringList" + )); + public String whatever; + public Object thing; + public static Object staticField; + public List stringList; + + } + + public static class SomeSubclass extends SomeClass { + static Set METHOD_SIGNATURES = new HashSet<>(Arrays.asList( + "method jenkins.security.stapler.StaplerSignaturesTest$SomeSubclass getFoo", + "method jenkins.security.stapler.StaplerSignaturesTest$SomeSubclass subtypeExclusive", + "method jenkins.security.stapler.StaplerSignaturesTest$SomeSubclass subtypeExclusive java.lang.String", + "method jenkins.security.stapler.StaplerSignaturesTest$SomeSubclass varargMethod [Ljava.lang.String;", + "method jenkins.security.stapler.StaplerSignaturesTest$SomeClass getFoo java.lang.String", + "staticMethod jenkins.security.stapler.StaplerSignaturesTest$SomeClass getFoo int", + "staticMethod jenkins.security.stapler.StaplerSignaturesTest$SomeClass getFoo long", + "method jenkins.security.stapler.StaplerSignaturesTest$SomeClass getFoo jenkins.security.stapler.StaplerSignaturesTest$SomeClass", + "method jenkins.security.stapler.StaplerSignaturesTest$SomeClass doFoo org.kohsuke.stapler.StaplerRequest org.kohsuke.stapler.StaplerResponse", + "method jenkins.security.stapler.StaplerSignaturesTest$SomeClass doWhatever java.lang.String", + "method java.lang.Object getClass", + "method java.lang.Object equals java.lang.Object", + "method java.lang.Object hashCode", + "method java.lang.Object notify", + "method java.lang.Object notifyAll", + "method java.lang.Object toString", + "method java.lang.Object wait long int", + "method java.lang.Object wait long", + "method java.lang.Object wait" + )); + public void getFoo() {} + public void subtypeExclusive(){} + public void subtypeExclusive(String arg){} + public void varargMethod(String... args){} + + static Set FIELD_SIGNATURES = new HashSet<>(Arrays.asList( + "field jenkins.security.stapler.StaplerSignaturesTest$SomeSubclass whatever", + "field jenkins.security.stapler.StaplerSignaturesTest$SomeClass whatever", + "field jenkins.security.stapler.StaplerSignaturesTest$SomeClass thing", + "staticField jenkins.security.stapler.StaplerSignaturesTest$SomeSubclass staticField", + "staticField jenkins.security.stapler.StaplerSignaturesTest$SomeClass staticField", + "field jenkins.security.stapler.StaplerSignaturesTest$SomeClass stringList" + )); + public String whatever; + public static Object staticField; + } +} diff --git a/test/pom.xml b/test/pom.xml index 975aea364dfaa3688e4323b52b6492715a891884..37da267d6c5c166c56b47dfc6032b52ebcf8ae90 100644 --- a/test/pom.xml +++ b/test/pom.xml @@ -68,6 +68,12 @@ THE SOFTWARE. 2.0 test + + org.jenkins-ci.plugins + cloudbees-folder + 6.3 + test + ${project.groupId} maven-plugin diff --git a/test/src/test/java/hudson/model/ViewTest.java b/test/src/test/java/hudson/model/ViewTest.java index 4de94099931b91441132d77977c50e6ddc56e184..3d73a15d6d3262afaf561f45a92d4045cd7682d6 100644 --- a/test/src/test/java/hudson/model/ViewTest.java +++ b/test/src/test/java/hudson/model/ViewTest.java @@ -23,10 +23,13 @@ */ package hudson.model; +import com.cloudbees.hudson.plugins.folder.Folder; import com.gargoylesoftware.htmlunit.WebRequest; import com.gargoylesoftware.htmlunit.html.DomNodeUtil; import com.gargoylesoftware.htmlunit.util.NameValuePair; import jenkins.model.Jenkins; +import org.jenkins.ui.icon.Icon; +import org.jenkins.ui.icon.IconSet; import org.jvnet.hudson.test.Issue; import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException; import com.gargoylesoftware.htmlunit.HttpMethod; @@ -194,6 +197,18 @@ public class ViewTest { @Issue("JENKINS-9367") @Test public void allImagesCanBeLoaded() throws Exception { User.get("user", true); + + // as long as the cloudbees-folder is included as test dependency, its Folder will load icon + boolean folderPluginActive = (j.jenkins.getPlugin("cloudbees-folder") != null); + // link to Folder class is done here to ensure if we remove the dependency, this code will fail and so will be removed + boolean folderPluginClassesLoaded = (j.jenkins.getDescriptor(Folder.class) != null); + // this could be written like this to avoid the hard dependency: + // boolean folderPluginClassesLoaded = (j.jenkins.getDescriptor("com.cloudbees.hudson.plugins.folder.Folder") != null); + if (!folderPluginActive && folderPluginClassesLoaded) { + // reset the icon added by Folder because the plugin resources are not reachable + IconSet.icons.addIcon(new Icon("icon-folder icon-md", "24x24/folder.gif", "width: 24px; height: 24px;")); + } + WebClient webClient = j.createWebClient(); webClient.getOptions().setJavaScriptEnabled(false); j.assertAllImageLoadSuccessfully(webClient.goTo("asynchPeople")); diff --git a/test/src/test/java/hudson/util/FormFieldValidatorTest.java b/test/src/test/java/hudson/util/FormFieldValidatorTest.java index 3aaabd067c2ed68b56d4132f200d353cac4393c6..ccab8b91de3b5ee6c5cc643352cefdb80eaa4d53 100644 --- a/test/src/test/java/hudson/util/FormFieldValidatorTest.java +++ b/test/src/test/java/hudson/util/FormFieldValidatorTest.java @@ -59,7 +59,7 @@ public class FormFieldValidatorTest { return true; } - public void doCheckXyz() { + public FormValidation doCheckXyz() { throw new Error("doCheckXyz is broken"); } } diff --git a/test/src/test/java/jenkins/security/stapler/CustomRoutingDecisionProviderTest.java b/test/src/test/java/jenkins/security/stapler/CustomRoutingDecisionProviderTest.java new file mode 100644 index 0000000000000000000000000000000000000000..aef0510fbbf8469b0fe6375dd1d0ef7a4c2b4507 --- /dev/null +++ b/test/src/test/java/jenkins/security/stapler/CustomRoutingDecisionProviderTest.java @@ -0,0 +1,114 @@ +/* + * The MIT License + * + * Copyright (c) 2018, 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.stapler; + +import com.gargoylesoftware.htmlunit.Page; +import hudson.model.UnprotectedRootAction; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.TestExtension; +import org.kohsuke.stapler.Stapler; +import org.kohsuke.stapler.StaplerResponse; +import org.kohsuke.stapler.WebMethod; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import java.io.IOException; + +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.assertThat; + +@Issue("SECURITY-400") +@For(RoutingDecisionProvider.class) +public class CustomRoutingDecisionProviderTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @TestExtension("customRoutingWhitelistProvider") + public static class XxxBlacklister extends RoutingDecisionProvider { + @Override + public Decision decide(@Nonnull String signature) { + if (signature.contains("xxx")) { + return Decision.REJECTED; + } + return Decision.UNKNOWN; + } + } + + @TestExtension + public static class OneMethodIsBlacklisted implements UnprotectedRootAction { + @Override + public @CheckForNull String getUrlName() { + return "custom"; + } + + @Override + public String getDisplayName() { + return null; + } + + @Override + public String getIconFileName() { + return null; + } + + public StaplerAbstractTest.Renderable getLegitGetter() { + return new StaplerAbstractTest.Renderable(); + } + + public StaplerAbstractTest.Renderable getLegitxxxGetter() { + return new StaplerAbstractTest.Renderable(); + } + } + + private static class Renderable { + public void doIndex() {replyOk();} + + @WebMethod(name = "valid") + public void valid() {replyOk();} + } + + private static void replyOk() { + StaplerResponse resp = Stapler.getCurrentResponse(); + try { + resp.getWriter().write("ok"); + resp.flushBuffer(); + } catch (IOException e) {} + } + + @Test + public void customRoutingWhitelistProvider() throws Exception { + Page okPage = j.createWebClient().goTo("custom/legitGetter", null); + assertThat(okPage.getWebResponse().getStatusCode(), is(200)); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + Page errorPage = wc.goTo("custom/legitxxxGetter", null); + assertThat(errorPage.getWebResponse().getStatusCode(), is(404)); + } +} diff --git a/test/src/test/java/jenkins/security/stapler/DoActionFilterTest.java b/test/src/test/java/jenkins/security/stapler/DoActionFilterTest.java new file mode 100644 index 0000000000000000000000000000000000000000..7d9b008b1b9d32d7c4a8d7b74f7eb8809ce9b05e --- /dev/null +++ b/test/src/test/java/jenkins/security/stapler/DoActionFilterTest.java @@ -0,0 +1,738 @@ +package jenkins.security.stapler; + +import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException; +import com.gargoylesoftware.htmlunit.HttpMethod; +import com.gargoylesoftware.htmlunit.Page; +import com.gargoylesoftware.htmlunit.WebRequest; +import com.gargoylesoftware.htmlunit.util.NameValuePair; +import net.sf.json.JSONArray; +import net.sf.json.JSONObject; +import org.junit.Test; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.TestExtension; +import org.kohsuke.stapler.AncestorInPath; +import org.kohsuke.stapler.CapturedParameterNames; +import org.kohsuke.stapler.CrumbIssuer; +import org.kohsuke.stapler.Header; +import org.kohsuke.stapler.HttpResponse; +import org.kohsuke.stapler.HttpResponses; +import org.kohsuke.stapler.LimitedTo; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.RequestImpl; +import org.kohsuke.stapler.ResponseImpl; +import org.kohsuke.stapler.StaplerProxy; +import org.kohsuke.stapler.StaplerRequest; +import org.kohsuke.stapler.StaplerResponse; +import org.kohsuke.stapler.WebMethod; +import org.kohsuke.stapler.bind.JavaScriptMethod; +import org.kohsuke.stapler.interceptor.JsonOutputFilter; +import org.kohsuke.stapler.interceptor.RequirePOST; +import org.kohsuke.stapler.interceptor.RespondSuccess; +import org.kohsuke.stapler.json.JsonBody; +import org.kohsuke.stapler.json.JsonResponse; +import org.kohsuke.stapler.json.SubmittedForm; +import org.kohsuke.stapler.verb.DELETE; +import org.kohsuke.stapler.verb.GET; +import org.kohsuke.stapler.verb.POST; +import org.kohsuke.stapler.verb.PUT; + +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URL; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * To check the previous behavior you can use: + *

+ * {@link org.kohsuke.stapler.MetaClass#LEGACY_WEB_METHOD_MODE} = true;
+ * 
+ * It will disable the usage of {@link DoActionFilter} + */ +@Issue("SECURITY-400") +public class DoActionFilterTest extends StaplerAbstractTest { + + @TestExtension + public static class TestAccessModifierUrl extends AbstractUnprotectedRootAction { + public TestAccessModifier getPublic() {return new TestAccessModifier();} + + protected TestAccessModifier getProtected() {return new TestAccessModifier();} + + TestAccessModifier getInternal() {return new TestAccessModifier();} + + private TestAccessModifier getPrivate() {return new TestAccessModifier();} + + public class TestAccessModifier { + @GET + public String doValue() { + return "hello"; + } + } + } + + @Test + public void testProtectedMethodDispatch() throws Exception { + try { + wc.goTo("testAccessModifierUrl/public/value", null); + } catch (FailingHttpStatusCodeException e) { + fail("should have access to a public method"); + } + try { + wc.goTo("testAccessModifierUrl/protected/value", null); + fail("should not have allowed protected access"); + } catch (FailingHttpStatusCodeException x) { + assertEquals(HttpServletResponse.SC_NOT_FOUND, x.getStatusCode()); + } + try { + wc.goTo("testAccessModifierUrl/internal/value", null); + fail("should not have allowed internal access"); + } catch (FailingHttpStatusCodeException x) { + assertEquals(HttpServletResponse.SC_NOT_FOUND, x.getStatusCode()); + } + try { + wc.goTo("testAccessModifierUrl/private/value", null); + fail("should not have allowed private access"); + } catch (FailingHttpStatusCodeException x) { + assertEquals(HttpServletResponse.SC_NOT_FOUND, x.getStatusCode()); + } + } + + //================================= doXxx methods ================================= + + @TestExtension + public static class TestNewRulesOk extends AbstractUnprotectedRootAction { + /* + * Method signature + */ + + public static void doStaticWithRequest(StaplerRequest request) { replyOk(); } + + public void doWithRequest(StaplerRequest request) { replyOk(); } + + public void doWithHttpRequest(HttpServletRequest request) { replyOk(); } + + // the return type is not taken into consideration if it's not a HttpResponse, it will not prevent the method + // to be considered as a web method + public String doWithRequestAndReturnString(StaplerRequest request) { return "ok"; } + + public void doWithResponse(StaplerResponse response) { replyOk(); } + + public void doWithHttpResponse(HttpServletResponse response) { replyOk(); } + + public void doWithThrowHttpResponseException() throws HttpResponses.HttpResponseException { replyOk(); } + + // special cases, child of above classes, normally reachable, as it satisfies the contract + // that requires to throw an exception that is an HttpResponseException + public void doWithThrowHttpResponseExceptionChild() throws HttpResponseExceptionChild { replyOk(); } + + // the declared exception just has to implement HttpResponse + public void doWithThrowExceptionImplementingOnlyHttpResponse() throws ExceptionImplementingOnlyHttpResponse { replyOk(); } + + public void doWithThrowOtherException() throws IOException { replyOk(); } + + public HttpResponse doWithReturnHttpResponse() { return HttpResponses.plainText("ok"); } + + public HttpResponseChild doWithReturnHttpResponseChild() { return new HttpResponseChild(); } + + /* + * Method annotations + */ + + @WebMethod(name = "webMethodUrl") + public void doWebMethod() { replyOk(); } + + // not requiring to have doXxx when using WebMethod + @WebMethod(name = "webMethodUrl2") + public void webMethod() { replyOk(); } + + @GET + public void doAnnotatedGet() { replyOk(); } + + @POST + public void doAnnotatedPost() { replyOk(); } + + @PUT + public void doAnnotatedPut() { replyOk(); } + + @DELETE + public void doAnnotatedDelete() { replyOk(); } + + @RequirePOST + public void doAnnotatedRequirePost() { replyOk(); } + + @JavaScriptMethod + public void annotatedJavascriptScriptMethod() { replyOk(); } + + @RespondSuccess + public void doAnnotatedResponseSuccess() { replyOk(); } + + @JsonResponse // does not support list + public Map doAnnotatedJsonResponse() { + return new HashMap() {{ + put("a", "b"); + }}; + } + + @LimitedTo("admin") + public void doAnnotatedLimitedTo() { replyOk(); } + + /* + * Parameter annotation + */ + + public void doAnnotatedParamQueryParameter(@QueryParameter String value) { replyOk(); } + + public void doAnnotatedParamAncestorInPath(@AncestorInPath DoActionFilterTest parent) { replyOk(); } + + public void doAnnotatedParamHeader(@Header("test-header") String testHeader) { replyOk(); } + + public void doAnnotatedParamJsonBody(@JsonBody Map names) { replyOk(); } + + public void doAnnotatedParamSubmittedForm(@SubmittedForm JSONObject form) { replyOk(); } + + /* + * Parameter annotation + */ + + public void do_CallMeBecauseOfMyUnderscore(StaplerRequest request) { replyOk(); } + + public void do$CallMeBecauseOfMyDollar(StaplerRequest request) { replyOk(); } + } + + public static class HttpResponseChild implements HttpResponse { + @Override + public void generateResponse(StaplerRequest req, StaplerResponse rsp, Object node) throws IOException, ServletException { + replyOk(); + } + } + + public static abstract class HttpResponseExceptionChild extends HttpResponses.HttpResponseException { + } + + public static class ExceptionImplementingOnlyHttpResponse extends RuntimeException implements HttpResponse { + @Override + public void generateResponse(StaplerRequest staplerRequest, StaplerResponse staplerResponse, Object o) throws IOException, ServletException { + replyOk(); + } + } + + //########### actual test methods ########### + @Test + public void testMethodSignatureOk_staticWithRequest() throws Exception { + assertReachable("testNewRulesOk/staticWithRequest/"); + } + + @Test + public void testMethodSignatureOk_withRequest() throws Exception { + assertReachable("testNewRulesOk/withRequest/"); + } + + @Test + public void testMethodSignatureOk_withRequestAndReturnString() throws Exception { + assertReachable("testNewRulesOk/withRequestAndReturnString/"); + } + + @Test + public void testMethodSignatureOk_withHttpRequest() throws Exception { + assertReachable("testNewRulesOk/withHttpRequest/"); + } + + @Test + public void testMethodSignatureOk_withHttpResponse() throws Exception { + assertReachable("testNewRulesOk/withHttpResponse/"); + } + + @Test + public void testMethodSignatureOk_withResponse() throws Exception { + assertReachable("testNewRulesOk/withResponse/"); + } + + @Test + public void testMethodSignatureOk_withThrowHttpResponseException() throws Exception { + assertReachable("testNewRulesOk/withThrowHttpResponseException/"); + } + + @Test + public void testMethodSignatureOk_withThrowHttpResponseExceptionChild() throws Exception { + assertReachable("testNewRulesOk/withThrowHttpResponseExceptionChild/"); + } + + @Test + public void testMethodSignatureOk_withThrowExceptionImplementingOnlyHttpResponse() throws Exception { + assertReachable("testNewRulesOk/withThrowExceptionImplementingOnlyHttpResponse/"); + } + + @Test + public void testMethodSignatureOk_withThrowOtherException() throws Exception { + assertNotReachable("testNewRulesOk/withThrowOtherException/"); + } + + @Test + public void testMethodSignatureOk_withReturnHttpResponse() throws Exception { + assertReachable("testNewRulesOk/withReturnHttpResponse/"); + } + + @Test + public void testMethodSignatureOk_withReturnHttpResponseChild() throws Exception { + assertReachable("testNewRulesOk/withReturnHttpResponseChild/"); + } + + @Test + public void testAnnotatedMethodOk_webMethodUrl() throws Exception { + assertReachable("testNewRulesOk/webMethodUrl/"); + } + + @Test + public void testAnnotatedMethodOk_webMethodUrl2() throws Exception { + assertReachable("testNewRulesOk/webMethodUrl2/"); + } + + @Test + public void testAnnotatedMethodOk_annotatedGet() throws Exception { + assertReachable("testNewRulesOk/annotatedGet/"); + } + + @Test + public void testAnnotatedMethodOk_annotatedPost() throws Exception { + WebRequest settings = new WebRequest(new URL(j.getURL(), "testNewRulesOk/annotatedPost/")); + settings.setHttpMethod(HttpMethod.POST); + settings.setRequestBody(""); + assertReachableWithSettings(settings); + } + + @Test + public void testAnnotatedMethodOk_annotatedPut() throws Exception { + WebRequest settings = new WebRequest(new URL(j.getURL(), "testNewRulesOk/annotatedPut/")); + settings.setHttpMethod(HttpMethod.PUT); + settings.setRequestBody(""); + assertReachableWithSettings(settings); + } + + @Test + public void testAnnotatedMethodOk_annotatedDelete() throws Exception { + assertReachable("testNewRulesOk/annotatedDelete/", HttpMethod.DELETE); + } + + @Test + public void testAnnotatedMethodOk_annotatedRequirePost() throws Exception { + WebRequest settings = new WebRequest(new URL(j.getURL(), "testNewRulesOk/annotatedRequirePost/")); + settings.setHttpMethod(HttpMethod.POST); + settings.setRequestBody(""); + assertReachableWithSettings(settings); + } + + @Test + public void testAnnotatedMethodOk_annotatedJavascriptScriptMethod() throws Exception { + webApp.setCrumbIssuer(new CrumbIssuer() { + @Override + public String issueCrumb(StaplerRequest request) { + return "test"; + } + + @Override + public void validateCrumb(StaplerRequest request, String submittedCrumb) { + // no exception thrown = validated + } + }); + + + WebRequest settings = new WebRequest(new URL(j.getURL(), "testNewRulesOk/annotatedJavascriptScriptMethod/")); + settings.setAdditionalHeader("Content-Type", "application/x-stapler-method-invocation"); + settings.setHttpMethod(HttpMethod.POST); + settings.setRequestBody(JSONArray.fromObject(Arrays.asList()).toString()); + assertReachableWithSettings(settings); + } + + @Test + public void testAnnotatedMethodOk_annotatedResponseSuccess() throws Exception { + assertReachable("testNewRulesOk/annotatedResponseSuccess/"); + } + + @Test + public void testAnnotatedMethodOk_annotatedJsonResponse() throws Exception { + WebRequest settings = new WebRequest(new URL(j.getURL(), "testNewRulesOk/annotatedJsonResponse/")); + settings.setHttpMethod(HttpMethod.POST); + settings.setRequestBody(JSONObject.fromObject(Collections.emptyMap()).toString()); + Page page = wc.getPage(settings); + assertEquals(200, page.getWebResponse().getStatusCode()); + } + + @Test + public void testAnnotatedMethodOk_annotatedLimitedTo() throws Exception { + try { + wc.getPage(new URL(j.getURL(), "testNewRulesOk/annotatedLimitedTo/")); + fail(); + } catch (FailingHttpStatusCodeException e) { + assertEquals(500, e.getStatusCode()); + assertTrue(e.getResponse().getContentAsString().contains("Needs to be in role")); + } + } + + @Test + public void testAnnotatedParameterOk_annotatedParamQueryParameter() throws Exception { + // parameter is optional by default + assertReachable("testNewRulesOk/annotatedParamQueryParameter/"); + assertReachable("testNewRulesOk/annotatedParamQueryParameter/?value=test"); + } + + @Test + public void testAnnotatedParameterOk_annotatedParamAncestorInPath() throws Exception { + assertReachable("testNewRulesOk/annotatedParamAncestorInPath/"); + } + + @Test + public void testAnnotatedParameterOk_annotatedParamHeader() throws Exception { + WebRequest settings = new WebRequest(new URL(j.getURL(), "testNewRulesOk/annotatedParamHeader/")); + settings.setAdditionalHeader("test-header", "TestBrowser"); + assertReachableWithSettings(settings); + } + + @Test + public void testAnnotatedParameterOk_annotatedParamJsonBody() throws Exception { + WebRequest settings = new WebRequest(new URL(j.getURL(), "testNewRulesOk/annotatedParamJsonBody/")); + // WebClient forces us to use POST to have the possibility to send requestBody + settings.setHttpMethod(HttpMethod.POST); + settings.setAdditionalHeader("Content-Type", "application/json"); + settings.setRequestBody(JSONObject.fromObject(new HashMap() {{ + put("name", "Test"); + }}).toString()); + assertReachableWithSettings(settings); + } + + @Test + public void testAnnotatedParameterOk_annotatedParamSubmittedForm() throws Exception { + WebRequest settings = new WebRequest(new URL(j.getURL(), "testNewRulesOk/annotatedParamSubmittedForm/")); + settings.setHttpMethod(HttpMethod.POST); + + settings.setRequestParameters(Arrays.asList( + new NameValuePair( + "json", + JSONObject.fromObject(new HashMap() {{ + put("name", "Test"); + }}).toString() + ) + )); + assertReachableWithSettings(settings); + } + + @Test + public void testOk__CallMeBecauseOfMyUnderscore() throws Exception { + assertReachable("testNewRulesOk/_CallMeBecauseOfMyUnderscore/"); + } + + @Test + public void testOk_$CallMeBecauseOfMyDollar() throws Exception { + assertReachable("testNewRulesOk/$CallMeBecauseOfMyDollar/"); + } + + @TestExtension + public static class TestNewRulesOkDynamic extends AbstractUnprotectedRootAction { + // sufficiently magical name to be reached + public void doDynamic() { replyOk(); } + } + + + @TestExtension + public static class TestNewRulesOkIndex extends AbstractUnprotectedRootAction { + // considered as index + @WebMethod(name = "") + public void methodWithoutNameEqualIndex() { replyOk(); } + } + + @TestExtension + public static class TestNewRulesOkDoIndex extends AbstractUnprotectedRootAction { + public void doIndex() { replyOk(); } + } + + @Test + public void testSpecialCasesOk() throws Exception { + assertReachable("testNewRulesOkDynamic/anyString/"); + assertReachable("testNewRulesOkIndex/"); + assertReachable("testNewRulesOkDoIndex/"); + } + + // those methods are accepted in legacy system but potentially dangerous + @TestExtension + public static class TestNewRulesNotOk extends AbstractUnprotectedRootAction { + // do not respect the do[^a-z].* format + public void dontCallMeBecauseOfMyDont(StaplerRequest request) { replyOk(); } + + // do not seem to be an expected web method, in case a developer has such methods, + // addition of WebMethod annotation is sufficient + public void doSomething() { replyOk(); } + + // returning a String is not sufficient to be considered as a web method + public String doReturnString() { return "ok"; } + + // returning a super class of HttpResponse is not sufficient + public Object doReturnObject() { return "ok"; } + } + + @Test + public void testNotOk_ntCallMeBecauseOfMyDont() throws Exception { + assertNotReachable("testNewRulesNotOk/ntCallMeBecauseOfMyDont/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } + + @Test + public void testNotOk_something() throws Exception { + assertNotReachable("testNewRulesNotOk/something/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } + + @Test + public void testNotOk_returnString() throws Exception { + assertNotReachable("testNewRulesNotOk/returnString/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } + + @Test + public void testNotOk_returnObject() throws Exception { + assertNotReachable("testNewRulesNotOk/returnObject/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } + + @TestExtension + public static class TestNewRulesNotOkSpecialCases extends AbstractUnprotectedRootAction { + public void doWithServletRequest(ServletRequest request) { replyOk(); } + + public void doWithServletResponse(ServletResponse response) { replyOk(); } + + // special cases, child of above classes + public void doWithRequestImpl(RequestImpl request) { replyOk(); } + + public void doWithResponseImpl(ResponseImpl response) { replyOk(); } + + public void doWithRequestAndResponse(RequestAndResponse requestAndResponse) { replyOk(); } + + // special case to keep Groovy parameter name, but does not seem to indicate it's automatically a web method + @CapturedParameterNames({"req"}) + public void doAnnotatedResponseSuccess(Object req) { replyOk(); } + +// // as mentioned in its documentation, it requires to have JavaScriptMethod, that has its own test +// @JsonOutputFilter +// public void doAnnotatedJsonOutputFilter() { replyOk(); } + } + + public static abstract class RequestAndResponse implements StaplerRequest, StaplerResponse { + @Override + public CollectionAndEnumeration getHeaderNames() { + return null; + } + + @Override + public CollectionAndEnumeration getHeaders(String name) { + return null; + } + + public static abstract class CollectionAndEnumeration implements Collection, Enumeration { + } + } + + @Test + public void testNotOkSpecialCases_withServletRequest() throws Exception { + assertNotReachable("testNewRulesNotOkSpecialCases/withServletRequest/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } + + @Test + public void testNotOkSpecialCases_withServletResponse() throws Exception { + assertNotReachable("testNewRulesNotOkSpecialCases/withServletResponse/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } + + @Test + public void testNotOkSpecialCases_withRequestImpl() throws Exception { + assertNotReachable("testNewRulesNotOkSpecialCases/withRequestImpl/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } + + @Test + public void testNotOkSpecialCases_withResponseImpl() throws Exception { + assertNotReachable("testNewRulesNotOkSpecialCases/withResponseImpl/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } + + @Test + public void testNotOkSpecialCases_withRequestAndResponse() throws Exception { + assertNotReachable("testNewRulesNotOkSpecialCases/withRequestAndResponse/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } + + @Test + public void testNotOkSpecialCases_annotatedResponseSuccess() throws Exception { + assertNotReachable("testNewRulesNotOkSpecialCases/annotatedResponseSuccess/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } + + // now JsonOutputFilter is accepted as a web method annotation +// @Test +// public void testNotOkSpecialCases_annotatedJsonOutputFilter() throws Exception { +// assertNotReachable("testNewRulesNotOkSpecialCases/annotatedJsonOutputFilter/"); +// assertDoActionRequestWasBlockedAndResetFlag(); +// } + + //================================= class inheritance ================================= + + public static class A { + public void doNotAnnotatedAtAll() { replyOk(); } + + @WebMethod(name = "onlyAnnotatedInA") + public void doOnlyAnnotatedInA() { replyOk(); } + + public void doOnlyAnnotatedInB() { replyOk(); } + + @WebMethod(name = "onlyAnnotatedInA-notOverrided") + public void doOnlyAnnotatedInANotOverrided() { replyOk(); } + + @WebMethod(name = "annotatedButDifferent1") + public void doAnnotatedButDifferent() { replyOk(); } + } + + public static class B extends A { + @Override + public void doNotAnnotatedAtAll() { replyOk(); } + + public void doOnlyAnnotatedInA() { replyOk(); } + + @WebMethod(name = "onlyAnnotatedInB") + public void doOnlyAnnotatedInB() { replyOk(); } + + // doOnlyAnnotatedInANotOverrided: not overrided + + @WebMethod(name = "annotatedButDifferent2") + public void doAnnotatedButDifferent() { replyOk(); } + } + + @TestExtension + public static class ABCase extends AbstractUnprotectedRootAction implements StaplerProxy { + @Override + public B getTarget() { + return new B(); + } + } + + @Test + public void testClassInheritance_notAnnotatedAtAll() throws Exception { + assertNotReachable("aBCase/notAnnotatedAtAll/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } + + @Test + public void testClassInheritance_onlyAnnotatedInA() throws Exception { + assertReachable("aBCase/onlyAnnotatedInA/"); + } + + @Test + public void testClassInheritance_onlyAnnotatedInB() throws Exception { + assertReachable("aBCase/onlyAnnotatedInB/"); + } + + @Test + public void testClassInheritance_onlyAnnotatedInANotOverrided() throws Exception { + assertNotReachable("aBCase/onlyAnnotatedInANotOverrided/"); + } + + @Test + public void testClassInheritance_annotatedButDifferent1() throws Exception { + // only the last webMethod annotation is used + //TODO it breaks the Liskov substitutability +// assertReachable("b/annotatedButDifferent1/"); + assertNotReachable("aBCase/annotatedButDifferent1/"); + } + + @Test + public void testClassInheritance_annotatedButDifferent2() throws Exception { + assertReachable("aBCase/annotatedButDifferent2/"); + } + + //================================= interface implementation ================================= + public interface I { + void doNotAnnotated(); + + @WebMethod(name = "annotatedBoth") + void doAnnotatedBoth(); + + @WebMethod(name = "annotatedOnlyI") + void doAnnotatedOnlyI(); + + void doAnnotatedOnlyJ(); + + @WebMethod(name = "annotatedButDifferent1") + void doAnnotatedButDifferent(); + } + + public static class J implements I { + @Override + public void doNotAnnotated() { replyOk(); } + + @Override + @WebMethod(name = "annotatedBoth") + public void doAnnotatedBoth() { replyOk(); } + + @Override + public void doAnnotatedOnlyI() { replyOk(); } + + @Override + @WebMethod(name = "annotatedOnlyJ") + public void doAnnotatedOnlyJ() { replyOk(); } + + @Override + @WebMethod(name = "annotatedButDifferent2") + public void doAnnotatedButDifferent() { replyOk(); } + } + + @TestExtension + public static class IJCase extends AbstractUnprotectedRootAction implements StaplerProxy { + @Override + public J getTarget() { + return new J(); + } + } + + @Test + public void testInterfaceImplementation_notAnnotated() throws Exception { + assertNotReachable("iJCase/notAnnotated/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } + + @Test + public void testInterfaceImplementation_annotatedBoth() throws Exception { + assertReachable("iJCase/annotatedBoth/"); + } + + @Test + public void testInterfaceImplementation_annotatedOnlyI() throws Exception { + assertReachable("iJCase/annotatedOnlyI/"); + } + + @Test + public void testInterfaceImplementation_annotatedOnlyJ() throws Exception { + assertReachable("iJCase/annotatedOnlyJ/"); + } + + @Test + public void testInterfaceImplementation_annotatedButDifferent1() throws Exception { + // only the last webMethod annotation is used + //TODO it breaks the Liskov substitutability + // assertReachable("j/annotatedButDifferent1/"); + assertNotReachable("iJCase/annotatedButDifferent1/"); + } + + @Test + public void testInterfaceImplementation_annotatedButDifferent2() throws Exception { + assertReachable("iJCase/annotatedButDifferent2/"); + } +} diff --git a/test/src/test/java/jenkins/security/stapler/DynamicTest.java b/test/src/test/java/jenkins/security/stapler/DynamicTest.java new file mode 100644 index 0000000000000000000000000000000000000000..ca0d5978745ff7c1ea46c45a596476b239d5c64f --- /dev/null +++ b/test/src/test/java/jenkins/security/stapler/DynamicTest.java @@ -0,0 +1,73 @@ +package jenkins.security.stapler; + +import hudson.model.UnprotectedRootAction; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.TestExtension; +import org.kohsuke.stapler.HttpResponse; +import org.kohsuke.stapler.HttpResponses; +import org.kohsuke.stapler.StaplerRequest; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; + +import javax.annotation.CheckForNull; +import java.util.Arrays; +import java.util.stream.Stream; + +@Issue("SECURITY-400") +public class DynamicTest { + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Test + public void testRequestsDispatchedToEligibleDynamic() throws Exception { + JenkinsRule.WebClient wc = j.createWebClient(); + Stream.of("whatever", "displayName", "iconFileName", "urlName", "response1", "response2").forEach(url -> + { + try { + assertThat(wc.goTo("root/" + url).getWebResponse().getContentAsString(), containsString(url)); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + @TestExtension + public static class Root implements UnprotectedRootAction { + + @CheckForNull + @Override + public String getIconFileName() { + return null; + } + + @CheckForNull + @Override + public String getDisplayName() { + return null; + } + + @StaplerNotDispatchable + public HttpResponse getResponse1() { + return null; + } + + @StaplerNotDispatchable + public HttpResponse doResponse2() { + return null; + } + + public void doDynamic(StaplerRequest req) { + throw HttpResponses.errorWithoutStack(200, req.getRestOfPath()); + } + + @CheckForNull + @Override + public String getUrlName() { + return "root"; + } + } +} diff --git a/test/src/test/java/jenkins/security/stapler/GetterMethodFilterTest.java b/test/src/test/java/jenkins/security/stapler/GetterMethodFilterTest.java new file mode 100644 index 0000000000000000000000000000000000000000..7c5753c137bd7f6f7ccaa124881d9fc2cfb10412 --- /dev/null +++ b/test/src/test/java/jenkins/security/stapler/GetterMethodFilterTest.java @@ -0,0 +1,500 @@ +/* + * The MIT License + * + * Copyright (c) 2018, 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.stapler; + +import com.cloudbees.hudson.plugins.folder.Folder; +import hudson.model.TopLevelItem; +import hudson.model.View; +import jenkins.model.Jenkins; +import org.junit.Test; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.TestExtension; + +import java.awt.*; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertFalse; + +/** + * To check the previous behavior you can use: + *
+ * {@link org.kohsuke.stapler.MetaClass#LEGACY_GETTER_MODE} = true;
+ * 
+ * It will disable the usage of {@link TypedFilter} + */ +@Issue("SECURITY-400") +@For(TypedFilter.class) +public class GetterMethodFilterTest extends StaplerAbstractTest { + + @TestExtension + public static class TestWithReturnJavaPlatformObject extends AbstractUnprotectedRootAction { + public static boolean called = false; + + public String getString() { return "a";} + + // cannot provide side-effect since the String has no side-effect methods + public Object getObjectString() { return "a";} + + // but it opens wide range of potentially dangerous classes + public Object getObjectCustom() { + return new Object() { + // in order to provide a web entry-point + public void doIndex() { + replyOk(); + } + }; + } + + public Point getPoint() { return new Point(1, 2);} + + public Point getPointCustomChild() { + return new Point() { + // in order to provide a web entry-point + public void doIndex() { + replyOk(); + } + }; + } + + public Point getPointWithListener() { + return new Point() { + @Override + public double getX() { + // just to demonstrate the potential side-effect + called = true; + return super.getX(); + } + }; + } + } + + @Test + public void testWithReturnJavaPlatformObject_string() throws Exception { + assertNotReachable("testWithReturnJavaPlatformObject/string/"); + } + + @Test + public void testWithReturnJavaPlatformObject_objectString() throws Exception { + assertNotReachable("testWithReturnJavaPlatformObject/objectString/"); + } + + @Test + public void testWithReturnJavaPlatformObject_objectCustom() throws Exception { + assertNotReachable("testWithReturnJavaPlatformObject/objectCustom/"); + } + + @Test + public void testWithReturnJavaPlatformObject_point() throws Exception { + assertNotReachable("testWithReturnJavaPlatformObject/point/"); + } + + // previously reachable and so potentially open to future security vulnerability + @Test + public void testWithReturnJavaPlatformObject_pointCustomChild() throws Exception { + assertNotReachable("testWithReturnJavaPlatformObject/pointCustomChild/"); + } + + @Test + public void testWithReturnJavaPlatformObject_pointWithListener() throws Exception { + TestWithReturnJavaPlatformObject.called = false; + assertFalse(TestWithReturnJavaPlatformObject.called); + // could potentially trigger some side-effects + assertNotReachable("testWithReturnJavaPlatformObject/pointWithListener/x/"); + assertFalse(TestWithReturnJavaPlatformObject.called); + } + + @TestExtension + public static class TestWithReturnMultiple extends AbstractUnprotectedRootAction { + public List getList() { + return Arrays.asList(new Renderable(), new Renderable()); + } + + // as we cannot determine the element class due to type erasure, this is reachable + public List getListOfPoint() { + return Collections.singletonList(new RenderablePoint()); + } + + public List> getListOfList() { + return Collections.singletonList(Arrays.asList(new Renderable(), new Renderable())); + } + + public Renderable[] getArray() { return new Renderable[]{new Renderable(), new Renderable()};} + + // will not be accepted since the componentType is from JVM + public Point[] getArrayOfPoint() { + return new Point[]{new Point() { + public void doIndex() {replyOk();} + }}; + } + + public Renderable[][] getArrayOfArray() { + return new Renderable[][]{ + new Renderable[]{new Renderable(), new Renderable()} + }; + } + + @SuppressWarnings("unchecked") + public List[] getArrayOfList() { + List list = Arrays.asList(new Renderable(), new Renderable()); + return (List[]) Collections.singletonList(list).toArray(new List[0]); + } + + public List getListOfArray() { + return Collections.singletonList( + new Renderable[]{new Renderable(), new Renderable()} + ); + } + + public Map getMap() { + return new HashMap() {{ + put("a", new Renderable()); + }}; + } + } + + @Test + public void testWithReturnMultiple_list() throws Exception { + assertNotReachable("testWithReturnMultiple/list/"); + assertNotReachable("testWithReturnMultiple/list/0/"); + assertNotReachable("testWithReturnMultiple/list/1/"); + assertNotReachable("testWithReturnMultiple/list/2/"); + } + + @Test + public void testWithReturnMultiple_listOfPoint() throws Exception { + assertNotReachable("testWithReturnMultiple/listOfPoint/"); + assertNotReachable("testWithReturnMultiple/listOfPoint/0/"); + assertNotReachable("testWithReturnMultiple/listOfPoint/1/"); + } + + @Test + public void testWithReturnMultiple_listOfList() throws Exception { + assertNotReachable("testWithReturnMultiple/listOfList/"); + assertNotReachable("testWithReturnMultiple/listOfList/0/"); + assertNotReachable("testWithReturnMultiple/listOfList/1/"); + assertNotReachable("testWithReturnMultiple/listOfList/0/0/"); + assertNotReachable("testWithReturnMultiple/listOfList/0/1/"); + assertNotReachable("testWithReturnMultiple/listOfList/0/2/"); + } + + @Test + public void testWithReturnMultiple_array() throws Exception { + assertNotReachable("testWithReturnMultiple/array/"); + assertReachable("testWithReturnMultiple/array/0/"); + assertReachable("testWithReturnMultiple/array/1/"); + assertNotReachable("testWithReturnMultiple/array/2/"); + } + + @Test + public void testWithReturnMultiple_arrayOfPoint() throws Exception { + assertNotReachable("testWithReturnMultiple/arrayOfPoint/"); + assertNotReachable("testWithReturnMultiple/arrayOfPoint/0/"); + assertNotReachable("testWithReturnMultiple/arrayOfPoint/1/"); + } + + @Test + public void testWithReturnMultiple_arrayOfArray() throws Exception { + assertNotReachable("testWithReturnMultiple/arrayOfArray/"); + assertNotReachable("testWithReturnMultiple/arrayOfArray/0/"); + assertNotReachable("testWithReturnMultiple/arrayOfArray/1/"); + assertReachable("testWithReturnMultiple/arrayOfArray/0/0/"); + assertReachable("testWithReturnMultiple/arrayOfArray/0/1/"); + assertNotReachable("testWithReturnMultiple/arrayOfArray/0/2/"); + } + + @Test + public void testWithReturnMultiple_arrayOfList() throws Exception { + assertNotReachable("testWithReturnMultiple/arrayOfList/"); + assertNotReachable("testWithReturnMultiple/arrayOfList/0/"); + assertNotReachable("testWithReturnMultiple/arrayOfList/1/"); + assertNotReachable("testWithReturnMultiple/arrayOfList/0/0/"); + assertNotReachable("testWithReturnMultiple/arrayOfList/0/1/"); + assertNotReachable("testWithReturnMultiple/arrayOfList/0/2/"); + } + + @Test + public void testWithReturnMultiple_listOfArray() throws Exception { + assertNotReachable("testWithReturnMultiple/listOfArray/"); + assertNotReachable("testWithReturnMultiple/listOfArray/0/"); + assertNotReachable("testWithReturnMultiple/listOfArray/1/"); + assertNotReachable("testWithReturnMultiple/listOfArray/0/0/"); + assertNotReachable("testWithReturnMultiple/listOfArray/0/1/"); + assertNotReachable("testWithReturnMultiple/listOfArray/0/2/"); + } + + @Test + public void testWithReturnMultiple_map() throws Exception { + assertNotReachable("testWithReturnMultiple/map/"); + assertNotReachable("testWithReturnMultiple/map/a/"); + assertNotReachable("testWithReturnMultiple/map/b/"); + } + + @TestExtension + public static class TestWithReturnCoreObject extends AbstractUnprotectedRootAction { + public View.People getPeople() { + // provide an index jelly view + return new View.People(Jenkins.getInstance()); + } + } + + @Test + public void testWithReturnCoreObject_people() throws Exception { + assertReachableWithoutOk("testWithReturnCoreObject/people/"); + } + + @Test + public void testTopLevelItemIsLegal() throws Exception { + TopLevelItem item = j.createFreeStyleProject(); + assertReachableWithoutOk("job/" + item.getName()); + } + + @TestExtension + public static class TestWithReturnPluginObject extends AbstractUnprotectedRootAction { + public Folder getFolder() { + return new Folder(Jenkins.getInstance(), "testFolder"); + } + } + + @Test + public void testWithReturnPluginObject_folder() throws Exception { + // the search part is just to get something from the call + assertReachableWithoutOk("testWithReturnPluginObject/folder/search/suggest/?query=xxx"); + } + + // full package name just to be explicit + @TestExtension + public static class TestWithReturnThirdPartyObject extends AbstractUnprotectedRootAction { + public org.apache.commons.codec.binary.Base64 getBase64() { + return new org.apache.commons.codec.binary.Base64(); + } + + public org.apache.commons.codec.Encoder getEncoder() { + return new org.apache.commons.codec.binary.Base64(); + } + + public org.apache.commons.codec.Encoder getEncoderCustomChild() { + return new org.apache.commons.codec.Encoder() { + @Override + public Object encode(Object source) throws org.apache.commons.codec.EncoderException { + // it's not about implementation... + return null; + } + + public void doIndex() { + // it's about sending a message + replyOk(); + } + }; + } + } + + // the class itself was reachable but no more interaction are available and so return 404 + + @Test + public void testWithReturnThirdPartyObject_base32() throws Exception { + assertNotReachable("testWithReturnThirdPartyObject/base32/"); + } + + // the class itself was reachable but no more interaction are available and so return 404, + // in case there is some callable methods, we could create some side-effect even we got 404 + @Test + public void testWithReturnThirdPartyObject_encoder() throws Exception { + assertNotReachable("testWithReturnThirdPartyObject/encoder/"); + } + + // as we add a entry-point in the class, now it can propose some interaction, + // dangerous behavior that is not prohibited + @Test + public void testWithReturnThirdPartyObject_encoderCustomChild() throws Exception { + assertNotReachable("testWithReturnThirdPartyObject/encoderCustomChild/"); + } + + + //================================= getter methods with primitives ================================= + + @TestExtension + public static class TestWithReturnPrimitives extends AbstractUnprotectedRootAction { + public int getInteger() { return 1;} + + public Integer getIntegerObject() { return 1;} + + public long getLong() { return 1L;} + + public Long getLongObject() { return 1L;} + + public short getShort() { return (short) 1;} + + public Short getShortObject() { return 1;} + + public byte getByte() { return (byte) 1;} + + public Byte getByteObject() { return (byte) 1;} + + public boolean getBoolean() { return true;} + + public Boolean getBooleanObject() { return Boolean.TRUE;} + + public char getChar() { return 'a';} + + public Character getCharObject() { return 'a';} + + public float getFloat() { return 1.0f;} + + public Float getFloatObject() { return 1.0f;} + + public double getDouble() { return 1.0;} + + public Double getDoubleObject() { return 1.0;} + + public void getVoid() { } + + public Void getVoidObject() { return null; } + } + + @Test + public void testTestWithReturnPrimitives_integer() throws Exception { + assertNotReachable("testWithReturnPrimitives/integer/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_integerObject() throws Exception { + assertNotReachable("testWithReturnPrimitives/integerObject/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_long() throws Exception { + assertNotReachable("testWithReturnPrimitives/long/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_longObject() throws Exception { + assertNotReachable("testWithReturnPrimitives/longObject/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_short() throws Exception { + assertNotReachable("testWithReturnPrimitives/short/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_shortObject() throws Exception { + assertNotReachable("testWithReturnPrimitives/shortObject/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_byte() throws Exception { + assertNotReachable("testWithReturnPrimitives/byte/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_byteObject() throws Exception { + assertNotReachable("testWithReturnPrimitives/byteObject/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_boolean() throws Exception { + assertNotReachable("testWithReturnPrimitives/boolean/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_booleanObject() throws Exception { + assertNotReachable("testWithReturnPrimitives/booleanObject/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_char() throws Exception { + assertNotReachable("testWithReturnPrimitives/char/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_charObject() throws Exception { + assertNotReachable("testWithReturnPrimitives/charObject/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_float() throws Exception { + assertNotReachable("testWithReturnPrimitives/float/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_floatObject() throws Exception { + assertNotReachable("testWithReturnPrimitives/floatObject/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_double() throws Exception { + assertNotReachable("testWithReturnPrimitives/double/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_doubleObject() throws Exception { + assertNotReachable("testWithReturnPrimitives/doubleObject/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_void() throws Exception { + assertNotReachable("testWithReturnPrimitives/void/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void testTestWithReturnPrimitives_voidObject() throws Exception { + assertNotReachable("testWithReturnPrimitives/voidObject/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + //================================= getter methods ================================= + + @TestExtension + public static class TestWithReturnWithinStaplerScope extends DoActionFilterTest.AbstractUnprotectedRootAction { + public Renderable getRenderable() { return new Renderable();} + } + + @Test + public void testWithReturnWithinStaplerScope_renderable() throws Exception { + assertReachable("testWithReturnWithinStaplerScope/renderable/"); + assertReachable("testWithReturnWithinStaplerScope/renderable/valid/"); + } +} diff --git a/test/src/test/java/jenkins/security/stapler/JenkinsSupportAnnotationsTest.java b/test/src/test/java/jenkins/security/stapler/JenkinsSupportAnnotationsTest.java new file mode 100644 index 0000000000000000000000000000000000000000..82a0408da46c6dcd94063b10866a0a73967b1f1a --- /dev/null +++ b/test/src/test/java/jenkins/security/stapler/JenkinsSupportAnnotationsTest.java @@ -0,0 +1,26 @@ +package jenkins.security.stapler; + +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.recipes.WithPlugin; + +@Issue("SECURITY-400") +@For({StaplerDispatchable.class, StaplerAccessibleType.class}) +public class JenkinsSupportAnnotationsTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Test + @WithPlugin("annotations-test.hpi") + public void testPluginWithAnnotations() throws Exception { + // test fails if TypedFilter ignores @StaplerDispatchable + j.createWebClient().goTo("annotationsTest/whatever", ""); + + // test fails if TypedFilter ignores @StaplerAccessibleType + j.createWebClient().goTo("annotationsTest/transit/response", ""); + } +} diff --git a/test/src/test/java/jenkins/security/stapler/PreventRoutingTest.java b/test/src/test/java/jenkins/security/stapler/PreventRoutingTest.java new file mode 100644 index 0000000000000000000000000000000000000000..b8c11cada8fb5758d3e81319a2439056f1e0fb59 --- /dev/null +++ b/test/src/test/java/jenkins/security/stapler/PreventRoutingTest.java @@ -0,0 +1,120 @@ +/* + * The MIT License + * + * Copyright (c) 2018, 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.stapler; + +import org.junit.Ignore; +import org.junit.Test; +import org.jvnet.hudson.test.TestExtension; +import org.kohsuke.stapler.Ancestor; +import org.kohsuke.stapler.HttpResponses; +import org.kohsuke.stapler.Stapler; +import org.kohsuke.stapler.StaplerProxy; +import org.kohsuke.stapler.StaplerRequest; + +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; +import java.util.List; + +public class PreventRoutingTest extends StaplerAbstractTest { + + @TestExtension + public static class TargetNull extends AbstractUnprotectedRootAction implements StaplerProxy { + @Override + public @CheckForNull String getUrlName() { + return "target-null"; + } + + @Override + public Object getTarget() { + // in case of null, it's "this" that is considered + return null; + } + + public Renderable getLegitRoutable(){ + return new Renderable(); + } + } + @Test + // TODO un-ignore once we use a Stapler release with the fix for this + @Ignore("Does not behave as intended before https://github.com/stapler/stapler/pull/149") + public void getTargetNull_isNotRoutable() throws Exception { + assertNotReachable("target-null/legitRoutable"); + } + + @TestExtension + public static class TargetNewObject extends AbstractUnprotectedRootAction implements StaplerProxy { + @Override + public @CheckForNull String getUrlName() { + return "target-new-object"; + } + + @Override + public Object getTarget() { + // Object is not routable + return new Object(); + } + + public Renderable getLegitRoutable(){ + return new Renderable(); + } + } + @Test + public void getTargetNewObject_isNotRoutable() throws Exception { + assertNotReachable("target-new-object/legitRoutable"); + } + + @TestExtension + public static class NotARequest extends AbstractUnprotectedRootAction { + @Override + public @CheckForNull String getUrlName() { + return "not-a-request"; + } + + public Renderable getLegitRoutable(){ + notStaplerGetter(this); + return new Renderable(); + } + + // just to validate it's ok + public Renderable getLegitRoutable2(){ + return new Renderable(); + } + } + + private static void notStaplerGetter(@Nonnull Object o){ + StaplerRequest req = Stapler.getCurrentRequest(); + if (req != null) { + List ancestors = req.getAncestors(); + if (!ancestors.isEmpty() && ancestors.get(ancestors.size() - 1).getObject() == o) { + throw HttpResponses.notFound(); + } + } + } + + @Test + public void regularGetter_notARequest() throws Exception { + assertReachable("not-a-request/legitRoutable2"); + assertNotReachable("not-a-request/legitRoutable"); + } +} diff --git a/test/src/test/java/jenkins/security/stapler/Security400Test.java b/test/src/test/java/jenkins/security/stapler/Security400Test.java new file mode 100644 index 0000000000000000000000000000000000000000..afb4db75aaaff0b9504482bb769b29a92281579f --- /dev/null +++ b/test/src/test/java/jenkins/security/stapler/Security400Test.java @@ -0,0 +1,609 @@ +/* + * The MIT License + * + * Copyright (c) 2018, 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.stapler; + +import com.cloudbees.hudson.plugins.folder.computed.FolderCron; +import com.gargoylesoftware.htmlunit.HttpMethod; +import com.gargoylesoftware.htmlunit.Page; +import com.gargoylesoftware.htmlunit.WebRequest; +import hudson.Launcher; +import hudson.model.AbstractBuild; +import hudson.model.AsyncPeriodicWork; +import hudson.model.BuildListener; +import hudson.model.Descriptor; +import hudson.model.FreeStyleBuild; +import hudson.model.FreeStyleProject; +import hudson.model.PeriodicWork; +import hudson.model.Result; +import hudson.model.TaskListener; +import hudson.model.queue.QueueTaskFuture; +import hudson.security.FullControlOnceLoggedInAuthorizationStrategy; +import hudson.security.HudsonPrivateSecurityRealm; +import hudson.tasks.Builder; +import jenkins.model.Jenkins; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.MockAuthorizationStrategy; +import org.jvnet.hudson.test.TestExtension; +import org.kohsuke.stapler.WebApp; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.not; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; + +/** + * To check the previous behavior you can use: + *
+ * {@link org.kohsuke.stapler.MetaClass#LEGACY_WEB_METHOD_MODE} = true;
+ * {@link org.kohsuke.stapler.MetaClass#LEGACY_GETTER_MODE} = true;
+ * 
+ */ +@Issue("SECURITY-400") +public class Security400Test { + @Rule + public JenkinsRule j = new JenkinsRule(); + + private static boolean filteredDoActionTriggered = false; + + @Before + public void prepareFilterListener(){ + WebApp webApp = WebApp.get(j.jenkins.servletContext); + webApp.setFilteredDoActionTriggerListener((f, req, rsp, node) -> { + filteredDoActionTriggered = true; + return false; + }); + webApp.setFilteredGetterTriggerListener((f, req, rsp, node, expression) -> { + filteredDoActionTriggered = true; + return false; + }); + } + + @After + public void resetFilter(){ + filteredDoActionTriggered = false; + } + + private void assertRequestWasBlockedAndResetFlag(){ + assertTrue("No request was blocked", filteredDoActionTriggered); + filteredDoActionTriggered = false; + } + + private void assertRequestWasNotBlocked(){ + assertFalse("There was at least a request that was blocked", filteredDoActionTriggered); + } + + @Test + @Issue("SECURITY-391") + public void asyncDoRun() throws Exception { + j.createWebClient().assertFails("extensionList/" + AsyncPeriodicWork.class.getName() + "/" + Work.class.getName() + "/run", HttpURLConnection.HTTP_NOT_FOUND); + Thread.sleep(1000); // give the thread a moment to finish + assertFalse("should never have run", ran); + } + + private static boolean ran; + + @TestExtension("asyncDoRun") + public static class Work extends AsyncPeriodicWork { + public Work() { + super("Test"); + } + + @Override + public long getRecurrencePeriod() { + return Long.MAX_VALUE; // do not run after init() + } + + @Override + protected void execute(TaskListener listener) throws IOException, InterruptedException { + ran = true; + } + } + + // require a dependency on cloudbees-folder-plugin + @Test + @Issue("SECURITY-397") + // particular case of SECURITY-391 + public void folderCronDoRun() throws Exception { + j.createWebClient().assertFails("extensionList/" + PeriodicWork.class.getName() + "/" + FolderCron.class.getName() + "/run", HttpURLConnection.HTTP_NOT_FOUND); + assertRequestWasBlockedAndResetFlag(); + } + + /** + * replacement of "computers/0/executors/0/contextClassLoader/context/handlers/0/sessionManager/stop" attack + */ + @Test + @Issue("SECURITY-404") + public void avoidDangerousAccessToSession() throws Exception { + j.jenkins.setCrumbIssuer(null); + + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + j.jenkins.setAuthorizationStrategy( + new MockAuthorizationStrategy() + .grant(Jenkins.ADMINISTER).everywhere().to("admin") + .grant(Jenkins.READ).everywhere().to("user") + ); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + wc.login("admin"); + + JenkinsRule.WebClient wc2 = j.createWebClient(); + wc2.getOptions().setThrowExceptionOnFailingStatusCode(false); + wc2.login("user"); + + Page page; + + page = wc.goTo("whoAmI/api/xml/", null); + System.out.println(page.getWebResponse().getContentAsString()); + assertThat(page.getWebResponse().getContentAsString(), containsString("false")); + + page = wc2.goTo("whoAmI/api/xml/", null); + System.out.println(page.getWebResponse().getContentAsString()); + assertThat(page.getWebResponse().getContentAsString(), containsString("false")); + + assertRequestWasNotBlocked(); + + // the doXxx fix prevents the doStop to be executed + // and in addition the getXxx fix prevents the getContextHandler to be used as navigation + + // the first beans/0 return the HashedSession + // the second beans/0 return the HashSessionManager + page = wc2.goTo("adjuncts//webApp/context/contextHandler/beans/0/beans/0/stop", null); + // other possible path + // page = wc.goTo("adjuncts//webApp/someStapler/currentRequest/session/servletContext/contextHandler/beans/0/beans/0/stop", null); + // page = wc.goTo("adjuncts//webApp/someStapler/currentRequest/servletContext/contextHandler/beans/0/beans/0/stop", null); + +// assertEquals(404, page.getWebResponse().getStatusCode()); +// assertRequestWasBlockedAndResetFlag(); + // getWebApp is now forbidden + assertEquals(403, page.getWebResponse().getStatusCode()); + + // if the call was successful, both are disconnected and anonymous would have been true + + page = wc.goTo("whoAmI/api/xml/", null); + System.out.println(page.getWebResponse().getContentAsString()); + assertThat(page.getWebResponse().getContentAsString(), containsString("false")); + + page = wc2.goTo("whoAmI/api/xml/", null); + System.out.println(page.getWebResponse().getContentAsString()); + assertThat(page.getWebResponse().getContentAsString(), containsString("false")); + + assertRequestWasNotBlocked(); + + // similar approach but different impact: + // can put null into desired session key (no impact yet) + // session impl. is HashedSession + // page = wc.goTo("adjuncts//webApp/someStapler/currentRequest/session/putOrRemove/ACEGI_SECURITY_CONTEXT/", null); + } + + @Test + @Issue("SECURITY-404") + public void ensureDoStopStillReachable() throws Exception { + j.jenkins.setCrumbIssuer(null); + JenkinsRule.WebClient wc = j.createWebClient(); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + + // used as a reference passed to the build step + AtomicInteger atomicResult = new AtomicInteger(0); + FreeStyleProject p = j.createFreeStyleProject(); + + final Semaphore semaphore = new Semaphore(0); + + p.getBuildersList().add(new SemaphoredBuilder(semaphore, atomicResult)); + + // to be sure to reach the correct one + j.jenkins.setNumExecutors(1); + + { // preliminary test, calling the stop method without any executor results in 404 + WebRequest request = new WebRequest(new URL(j.getURL() + "computers/0/executors/0/stop"), HttpMethod.POST); + Page page = wc.getPage(request); + assertEquals(404, page.getWebResponse().getStatusCode()); + assertRequestWasNotBlocked(); + } + + { // first try, we let the build finishes normally + QueueTaskFuture futureBuild = p.scheduleBuild2(0); + futureBuild.waitForStart(); + + // let the build finishes + semaphore.release(1); + j.assertBuildStatus(Result.SUCCESS, futureBuild); + assertEquals(1, atomicResult.get()); + } + + { // second try, we need to reach the stop method in executor to interrupt the build + atomicResult.set(0); + assertEquals(0, atomicResult.get()); + QueueTaskFuture futureBuild = p.scheduleBuild2(0); + futureBuild.waitForStart(); + + WebRequest request = new WebRequest(new URL(j.getURL() + "computers/0/executors/0/stop"), HttpMethod.POST); + Page page = wc.getPage(request); + assertEquals(404, page.getWebResponse().getStatusCode()); + assertRequestWasNotBlocked(); + + j.assertBuildStatus(Result.FAILURE, futureBuild); + assertEquals(3, atomicResult.get()); + } + } + + @Test + @Issue("SECURITY-404") + public void anonCannotReadTextConsole() throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + FullControlOnceLoggedInAuthorizationStrategy authorizationStrategy = new FullControlOnceLoggedInAuthorizationStrategy(); + authorizationStrategy.setAllowAnonymousRead(false); + j.jenkins.setAuthorizationStrategy(authorizationStrategy); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + + FreeStyleProject p = j.createFreeStyleProject(); + + Semaphore semaphore = new Semaphore(0); + + p.getBuildersList().add(new SemaphoredBuilder(semaphore, new AtomicInteger(0))); + + // to be sure to reach the correct one + j.jenkins.setNumExecutors(1); + + { // preliminary test, calling the consoleText method without any executor results in 404 + Page page = wc.goTo("computers/0/executors/0/currentExecutable/consoleText", null); + checkPageIsRedirectedToLogin(page); + assertRequestWasNotBlocked(); + } + + { // as Connected User, we start the build and try to get the console, ensure current expected behavior still works + wc.login("foo"); + + QueueTaskFuture futureBuild = p.scheduleBuild2(0); + futureBuild.waitForStart(); + + Page page = wc.goTo("computers/0/executors/0/currentExecutable/consoleText", null); + assertEquals(200, page.getWebResponse().getStatusCode()); + assertThat(page.getWebResponse().getContentAsString(), containsString(SemaphoredBuilder.START_MESSAGE)); + assertRequestWasNotBlocked(); + + semaphore.release(1); + j.assertBuildStatus(Result.SUCCESS, futureBuild); + } + + { // as Anonymous, we start the build and try to get the console + wc = j.createWebClient(); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + + QueueTaskFuture futureBuild = p.scheduleBuild2(0); + futureBuild.waitForStart(); + + Page page = wc.goTo("computers/0/executors/0/currentExecutable/consoleText", null); + checkPageIsRedirectedToLogin(page); + assertThat(page.getWebResponse().getContentAsString(), not(containsString(SemaphoredBuilder.START_MESSAGE))); + assertRequestWasNotBlocked(); + + semaphore.release(1); + j.assertBuildStatus(Result.SUCCESS, futureBuild); + } + } + + + @Test + @Issue("SECURITY-404") + public void anonCannotAccessExecutorApi() throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + FullControlOnceLoggedInAuthorizationStrategy authorizationStrategy = new FullControlOnceLoggedInAuthorizationStrategy(); + authorizationStrategy.setAllowAnonymousRead(false); + j.jenkins.setAuthorizationStrategy(authorizationStrategy); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + + FreeStyleProject p = j.createFreeStyleProject(); + + Semaphore semaphore = new Semaphore(0); + + p.getBuildersList().add(new SemaphoredBuilder(semaphore, new AtomicInteger(0))); + + // to be sure to reach the correct one + j.jenkins.setNumExecutors(1); + + { + Page page = wc.goTo("computers/0/executors/0/api/xml", null); + checkPageIsRedirectedToLogin(page); + assertRequestWasNotBlocked(); + } + + { // as Connected User, we start the build and can access the executor api + QueueTaskFuture futureBuild = p.scheduleBuild2(0); + futureBuild.waitForStart(); + + wc.login("foo"); + Page page = wc.goTo("computers/0/executors/0/api/xml", null); + assertEquals(200, page.getWebResponse().getStatusCode()); + assertThat(page.getWebResponse().getContentAsString(), containsString(p.getUrl())); + assertRequestWasNotBlocked(); + + semaphore.release(1); + j.assertBuildStatus(Result.SUCCESS, futureBuild); + + wc = j.createWebClient(); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + } + + { // as Anonymous, we start the build and cannot access the executor api + QueueTaskFuture futureBuild = p.scheduleBuild2(0); + futureBuild.waitForStart(); + + Page page = wc.goTo("computers/0/executors/0/api/xml", null); + checkPageIsRedirectedToLogin(page); + assertThat(page.getWebResponse().getContentAsString(), not(containsString(p.getUrl()))); + assertRequestWasNotBlocked(); + + semaphore.release(1); + j.assertBuildStatus(Result.SUCCESS, futureBuild); + } + } + + @Test + @Issue("SECURITY-404") + public void anonCannotAccessJenkinsItemMap() throws Exception { + JenkinsRule.WebClient wc = j.createWebClient(); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + + FreeStyleProject p = j.createFreeStyleProject(); + + { // try to access /itemMap/ + wc.login("foo"); + Page page = wc.goTo("itemMap/" + p.getName() + "/api/xml", null); + assertEquals(404, page.getWebResponse().getStatusCode()); + assertThat(page.getWebResponse().getContentAsString(), not(containsString(" build, Launcher launcher, BuildListener listener) throws InterruptedException, IOException { + try { + listener.getLogger().println(START_MESSAGE); + boolean result = semaphore.tryAcquire(20, TimeUnit.SECONDS); + if (result) { + listener.getLogger().println("permit acquired"); + atomicInteger.set(1); + return true; + } else { + atomicInteger.set(2); + return false; + } + } catch (InterruptedException e) { + atomicInteger.set(3); + return false; + } + } + + @TestExtension + public static class DescriptorImpl extends Descriptor {} + } + + // currently there is no other way to reach logRecorderManager in core / or plugin + @Test + @Issue("SECURITY-471") + public void ensureLogRecordManagerAccessibleOnlyByAdmin() throws Exception { + j.jenkins.setCrumbIssuer(null); + + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + j.jenkins.setAuthorizationStrategy( + new MockAuthorizationStrategy() + .grant(Jenkins.ADMINISTER).everywhere().to("admin") + .grant(Jenkins.READ).everywhere().to("user") + ); + + String logNameForAdmin = "testLoggerAdmin"; + String logNameForUser = "testLoggerUser"; + + { // admin can do everything + JenkinsRule.WebClient wc = j.createWebClient(); + wc.login("admin"); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + + // ensure the logger does not exist before the creation + assertEquals(404, wc.goTo("log/" + logNameForAdmin + "/autoCompleteLoggerName/?value=a", null).getWebResponse().getStatusCode()); + assertRequestWasNotBlocked(); + + WebRequest request = new WebRequest(new URL(j.getURL() + "log/newLogRecorder/?name=" + logNameForAdmin), HttpMethod.POST); + + wc.getOptions().setRedirectEnabled(false); + Page page = wc.getPage(request); + assertEquals(302, page.getWebResponse().getStatusCode()); + assertRequestWasNotBlocked(); + + // after creation the logger exists + j.assertGoodStatus(wc.goTo("log/" + logNameForAdmin + "/autoCompleteLoggerName/?value=a", null)); + assertRequestWasNotBlocked(); + + assertEquals(404, wc.goTo("log/" + "nonExistingName" + "/autoCompleteLoggerName/?value=a", null).getWebResponse().getStatusCode()); + assertRequestWasNotBlocked(); + } + + { // user is blocked + JenkinsRule.WebClient wc = j.createWebClient(); + wc.login("user"); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + + // no right to check the existence of a logger + assertEquals(403, wc.goTo("log/" + logNameForUser + "/autoCompleteLoggerName/?value=a", null).getWebResponse().getStatusCode()); + assertRequestWasNotBlocked(); + + WebRequest request = new WebRequest(new URL(j.getURL() + "log/newLogRecorder/?name=" + logNameForUser), HttpMethod.POST); + + wc.getOptions().setRedirectEnabled(false); + Page page = wc.getPage(request); + assertEquals(403, page.getWebResponse().getStatusCode()); + assertRequestWasNotBlocked(); + + // after the failed attempt, the logger is not created + assertEquals(403, wc.goTo("log/" + logNameForUser + "/autoCompleteLoggerName/?value=a", null).getWebResponse().getStatusCode()); + assertRequestWasNotBlocked(); + } + + { // admin can check the non-existence after user failed creation also + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.login("admin"); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + + // ensure the logger was not created by the user (check in case the request returned 403 but created the logger silently) + assertEquals(404, wc.goTo("log/" + logNameForUser + "/autoCompleteLoggerName/?value=a", null).getWebResponse().getStatusCode()); + assertRequestWasNotBlocked(); + } + } + + @Test + public void anonCannotHaveTheListOfUsers() throws Exception { + j.jenkins.setCrumbIssuer(null); + + FullControlOnceLoggedInAuthorizationStrategy authorizationStrategy = new FullControlOnceLoggedInAuthorizationStrategy(); + j.jenkins.setAuthorizationStrategy(authorizationStrategy); + + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); + j.jenkins.setSecurityRealm(securityRealm); + securityRealm.createAccount("admin", "admin"); + securityRealm.createAccount("secretUser", "secretUser"); + + { // admin should have access to the user list + JenkinsRule.WebClient wc = j.createWebClient(); + wc.login("admin"); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + + Page page = wc.goTo("securityRealm"); + assertEquals(200, page.getWebResponse().getStatusCode()); + assertThat(page.getWebResponse().getContentAsString(), containsString("secretUser")); + assertRequestWasNotBlocked(); + } + + // with or without the anonymousRead, anonymous are not allowed to have access to + // list of users in securityRealm + authorizationStrategy.setAllowAnonymousRead(true); + { // without any read permission the anon have access to the user list + JenkinsRule.WebClient wc = j.createWebClient(); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + wc.getOptions().setRedirectEnabled(false); + + Page page = wc.goTo("securityRealm/", null); + checkPageIsRedirectedToLogin(page); + assertThat(page.getWebResponse().getContentAsString(), not(containsString("secretUser"))); + assertRequestWasNotBlocked(); + + page = wc.goTo("asynchPeople/", null); + assertEquals(200, page.getWebResponse().getStatusCode()); + // javascript will load the user list asynch + assertThat(page.getWebResponse().getContentAsString(), containsString("Includes all known")); + assertRequestWasNotBlocked(); + } + + authorizationStrategy.setAllowAnonymousRead(false); + { // and with restriction, the anonymous users cannot read the user list + JenkinsRule.WebClient wc = j.createWebClient(); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + wc.getOptions().setRedirectEnabled(false); + + Page page = wc.goTo("securityRealm/", null); + checkPageIsRedirectedToLogin(page); + assertThat(page.getWebResponse().getContentAsString(), not(containsString("secretUser"))); + assertRequestWasNotBlocked(); + + // with the restriction we disallow the anon to even read the list of all users + page = wc.goTo("asynchPeople/", null); + checkPageIsRedirectedToLogin(page); + assertThat(page.getWebResponse().getContentAsString(), not(containsString("secretUser"))); + assertRequestWasNotBlocked(); + } + } + + @Test + @Issue("SECURITY-722") + public void noAccessToAllUsers() throws Exception { + j.jenkins.setCrumbIssuer(null); + HudsonPrivateSecurityRealm securityRealm = new HudsonPrivateSecurityRealm(false, false, null); + j.jenkins.setSecurityRealm(securityRealm); + securityRealm.createAccount("admin", "admin"); + + j.jenkins.setAuthorizationStrategy( + new MockAuthorizationStrategy() + .grant(Jenkins.ADMINISTER).everywhere().to("admin") + ); + + { // neither anon have access to the allUsers end point + JenkinsRule.WebClient wc = j.createWebClient(); + // anonymous user + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + + Page page = wc.goTo("securityRealm/allUsers/" + 0 + "/descriptorByName/jenkins.security.ApiTokenProperty/help/apiToken/"); + assertEquals(404, page.getWebResponse().getStatusCode()); + assertRequestWasBlockedAndResetFlag(); + } + + { // nor the admin have that access + JenkinsRule.WebClient wc = j.createWebClient(); + wc.login("admin"); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + + Page page = wc.goTo("securityRealm/allUsers/" + 0 + "/descriptorByName/jenkins.security.ApiTokenProperty/help/apiToken/"); + assertEquals(404, page.getWebResponse().getStatusCode()); + assertRequestWasBlockedAndResetFlag(); + } + } + + // // does not work in 2.60 since the method was added in 2.91+ + // String newLogin = "newUser"; + // j.createWebClient().goTo("securityRealm/allUsers/0/orCreateByIdOrFullName/" + newLogin + "/"); + + private void checkPageIsRedirectedToLogin(Page page) { + assertEquals(200, page.getWebResponse().getStatusCode()); + assertThat(page.getUrl().getPath(), containsString("login")); + assertThat(page.getUrl().getQuery(), containsString("from")); + } +} diff --git a/test/src/test/java/jenkins/security/stapler/StaplerAbstractTest.java b/test/src/test/java/jenkins/security/stapler/StaplerAbstractTest.java new file mode 100644 index 0000000000000000000000000000000000000000..ccc40a4f37faf33dc0ea8e47c0f5281340b365e9 --- /dev/null +++ b/test/src/test/java/jenkins/security/stapler/StaplerAbstractTest.java @@ -0,0 +1,205 @@ +/* + * The MIT License + * + * Copyright (c) 2018, 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.stapler; + +import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException; +import com.gargoylesoftware.htmlunit.HttpMethod; +import com.gargoylesoftware.htmlunit.Page; +import com.gargoylesoftware.htmlunit.WebRequest; +import hudson.model.UnprotectedRootAction; +import org.apache.commons.lang3.StringUtils; +import org.junit.Before; +import org.junit.ClassRule; +import org.jvnet.hudson.test.JenkinsRule; +import org.kohsuke.stapler.Stapler; +import org.kohsuke.stapler.StaplerResponse; +import org.kohsuke.stapler.WebApp; +import org.kohsuke.stapler.WebMethod; + +import javax.annotation.CheckForNull; +import java.awt.*; +import java.io.IOException; +import java.net.URL; + +import static org.hamcrest.CoreMatchers.startsWith; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public abstract class StaplerAbstractTest { + @ClassRule + public static JenkinsRule rule = new JenkinsRule(); + protected JenkinsRule j; + + protected JenkinsRule.WebClient wc; + + protected WebApp webApp; + + protected static boolean filteredGetMethodTriggered = false; + protected static boolean filteredDoActionTriggered = false; + protected static boolean filteredFieldTriggered = false; + + @Before + public void setUp() throws Exception { + j = rule; + j.jenkins.setCrumbIssuer(null); + wc = j.createWebClient(); + + this.webApp = (WebApp) j.jenkins.servletContext.getAttribute(WebApp.class.getName()); + + webApp.setFilteredGetterTriggerListener((f, req, rst, node, expression) -> { + filteredGetMethodTriggered = true; + return false; + }); + webApp.setFilteredDoActionTriggerListener((f, req, rsp, node) -> { + filteredDoActionTriggered = true; + return false; + }); + webApp.setFilteredFieldTriggerListener((f, req, rsp, node, expression) -> { + filteredFieldTriggered = true; + return false; + }); + + filteredGetMethodTriggered = false; + filteredDoActionTriggered = false; + filteredFieldTriggered = false; + } + + //================================= utility class ================================= + + protected static class AbstractUnprotectedRootAction implements UnprotectedRootAction { + @Override + public @CheckForNull String getIconFileName() { + return null; + } + + @Override + public @CheckForNull String getDisplayName() { + return null; + } + + @Override + public @CheckForNull String getUrlName() { + return StringUtils.uncapitalize(this.getClass().getSimpleName()); + } + } + + public static final String RENDERABLE_CLASS_SIGNATURE = "class jenkins.security.stapler.StaplerAbstractTest.Renderable"; + protected static class Renderable { + + public void doIndex() {replyOk();} + + @WebMethod(name = "valid") + public void valid() {replyOk();} + } + + protected static class ParentRenderable { + public Renderable getRenderable(){ + return new Renderable(); + } + } + + protected static class RenderablePoint extends Point { + public void doIndex() {replyOk();} + } + + //================================= utility methods ================================= + + protected static void replyOk() { + StaplerResponse resp = Stapler.getCurrentResponse(); + try { + resp.getWriter().write("ok"); + resp.flushBuffer(); + } catch (IOException e) {} + } + + //================================= testing methods ================================= + + protected void assertGetMethodRequestWasBlockedAndResetFlag() { + assertTrue("No get method request was blocked", filteredGetMethodTriggered); + filteredGetMethodTriggered = false; + } + protected void assertDoActionRequestWasBlockedAndResetFlag() { + assertTrue("No do action request was blocked", filteredDoActionTriggered); + filteredDoActionTriggered = false; + } + protected void assertFieldRequestWasBlockedAndResetFlag() { + assertTrue("No field request was blocked", filteredFieldTriggered); + filteredFieldTriggered = false; + } + protected void assertGetMethodActionRequestWasNotBlocked() { + assertFalse("There was at least one get method request that was blocked", filteredGetMethodTriggered); + } + protected void assertDoActionRequestWasNotBlocked() { + assertFalse("There was at least one do action request that was blocked", filteredDoActionTriggered); + } + protected void assertFieldRequestWasNotBlocked() { + assertFalse("There was at least one field request that was blocked", filteredFieldTriggered); + } + + protected void assertReachable(String url, HttpMethod method) throws IOException { + try { + Page page = wc.getPage(new WebRequest(new URL(j.getURL(), url), method)); + assertEquals(200, page.getWebResponse().getStatusCode()); + assertThat(page.getWebResponse().getContentAsString(), startsWith("ok")); + + assertDoActionRequestWasNotBlocked(); + assertGetMethodActionRequestWasNotBlocked(); + assertFieldRequestWasNotBlocked(); + } catch (FailingHttpStatusCodeException e) { + fail("Url " + url + " should be reachable, received " + e.getMessage() + " (" + e.getStatusCode() + ") instead."); + } + } + + protected void assertReachable(String url) throws IOException { + assertReachable(url, HttpMethod.GET); + } + + protected void assertReachableWithSettings(WebRequest request) throws IOException { + Page page = wc.getPage(request); + assertEquals(200, page.getWebResponse().getStatusCode()); + assertEquals("ok", page.getWebResponse().getContentAsString()); + assertDoActionRequestWasNotBlocked(); + } + + protected void assertReachableWithoutOk(String url) throws IOException { + try { + Page page = wc.getPage(new URL(j.getURL(), url)); + assertEquals(200, page.getWebResponse().getStatusCode()); + } catch (FailingHttpStatusCodeException e) { + fail("Url " + url + " should be reachable, received " + e.getMessage() + " (" + e.getStatusCode() + ") instead."); + } + } + + protected void assertNotReachable(String url) throws IOException { + try { + wc.getPage(new URL(j.getURL(), url)); + fail("Url " + url + " is reachable but should not be, an not-found error is expected"); + } catch (FailingHttpStatusCodeException e) { + assertEquals("Url " + url + " returns an error different from 404", 404, e.getResponse().getStatusCode()); + } + } +} diff --git a/test/src/test/java/jenkins/security/stapler/StaplerRoutableActionTest.java b/test/src/test/java/jenkins/security/stapler/StaplerRoutableActionTest.java new file mode 100644 index 0000000000000000000000000000000000000000..3a645ad6801a7cbaaf664a3db9dd5b9acf7e7fe5 --- /dev/null +++ b/test/src/test/java/jenkins/security/stapler/StaplerRoutableActionTest.java @@ -0,0 +1,90 @@ +package jenkins.security.stapler; + +import org.junit.Test; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.TestExtension; +import org.kohsuke.stapler.HttpResponses; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.StaplerRequest; +import org.kohsuke.stapler.WebMethod; + +@Issue("SECURITY-400") +@For({StaplerDispatchable.class, StaplerNotDispatchable.class, DoActionFilter.class}) +public class StaplerRoutableActionTest extends StaplerAbstractTest { + + @TestExtension + public static class TestNewRulesRoutableAction extends AbstractUnprotectedRootAction { + // StaplerDispatchable is not enough, the method needs to have at least either a name starting with do* or a WebMethod annotation + @StaplerDispatchable + public void notDoName() { replyOk(); } + + @StaplerDispatchable // could be used to indicate that's a web method, without having to use @WebMethod + public void doWebMethod1() { replyOk(); } + + // without annotation, returnType, parameter, exception => not a web method + public void doWebMethod2() { replyOk(); } + + public void doWebMethod3() throws HttpResponses.HttpResponseException { + replyOk(); + } + + public void doWebMethod4(StaplerRequest request) { + replyOk(); + } + + public void doWebMethod5(@QueryParameter String foo) { + replyOk(); + } + } + + @Test + public void testNewRulesRoutableAction_notDoName() throws Exception { + assertNotReachable("testNewRulesRoutableAction/notDoName/"); + // not even considered as a blocked action because the filter is not even called, they are lacking do* or @WebMethod + // assertDoActionRequestWasBlockedAndResetFlag(); + assertNotReachable("testNewRulesRoutableAction/tDoName/"); + // assertDoActionRequestWasBlockedAndResetFlag(); + } + + @Test + public void testNewRulesRoutableAction_webMethod1() throws Exception { + assertReachable("testNewRulesRoutableAction/webMethod1/"); + } + + @Test + public void testNewRulesRoutableAction_webMethod3Through5() throws Exception { + assertReachable("testNewRulesRoutableAction/webMethod3/"); + assertReachable("testNewRulesRoutableAction/webMethod4/"); + assertReachable("testNewRulesRoutableAction/webMethod5/"); + } + + @Test + public void testNewRulesRoutableAction_webMethod2() throws Exception { + assertNotReachable("testNewRulesRoutableAction/webMethod2/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } + + @TestExtension + public static class TestNewRulesNonroutableAction extends AbstractUnprotectedRootAction { + @StaplerNotDispatchable + public void doWebMethod1() { replyOk(); } + + @StaplerNotDispatchable + @WebMethod(name = "webMethod2") + public void doWebMethod2() { replyOk(); } + } + + @Test + public void testNewRulesNonroutableAction_webMethod1() throws Exception { + assertNotReachable("testNewRulesNonroutableAction/webMethod1/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } + + @Test + public void testNewRulesNonroutableAction_webMethod2() throws Exception { + // priority of negative over positive + assertNotReachable("testNewRulesNonroutableAction/webMethod2/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } +} diff --git a/test/src/test/java/jenkins/security/stapler/StaplerRoutableFieldTest.java b/test/src/test/java/jenkins/security/stapler/StaplerRoutableFieldTest.java new file mode 100644 index 0000000000000000000000000000000000000000..f278603fb502a8f8e5c261ab6b48e4c18ca61fe7 --- /dev/null +++ b/test/src/test/java/jenkins/security/stapler/StaplerRoutableFieldTest.java @@ -0,0 +1,156 @@ +package jenkins.security.stapler; + +import org.junit.Test; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.TestExtension; + +@Issue("SECURITY-595") +@For({StaplerDispatchable.class, StaplerNotDispatchable.class, TypedFilter.class}) +public class StaplerRoutableFieldTest extends StaplerAbstractTest { + @TestExtension + public static class TestRootAction extends AbstractUnprotectedRootAction { + @Override + public String getUrlName() { + return "test"; + } + + public Renderable renderableNotAnnotated = new Renderable(); + + public ParentRenderable parentRenderableNotAnnotated = new ParentRenderable(); + + public Object objectNotAnnotated = new Renderable(); + + @StaplerDispatchable + public Renderable renderableAnnotatedOk = new Renderable(); + + @StaplerDispatchable + public ParentRenderable parentRenderableAnnotatedOk = new ParentRenderable(); + + @StaplerDispatchable + public Object objectAnnotatedOk = new Renderable(); + + @StaplerNotDispatchable + public Renderable renderableAnnotatedKo = new Renderable(); + + @StaplerNotDispatchable + public Object objectAnnotatedKo = new Renderable(); + + @StaplerDispatchable + @StaplerNotDispatchable + public Renderable renderableDoubleAnnotated = new Renderable(); + + @StaplerDispatchable + @StaplerNotDispatchable + public Object objectDoubleAnnotated = new Renderable(); + + public static Renderable staticRenderableNotAnnotated = new Renderable(); + + public static Object staticObjectNotAnnotated = new Renderable(); + + @StaplerDispatchable + public static Renderable staticRenderableAnnotatedOk = new Renderable(); + + @StaplerDispatchable + public static Object staticObjectAnnotatedOk = new Renderable(); + } + + @Test + public void testFieldNotAnnotated() throws Exception { + assertReachable("test/renderableNotAnnotated/"); + assertReachable("test/renderableNotAnnotated/valid/"); + + assertNotReachable("test/parentRenderableNotAnnotated/"); + assertNotReachable("test/parentRenderableNotAnnotated/renderable/"); + assertNotReachable("test/parentRenderableNotAnnotated/renderable/valid/"); + + assertNotReachable("test/objectNotAnnotated/"); + assertNotReachable("test/objectNotAnnotated/valid/"); + } + + @Test + public void testFieldNotAnnotated_escapeHatch() throws Exception { + boolean currentValue = TypedFilter.SKIP_TYPE_CHECK; + try { + TypedFilter.SKIP_TYPE_CHECK = true; + // to apply the new configuration + webApp.clearMetaClassCache(); + + assertReachable("test/renderableNotAnnotated/"); + assertReachable("test/renderableNotAnnotated/valid/"); + + assertNotReachable("test/parentRenderableNotAnnotated/"); + assertReachable("test/parentRenderableNotAnnotated/renderable/"); + assertReachable("test/parentRenderableNotAnnotated/renderable/valid/"); + } finally { + TypedFilter.SKIP_TYPE_CHECK = currentValue; + // to reset the configuration + webApp.clearMetaClassCache(); + } + } + + @Test + public void testFieldAnnotatedOk() throws Exception { + assertReachable("test/renderableAnnotatedOk/"); + assertReachable("test/renderableAnnotatedOk/valid/"); + + assertReachable("test/objectAnnotatedOk/"); + assertReachable("test/objectAnnotatedOk/valid/"); + } + + @Test + public void testFieldAnnotatedKo() throws Exception { + assertNotReachable("test/renderableAnnotatedKo/"); + assertNotReachable("test/renderableAnnotatedKo/valid/"); + + assertNotReachable("test/objectAnnotatedKo/"); + assertNotReachable("test/objectAnnotatedKo/valid/"); + } + + @Test + public void testFieldDoubleAnnotated() throws Exception { + assertNotReachable("test/renderableDoubleAnnotated/"); + assertNotReachable("test/renderableDoubleAnnotated/valid/"); + + assertNotReachable("test/objectDoubleAnnotated/"); + assertNotReachable("test/objectDoubleAnnotated/valid/"); + } + + @Test + public void testStaticFieldNotAnnotated() throws Exception { + assertNotReachable("test/staticRenderableNotAnnotated/"); + assertNotReachable("test/staticRenderableNotAnnotated/valid/"); + + assertNotReachable("test/staticObjectNotAnnotated/"); + assertNotReachable("test/staticObjectNotAnnotated/valid/"); + } + + @Test + public void testStaticFieldNotAnnotated_escapeHatch() throws Exception { + boolean currentValue = TypedFilter.PROHIBIT_STATIC_ACCESS; + try { + TypedFilter.PROHIBIT_STATIC_ACCESS = false; + // to apply the new configuration + webApp.clearMetaClassCache(); + + assertReachable("test/staticRenderableNotAnnotated/"); + assertReachable("test/staticRenderableNotAnnotated/valid/"); + + assertNotReachable("test/staticObjectNotAnnotated/"); + assertNotReachable("test/staticObjectNotAnnotated/valid/"); + } finally { + TypedFilter.PROHIBIT_STATIC_ACCESS = currentValue; + // to reset the configuration + webApp.clearMetaClassCache(); + } + } + + @Test + public void testStaticFieldAnnotatedOk() throws Exception { + assertReachable("test/staticRenderableAnnotatedOk/"); + assertReachable("test/staticRenderableAnnotatedOk/valid/"); + + assertReachable("test/staticObjectAnnotatedOk/"); + assertReachable("test/staticObjectAnnotatedOk/valid/"); + } +} diff --git a/test/src/test/java/jenkins/security/stapler/StaplerRoutableGetterTest.java b/test/src/test/java/jenkins/security/stapler/StaplerRoutableGetterTest.java new file mode 100644 index 0000000000000000000000000000000000000000..620573ac87ef1075f6369a910871bdec69ebbda9 --- /dev/null +++ b/test/src/test/java/jenkins/security/stapler/StaplerRoutableGetterTest.java @@ -0,0 +1,172 @@ +/* + * The MIT License + * + * Copyright (c) 2018, 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.stapler; + +import org.junit.Test; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.TestExtension; + +@Issue("SECURITY-400") +@For({StaplerDispatchable.class, StaplerNotDispatchable.class, TypedFilter.class}) +public class StaplerRoutableGetterTest extends StaplerAbstractTest { + @TestExtension + public static class TestRootAction extends AbstractUnprotectedRootAction { + @Override + public String getUrlName() { + return "test"; + } + + public Object getFalseWithoutAnnotation() { + return new Renderable(); + } + + @StaplerDispatchable + public Object getFalseWithAnnotation() { + return new Renderable(); + } + + public Renderable getTrueWithoutAnnotation() { + return new Renderable(); + } + + @StaplerNotDispatchable + public Renderable getTrueWithAnnotation() { + return new Renderable(); + } + + @StaplerDispatchable + @StaplerNotDispatchable + public Renderable getPriorityToNegative() { + return new Renderable(); + } + } + + @Test + public void testForceGetterMethod() throws Exception { + assertNotReachable("test/falseWithoutAnnotation/"); + assertNotReachable("test/falseWithoutAnnotation/valid/"); + + filteredGetMethodTriggered = false; + + assertReachable("test/falseWithAnnotation/"); + assertReachable("test/falseWithAnnotation/valid/"); + } + + @Test + public void testForceNotGetterMethod() throws Exception { + assertReachable("test/trueWithoutAnnotation/"); + assertReachable("test/trueWithoutAnnotation/valid/"); + assertNotReachable("test/trueWithAnnotation/"); + assertNotReachable("test/trueWithAnnotation/valid/"); + } + + @Test + public void testPriorityIsNegative() throws Exception { + assertNotReachable("test/priorityToNegative/"); + } + + public static class TestRootActionParent extends AbstractUnprotectedRootAction { + @StaplerNotDispatchable + public Renderable getParentKoButChildOk() { + return new Renderable(); + } + + @StaplerNotDispatchable + public Renderable getParentKoButChildNone() { + return new Renderable(); + } + + public Renderable getParentNoneButChildOk() { + return new Renderable(); + } + + public Renderable getParentNoneButChildKo() { + return new Renderable(); + } + + @StaplerDispatchable + public Renderable getParentOkButChildKo() { + return new Renderable(); + } + + @StaplerDispatchable + public Renderable getParentOkButChildNone() { + return new Renderable(); + } + } + + @TestExtension + public static class TestRootActionChild extends TestRootActionParent { + @Override + public String getUrlName() { + return "test-child"; + } + + @StaplerDispatchable + public Renderable getParentKoButChildOk() { + return new Renderable(); + } + + public Renderable getParentKoButChildNone() { + return new Renderable(); + } + + @StaplerDispatchable + public Renderable getParentNoneButChildOk() { + return new Renderable(); + } + + @StaplerNotDispatchable + public Renderable getParentNoneButChildKo() { + return new Renderable(); + } + + @StaplerNotDispatchable + public Renderable getParentOkButChildKo() { + return new Renderable(); + } + + public Renderable getParentOkButChildNone() { + return new Renderable(); + } + } + + @Test + public void testInheritanceOfAnnotation_childHasLastWord() throws Exception { + assertNotReachable("test-child/parentKoButChildOk/"); + assertNotReachable("test-child/parentKoButChildNone/"); + + filteredGetMethodTriggered = false; + + assertReachable("test-child/parentNoneButChildOk/"); + + assertNotReachable("test-child/parentNoneButChildKo/"); + assertNotReachable("test-child/parentOkButChildKo/"); + + filteredGetMethodTriggered = false; + + assertReachable("test-child/parentOkButChildNone/"); + } +} diff --git a/test/src/test/java/jenkins/security/stapler/StaticRoutingDecisionProvider2Test.java b/test/src/test/java/jenkins/security/stapler/StaticRoutingDecisionProvider2Test.java new file mode 100644 index 0000000000000000000000000000000000000000..06ebd0557e4305b666d831512b7f41fb5818c6ee --- /dev/null +++ b/test/src/test/java/jenkins/security/stapler/StaticRoutingDecisionProvider2Test.java @@ -0,0 +1,236 @@ +/* + * The MIT License + * + * Copyright (c) 2018, 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.stapler; + +import org.apache.commons.io.FileUtils; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.recipes.LocalData; + +import java.io.File; + +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; + +/** + * Due to the fact we are using a @ClassRule for the other tests to improve performance, + * we cannot use @LocalData to test the loading of the whitelist as that annotation seem to not work with @ClassRule. + */ +@Issue("SECURITY-400") +@For(StaticRoutingDecisionProvider.class) +public class StaticRoutingDecisionProvider2Test { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Test + @LocalData("whitelist_empty") + public void userControlledWhitelist_empty_Loading() throws Exception { + StaticRoutingDecisionProvider wl = new StaticRoutingDecisionProvider(); + assertThat( + wl.decide("public java.lang.Object jenkins.security.stapler.StaticRoutingDecisionProviderTest$ContentProvider.getObjectCustom()"), + is(RoutingDecisionProvider.Decision.UNKNOWN) + ); + assertThat( + wl.decide("blabla"), + is(RoutingDecisionProvider.Decision.UNKNOWN) + ); + } + + @Test + @LocalData("whitelist_monoline") + public void userControlledWhitelist_monoline_Loading() throws Exception { + StaticRoutingDecisionProvider wl = new StaticRoutingDecisionProvider(); + assertThat( + wl.decide("method jenkins.security.stapler.StaticRoutingDecisionProviderTest$ContentProvider getObjectCustom"), + is(RoutingDecisionProvider.Decision.ACCEPTED) + ); + assertThat( + wl.decide("blabla"), + is(RoutingDecisionProvider.Decision.UNKNOWN) + ); + } + + @Test + @LocalData("whitelist_multiline") + public void userControlledWhitelist_multiline_Loading() throws Exception { + StaticRoutingDecisionProvider wl = new StaticRoutingDecisionProvider(); + assertThat( + wl.decide("method jenkins.security.stapler.StaticRoutingDecisionProviderTest$ContentProvider getObjectCustom"), + is(RoutingDecisionProvider.Decision.ACCEPTED) + ); + assertThat( + wl.decide("method jenkins.security.stapler.StaticRoutingDecisionProviderTest$ContentProvider getObjectCustom2"), + is(RoutingDecisionProvider.Decision.ACCEPTED) + ); + assertThat( + wl.decide("blabla"), + is(RoutingDecisionProvider.Decision.UNKNOWN) + ); + } + + @Test + @LocalData("comment_ignored") + public void userControlledWhitelist_commentsAreIgnored() throws Exception { + StaticRoutingDecisionProvider wl = new StaticRoutingDecisionProvider(); + assertThat(wl.decide("this line is not read"), is(RoutingDecisionProvider.Decision.UNKNOWN)); + assertThat(wl.decide("not-this-one"), is(RoutingDecisionProvider.Decision.UNKNOWN)); + assertThat(wl.decide("neither"), is(RoutingDecisionProvider.Decision.UNKNOWN)); + assertThat(wl.decide("finally-not"), is(RoutingDecisionProvider.Decision.UNKNOWN)); + + assertThat(wl.decide("this-one-is"), is(RoutingDecisionProvider.Decision.ACCEPTED)); + assertThat(wl.decide("this-one-also"), is(RoutingDecisionProvider.Decision.ACCEPTED)); + } + + @Test + @LocalData("whitelist_emptyline") + public void userControlledWhitelist_emptyLinesAreIgnored() throws Exception { + StaticRoutingDecisionProvider wl = new StaticRoutingDecisionProvider(); + assertThat(wl.decide("signature-1"), is(RoutingDecisionProvider.Decision.ACCEPTED)); + assertThat(wl.decide("signature-2"), is(RoutingDecisionProvider.Decision.ACCEPTED)); + assertThat(wl.decide("signature-3"), is(RoutingDecisionProvider.Decision.ACCEPTED)); + // neither the empty line or an exclamation mark followed by nothing or spaces are not considered + assertThat(wl.decide(""), is(RoutingDecisionProvider.Decision.UNKNOWN)); + } + + @Test + @LocalData("greylist_multiline") + public void userControlledWhitelist_whiteAndBlack() throws Exception { + StaticRoutingDecisionProvider wl = new StaticRoutingDecisionProvider(); + assertThat(wl.decide("signature-1-ok"), is(RoutingDecisionProvider.Decision.ACCEPTED)); + assertThat(wl.decide("signature-3-ok"), is(RoutingDecisionProvider.Decision.ACCEPTED)); + + assertThat(wl.decide("signature-2-not-ok"), is(RoutingDecisionProvider.Decision.REJECTED)); + assertThat(wl.decide("signature-4-not-ok"), is(RoutingDecisionProvider.Decision.REJECTED)); + + // the exclamation mark is not used + assertThat(wl.decide("!signature-2-not-ok"), is(RoutingDecisionProvider.Decision.UNKNOWN)); + } + + @Test + public void defaultList() throws Exception { + StaticRoutingDecisionProvider wl = new StaticRoutingDecisionProvider(); + + assertThat( + wl.decide("method io.jenkins.blueocean.service.embedded.rest.AbstractRunImpl getLog"), + is(RoutingDecisionProvider.Decision.ACCEPTED) + ); + assertThat( + wl.decide("method io.jenkins.blueocean.rest.impl.pipeline.PipelineNodeImpl getLog"), + is(RoutingDecisionProvider.Decision.ACCEPTED) + ); + assertThat( + wl.decide("method io.jenkins.blueocean.rest.impl.pipeline.PipelineStepImpl getLog"), + is(RoutingDecisionProvider.Decision.ACCEPTED) + ); + + assertThat(wl.decide("method jenkins.security.stapler.StaticRoutingDecisionProviderTest$ContentProvider getObjectCustom"), + is(RoutingDecisionProvider.Decision.UNKNOWN) + ); + assertThat(wl.decide("blabla"), + is(RoutingDecisionProvider.Decision.UNKNOWN) + ); + } + + @Test + public void userControlledWhitelist_savedCorrectly() throws Exception { + File whitelistUserControlledList = new File(j.jenkins.getRootDir(), "stapler-whitelist.txt"); + + assertFalse(whitelistUserControlledList.exists()); + + StaticRoutingDecisionProvider wl = new StaticRoutingDecisionProvider(); + + assertFalse(whitelistUserControlledList.exists()); + + assertThat(wl.decide("nothing"), is(RoutingDecisionProvider.Decision.UNKNOWN)); + + wl.save(); + assertTrue(whitelistUserControlledList.exists()); + assertThat(FileUtils.readFileToString(whitelistUserControlledList), is("")); + + wl.add("white-1"); + + assertThat(wl.decide("white-1"), is(RoutingDecisionProvider.Decision.ACCEPTED)); + + assertTrue(whitelistUserControlledList.exists()); + assertThat(FileUtils.readFileToString(whitelistUserControlledList), containsString("white-1")); + { + StaticRoutingDecisionProvider temp = new StaticRoutingDecisionProvider(); + assertThat(temp.decide("white-1"), is(RoutingDecisionProvider.Decision.ACCEPTED)); + } + + wl.addBlacklistSignature("black-2"); + + assertThat(wl.decide("white-1"), is(RoutingDecisionProvider.Decision.ACCEPTED)); + assertThat(wl.decide("black-2"), is(RoutingDecisionProvider.Decision.REJECTED)); + assertThat(FileUtils.readFileToString(whitelistUserControlledList), allOf( + containsString("white-1"), + containsString("!black-2") + )); + + { + StaticRoutingDecisionProvider temp = new StaticRoutingDecisionProvider(); + assertThat(temp.decide("white-1"), is(RoutingDecisionProvider.Decision.ACCEPTED)); + assertThat(temp.decide("black-2"), is(RoutingDecisionProvider.Decision.REJECTED)); + } + + wl.removeBlacklistSignature("black-2"); + + assertThat(wl.decide("white-1"), is(RoutingDecisionProvider.Decision.ACCEPTED)); + assertThat(wl.decide("black-2"), is(RoutingDecisionProvider.Decision.UNKNOWN)); + assertThat(FileUtils.readFileToString(whitelistUserControlledList), allOf( + containsString("white-1"), + not(containsString("black-2")) + )); + + { + StaticRoutingDecisionProvider temp = new StaticRoutingDecisionProvider(); + assertThat(temp.decide("white-1"), is(RoutingDecisionProvider.Decision.ACCEPTED)); + assertThat(temp.decide("black-2"), is(RoutingDecisionProvider.Decision.UNKNOWN)); + } + + wl.remove("white-1"); + + assertThat(wl.decide("white-1"), is(RoutingDecisionProvider.Decision.UNKNOWN)); + assertThat(wl.decide("black-2"), is(RoutingDecisionProvider.Decision.UNKNOWN)); + assertThat(FileUtils.readFileToString(whitelistUserControlledList), allOf( + not(containsString("white-1")), + not(containsString("black-2")) + )); + + { + StaticRoutingDecisionProvider temp = new StaticRoutingDecisionProvider(); + assertThat(temp.decide("white-1"), is(RoutingDecisionProvider.Decision.UNKNOWN)); + assertThat(temp.decide("black-2"), is(RoutingDecisionProvider.Decision.UNKNOWN)); + } + } +} diff --git a/test/src/test/java/jenkins/security/stapler/StaticRoutingDecisionProviderTest.java b/test/src/test/java/jenkins/security/stapler/StaticRoutingDecisionProviderTest.java new file mode 100644 index 0000000000000000000000000000000000000000..3ffde5969862f084a945f7f113730637f8fee389 --- /dev/null +++ b/test/src/test/java/jenkins/security/stapler/StaticRoutingDecisionProviderTest.java @@ -0,0 +1,513 @@ +/* + * The MIT License + * + * Copyright (c) 2018, 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.stapler; + +import hudson.ExtensionList; +import hudson.model.FreeStyleProject; +import jenkins.model.Jenkins; +import org.apache.commons.io.FileUtils; +import org.junit.Before; +import org.junit.Test; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.TestExtension; +import org.kohsuke.stapler.StaplerRequest; +import org.kohsuke.stapler.WebMethod; + +import javax.annotation.CheckForNull; +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +@Issue("SECURITY-400") +public class StaticRoutingDecisionProviderTest extends StaplerAbstractTest { + @TestExtension + public static class ContentProvider extends AbstractUnprotectedRootAction { + // simulate side effect + public static boolean called = false; + public static boolean called2 = false; + + public FreeStyleProject getJob() { + called = true; + return (FreeStyleProject) Jenkins.get().getItem("testProject"); + } + + public String getString() { + called = true; + return "a"; + } + + // cannot provide side-effect since the String has no side-effect methods + public Object getObjectString() { + called = true; + return "a"; + } + + public static String OBJECT_CUSTOM_SIGNATURE = "method jenkins.security.stapler.StaticRoutingDecisionProviderTest$ContentProvider getObjectCustom"; + + // but it opens wide range of potentially dangerous classes + public Object getObjectCustom() { + called = true; + return new Object() { + // in order to provide a web entry-point + public void doIndex() { + called2 = true; + replyOk(); + } + }; + } + } + + @Before + public void preparation() throws Exception { + ContentProvider.called = false; + ContentProvider.called2 = false; + } + + @Before + public void resetWhitelist() throws Exception { + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).resetAndSave(); + } + + @Test + public void test_job_index() throws Exception { + j.createFreeStyleProject("testProject"); + assertReachableWithoutOk("contentProvider/job/"); + assertTrue(ContentProvider.called); + } + + @Test + public void test_string() throws Exception { + assertNotReachable("contentProvider/string/"); + assertFalse(ContentProvider.called); + } + + @Test + public void test_objectString() throws Exception { + assertNotReachable("contentProvider/objectString/"); + assertFalse(ContentProvider.called); + } + + @Test + public void test_objectCustom() throws Exception { + assertNotReachable("contentProvider/objectCustom/"); + assertFalse(ContentProvider.called); + } + + //for more test about the whitelist initial loading, please refer to StaticRoutingDecisionProvider2Test + @Test + public void test_objectCustom_withUserControlledSavedWhitelist() throws Throwable { + String whitelist = ContentProvider.OBJECT_CUSTOM_SIGNATURE + "\n"; + File whitelistFile = new File(j.jenkins.getRootDir(), "stapler-whitelist.txt"); + FileUtils.write(whitelistFile, whitelist); + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).reload(); + try { + assertNotReachable("contentProvider/objectString/"); + assertFalse(ContentProvider.called); + assertGetMethodRequestWasBlockedAndResetFlag(); + assertReachable("contentProvider/objectCustom/"); + assertTrue(ContentProvider.called); + } finally { + whitelistFile.delete(); + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).reload(); + } + } + + @Test + public void test_objectCustom_withUserControlledEditedWhitelist() throws Exception { + try { + assertNotReachable("contentProvider/objectString/"); + assertFalse(ContentProvider.called); + assertNotReachable("contentProvider/objectCustom/"); + assertFalse(ContentProvider.called); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).add(ContentProvider.OBJECT_CUSTOM_SIGNATURE); + + assertNotReachable("contentProvider/objectString/"); + assertFalse(ContentProvider.called); + assertFalse(ContentProvider.called2); + assertGetMethodRequestWasBlockedAndResetFlag(); + + assertReachable("contentProvider/objectCustom/"); + assertTrue(ContentProvider.called); + assertTrue(ContentProvider.called2); + + ContentProvider.called = false; + ContentProvider.called2 = false; + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).remove(ContentProvider.OBJECT_CUSTOM_SIGNATURE); + + assertNotReachable("contentProvider/objectString/"); + assertFalse(ContentProvider.called); + assertNotReachable("contentProvider/objectCustom/"); + assertFalse(ContentProvider.called); + } finally { + //TODO check if the file is created per test or in general + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).reload(); + } + } + + @Test + public void test_objectCustom_withStandardWhitelist() throws Exception { + assertNotReachable("contentProvider/objectString/"); + assertFalse(ContentProvider.called); + assertGetMethodRequestWasBlockedAndResetFlag(); + assertNotReachable("contentProvider/objectCustom/"); + assertFalse(ContentProvider.called); + + StaticRoutingDecisionProvider whitelist = ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class); + + {// add entry in the set loaded from the standard whitelist file and reload + Method resetMetaClassCache = StaticRoutingDecisionProvider.class.getDeclaredMethod("resetMetaClassCache"); + resetMetaClassCache.setAccessible(true); + + Field field = StaticRoutingDecisionProvider.class.getDeclaredField("whitelistSignaturesFromFixedList"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + Set standardWhitelist = (Set) field.get(whitelist); + + standardWhitelist.add(ContentProvider.OBJECT_CUSTOM_SIGNATURE); + // just call this method to avoid to reload the file and so override our new signature + resetMetaClassCache.invoke(whitelist); + } + + assertNotReachable("contentProvider/objectString/"); + assertFalse(ContentProvider.called); + assertFalse(ContentProvider.called2); + assertGetMethodRequestWasBlockedAndResetFlag(); + assertReachable("contentProvider/objectCustom/"); + assertTrue(ContentProvider.called); + assertTrue(ContentProvider.called2); + + {// reset to previous state + ContentProvider.called = false; + ContentProvider.called2 = false; + + whitelist.reload(); + } + + assertNotReachable("contentProvider/objectString/"); + assertFalse(ContentProvider.called); + assertNotReachable("contentProvider/objectCustom/"); + assertFalse(ContentProvider.called); + } + + @TestExtension + public static class ActionWithWhitelist extends AbstractUnprotectedRootAction { + @Override + public @CheckForNull String getUrlName() { + return "do-action"; + } + + public static String DO_ACTION_SIGNATURE = "method jenkins.security.stapler.StaticRoutingDecisionProviderTest$ActionWithWhitelist doAction org.kohsuke.stapler.StaplerRequest"; + + public void doAction(StaplerRequest request) { + replyOk(); + } + + public static String DO_ACTION_STAPLER_ROUTABLE_SIGNATURE = "method jenkins.security.stapler.StaticRoutingDecisionProviderTest$ActionWithWhitelist doActionWithStaplerDispatchable org.kohsuke.stapler.StaplerRequest"; + + @StaplerDispatchable + public void doActionWithStaplerDispatchable(StaplerRequest request) { + replyOk(); + } + + public static String DO_ACTION_STAPLER_NONROUTABLE_SIGNATURE = "method jenkins.security.stapler.StaticRoutingDecisionProviderTest$ActionWithWhitelist doActionWithStaplerNotDispatchable org.kohsuke.stapler.StaplerRequest"; + + @StaplerNotDispatchable + public void doActionWithStaplerNotDispatchable(StaplerRequest request) { + replyOk(); + } + + public static String DO_ACTION_STAPLER_WEBMETHOD_SIGNATURE = "method jenkins.security.stapler.StaticRoutingDecisionProviderTest$ActionWithWhitelist doActionWithWebMethod org.kohsuke.stapler.StaplerRequest"; + + @WebMethod(name = "actionWithWebMethod") + public void doActionWithWebMethod(StaplerRequest request) { + replyOk(); + } + } + + @Test + public void doAction_regular() throws Exception { + assertReachable("do-action/action/"); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).add(ActionWithWhitelist.DO_ACTION_SIGNATURE); + + assertReachable("do-action/action/"); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).remove(ActionWithWhitelist.DO_ACTION_SIGNATURE); + + assertReachable("do-action/action/"); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).addBlacklistSignature(ActionWithWhitelist.DO_ACTION_SIGNATURE); + + assertNotReachable("do-action/action/"); + assertDoActionRequestWasBlockedAndResetFlag(); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).removeBlacklistSignature(ActionWithWhitelist.DO_ACTION_SIGNATURE); + + assertReachable("do-action/action/"); + } + + @Test + public void doAction_actionWithStaplerDispatchable() throws Exception { + assertReachable("do-action/actionWithStaplerDispatchable/"); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).addBlacklistSignature(ActionWithWhitelist.DO_ACTION_STAPLER_ROUTABLE_SIGNATURE); + + assertReachable("do-action/actionWithStaplerDispatchable/"); + } + + @Test + public void doAction_actionWithWebMethod() throws Exception { + assertReachable("do-action/actionWithWebMethod/"); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).addBlacklistSignature(ActionWithWhitelist.DO_ACTION_STAPLER_WEBMETHOD_SIGNATURE); + + assertNotReachable("do-action/actionWithWebMethod/"); + assertDoActionRequestWasBlockedAndResetFlag(); + } + + @TestExtension + public static class GetterWithWhitelist extends AbstractUnprotectedRootAction { + @Override + public @CheckForNull String getUrlName() { + return "getter"; + } + + public static String GET_ITEM_SIGNATURE = "method jenkins.security.stapler.StaticRoutingDecisionProviderTest$GetterWithWhitelist getItem"; + + public Renderable getItem() { + return new Renderable(); + } + + public static String GET_ITEM_STAPLER_ROUTABLE_SIGNATURE = "method jenkins.security.stapler.StaticRoutingDecisionProviderTest$GetterWithWhitelist getItemWithStaplerDispatchable"; + + @StaplerDispatchable + public Renderable getItemWithStaplerDispatchable() { + return new Renderable(); + } + + public static String GET_ITEM_STAPLER_NONROUTABLE_SIGNATURE = "method jenkins.security.stapler.StaticRoutingDecisionProviderTest$GetterWithWhitelist getItemWithStaplerNotDispatchable"; + + @StaplerNotDispatchable + public Renderable getItemWithStaplerNotDispatchable() { + return new Renderable(); + } + } + + @Test + public void getItem_regular() throws Exception { + assertReachable("getter/item/"); + assertReachable("getter/item/valid"); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).addBlacklistSignature(GetterWithWhitelist.GET_ITEM_SIGNATURE); + + assertNotReachable("getter/item/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + assertNotReachable("getter/item/valid"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @Test + public void getItem_getterWithStaplerDispatchable() throws Exception { + assertReachable("getter/itemWithStaplerDispatchable/"); + assertReachable("getter/itemWithStaplerDispatchable/valid"); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).addBlacklistSignature(GetterWithWhitelist.GET_ITEM_STAPLER_ROUTABLE_SIGNATURE); + + // Annotation overrides whitelist/blacklist + assertReachable("getter/itemWithStaplerDispatchable/"); + assertReachable("getter/itemWithStaplerDispatchable/valid"); + } + + @Test + public void getItem_getterWithStaplerNotDispatchable() throws Exception { + assertNotReachable("getter/itemWithStaplerNotDispatchable/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + assertNotReachable("getter/itemWithStaplerNotDispatchable/valid"); + assertGetMethodRequestWasBlockedAndResetFlag(); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).add(GetterWithWhitelist.GET_ITEM_STAPLER_NONROUTABLE_SIGNATURE); + + // Annotation overrides whitelist/blacklist + assertNotReachable("getter/itemWithStaplerNotDispatchable/"); + assertGetMethodRequestWasBlockedAndResetFlag(); + assertNotReachable("getter/itemWithStaplerNotDispatchable/valid"); + assertGetMethodRequestWasBlockedAndResetFlag(); + } + + @TestExtension + public static class FieldWithWhitelist extends AbstractUnprotectedRootAction { + @Override + public @CheckForNull String getUrlName() { + return "field"; + } + + public static String FIELD_SIGNATURE = "field jenkins.security.stapler.StaticRoutingDecisionProviderTest$FieldWithWhitelist renderable"; + + public Renderable renderable = new Renderable(); + + public static String FIELD_STAPLER_ROUTABLE_SIGNATURE = "field jenkins.security.stapler.StaticRoutingDecisionProviderTest$FieldWithWhitelist renderableWithStaplerDispatchable"; + + @StaplerDispatchable + public Renderable renderableWithStaplerDispatchable = new Renderable(); + + public static String FIELD_STAPLER_NONROUTABLE_SIGNATURE = "field jenkins.security.stapler.StaticRoutingDecisionProviderTest$FieldWithWhitelist renderableWithStaplerNotDispatchable"; + + @StaplerNotDispatchable + public Renderable renderableWithStaplerNotDispatchable = new Renderable(); + + public static String FIELD_STATIC_SIGNATURE = "staticField jenkins.security.stapler.StaticRoutingDecisionProviderTest$FieldWithWhitelist staticRenderable"; + + public static Renderable staticRenderable = new Renderable(); + + public static String FIELD_STATIC_STAPLER_ROUTABLE_SIGNATURE = "staticField jenkins.security.stapler.StaticRoutingDecisionProviderTest$FieldWithWhitelist staticRenderableWithStaplerDispatchable"; + + @StaplerDispatchable + public static Renderable staticRenderableWithStaplerDispatchable = new Renderable(); + + public static String FIELD_STATIC_STAPLER_NONROUTABLE_SIGNATURE = "staticField jenkins.security.stapler.StaticRoutingDecisionProviderTest$FieldWithWhitelist staticRenderableWithStaplerNotDispatchable"; + + @StaplerNotDispatchable + public static Renderable staticRenderableWithStaplerNotDispatchable = new Renderable(); + } + + @Test + public void field_regular() throws Exception { + assertReachable("field/renderable/"); + assertReachable("field/renderable/valid"); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).addBlacklistSignature(FieldWithWhitelist.FIELD_SIGNATURE); + + assertNotReachable("field/renderable/"); + assertFieldRequestWasBlockedAndResetFlag(); + assertNotReachable("field/renderable/valid"); + assertFieldRequestWasBlockedAndResetFlag(); + } + + @Test + public void field_regular_returnType() throws Exception { + assertReachable("field/renderable/"); + assertReachable("field/renderable/valid"); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).addBlacklistSignature(RENDERABLE_CLASS_SIGNATURE); + + assertNotReachable("field/renderable/"); + assertFieldRequestWasBlockedAndResetFlag(); + assertNotReachable("field/renderable/valid"); + assertFieldRequestWasBlockedAndResetFlag(); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).removeBlacklistSignature(RENDERABLE_CLASS_SIGNATURE); + + assertReachable("field/renderable/"); + assertReachable("field/renderable/valid"); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).add(RENDERABLE_CLASS_SIGNATURE); + // method is checked first as it's more specific + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).addBlacklistSignature(FieldWithWhitelist.FIELD_SIGNATURE); + + assertNotReachable("field/renderable/"); + assertFieldRequestWasBlockedAndResetFlag(); + assertNotReachable("field/renderable/valid"); + assertFieldRequestWasBlockedAndResetFlag(); + + // reverse, now we blacklist the type but whitelist the method => it's ok + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).remove(RENDERABLE_CLASS_SIGNATURE); + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).removeBlacklistSignature(FieldWithWhitelist.FIELD_SIGNATURE); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).addBlacklistSignature(RENDERABLE_CLASS_SIGNATURE); + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).add(FieldWithWhitelist.FIELD_SIGNATURE); + + assertReachable("field/renderable/"); + assertReachable("field/renderable/valid"); + } + + @Test + public void field_withStaplerDispatchable() throws Exception { + assertReachable("field/renderableWithStaplerDispatchable/"); + assertReachable("field/renderableWithStaplerDispatchable/valid"); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).addBlacklistSignature(FieldWithWhitelist.FIELD_STAPLER_ROUTABLE_SIGNATURE); + + assertReachable("field/renderableWithStaplerDispatchable/"); + } + + @Test + public void field_withStaplerNotDispatchable() throws Exception { + assertNotReachable("field/renderableWithStaplerNotDispatchable/"); + assertFieldRequestWasBlockedAndResetFlag(); + assertNotReachable("field/renderableWithStaplerNotDispatchable/valid"); + assertFieldRequestWasBlockedAndResetFlag(); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).add(FieldWithWhitelist.FIELD_STAPLER_NONROUTABLE_SIGNATURE); + + assertNotReachable("field/renderableWithStaplerNotDispatchable/"); + assertFieldRequestWasBlockedAndResetFlag(); + assertNotReachable("field/renderableWithStaplerNotDispatchable/valid"); + assertFieldRequestWasBlockedAndResetFlag(); + } + + @Test + public void fieldStatic_regular() throws Exception { + assertNotReachable("field/staticRenderable/"); + assertFieldRequestWasBlockedAndResetFlag(); + assertNotReachable("field/staticRenderable/valid"); + assertFieldRequestWasBlockedAndResetFlag(); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).add(FieldWithWhitelist.FIELD_STATIC_SIGNATURE); + + assertReachable("field/staticRenderable/"); + assertReachable("field/staticRenderable/valid"); + } + + @Test + public void fieldStatic_withStaplerDispatchable() throws Exception { + assertReachable("field/staticRenderableWithStaplerDispatchable/"); + assertReachable("field/staticRenderableWithStaplerDispatchable/valid"); + + // doesn't do anything + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).addBlacklistSignature(FieldWithWhitelist.FIELD_STATIC_STAPLER_ROUTABLE_SIGNATURE); + + assertReachable("field/staticRenderableWithStaplerDispatchable/"); + } + + @Test + public void fieldStatic_withStaplerNotDispatchable() throws Exception { + assertNotReachable("field/staticRenderableWithStaplerNotDispatchable/"); + assertFieldRequestWasBlockedAndResetFlag(); + assertNotReachable("field/staticRenderableWithStaplerNotDispatchable/valid"); + assertFieldRequestWasBlockedAndResetFlag(); + + ExtensionList.lookupSingleton(StaticRoutingDecisionProvider.class).add(FieldWithWhitelist.FIELD_STATIC_STAPLER_NONROUTABLE_SIGNATURE); + + assertNotReachable("field/staticRenderableWithStaplerNotDispatchable/"); + assertFieldRequestWasBlockedAndResetFlag(); + assertNotReachable("field/staticRenderableWithStaplerNotDispatchable/valid"); + assertFieldRequestWasBlockedAndResetFlag(); + } +} \ No newline at end of file diff --git a/test/src/test/java/jenkins/security/stapler/TypedFilterTest.java b/test/src/test/java/jenkins/security/stapler/TypedFilterTest.java new file mode 100644 index 0000000000000000000000000000000000000000..ba9f6b4a4a5e2429702b8ab93ce0463f8d460ef3 --- /dev/null +++ b/test/src/test/java/jenkins/security/stapler/TypedFilterTest.java @@ -0,0 +1,209 @@ +package jenkins.security.stapler; + +import org.junit.Test; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.TestExtension; +import org.kohsuke.stapler.StaplerProxy; +import org.kohsuke.stapler.StaplerRequest; + +@Issue("SECURITY-400") +public class TypedFilterTest extends StaplerAbstractTest { + @TestExtension + public static class GetTarget1 extends AbstractUnprotectedRootAction { + public Renderable getTarget(){ + return new Renderable(); + } + } + @Test + public void getTarget_withoutArg_isNotRoutableDirectly() throws Exception { + assertNotReachable("getTarget1/target/"); + } + + @TestExtension + public static class GetTarget2 extends AbstractUnprotectedRootAction { + @StaplerDispatchable + public Renderable getTarget(){ + return new Renderable(); + } + } + @Test + public void getTarget_withoutArg_isRoutableWithAnnotation() throws Exception { + assertReachable("getTarget2/target/"); + } + + @TestExtension + public static class GetTarget3 extends AbstractUnprotectedRootAction { + @StaplerNotDispatchable + public Renderable getTarget(){ + return new Renderable(); + } + } + @Test + public void getTarget_withArg_isNotRoutableWithStaplerNotDispatchable() throws Exception { + assertNotReachable("getTarget3/target/"); + } + + @TestExtension + public static class GetTarget4 extends AbstractUnprotectedRootAction { + public Renderable getTarget(StaplerRequest req){ + return new Renderable(); + } + } + @Test + public void getTarget_withArg_isRoutable() throws Exception { + assertReachable("getTarget4/target/"); + } + + @TestExtension + public static class GetStaplerFallback1 extends AbstractUnprotectedRootAction { + public Renderable getStaplerFallback(){ + return new Renderable(); + } + } + @Test + public void getStaplerFallback_withoutArg_isNotRoutableDirectly() throws Exception { + assertNotReachable("getStaplerFallback1/staplerFallback/"); + } + + @TestExtension + public static class GetStaplerFallback2 extends AbstractUnprotectedRootAction { + @StaplerDispatchable + public Renderable getStaplerFallback(){ + return new Renderable(); + } + } + @Test + public void getStaplerFallback_withoutArg_isRoutableWithAnnotation() throws Exception { + assertReachable("getStaplerFallback2/staplerFallback/"); + } + + @TestExtension + public static class GetStaplerFallback3 extends AbstractUnprotectedRootAction { + @StaplerNotDispatchable + public Renderable getStaplerFallback(){ + return new Renderable(); + } + } + @Test + public void getStaplerFallback_withArg_isNotRoutableWithStaplerNotDispatchable() throws Exception { + assertNotReachable("getStaplerFallback3/staplerFallback/"); + } + + @TestExtension + public static class GetStaplerFallback4 extends AbstractUnprotectedRootAction { + public Renderable getStaplerFallback(StaplerRequest req){ + return new Renderable(); + } + } + @Test + public void getStaplerFallback_withArg_isRoutable() throws Exception { + assertReachable("getStaplerFallback4/staplerFallback/"); + } + + public static class TypeImplementingStaplerProxy implements StaplerProxy { + @Override + public Object getTarget() { + return new Renderable(); + } + } + public static class TypeExtendingTypeImplementingStaplerProxy extends TypeImplementingStaplerProxy { + } + // FIXME @StaplerNotDispatchable + public static class TypeImplementingStaplerProxy2 implements StaplerProxy { + @Override + public Object getTarget() { + return new Renderable(); + } + } + public static class TypeExtendingTypeImplementingStaplerProxy2 extends TypeImplementingStaplerProxy2 { + } + + @TestExtension + public static class GetTypeImplementingStaplerProxy extends AbstractUnprotectedRootAction { + public TypeImplementingStaplerProxy getTypeImplementingStaplerProxy(){ + return new TypeImplementingStaplerProxy(); + } + public TypeExtendingTypeImplementingStaplerProxy getTypeExtendingTypeImplementingStaplerProxy(){ + return new TypeExtendingTypeImplementingStaplerProxy(); + } + public TypeImplementingStaplerProxy2 getTypeImplementingStaplerProxy2(){ + return new TypeImplementingStaplerProxy2(); + } + public TypeExtendingTypeImplementingStaplerProxy2 getTypeExtendingTypeImplementingStaplerProxy2(){ + return new TypeExtendingTypeImplementingStaplerProxy2(); + } + } + + @Test + public void typeImplementingStaplerProxy_isRoutableByDefault() throws Exception { + assertReachable("getTypeImplementingStaplerProxy/typeImplementingStaplerProxy/"); + assertReachable("getTypeImplementingStaplerProxy/typeImplementingStaplerProxy/valid"); + } + @Test + public void typeExtendingParentImplementingStaplerProxy_isRoutableByDefault() throws Exception { + assertReachable("getTypeImplementingStaplerProxy/typeExtendingTypeImplementingStaplerProxy/"); + assertReachable("getTypeImplementingStaplerProxy/typeExtendingTypeImplementingStaplerProxy/valid/"); + } + @Test + public void typeImplementingStaplerProxy_isNotRoutableWithNonroutable() throws Exception { + //TODO no way to avoid routability if implementing StaplerProxy +// assertNotReachable("getTypeImplementingStaplerProxy/typeImplementingStaplerProxy2/"); +// assertNotReachable("getTypeImplementingStaplerProxy/typeImplementingStaplerProxy2/valid/"); + } + @Test + public void typeExtendingParentImplementingStaplerProxy_isNotRoutableWithNonroutable() throws Exception { + //TODO no way to avoid routability if super type implementing StaplerProxy +// assertNotReachable("getTypeImplementingStaplerProxy/typeExtendingTypeImplementingStaplerProxy2/"); +// assertNotReachable("getTypeImplementingStaplerProxy/typeExtendingTypeImplementingStaplerProxy2/valid/"); + } + + @TestExtension + public static class GetDynamic1 extends AbstractUnprotectedRootAction { + public Renderable getDynamic(){ + return new Renderable(); + } + } + @Test + public void getDynamic_withoutArg_isRoutable() throws Exception { + assertReachable("getDynamic1/dynamic/"); + assertNotReachable("getDynamic1//"); + } + + @TestExtension + public static class GetDynamic2 extends AbstractUnprotectedRootAction { + public Renderable getDynamic(String someArgs){ + return new Renderable(); + } + } + @Test + public void getDynamic_withArgStartingWithString_isRoutable() throws Exception { + // dynamic is "just" a subcase of regular getDynamic usage + assertReachable("getDynamic2/dynamic/"); + assertReachable("getDynamic2//"); + } + + @TestExtension + public static class GetDynamic3 extends AbstractUnprotectedRootAction { + public Renderable getDynamic(StaplerRequest req, String someArgs){ + return new Renderable(); + } + } + @Test + public void getDynamic_withArgNotStartingWithString_isNotRoutable() throws Exception { + assertNotReachable("getDynamic3/dynamic/"); + assertNotReachable("getDynamic3//"); + } + + @TestExtension + public static class GetDynamic4 extends AbstractUnprotectedRootAction { + public Renderable getDynamic(StaplerRequest req){ + return new Renderable(); + } + } + @Test + public void getDynamic_withArgNotIncludingString_isRoutable() throws Exception { + assertReachable("getDynamic4/dynamic/"); + // there is no magic here, as the string argument is missing, just a regular getter + assertNotReachable("getDynamic4//"); + } +} diff --git a/test/src/test/resources/hudson/model/UsageStatisticsTest/jobs.json b/test/src/test/resources/hudson/model/UsageStatisticsTest/jobs.json index 83beb88336a5a77f9513d5323a9ab9f89065e45c..60d12884065991d77d9005934a9b94b93eb7167e 100644 --- a/test/src/test/resources/hudson/model/UsageStatisticsTest/jobs.json +++ b/test/src/test/resources/hudson/model/UsageStatisticsTest/jobs.json @@ -1 +1 @@ -{"hudson-matrix-MatrixProject":0,"hudson-maven-MavenModuleSet":0,"hudson-model-FreeStyleProject":0,"org-jvnet-hudson-test-MockFolder":0,"org-jvnet-hudson-test-SecuredMockFolder":0} \ No newline at end of file +{"com-cloudbees-hudson-plugins-folder-Folder":0,"hudson-matrix-MatrixProject":0,"hudson-maven-MavenModuleSet":0,"hudson-model-FreeStyleProject":0,"org-jvnet-hudson-test-MockFolder":0,"org-jvnet-hudson-test-SecuredMockFolder":0} \ No newline at end of file diff --git a/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/comment_ignored/stapler-whitelist.txt b/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/comment_ignored/stapler-whitelist.txt new file mode 100644 index 0000000000000000000000000000000000000000..e8e8b3f4bebd0f2d4a4d166add713b784057a7ca --- /dev/null +++ b/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/comment_ignored/stapler-whitelist.txt @@ -0,0 +1,6 @@ +# this line is not read +this-one-is +# not-this-one +#neither +this-one-also +# finally-not diff --git a/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/greylist_multiline/stapler-whitelist.txt b/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/greylist_multiline/stapler-whitelist.txt new file mode 100644 index 0000000000000000000000000000000000000000..0979aa05657bf05bae07cce17978116337020d18 --- /dev/null +++ b/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/greylist_multiline/stapler-whitelist.txt @@ -0,0 +1,4 @@ +signature-1-ok +!signature-2-not-ok +signature-3-ok +!signature-4-not-ok diff --git a/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/whitelist_empty/stapler-whitelist.txt b/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/whitelist_empty/stapler-whitelist.txt new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/whitelist_emptyline/stapler-whitelist.txt b/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/whitelist_emptyline/stapler-whitelist.txt new file mode 100644 index 0000000000000000000000000000000000000000..b11fc155926f748ca273dd03d89d9e706177f659 --- /dev/null +++ b/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/whitelist_emptyline/stapler-whitelist.txt @@ -0,0 +1,9 @@ +signature-1 +# just an empty line + +signature-2 +# space after the exclamation mark +! +# no space +! +signature-3 diff --git a/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/whitelist_monoline/stapler-whitelist.txt b/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/whitelist_monoline/stapler-whitelist.txt new file mode 100644 index 0000000000000000000000000000000000000000..96b147fc19f018c9ce34ade6136e7720692b3e00 --- /dev/null +++ b/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/whitelist_monoline/stapler-whitelist.txt @@ -0,0 +1 @@ +method jenkins.security.stapler.StaticRoutingDecisionProviderTest$ContentProvider getObjectCustom diff --git a/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/whitelist_multiline/stapler-whitelist.txt b/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/whitelist_multiline/stapler-whitelist.txt new file mode 100644 index 0000000000000000000000000000000000000000..1d36e5a638da5e064497ac8bb4eda30108bc608c --- /dev/null +++ b/test/src/test/resources/jenkins/security/stapler/StaticRoutingDecisionProvider2Test/whitelist_multiline/stapler-whitelist.txt @@ -0,0 +1,2 @@ +method jenkins.security.stapler.StaticRoutingDecisionProviderTest$ContentProvider getObjectCustom +method jenkins.security.stapler.StaticRoutingDecisionProviderTest$ContentProvider getObjectCustom2 diff --git a/test/src/test/resources/plugins/annotations-test-sources.jar b/test/src/test/resources/plugins/annotations-test-sources.jar new file mode 100644 index 0000000000000000000000000000000000000000..a320c1c60d1a4d4345fcbbdf14d6fc9bee9fb39a Binary files /dev/null and b/test/src/test/resources/plugins/annotations-test-sources.jar differ diff --git a/test/src/test/resources/plugins/annotations-test.hpi b/test/src/test/resources/plugins/annotations-test.hpi new file mode 100644 index 0000000000000000000000000000000000000000..347ef64e4b11883ed753c193dc9bab3e2ce23ea7 Binary files /dev/null and b/test/src/test/resources/plugins/annotations-test.hpi differ