Skip to content

Commit

Permalink
[SECURITY-359]
Browse files Browse the repository at this point in the history
  • Loading branch information
dwnusbaum committed May 4, 2022
1 parent 6bd4e8b commit 76a7681
Show file tree
Hide file tree
Showing 4 changed files with 343 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/*
* 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 jenkins.util.SystemProperties;
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 = SystemProperties.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<GroovySourceFileAllowlist> 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<URL> getResources(String name) throws IOException {
Enumeration<URL> urls = super.getResources(name);
if (DISABLED || !urls.hasMoreElements() || !endsWithIgnoreCase(name, ".groovy")) {
return urls;
}
List<URL> 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<String> 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 = SystemProperties.getString(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<String> 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;
}
}

}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -61,6 +62,8 @@
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.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
Expand All @@ -71,6 +74,7 @@
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;
Expand All @@ -83,6 +87,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<Boolean> 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<String> groovySourceFileAllowlistDisabled = FlagRule.systemProperty("org.jenkinsci.plugins.workflow.cps.GroovySourceFileAllowlist.DISABLED");
@Rule public FlagRule<String> groovySourceFileAllowlistFiles = FlagRule.systemProperty("org.jenkinsci.plugins.workflow.cps.GroovySourceFileAllowlist.DefaultAllowlist.ALLOWED_SOURCE_FILES");

@Test public void getCurrentExecutions() throws Throwable {
sessions.then(r -> {
Expand Down Expand Up @@ -441,7 +449,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));
Expand All @@ -464,4 +471,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");
}
}
}

0 comments on commit 76a7681

Please sign in to comment.