Missing Authorization in Jenkins
Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.
Affected versions
0 → fixed in 2.176.22.177 → fixed in 2.1860 → fixed in 1.257.1
Details
A vulnerability in the Stapler web framework used in Jenkins 2.185 and earlier, LTS 2.176.1 and earlier allowed attackers to access view fragments directly, bypassing permission checks and possibly obtain sensitive information.
The fix
[SECURITY-534]
core/pom.xml+2 −2
@@ -39,7 +39,7 @@ THE SOFTWARE.<properties><staplerFork>true</staplerFork>-<stapler.version>1.256</stapler.version>+<stapler.version>1.256.1</stapler.version><spring.version>2.5.6.SEC03</spring.version><groovy.version>2.4.11</groovy.version></properties>@@ -180,7 +180,7 @@ THE SOFTWARE.<dependency><groupId>io.jenkins.stapler</groupId><artifactId>jenkins-stapler-support</artifactId>-<version>1.0</version>+<version>1.1</version></dependency><dependency><groupId>org.hamcrest</groupId>
core/src/main/java/hudson/Functions.java+1 −0
@@ -240,6 +240,7 @@ public static boolean isExtensionsAvailable() {public static void initPageVariables(JellyContext context) {StaplerRequest currentRequest = Stapler.getCurrentRequest();+currentRequest.getWebApp().getDispatchValidator().allowDispatch(currentRequest, Stapler.getCurrentResponse());String rootURL = currentRequest.getContextPath();Functions h = new Functions();
core/src/main/java/hudson/widgets/RenderOnDemandClosure.java+1 −0
@@ -95,6 +95,7 @@ public RenderOnDemandClosure(JellyContext context, String attributesToCapture) {public HttpResponse render() {return new HttpResponse() {public void generateResponse(StaplerRequest req, StaplerResponse rsp, Object node) throws IOException, ServletException {+req.getWebApp().getDispatchValidator().allowDispatch(req, rsp);try {new DefaultScriptInvoker() {@Override
core/src/main/java/jenkins/model/Jenkins.java+6 −2
@@ -39,6 +39,7 @@import jenkins.AgentProtocol;import jenkins.diagnostics.URICheckEncodingMonitor;import jenkins.security.stapler.DoActionFilter;+import jenkins.security.stapler.StaplerDispatchValidator;import jenkins.security.stapler.StaplerFilteredActionListener;import jenkins.security.stapler.StaplerDispatchable;import jenkins.security.RedactSecretJsonInErrorMessageSanitizer;@@ -913,6 +914,9 @@ protected Jenkins(File root, ServletContext context, PluginManager pluginManagerwebApp.setFilteredDoActionTriggerListener(actionListener);webApp.setFilteredFieldTriggerListener(actionListener);+webApp.setDispatchValidator(new StaplerDispatchValidator());+webApp.setFilteredDispatchTriggerListener(actionListener);+adjuncts = new AdjunctManager(servletContext, pluginManager.uberClassLoader,"adjuncts/"+SESSION_HASH, TimeUnit.DAYS.toMillis(365));ClassFilterImpl.register();@@ -4416,9 +4420,9 @@ public static void _doScript(StaplerRequest req, StaplerResponse rsp, RequestDis@RequirePOSTpublic void doEval(StaplerRequest req, StaplerResponse rsp) throws IOException, ServletException {checkPermission(RUN_SCRIPTS);-+req.getWebApp().getDispatchValidator().allowDispatch(req, rsp);try {-MetaClass mc = WebApp.getCurrent().getMetaClass(getClass());+MetaClass mc = req.getWebApp().getMetaClass(getClass());Script script = mc.classLoader.loadTearOff(JellyClassLoaderTearOff.class).createContext().compileScript(new InputSource(req.getReader()));new JellyRequestDispatcher(this,script).forward(req,rsp);} catch (JellyException e) {
core/src/main/java/jenkins/security/stapler/StaplerDispatchValidator.java+354 −0
@@ -0,0 +1,354 @@+/*+* The MIT License+*+* Copyright (c) 2019 CloudBees, Inc.+*+* Permission is hereby granted, free of charge, to any person obtaining a copy+* of this software and associated documentation files (the "Software"), to deal+* in the Software without restriction, including without limitation the rights+* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+* copies of the Software, and to permit persons to whom the Software is+* furnished to do so, subject to the following conditions:+*+* The above copyright notice and this permission notice shall be included in+* all copies or substantial portions of the Software.+*+* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN+* THE SOFTWARE.+*/++package jenkins.security.stapler;++import com.google.common.annotations.VisibleForTesting;+import jenkins.model.Jenkins;+import jenkins.util.SystemProperties;+import org.apache.commons.io.IOUtils;+import org.kohsuke.accmod.Restricted;+import org.kohsuke.accmod.restrictions.NoExternalUse;+import org.kohsuke.stapler.CancelRequestHandlingException;+import org.kohsuke.stapler.DispatchValidator;+import org.kohsuke.stapler.StaplerRequest;+import org.kohsuke.stapler.StaplerResponse;+import org.kohsuke.stapler.WebApp;++import javax.annotation.CheckForNull;+import javax.annotation.Nonnull;+import javax.servlet.ServletContext;+import java.io.IOException;+import java.io.InputStream;+import java.nio.charset.StandardCharsets;+import java.nio.file.Files;+import java.nio.file.Path;+import java.nio.file.Paths;+import java.util.ArrayList;+import java.util.Arrays;+import java.util.Collection;+import java.util.Collections;+import java.util.HashMap;+import java.util.HashSet;+import java.util.List;+import java.util.Map;+import java.util.Set;+import java.util.concurrent.ConcurrentHashMap;+import java.util.concurrent.locks.ReadWriteLock;+import java.util.concurrent.locks.ReentrantReadWriteLock;+import java.util.function.Function;+import java.util.function.Supplier;+import java.util.logging.Level;+import java.util.logging.Logger;++/**+* Validates views dispatched by Stapler. This validation consists of two phases:+* <ul>+* <li>Before views are loaded, the model class is checked for {@link StaplerViews}/{@link StaplerFragments} along+* with whitelist entries specified by the default views whitelist and the optionally defined whitelist specified+* by the system property {@code jenkins.security.stapler.StaplerDispatchValidator.whitelist}. Then,+* the model class's superclass and interfaces are recursively inspected adding views and fragments that do not+* conflict with the views and fragments already declared. This effectively allows model classes to override+* parent classes.</li>+* <li>Before views write any response output, this validator is checked to see if the view has declared itself+* dispatchable using the {@code l:view} Jelly tag. As this validation comes later, annotations will take+* precedence over the use or lack of a layout tag.</li>+* </ul>+* <p>Validation can be disabled by setting the system property+* {@code jenkins.security.stapler.StaplerDispatchValidator.disabled=true} or setting {@link #DISABLED} to+* {@code true} in the script console.</p>+*+* @since TODO+*/+@Restricted(NoExternalUse.class)+public class StaplerDispatchValidator implements DispatchValidator {++private static final Logger LOGGER = Logger.getLogger(StaplerDispatchValidator.class.getName());+private static final String ATTRIBUTE_NAME = StaplerDispatchValidator.class.getName() + ".status";+private static final String ESCAPE_HATCH = StaplerDispatchValidator.class.getName() + ".disabled";+/**+* Escape hatch to disable dispatch validation.+*/+public static /* script-console editable */ boolean DISABLED = SystemProperties.getBoolean(ESCAPE_HATCH);++private static @CheckForNull Boolean setStatus(@Nonnull StaplerRequest req, @CheckForNull Boolean status) {+if (status == null) {+return null;+}+LOGGER.fine(() -> "Request dispatch set status to " + status + " for URL " + req.getPathInfo());+req.setAttribute(ATTRIBUTE_NAME, status);+return status;+}++private static @CheckForNull Boolean computeStatusIfNull(@Nonnull StaplerRequest req, @Nonnull Supplier<Boolean> statusIfNull) {+Object requestStatus = req.getAttribute(ATTRIBUTE_NAME);+return requestStatus instanceof Boolean ? (Boolean) requestStatus : setStatus(req, statusIfNull.get());+}++private final ValidatorCache cache;++public StaplerDispatchValidator() {+cache = new ValidatorCache();+cache.load();+}++@Override+public @CheckForNull Boolean isDispatchAllowed(@Nonnull StaplerRequest req, @Nonnull StaplerResponse rsp) {+if (DISABLED) {+return true;+}+Boolean status = computeStatusIfNull(req, () -> {+if (rsp.getContentType() != null) {+return true;+}+if (rsp.getStatus() >= 300) {+return true;+}+return null;+});+LOGGER.finer(() -> req.getRequestURI() + " -> " + status);+return status;+}++@Override+public @CheckForNull Boolean isDispatchAllowed(@Nonnull StaplerRequest req, @Nonnull StaplerResponse rsp, @Nonnull String viewName, @CheckForNull Object node) {+if (DISABLED) {+return true;+}+Boolean status = computeStatusIfNull(req, () -> {+if (viewName.equals("index")) {+return true;+}+if (node == null) {+return null;+}+return cache.find(node.getClass()).isViewValid(viewName);+});+LOGGER.finer(() -> "<" + req.getRequestURI() + ", " + viewName + ", " + node + "> -> " + status);+return status;+}++@Override+public void allowDispatch(@Nonnull StaplerRequest req, @Nonnull StaplerResponse rsp) {+if (DISABLED) {+return;+}+setStatus(req, true);+}++@Override+public void requireDispatchAllowed(@Nonnull StaplerRequest req, @Nonnull StaplerResponse rsp) throws CancelRequestHandlingException {+if (DISABLED) {+return;+}+Boolean status = isDispatchAllowed(req, rsp);+if (status == null || !status) {+LOGGER.fine(() -> "Cancelling dispatch for " + req.getRequestURI());+throw new CancelRequestHandlingException();+}+}++@VisibleForTesting+static StaplerDispatchValidator getInstance(@Nonnull ServletContext context) {+return (StaplerDispatchValidator) WebApp.get(context).getDispatchValidator();+}++@VisibleForTesting+void loadWhitelist(@Nonnull InputStream in) throws IOException {+cache.loadWhitelist(IOUtils.readLines(in));+}++private static class ValidatorCache {+private final Map<String, Validator> validators = new HashMap<>();+private final ReadWriteLock lock = new ReentrantReadWriteLock();++// provided as alternative to ConcurrentHashMap.computeIfAbsent which doesn't allow for recursion in the supplier+// see https://stackoverflow.com/q/28840047+private @Nonnull Validator computeIfAbsent(@Nonnull String className, @Nonnull Function<String, Validator> constructor) {+lock.readLock().lock();+try {+if (validators.containsKey(className)) {+// cached value+return validators.get(className);+}+} finally {+lock.readLock().unlock();+}+lock.writeLock().lock();+try {+if (validators.containsKey(className)) {+// cached between readLock.unlock and writeLock.lock+return validators.get(className);+}+Validator value = constructor.apply(className);+validators.put(className, value);+return value;+} finally {+lock.writeLock().unlock();+}+}++private @Nonnull Validator find(@Nonnull Class<?> clazz) {+return computeIfAbsent(clazz.getName(), name -> create(clazz));+}++private @Nonnull Validator find(@Nonnull String className) {+return computeIfAbsent(className, this::create);+}++private @Nonnull Collection<Validator> findParents(@Nonnull Class<?> clazz) {+List<Validator> parents = new ArrayList<>();+Class<?> superclass = clazz.getSuperclass();+if (superclass != null) {+parents.add(find(superclass));+}+for (Class<?> iface : clazz.getInterfaces()) {+parents.add(find(iface));+}+return parents;+}++private @Nonnull Validator create(@Nonnull Class<?> clazz) {+Set<String> allowed = new HashSet<>();+StaplerViews views = clazz.getDeclaredAnnotation(StaplerViews.class);+if (views != null) {+allowed.addAll(Arrays.asList(views.value()));+}+Set<String> denied = new HashSet<>();+StaplerFragments fragments = clazz.getDeclaredAnnotation(StaplerFragments.class);+if (fragments != null) {+denied.addAll(Arrays.asList(fragments.value()));+}+return new Validator(() -> findParents(clazz), allowed, denied);+}++private @Nonnull Validator create(@Nonnull String className) {+ClassLoader loader = Jenkins.get().pluginManager.uberClassLoader;+return new Validator(() -> {+try {+return findParents(loader.loadClass(className));+} catch (ClassNotFoundException e) {+LOGGER.log(Level.WARNING, e, () -> "Could not load class " + className + " to validate views");+return Collections.emptySet();+}+});+}++private void load() {+try {+try (InputStream is = Validator.class.getResourceAsStream("default-views-whitelist.txt")) {+loadWhitelist(IOUtils.readLines(is, StandardCharsets.UTF_8));+}+} catch (IOException e) {+LOGGER.log(Level.WARNING, "Could not load default views whitelist", e);+}+String whitelist = SystemProperties.getString(StaplerDispatchValidator.class.getName() + ".whitelist");+Path configFile = whitelist != null ? Paths.get(whitelist) : Jenkins.get().getRootDir().toPath().resolve("stapler-views-whitelist.txt");+if (Files.exists(configFile)) {+try {+loadWhitelist(Files.readAllLines(configFile));+} catch (IOException e) {+LOGGER.log(Level.WARNING, e, () -> "Could not load user defined whitelist from " + configFile);+}+}+}++private void loadWhitelist(@Nonnull List<String> whitelistLines) {+for (String line : whitelistLines) {+if (line.matches("#.*|\\s*")) {+// commented line+continue;+}+String[] parts = line.split("\\s+");+if (parts.length < 2) {+// invalid input format+LOGGER.warning(() -> "Cannot update validator with malformed line: " + line);+continue;+}+Validator validator = find(parts[0]);+for (int i = 1; i < parts.length; i++) {+String view = parts[i];+if (view.startsWith("!")) {+validator.denyView(view.substring(1));+} else {+validator.allowView(view);+}+}+}+}++private class Validator {+// lazy load parents to avoid trying to load potentially unavailable classes+private final Supplier<Collection<Validator>> parentsSupplier;+private volatile Collection<Validator> parents;+private final Set<String> allowed = ConcurrentHashMap.newKeySet();+private final Set<String> denied = ConcurrentHashMap.newKeySet();++private Validator(@Nonnull Supplier<Collection<Validator>> parentsSupplier) {+this.parentsSupplier = parentsSupplier;+}++private Validator(@Nonnull Supplier<Collection<Validator>> parentsSupplier, @Nonnull Collection<String> allowed, @Nonnull Collection<String> denied) {+this(parentsSupplier);+this.allowed.addAll(allowed);+this.denied.addAll(denied);+}++private @Nonnull Collection<Validator> getParents() {+if (parents == null) {+synchronized (this) {+if (parents == null) {+parents = parentsSupplier.get();+}+}+}+return parents;+}++private @CheckForNull Boolean isViewValid(@Nonnull String viewName) {+if (allowed.contains(viewName)) {+return Boolean.TRUE;+}+if (denied.contains(viewName)) {+return Boolean.FALSE;+}+for (Validator parent : getParents()) {+Boolean result = parent.isViewValid(viewName);+if (result != null) {+return result;+}+}+return null;+}++private void allowView(@Nonnull String viewName) {+allowed.add(viewName);+}++private void denyView(@Nonnull String viewName) {+denied.add(viewName);+}+}+}+}
core/src/main/java/jenkins/security/stapler/StaplerFilteredActionListener.java+11 −2
@@ -28,6 +28,7 @@import org.kohsuke.stapler.Function;import org.kohsuke.stapler.StaplerRequest;import org.kohsuke.stapler.StaplerResponse;+import org.kohsuke.stapler.event.FilteredDispatchTriggerListener;import org.kohsuke.stapler.event.FilteredDoActionTriggerListener;import org.kohsuke.stapler.event.FilteredFieldTriggerListener;import org.kohsuke.stapler.event.FilteredGetterTriggerListener;@@ -37,10 +38,10 @@import java.util.logging.Logger;/**-* Log a warning message when a "getter" or "doAction" function that was filtered out by SECURITY-400 new rules+* Log a warning message when a "getter" or "doAction" function or fragment view that was filtered out by SECURITY-400 new rules*/@Restricted(NoExternalUse.class)-public class StaplerFilteredActionListener implements FilteredDoActionTriggerListener, FilteredGetterTriggerListener, FilteredFieldTriggerListener {+public class StaplerFilteredActionListener implements FilteredDoActionTriggerListener, FilteredGetterTriggerListener, FilteredFieldTriggerListener, FilteredDispatchTriggerListener {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. " +@@ -73,4 +74,12 @@ public boolean onFieldTrigger(FieldRef f, StaplerRequest req, StaplerResponse st});return false;}++@Override+public boolean onDispatchTrigger(StaplerRequest req, StaplerResponse rsp, Object node, String viewName) {+LOGGER.warning(() -> "New Stapler dispatch rules result in the URL \"" + req.getPathInfo() + "\" no longer being allowed. " ++"If you consider it safe to use, add the following to the whitelist: \"" + node.getClass().getName() + " " + viewName + "\". "++"Learn more: https://jenkins.io/redirect/stapler-facet-restrictions");+return false;+}}
core/src/main/resources/hudson/atom.jelly+1 −3
@@ -23,9 +23,7 @@ THE SOFTWARE.--><?jelly escape-by-default='true'?>-<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout" xmlns:t="/lib/hudson"><!-- No whitespace before xml header: --><?xml version="1.0" encoding="UTF-8"?>-<st:contentType value="application/atom+xml;charset=UTF-8" />-<j:new var="h" className="hudson.Functions" /><!-- instead of JSP functions -->+<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler"><st:contentType value="application/atom+xml;charset=UTF-8" /><!-- No whitespace before xml header: --><?xml version="1.0" encoding="UTF-8"?><!-- ATOM. See http://atompub.org/rfc4287.html for the format --><feed xmlns="http://www.w3.org/2005/Atom">
core/src/main/resources/hudson/model/Computer/_scriptText.jelly+5 −2
@@ -26,5 +26,8 @@ THE SOFTWARE.Called from doScriptText() to display the execution result.--><?jelly escape-by-default='true'?>-<st:compress xmlns:j="jelly:core" xmlns:st="jelly:stapler">-<st:contentType value="text/plain;charset=UTF-8" /><j:out value="${output}"/></st:compress>+<st:compress xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:l="/lib/layout">+<l:view contentType="text/plain;charset=UTF-8">+<j:out value="${output}"/>+</l:view>+</st:compress>
More files changed — see the full commit.
[SECURITY-534]
.gitignore+2 −1
@@ -3,7 +3,8 @@*.iml*.iwstarget+pom.xml.versionsBackup.project.settings/-.classpath+.classpath
core/src/main/java/org/kohsuke/stapler/DispatchValidator.java+97 −0
@@ -0,0 +1,97 @@+/*+* The MIT License+*+* Copyright (c) 2019 CloudBees, Inc.+*+* Permission is hereby granted, free of charge, to any person obtaining a copy+* of this software and associated documentation files (the "Software"), to deal+* in the Software without restriction, including without limitation the rights+* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+* copies of the Software, and to permit persons to whom the Software is+* furnished to do so, subject to the following conditions:+*+* The above copyright notice and this permission notice shall be included in+* all copies or substantial portions of the Software.+*+* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN+* THE SOFTWARE.+*/++package org.kohsuke.stapler;++import javax.annotation.CheckForNull;+import javax.annotation.Nonnull;++/**+* Validates dispatch requests. This validator is configured through {@link WebApp#setDispatchValidator(DispatchValidator)}+* and is automatically used by dispatchers created through {@link Facet#createValidatingDispatcher(AbstractTearOff, ScriptExecutor)}.+* Extends {@linkplain Facet#createValidatingDispatcher(AbstractTearOff, ScriptExecutor) facet dispatchers} to provide validation+* of views before they dispatch, thus allowing a final veto before dispatch begins writing any response body to the+* client.+*+* @see WebApp#setDispatchValidator(DispatchValidator)+* @since TODO+*/+public interface DispatchValidator {++/**+* Checks if the given request and response should be allowed to dispatch. Returns {@code null} to indicate an+* unknown or neutral result.+*+* @param req the HTTP request to validate+* @param rsp the HTTP response+* @return true if the request should be dispatched, false if not, or null if unknown or neutral+*/+@CheckForNull Boolean isDispatchAllowed(@Nonnull StaplerRequest req, @Nonnull StaplerResponse rsp);++/**+* Checks if the given request and response should be allowed to dispatch a view on an optionally present node+* object. Returns {@code null} to indicate an unknown or neutral result.+*+* @param req the HTTP request to validate+* @param rsp the HTTP response+* @param viewName the name of the view to dispatch+* @param node the node being dispatched if present+* @return true if the view should be allowed to dispatch, false if it should not, or null if unknown+*/+default @CheckForNull Boolean isDispatchAllowed(@Nonnull StaplerRequest req, @Nonnull StaplerResponse rsp, @Nonnull String viewName, @CheckForNull Object node) {+return isDispatchAllowed(req, rsp);+}++/**+* Allows the given request to be dispatched. Further calls to {@link #isDispatchAllowed(StaplerRequest, StaplerResponse)}+* should return true for the same request.+*/+void allowDispatch(@Nonnull StaplerRequest req, @Nonnull StaplerResponse rsp);++/**+* Throws a {@link CancelRequestHandlingException} if the given request is not+* {@linkplain #isDispatchAllowed(StaplerRequest, StaplerResponse) allowed}.+*/+default void requireDispatchAllowed(@Nonnull StaplerRequest req, @Nonnull StaplerResponse rsp) throws CancelRequestHandlingException {+Boolean allowed = isDispatchAllowed(req, rsp);+if (allowed == null || !allowed) {+throw new CancelRequestHandlingException();+}+}++/**+* Default validator implementation that explicitly allows all dispatch requests to proceed.+*/+DispatchValidator DEFAULT = new DispatchValidator() {+@Override+public Boolean isDispatchAllowed(@Nonnull StaplerRequest req, @Nonnull StaplerResponse rsp) {+return true;+}++@Override+public void allowDispatch(@Nonnull StaplerRequest req, @Nonnull StaplerResponse rsp) {+// no-op+}+};+}
core/src/main/java/org/kohsuke/stapler/Facet.java+141 −8
@@ -27,8 +27,11 @@import org.apache.commons.discovery.resource.ClassLoaders;import org.apache.commons.discovery.resource.names.DiscoverServiceNames;import org.kohsuke.MetaInfServices;+import org.kohsuke.stapler.event.FilteredDispatchTriggerListener;import org.kohsuke.stapler.lang.Klass;+import javax.annotation.CheckForNull;+import javax.annotation.Nonnull;import javax.servlet.RequestDispatcher;import javax.servlet.ServletException;import java.io.IOException;@@ -50,6 +53,7 @@ public abstract class Facet {/*** Adds {@link Dispatcher}s that look at one token and binds that* to the views associated with the 'it' object.+* @see #createValidatingDispatcher(AbstractTearOff, ScriptExecutor)*/public abstract void buildViewDispatchers(MetaClass owner, List<Dispatcher> dispatchers);@@ -89,18 +93,16 @@ public static <T> List<T> discoverExtensions(Class<T> type, ClassLoader... cls)String name = itr.nextResourceName();if (!classNames.add(name)) continue; // avoid duplication-Class<?> c;+Class<? extends T> c;try {-c = cl.loadClass(name);+c = cl.loadClass(name).asSubclass(type);} catch (ClassNotFoundException e) {LOGGER.log(Level.WARNING, "Failed to load "+name,e);continue;}try {-r.add((T)c.newInstance());-} catch (InstantiationException e) {-LOGGER.log(Level.WARNING, "Failed to instantiate "+c,e);-} catch (IllegalAccessException e) {+r.add(c.newInstance());+} catch (InstantiationException | IllegalAccessException e) {LOGGER.log(Level.WARNING, "Failed to instantiate "+c,e);}}@@ -119,12 +121,13 @@ public static <T> List<T> discoverExtensions(Class<T> type, ClassLoader... cls)* @param type* If "it" is non-null, {@code it.getClass()}. Otherwise the class* from which the view is searched.+* @see #createRequestDispatcher(AbstractTearOff, ScriptExecutor, Object, String)*/-public RequestDispatcher createRequestDispatcher(RequestImpl request, Klass<?> type, Object it, String viewName) throws IOException {+@CheckForNull public RequestDispatcher createRequestDispatcher(RequestImpl request, Klass<?> type, Object it, String viewName) throws IOException {return null; // should be really abstract, but not}-public RequestDispatcher createRequestDispatcher(RequestImpl request, Class type, Object it, String viewName) throws IOException {+@CheckForNull public RequestDispatcher createRequestDispatcher(RequestImpl request, Class type, Object it, String viewName) throws IOException {return createRequestDispatcher(request,Klass.java(type),it,viewName);}@@ -133,6 +136,7 @@ public RequestDispatcher createRequestDispatcher(RequestImpl request, Class type** @return* true if the processing succeeds. Otherwise false.+* @see #handleIndexRequest(AbstractTearOff, ScriptExecutor, RequestImpl, ResponseImpl, Object)*/public abstract boolean handleIndexRequest(RequestImpl req, ResponseImpl rsp, Object node, MetaClass nodeMetaClass) throws IOException, ServletException;@@ -162,4 +166,133 @@ protected boolean isBasename(String potentialPath){return true;}}++/**+* Creates a Dispatcher that integrates {@link DispatchValidator} with the provided script loader and executor.+* If an exception or one of its causes is a {@link CancelRequestHandlingException}, this will cause the+* Dispatcher to cancel and return false, thus allowing for further dispatchers to attempt to handle the request.+* This also requires validation to pass before any output can be written to the response.+* In any error case, the configured {@link FilteredDispatchTriggerListener} will be notified.+*+* @param scriptLoader tear-off script loader to find views+* @param scriptExecutor script executor for rendering views+* @param <S> type of script+* @return dispatcher that handles scripts+* @see WebApp#setDispatchValidator(DispatchValidator)+* @see WebApp#setFilteredDispatchTriggerListener(FilteredDispatchTriggerListener)+* @since TODO+*/+@Nonnull protected <S> Dispatcher createValidatingDispatcher(@Nonnull AbstractTearOff<?, ? extends S, ?> scriptLoader,+@Nonnull ScriptExecutor<? super S> scriptExecutor) {+return new Dispatcher() {+@Override+public boolean dispatch(@Nonnull RequestImpl req, @Nonnull ResponseImpl rsp, @CheckForNull Object node) throws ServletException {+String next = req.tokens.peek();+if (next == null) {+return false;+}+// only match end of URL+if (req.tokens.countRemainingTokens() > 1) {+return false;+}+// avoid serving both foo and foo/ as they have different URL semantics+if (req.tokens.endsWithSlash) {+return false;+}+// prevent potential path traversal+if (!isBasename(next)) {+return false;+}+DispatchValidator validator = req.getWebApp().getDispatchValidator();+FilteredDispatchTriggerListener listener = req.getWebApp().getFilteredDispatchTriggerListener();+Boolean valid = validator.isDispatchAllowed(req, rsp, next, node);+if (valid != null && !valid) {+return listener.onDispatchTrigger(req, rsp, node, next);+}+S script;+try {+script = scriptLoader.findScript(next);+} catch (Exception e) {+throw new ServletException(e);+}+if (script == null) {+return false;+}+req.tokens.next();+anonymizedTraceEval(req, rsp, node, "%s: View: %s%s", next, scriptLoader.getDefaultScriptExtension());+if (traceable()) {+trace(req, rsp, "-> %s on <%s>", next, node);+}+try {+scriptExecutor.execute(req, rsp, script, node);+return true;+} catch (Exception e) {+req.tokens.prev();+for (Throwable cause = e; cause != null; cause = cause.getCause()) {+if (cause instanceof CancelRequestHandlingException) {+return listener.onDispatchTrigger(req, rsp, node, next);+}+}+throw new ServletException(e);+}+}++@Override+public String toString() {+return "VIEW" + scriptLoader.getDefaultScriptExtension() + " for url=/VIEW";+}+};+}++/**+* Handles an index request by dispatching a script.+* @since TODO+*/+protected <S> boolean handleIndexRequest(@Nonnull AbstractTearOff<?, ? extends S, ?> scriptLoader,+@Nonnull ScriptExecutor<? super S> scriptExecutor,+@Nonnull RequestImpl req,+@Nonnull ResponseImpl rsp,+@CheckForNull Object node)+throws ServletException, IOException {+S script;+try {+script = scriptLoader.findScript("index");+} catch (Exception e) {+throw new ServletException(e);+}+if (script == null) {+return false;+}+Dispatcher.anonymizedTraceEval(req, rsp, node, "Index: index%s", scriptLoader.getDefaultScriptExtension());+if (Dispatcher.traceable()) {+Dispatcher.trace(req, rsp, "-> index on <%s>", node);+}+try {+scriptExecutor.execute(req, rsp, script, node);+return true;+} catch (Exception e) {+throw new ServletException(e);+}+}++/**+* Creates a RequestDispatcher that integrates with {@link DispatchValidator} and+* {@link FilteredDispatchTriggerListener}.+*+* @param scriptLoader tear-off script loader for finding views+* @param scriptExecutor script executor for rendering views+* @param it the model node being dispatched against+* @param viewName name of the view to load and execute+* @param <S> view type+* @return a RequestDispatcher that performs similar validation to {@link #createValidatingDispatcher(AbstractTearOff, ScriptExecutor)}+* @see WebApp#setDispatchValidator(DispatchValidator)+* @see WebApp#setFilteredDispatchTriggerListener(FilteredDispatchTriggerListener)+* @since TODO+*/+@CheckForNull protected <S> RequestDispatcher createRequestDispatcher(@Nonnull AbstractTearOff<?, ? extends S, ?> scriptLoader,+@Nonnull ScriptExecutor<? super S> scriptExecutor,+@CheckForNull Object it,+@Nonnull String viewName) {+return ScriptRequestDispatcher.newRequestDispatcher(scriptLoader, scriptExecutor, viewName, it);+}}
core/src/main/java/org/kohsuke/stapler/IndexViewDispatcher.java+2 −0
@@ -27,6 +27,8 @@ public boolean dispatch(RequestImpl req, ResponseImpl rsp, Object node) throws Iif (req.tokens.hasMore())return false;+// always allow index views to be dispatched+req.getWebApp().getDispatchValidator().allowDispatch(req, rsp);return facet.handleIndexRequest(req, rsp, node, metaClass);}
core/src/main/java/org/kohsuke/stapler/ScriptExecutor.java+42 −0
@@ -0,0 +1,42 @@+/*+* The MIT License+*+* Copyright (c) 2019 CloudBees, Inc.+*+* Permission is hereby granted, free of charge, to any person obtaining a copy+* of this software and associated documentation files (the "Software"), to deal+* in the Software without restriction, including without limitation the rights+* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+* copies of the Software, and to permit persons to whom the Software is+* furnished to do so, subject to the following conditions:+*+* The above copyright notice and this permission notice shall be included in+* all copies or substantial portions of the Software.+*+* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN+* THE SOFTWARE.+*/++package org.kohsuke.stapler;++import javax.annotation.CheckForNull;+import javax.annotation.Nonnull;++/**+* Execution strategy for handling views written in other scripting languages.+*+* @param <S> script type+* @since TODO+*/+public interface ScriptExecutor<S> {++/**+* Executes the given script on the given node and request, rendering output to the given response.+*/+void execute(@Nonnull StaplerRequest req, @Nonnull StaplerResponse rsp, @Nonnull S script, @CheckForNull Object it) throws Exception;+}
core/src/main/java/org/kohsuke/stapler/ScriptRequestDispatcher.java+108 −0
@@ -0,0 +1,108 @@+/*+* The MIT License+*+* Copyright (c) 2019 CloudBees, Inc.+*+* Permission is hereby granted, free of charge, to any person obtaining a copy+* of this software and associated documentation files (the "Software"), to deal+* in the Software without restriction, including without limitation the rights+* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+* copies of the Software, and to permit persons to whom the Software is+* furnished to do so, subject to the following conditions:+*+* The above copyright notice and this permission notice shall be included in+* all copies or substantial portions of the Software.+*+* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN+* THE SOFTWARE.+*/++package org.kohsuke.stapler;++import javax.annotation.CheckForNull;+import javax.annotation.Nonnull;+import javax.servlet.RequestDispatcher;+import javax.servlet.ServletException;+import javax.servlet.ServletRequest;+import javax.servlet.ServletResponse;+import java.io.IOException;+import java.util.logging.Level;+import java.util.logging.Logger;++/**+* Implements a RequestDispatcher for a given model node and view. Unlike dispatchers created through+* {@link Facet#createValidatingDispatcher(AbstractTearOff, ScriptExecutor)}, these dispatchers always allow scripts+* to be dispatched.+* @param <S> script view type+* @since TODO+*/+class ScriptRequestDispatcher<S> implements RequestDispatcher {++private static final Logger LOGGER = Logger.getLogger(ScriptRequestDispatcher.class.getName());++@CheckForNull static <S> ScriptRequestDispatcher<S> newRequestDispatcher(@Nonnull AbstractTearOff<?, ? extends S, ?> scriptLoader,+@Nonnull ScriptExecutor<? super S> scriptExecutor,+@Nonnull String viewName,+@CheckForNull Object node) {+S script;+try {+script = scriptLoader.findScript(viewName);+} catch (Exception e) {+LOGGER.log(Level.WARNING, e, () -> "Could not load requested view " + viewName + " on model class " + (node == null ? null : node.getClass().getName()));+return null;+}+if (script == null) {+return null;+}+return new ScriptRequestDispatcher<>(scriptLoader.getDefaultScriptExtension(), scriptExecutor, viewName, script, node);+}++private final @Nonnull String defaultScriptExtension;+private final @Nonnull ScriptExecutor<? super S> scriptExecutor;+private final @Nonnull String viewName;+private final @Nonnull S script;+private final @CheckForNull Object node;++private ScriptRequestDispatcher(@Nonnull String defaultScriptExtension,+@Nonnull ScriptExecutor<? super S> scriptExecutor,+@Nonnull String viewName,+@Nonnull S script,+@CheckForNull Object node) {+this.defaultScriptExtension = defaultScriptExtension;+this.scriptExecutor = scriptExecutor;+this.viewName = viewName;+this.script = script;+this.node = node;+}+++@Override+public void forward(ServletRequest request, ServletResponse response) throws ServletException, IOException {+StaplerRequest req = (StaplerRequest) request;+StaplerResponse rsp = (StaplerResponse) response;+DispatchValidator validator = req.getWebApp().getDispatchValidator();+validator.allowDispatch(req, rsp);+try {+Dispatcher.anonymizedTraceEval(req, rsp, node, "%s: View: %s%s", viewName, defaultScriptExtension);+if (Dispatcher.traceable()) {+Dispatcher.trace(req, rsp, "-> %s on <%s>", viewName, node);+}+scriptExecutor.execute(req, rsp, script, node);+} catch (ServletException | IOException | RuntimeException e) {+throw e;+} catch (Exception e) {+throw new ServletException(e);+}++}++@Override+public void include(ServletRequest request, ServletResponse response) throws ServletException, IOException {+forward(request, response);+}+}
core/src/main/java/org/kohsuke/stapler/WebApp.java+34 −2
@@ -28,6 +28,7 @@import org.kohsuke.stapler.event.FilteredDoActionTriggerListener;import org.kohsuke.stapler.event.FilteredFieldTriggerListener;import org.kohsuke.stapler.event.FilteredGetterTriggerListener;+import org.kohsuke.stapler.event.FilteredDispatchTriggerListener;import org.kohsuke.stapler.lang.FieldRef;import org.kohsuke.stapler.lang.KInstance;import org.kohsuke.stapler.lang.Klass;@@ -145,12 +146,15 @@ public static WebApp get(ServletContext context) {private FunctionList.Filter filterForGetMethods = FunctionList.Filter.ALWAYS_OK;private FunctionList.Filter filterForDoActions = FunctionList.Filter.ALWAYS_OK;private FieldRef.Filter filterForFields = FieldRef.Filter.ALWAYS_OK;-+private DispatchersFilter dispatchersFilter;private FilteredDoActionTriggerListener filteredDoActionTriggerListener = FilteredDoActionTriggerListener.JUST_WARN;private FilteredGetterTriggerListener filteredGetterTriggerListener = FilteredGetterTriggerListener.JUST_WARN;private FilteredFieldTriggerListener filteredFieldTriggerListener = FilteredFieldTriggerListener.JUST_WARN;-++private DispatchValidator dispatchValidator = DispatchValidator.DEFAULT;+private FilteredDispatchTriggerListener filteredDispatchTriggerListener = FilteredDispatchTriggerListener.JUST_WARN;+/*** Give the application a way to customize the JSON before putting it inside Stacktrace when something wrong happened.* By default it just returns the given JSON.@@ -412,4 +416,32 @@ public JsonInErrorMessageSanitizer getJsonInErrorMessageSanitizer() {public void setJsonInErrorMessageSanitizer(JsonInErrorMessageSanitizer jsonInErrorMessageSanitizer) {this.jsonInErrorMessageSanitizer = jsonInErrorMessageSanitizer;}++public DispatchValidator getDispatchValidator() {+if (dispatchValidator == null) {+dispatchValidator = DispatchValidator.DEFAULT;+}+return dispatchValidator;+}++/**+* Sets the validator used with facet dispatchers.+*/+public void setDispatchValidator(DispatchValidator dispatchValidator) {+this.dispatchValidator = dispatchValidator;+}++public FilteredDispatchTriggerListener getFilteredDispatchTriggerListener() {+if (filteredDispatchTriggerListener == null) {+filteredDispatchTriggerListener = FilteredDispatchTriggerListener.JUST_WARN;+}+return filteredDispatchTriggerListener;+}++/**+* Sets the event listener used for reacting to filtered dispatch requests.+*/+public void setFilteredDispatchTriggerListener(FilteredDispatchTriggerListener filteredDispatchTriggerListener) {+this.filteredDispatchTriggerListener = filteredDispatchTriggerListener;+}}
core/src/main/java/org/kohsuke/stapler/event/FilteredDispatchTriggerListener.java+50 −0
@@ -0,0 +1,50 @@+/*+* The MIT License+*+* Copyright (c) 2019 CloudBees, Inc.+*+* Permission is hereby granted, free of charge, to any person obtaining a copy+* of this software and associated documentation files (the "Software"), to deal+* in the Software without restriction, including without limitation the rights+* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell+* copies of the Software, and to permit persons to whom the Software is+* furnished to do so, subject to the following conditions:+*+* The above copyright notice and this permission notice shall be included in+* all copies or substantial portions of the Software.+*+* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,+* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE+* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER+* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,+* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN+* THE SOFTWARE.+*/++package org.kohsuke.stapler.event;++import org.kohsuke.stapler.StaplerRequest;+import org.kohsuke.stapler.StaplerResponse;++import java.util.logging.Logger;++/**+* Listens to filtered dispatch events from {@link org.kohsuke.stapler.DispatchValidator}.+*+* @since TODO+* @see org.kohsuke.stapler.WebApp#setFilteredDispatchTriggerListener(FilteredDispatchTriggerListener)+*/+public interface FilteredDispatchTriggerListener {+boolean onDispatchTrigger(StaplerRequest req, StaplerResponse rsp, Object node, String viewName);++FilteredDispatchTriggerListener JUST_WARN = new FilteredDispatchTriggerListener() {+private final Logger LOGGER = Logger.getLogger(FilteredDispatchTriggerListener.class.getName());++@Override+public boolean onDispatchTrigger(StaplerRequest req, StaplerResponse rsp, Object node, String viewName) {+LOGGER.warning(() -> "BLOCKED -> <" + node + ">." + viewName);+return false;+}+};+}
More files changed — see the full commit.
References
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2019-10354
- WEBhttps://github.com/jenkinsci/jenkins/commit/279d8109eddb7a494428baf25af9756c2e33576b
- WEBhttps://github.com/jenkinsci/stapler/commit/19637555a9f32d3875356b47234131d8b1e9fee4
- WEBhttps://access.redhat.com/errata/RHSA-2019:2503
- WEBhttps://access.redhat.com/errata/RHSA-2019:2548
- WEBhttps://jenkins.io/security/advisory/2019-07-17/#SECURITY-534
- WEBhttp://www.openwall.com/lists/oss-security/2019/07/17/2