From 4cec03ee1142b8b31d16ad6f5822790f0a1999bf Mon Sep 17 00:00:00 2001 From: Devin Nusbaum Date: Wed, 4 May 2022 15:04:53 -0400 Subject: [PATCH] [SECURITY-359] (cherry picked from commit 76a7681702f42d65f77bbaa5463f146876ea62db) --- .../workflow/cps/CpsGroovyShellFactory.java | 2 +- .../cps/GroovySourceFileAllowlist.java | 224 ++++++++++++++++++ .../default-allowlist | 53 +++++ .../workflow/cps/CpsFlowExecutionTest.java | 67 +++++- 4 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/jenkinsci/plugins/workflow/cps/GroovySourceFileAllowlist.java create mode 100644 src/main/resources/org/jenkinsci/plugins/workflow/cps/GroovySourceFileAllowlist/default-allowlist diff --git a/src/main/java/org/jenkinsci/plugins/workflow/cps/CpsGroovyShellFactory.java b/src/main/java/org/jenkinsci/plugins/workflow/cps/CpsGroovyShellFactory.java index 45332ea8f..7681ea651 100644 --- a/src/main/java/org/jenkinsci/plugins/workflow/cps/CpsGroovyShellFactory.java +++ b/src/main/java/org/jenkinsci/plugins/workflow/cps/CpsGroovyShellFactory.java @@ -111,7 +111,7 @@ private ImportCustomizer makeImportCustomizer() { private ClassLoader makeClassLoader() { ClassLoader cl = Jenkins.get().getPluginManager().uberClassLoader; - return GroovySandbox.createSecureClassLoader(cl); + return new GroovySourceFileAllowlist.ClassLoaderImpl(execution, GroovySandbox.createSecureClassLoader(cl)); } public CpsGroovyShell build() { diff --git a/src/main/java/org/jenkinsci/plugins/workflow/cps/GroovySourceFileAllowlist.java b/src/main/java/org/jenkinsci/plugins/workflow/cps/GroovySourceFileAllowlist.java new file mode 100644 index 000000000..4d4e66a24 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/workflow/cps/GroovySourceFileAllowlist.java @@ -0,0 +1,224 @@ +/* + * The MIT License + * + * Copyright 2022 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.jenkinsci.plugins.workflow.cps; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import hudson.Extension; +import hudson.ExtensionList; +import hudson.ExtensionPoint; +import hudson.Main; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.apache.commons.lang.StringUtils; + +/** + * Determines what Groovy source files can be loaded in Pipelines. + * + * In Pipeline, the standard behavior of {@code GroovyClassLoader} would allow Groovy source files from core or plugins + * to be loaded as long as they are somewhere on the classpath. This includes things like Groovy views, which are not + * intended to be available to pipelines. When these files are loaded, they are loaded by the trusted + * {@link CpsGroovyShell} and are not sandbox-transformed, which means that allowing arbitrary Groovy source files to + * be loaded is potentially unsafe. + * + * {@link ClassLoaderImpl} blocks all Groovy source files from being loaded by default unless they are allowed by an + * implementation of this extension point. + */ +public abstract class GroovySourceFileAllowlist implements ExtensionPoint { + private static final Logger LOGGER = Logger.getLogger(GroovySourceFileAllowlist.class.getName()); + private static final String DISABLED_PROPERTY = GroovySourceFileAllowlist.class.getName() + ".DISABLED"; + @SuppressFBWarnings(value = "MS_SHOULD_BE_FINAL", justification = "Non-final for script console access") + static boolean DISABLED = Boolean.getBoolean(DISABLED_PROPERTY); + + /** + * Checks whether a given Groovy source file is allowed to be loaded by {@link CpsFlowExecution#getTrustedShell}. + * + * @param groovySourceFileUrl the absolute URL to the Groovy source file as returned by {@link ClassLoader#getResource} + * @return {@code true} if the Groovy source file may be loaded, {@code false} otherwise + */ + public abstract boolean isAllowed(String groovySourceFileUrl); + + public static List all() { + return ExtensionList.lookup(GroovySourceFileAllowlist.class); + } + + /** + * {@link ClassLoader} that acts normally except for returning {@code null} from {@link #getResource} and + * {@link #getResources} when looking up Groovy source files if the files are not allowed by + * {@link GroovySourceFileAllowlist}. + */ + static class ClassLoaderImpl extends ClassLoader { + private static final String LOG_MESSAGE_TEMPLATE = + "Preventing {0} from being loaded without sandbox protection in {1}. " + + "To allow access to this file, add any suffix of its URL to the system property ‘" + + DefaultAllowlist.ALLOWED_SOURCE_FILES_PROPERTY + "’ (use commas to separate multiple files). If you " + + "want to allow any Groovy file on the Jenkins classpath to be accessed, you may set the system " + + "property ‘" + DISABLED_PROPERTY + "’ to true."; + + private final String owner; + + public ClassLoaderImpl(@CheckForNull CpsFlowExecution execution, ClassLoader parent) { + super(parent); + this.owner = describeOwner(execution); + } + + private static String describeOwner(@CheckForNull CpsFlowExecution execution) { + if (execution != null) { + try { + return execution.getOwner().getExecutable().toString(); + } catch (IOException e) { + // Not significant in this context. + } + } + return "unknown"; + } + + @Override + public URL getResource(String name) { + URL url = super.getResource(name); + if (DISABLED || url == null || !endsWithIgnoreCase(name, ".groovy") || isAllowed(url)) { + return url; + } + // Note: This message gets printed twice because of https://github.com/apache/groovy/blob/41b990d0a20e442f29247f0e04cbed900f3dcad4/src/main/org/codehaus/groovy/control/ClassNodeResolver.java#L184-L186. + LOGGER.log(Level.WARNING, LOG_MESSAGE_TEMPLATE, new Object[] { url, owner }); + return null; + } + + @Override + public Enumeration getResources(String name) throws IOException { + Enumeration urls = super.getResources(name); + if (DISABLED || !urls.hasMoreElements() || !endsWithIgnoreCase(name, ".groovy")) { + return urls; + } + List filteredUrls = new ArrayList<>(); + while (urls.hasMoreElements()) { + URL url = urls.nextElement(); + if (isAllowed(url)) { + filteredUrls.add(url); + } else { + LOGGER.log(Level.WARNING, LOG_MESSAGE_TEMPLATE, new Object[] { url, owner }); + } + } + return Collections.enumeration(filteredUrls); + } + + private static boolean isAllowed(URL url) { + String urlString = url.toString(); + for (GroovySourceFileAllowlist allowlist : GroovySourceFileAllowlist.all()) { + if (allowlist.isAllowed(urlString)) { + return true; + } + } + return false; + } + + private static boolean endsWithIgnoreCase(String value, String suffix) { + int suffixLength = suffix.length(); + return value.regionMatches(true, value.length() - suffixLength, suffix, 0, suffixLength); + } + } + + /** + * Allows Groovy source files used to implement DSLs in plugins that were created before + * {@link GroovySourceFileAllowlist} was introduced. + */ + @Extension + public static class DefaultAllowlist extends GroovySourceFileAllowlist { + private static final Logger LOGGER = Logger.getLogger(DefaultAllowlist.class.getName()); + private static final String ALLOWED_SOURCE_FILES_PROPERTY = DefaultAllowlist.class.getCanonicalName() + ".ALLOWED_SOURCE_FILES"; + /** + * A list containing suffixes of known-good Groovy source file URLs that need to be accessible to Pipeline code. + */ + /* Note: Actual ClassLoader resource URLs depend on environmental factors such as webroot settings and whether + * we are currently testing one of the plugins in the list, so default-allowlist only contains the path + * component of the resource URLs, and we allow any resource URL that ends with one of the entries in the list. + * + * We could try to load the exact URLs at runtime, but then we would have to account for dynamic plugin loading + * (especially when a new Jenkins controller is initialized) and the fact that workflow-cps is always a + * dependency of these plugins. + */ + static final List ALLOWED_SOURCE_FILES = new ArrayList<>(); + + public DefaultAllowlist() throws IOException { + // We load custom entries first to improve performance in case .groovy is used for the property. + String propertyValue = System.getProperty(ALLOWED_SOURCE_FILES_PROPERTY, ""); + for (String groovyFile : propertyValue.split(",")) { + groovyFile = StringUtils.trimToNull(groovyFile); + if (groovyFile != null) { + if (groovyFile.endsWith(".groovy")) { + ALLOWED_SOURCE_FILES.add(groovyFile); + LOGGER.log(Level.INFO, "Allowing Pipelines to access {0}", groovyFile); + } else { + LOGGER.log(Level.WARNING, "Ignoring invalid Groovy source file: {0}", groovyFile); + } + } + } + loadDefaultAllowlist(ALLOWED_SOURCE_FILES); + // Some plugins use test-specific Groovy DSLs. + if (Main.isUnitTest) { + ALLOWED_SOURCE_FILES.addAll(Arrays.asList( + // pipeline-model-definition + "/org/jenkinsci/plugins/pipeline/modeldefinition/agent/impl/LabelAndOtherFieldAgentScript.groovy", + "/org/jenkinsci/plugins/pipeline/modeldefinition/parser/GlobalStageNameTestConditionalScript.groovy" + )); + } + } + + private static void loadDefaultAllowlist(List allowlist) throws IOException { + try (InputStream is = GroovySourceFileAllowlist.class.getResourceAsStream("GroovySourceFileAllowlist/default-allowlist"); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8));) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (!line.isEmpty() && !line.startsWith("#")) { + allowlist.add(line); + } + } + } + } + + @Override + public boolean isAllowed(String groovySourceFileUrl) { + for (String sourceFile : ALLOWED_SOURCE_FILES) { + if (groovySourceFileUrl.endsWith(sourceFile)) { + return true; + } + } + return false; + } + } + +} diff --git a/src/main/resources/org/jenkinsci/plugins/workflow/cps/GroovySourceFileAllowlist/default-allowlist b/src/main/resources/org/jenkinsci/plugins/workflow/cps/GroovySourceFileAllowlist/default-allowlist new file mode 100644 index 000000000..132684793 --- /dev/null +++ b/src/main/resources/org/jenkinsci/plugins/workflow/cps/GroovySourceFileAllowlist/default-allowlist @@ -0,0 +1,53 @@ +# This list is ordered from most popular to least popular plugin to minimize performance impact. +# pipeline-model-definition +/org/jenkinsci/plugins/pipeline/modeldefinition/ModelInterpreter.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/agent/impl/AnyScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/agent/impl/LabelScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/agent/impl/NoneScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/when/impl/AbstractChangelogConditionalScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/when/impl/AllOfConditionalScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/when/impl/AnyOfConditionalScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/when/impl/BranchConditionalScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/when/impl/ChangeLogConditionalScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/when/impl/ChangeRequestConditionalScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/when/impl/ChangeSetConditionalScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/when/impl/EnvironmentConditionalScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/when/impl/EqualsConditionalScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/when/impl/ExpressionConditionalScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/when/impl/IsRestartedRunConditionalScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/when/impl/NotConditionalScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/when/impl/TagConditionalScript.groovy +/org/jenkinsci/plugins/pipeline/modeldefinition/when/impl/TriggeredByConditionalScript.groovy +# pipeline-model-extensions +/org/jenkinsci/plugins/pipeline/modeldefinition/agent/CheckoutScript.groovy +# docker-workflow +/org/jenkinsci/plugins/docker/workflow/Docker.groovy +/org/jenkinsci/plugins/docker/workflow/declarative/AbstractDockerPipelineScript.groovy +/org/jenkinsci/plugins/docker/workflow/declarative/DockerPipelineFromDockerfileScript.groovy +/org/jenkinsci/plugins/docker/workflow/declarative/DockerPipelineScript.groovy +# kubernetes +/org/csanchez/jenkins/plugins/kubernetes/pipeline/KubernetesDeclarativeAgentScript.groovy +# amazon-ecs +/com/cloudbees/jenkins/plugins/amazonecs/pipeline/ECSDeclarativeAgentScript.groovy +# workflow-remote-loader: +/org/jenkinsci/plugins/workflow/remoteloader/FileLoaderDSL/FileLoaderDSLImpl.groovy +# confluence-publisher +/com/myyearbook/hudson/plugins/confluence/publishConfluence.groovy +# openshift-client +/com/openshift/jenkins/plugins/OpenShiftDSL.groovy +# ownership: +/org/jenkinsci/plugins/ownership/model/workflow/OwnershipGlobalVariable/Impl.groovy +# templating-engine: +/org/boozallen/plugins/jte/init/primitives/hooks/AnnotatedMethod.groovy +/org/boozallen/plugins/jte/init/primitives/hooks/Hooks.groovy +# datetime-constraint +/org/jenkinsci/plugins/curfew/Checkpoint.groovy +/org/jenkinsci/plugins/curfew/Curfew.groovy +# redis-notifier +/com/tsoft/jenkins/plugin/RedisClient.groovy +# alauda-pipeline +/io/alauda/jenkins/plugins/pipeline/AlaudaDSL.groovy +# alauda-devops-pipeline +/com/alauda/jenkins/plugins/AlaudaDevopsDSL.groovy +/com/alauda/jenkins/plugins/AlaudaPlatformDSL.groovy +/com/alauda/jenkins/plugins/StorageDSL.groovy diff --git a/src/test/java/org/jenkinsci/plugins/workflow/cps/CpsFlowExecutionTest.java b/src/test/java/org/jenkinsci/plugins/workflow/cps/CpsFlowExecutionTest.java index 87e63d9f7..4a111813d 100644 --- a/src/test/java/org/jenkinsci/plugins/workflow/cps/CpsFlowExecutionTest.java +++ b/src/test/java/org/jenkinsci/plugins/workflow/cps/CpsFlowExecutionTest.java @@ -50,6 +50,7 @@ import org.hamcrest.Matchers; import org.jenkinsci.plugins.scriptsecurity.sandbox.RejectedAccessException; import org.jenkinsci.plugins.scriptsecurity.sandbox.whitelists.Whitelisted; +import org.jenkinsci.plugins.workflow.cps.GroovySourceFileAllowlist.DefaultAllowlist; import org.jenkinsci.plugins.workflow.flow.FlowExecution; import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner; import org.jenkinsci.plugins.workflow.job.WorkflowJob; @@ -61,11 +62,16 @@ import org.jenkinsci.plugins.workflow.support.pickles.TryRepeatedly; import org.jenkinsci.plugins.workflow.test.steps.SemaphoreStep; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; + import static org.junit.Assert.*; + import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.jvnet.hudson.test.BuildWatcher; +import org.jvnet.hudson.test.FlagRule; import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.JenkinsSessionRule; @@ -78,6 +84,10 @@ public class CpsFlowExecutionTest { @ClassRule public static BuildWatcher buildWatcher = new BuildWatcher(); @Rule public JenkinsSessionRule sessions = new JenkinsSessionRule(); @Rule public LoggerRule logger = new LoggerRule(); + @Rule public FlagRule secretField = new FlagRule<>(() -> CpsFlowExecutionTest.SECRET, v -> CpsFlowExecutionTest.SECRET = v); + // We intentionally avoid using the static fields so that tests can call setProperty before the classes are initialized. + @Rule public FlagRule groovySourceFileAllowlistDisabled = FlagRule.systemProperty("org.jenkinsci.plugins.workflow.cps.GroovySourceFileAllowlist.DISABLED"); + @Rule public FlagRule groovySourceFileAllowlistFiles = FlagRule.systemProperty("org.jenkinsci.plugins.workflow.cps.GroovySourceFileAllowlist.DefaultAllowlist.ALLOWED_SOURCE_FILES"); @Test public void getCurrentExecutions() throws Throwable { sessions.then(r -> { @@ -436,7 +446,6 @@ public void configureShell(@CheckForNull CpsFlowExecution context, GroovyShell s } private void trustedShell(final boolean pos) throws Throwable { - SECRET = false; sessions.then(r -> { WorkflowJob p = r.jenkins.createProject(WorkflowJob.class, "p"); p.setDefinition(new CpsFlowDefinition("new foo().attempt()", true)); @@ -459,4 +468,60 @@ private void trustedShell(final boolean pos) throws Throwable { * This field shouldn't be visible to regular script. */ public static boolean SECRET; + + @Issue("SECURITY-359") + @Test public void groovySourcesCannotBeUsedByDefault() throws Throwable { + logger.record(GroovySourceFileAllowlist.class, Level.INFO).capture(100); + sessions.then(r -> { + WorkflowJob p = r.createProject(WorkflowJob.class); + p.setDefinition(new CpsFlowDefinition( + "new hudson.model.View.main()", true)); + WorkflowRun b = r.buildAndAssertStatus(Result.FAILURE, p); + r.assertLogContains("unable to resolve class hudson.model.View.main", b); + assertThat(logger.getMessages(), hasItem(containsString("/hudson/model/View/main.groovy from being loaded without sandbox protection in " + b))); + }); + } + + @Issue("SECURITY-359") + @Test public void groovySourcesCanBeUsedIfAllowlistIsDisabled() throws Throwable { + System.setProperty("org.jenkinsci.plugins.workflow.cps.GroovySourceFileAllowlist.DISABLED", "true"); + sessions.then(r -> { + WorkflowJob p = r.createProject(WorkflowJob.class); + p.setDefinition(new CpsFlowDefinition( + "new hudson.model.View.main()", true)); + WorkflowRun b = r.buildAndAssertSuccess(p); + }); + } + + @Issue("SECURITY-359") + @Test public void groovySourcesCanBeUsedIfAddedToSystemProperty() throws Throwable { + System.setProperty("org.jenkinsci.plugins.workflow.cps.GroovySourceFileAllowlist.DefaultAllowlist.ALLOWED_SOURCE_FILES", "/just/an/example.groovy,/hudson/model/View/main.groovy"); + logger.record(DefaultAllowlist.class, Level.INFO).capture(100); + sessions.then(r -> { + WorkflowJob p = r.createProject(WorkflowJob.class); + p.setDefinition(new CpsFlowDefinition( + "new hudson.model.View.main()", true)); + WorkflowRun b = r.buildAndAssertSuccess(p); + assertThat(logger.getMessages(), hasItem(containsString("Allowing Pipelines to access /hudson/model/View/main.groovy"))); + }); + } + + @Issue("SECURITY-359") + @Test public void groovySourcesCanBeUsedIfAllowed() throws Throwable { + sessions.then(r -> { + WorkflowJob p = r.createProject(WorkflowJob.class); + p.setDefinition(new CpsFlowDefinition( + "(new trusted.foo()).attempt()", true)); + WorkflowRun b = r.buildAndAssertSuccess(p); + assertTrue(SECRET); + }); + } + + @TestExtension("groovySourcesCanBeUsedIfAllowed") + public static class TestAllowlist extends GroovySourceFileAllowlist { + @Override + public boolean isAllowed(String groovyResourceUrl) { + return groovyResourceUrl.endsWith("/trusted/foo.groovy"); + } + } }