Skip to content

Commit 6bdb5a5

Browse files
authored
Merge branch 'master' into CpsStepContext.completed
2 parents d17e71c + 8732e31 commit 6bdb5a5

File tree

10 files changed

+559
-11
lines changed

10 files changed

+559
-11
lines changed

.github/workflows/cd.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3232

3333
- name: Check interesting categories
34-
uses: jenkins-infra/interesting-category-action@v1.0.0
34+
uses: jenkins-infra/interesting-category-action@v1.1.0
3535
id: interesting-categories
3636
if: steps.verify-ci-status.outputs.result == 'success'
3737
with:
@@ -47,12 +47,12 @@ jobs:
4747
with:
4848
fetch-depth: 0
4949
- name: Set up JDK 8
50-
uses: actions/setup-java@v2
50+
uses: actions/setup-java@v3
5151
with:
52-
distribution: 'adopt'
52+
distribution: temurin
5353
java-version: 8
5454
- name: Release
55-
uses: jenkins-infra/jenkins-maven-cd-action@v1.2.0
55+
uses: jenkins-infra/jenkins-maven-cd-action@v1.3.0
5656
with:
5757
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5858
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}

pom.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
<dependency>
102102
<groupId>org.jenkins-ci.plugins</groupId>
103103
<artifactId>script-security</artifactId>
104+
<version>1172.v35f6a_0b_8207e</version> <!-- TODO: Remove once this version is included in BOM. -->
104105
</dependency>
105106
<dependency>
106107
<groupId>org.jenkins-ci.plugins</groupId>
@@ -148,6 +149,7 @@
148149
<groupId>org.jenkins-ci.plugins.workflow</groupId>
149150
<artifactId>workflow-job</artifactId>
150151
<scope>test</scope>
152+
<version>1181.va_25d15548158</version> <!-- TODO: Remove once this version is included in BOM. -->
151153
</dependency>
152154
<dependency>
153155
<groupId>org.jenkins-ci.plugins.workflow</groupId>

src/main/java/org/jenkinsci/plugins/workflow/cps/CpsFlowDefinition.java

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
package org.jenkinsci.plugins.workflow.cps;
2626

27+
import edu.umd.cs.findbugs.annotations.NonNull;
2728
import hudson.Extension;
2829
import hudson.model.Action;
2930
import hudson.model.Item;
@@ -33,6 +34,8 @@
3334
import hudson.model.TaskListener;
3435
import hudson.util.FormValidation;
3536
import hudson.util.StreamTaskListener;
37+
import net.sf.json.JSONObject;
38+
import org.apache.commons.lang.StringUtils;
3639
import org.jenkinsci.plugins.workflow.cps.persistence.PersistIn;
3740
import org.jenkinsci.plugins.workflow.flow.DurabilityHintProvider;
3841
import org.jenkinsci.plugins.workflow.flow.FlowDefinition;
@@ -80,7 +83,8 @@ public CpsFlowDefinition(String script) {
8083
@DataBoundConstructor
8184
public CpsFlowDefinition(String script, boolean sandbox) {
8285
StaplerRequest req = Stapler.getCurrentRequest();
83-
this.script = sandbox ? script : ScriptApproval.get().configuring(script, GroovyLanguage.get(), ApprovalContext.create().withCurrentUser().withItemAsKey(req != null ? req.findAncestorObject(Item.class) : null));
86+
this.script = sandbox ? script : ScriptApproval.get().configuring(script, GroovyLanguage.get(),
87+
ApprovalContext.create().withCurrentUser().withItemAsKey(req != null ? req.findAncestorObject(Item.class) : null), req == null);
8488
this.sandbox = sandbox;
8589
}
8690

@@ -123,14 +127,41 @@ public CpsFlowExecution create(FlowExecutionOwner owner, TaskListener listener,
123127
@Extension
124128
public static class DescriptorImpl extends FlowDefinitionDescriptor {
125129

130+
/* In order to fix SECURITY-2450 without causing significant UX regressions, we decided to continue to
131+
* automatically approve scripts on save if the script was modified by an administrator. To make this possible,
132+
* we added a new hidden input field to the config.jelly to track the pre-save version of the script. Since
133+
* CpsFlowDefinition calls ScriptApproval.configuring in its @DataBoundConstructor, the normal way to handle
134+
* things would be to add an oldScript parameter to the constructor and perform the relevant logic there.
135+
*
136+
* However, that would have compatibility implications for tools like JobDSL, since @DataBoundConstructor
137+
* parameters are required. We cannot use a @DataBoundSetter with a corresponding field and getter to trivially
138+
* make oldScript optional, because we would need to call ScriptApproval.configuring after all
139+
* @DataBoundSetters have been invoked (rather than in the @DataBoundConstructor), which is why we use Descriptor.newInstance.
140+
*/
141+
@Override
142+
public FlowDefinition newInstance(@NonNull StaplerRequest req, @NonNull JSONObject formData) throws FormException {
143+
CpsFlowDefinition cpsFlowDefinition = (CpsFlowDefinition) super.newInstance(req, formData);
144+
if (!cpsFlowDefinition.sandbox && formData.get("oldScript") != null) {
145+
String oldScript = formData.getString("oldScript");
146+
boolean approveIfAdmin = !StringUtils.equals(oldScript, cpsFlowDefinition.script);
147+
if (approveIfAdmin) {
148+
ScriptApproval.get().configuring(cpsFlowDefinition.script, GroovyLanguage.get(),
149+
ApprovalContext.create().withCurrentUser().withItemAsKey(req.findAncestorObject(Item.class)), true);
150+
}
151+
}
152+
return cpsFlowDefinition;
153+
}
154+
126155
@Override
127156
public String getDisplayName() {
128157
return "Pipeline script";
129158
}
130159

131160
@RequirePOST
132-
public FormValidation doCheckScript(@QueryParameter String value, @QueryParameter boolean sandbox) {
133-
return sandbox ? FormValidation.ok() : ScriptApproval.get().checking(value, GroovyLanguage.get());
161+
public FormValidation doCheckScript(@QueryParameter String value, @QueryParameter String oldScript,
162+
@QueryParameter boolean sandbox) {
163+
return sandbox ? FormValidation.ok() :
164+
ScriptApproval.get().checking(value, GroovyLanguage.get(), !StringUtils.equals(oldScript, value));
134165
}
135166

136167
@RequirePOST

src/main/java/org/jenkinsci/plugins/workflow/cps/CpsGroovyShellFactory.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ private ImportCustomizer makeImportCustomizer() {
111111

112112
private ClassLoader makeClassLoader() {
113113
ClassLoader cl = Jenkins.get().getPluginManager().uberClassLoader;
114-
return GroovySandbox.createSecureClassLoader(cl);
114+
return new GroovySourceFileAllowlist.ClassLoaderImpl(execution, GroovySandbox.createSecureClassLoader(cl));
115115
}
116116

117117
public CpsGroovyShell build() {
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/*
2+
* The MIT License
3+
*
4+
* Copyright 2022 CloudBees, Inc.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
*/
24+
25+
package org.jenkinsci.plugins.workflow.cps;
26+
27+
import edu.umd.cs.findbugs.annotations.CheckForNull;
28+
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
29+
import hudson.Extension;
30+
import hudson.ExtensionList;
31+
import hudson.ExtensionPoint;
32+
import hudson.Main;
33+
import java.io.BufferedReader;
34+
import java.io.IOException;
35+
import java.io.InputStream;
36+
import java.io.InputStreamReader;
37+
import java.net.URL;
38+
import java.nio.charset.StandardCharsets;
39+
import java.util.ArrayList;
40+
import java.util.Arrays;
41+
import java.util.Collections;
42+
import java.util.Enumeration;
43+
import java.util.List;
44+
import java.util.logging.Level;
45+
import java.util.logging.Logger;
46+
import jenkins.util.SystemProperties;
47+
import org.apache.commons.lang.StringUtils;
48+
49+
/**
50+
* Determines what Groovy source files can be loaded in Pipelines.
51+
*
52+
* In Pipeline, the standard behavior of {@code GroovyClassLoader} would allow Groovy source files from core or plugins
53+
* to be loaded as long as they are somewhere on the classpath. This includes things like Groovy views, which are not
54+
* intended to be available to pipelines. When these files are loaded, they are loaded by the trusted
55+
* {@link CpsGroovyShell} and are not sandbox-transformed, which means that allowing arbitrary Groovy source files to
56+
* be loaded is potentially unsafe.
57+
*
58+
* {@link ClassLoaderImpl} blocks all Groovy source files from being loaded by default unless they are allowed by an
59+
* implementation of this extension point.
60+
*/
61+
public abstract class GroovySourceFileAllowlist implements ExtensionPoint {
62+
private static final Logger LOGGER = Logger.getLogger(GroovySourceFileAllowlist.class.getName());
63+
private static final String DISABLED_PROPERTY = GroovySourceFileAllowlist.class.getName() + ".DISABLED";
64+
@SuppressFBWarnings(value = "MS_SHOULD_BE_FINAL", justification = "Non-final for script console access")
65+
static boolean DISABLED = SystemProperties.getBoolean(DISABLED_PROPERTY);
66+
67+
/**
68+
* Checks whether a given Groovy source file is allowed to be loaded by {@link CpsFlowExecution#getTrustedShell}.
69+
*
70+
* @param groovySourceFileUrl the absolute URL to the Groovy source file as returned by {@link ClassLoader#getResource}
71+
* @return {@code true} if the Groovy source file may be loaded, {@code false} otherwise
72+
*/
73+
public abstract boolean isAllowed(String groovySourceFileUrl);
74+
75+
public static List<GroovySourceFileAllowlist> all() {
76+
return ExtensionList.lookup(GroovySourceFileAllowlist.class);
77+
}
78+
79+
/**
80+
* {@link ClassLoader} that acts normally except for returning {@code null} from {@link #getResource} and
81+
* {@link #getResources} when looking up Groovy source files if the files are not allowed by
82+
* {@link GroovySourceFileAllowlist}.
83+
*/
84+
static class ClassLoaderImpl extends ClassLoader {
85+
private static final String LOG_MESSAGE_TEMPLATE =
86+
"Preventing {0} from being loaded without sandbox protection in {1}. " +
87+
"To allow access to this file, add any suffix of its URL to the system property ‘" +
88+
DefaultAllowlist.ALLOWED_SOURCE_FILES_PROPERTY + "’ (use commas to separate multiple files). If you " +
89+
"want to allow any Groovy file on the Jenkins classpath to be accessed, you may set the system " +
90+
"property ‘" + DISABLED_PROPERTY + "’ to true.";
91+
92+
private final String owner;
93+
94+
public ClassLoaderImpl(@CheckForNull CpsFlowExecution execution, ClassLoader parent) {
95+
super(parent);
96+
this.owner = describeOwner(execution);
97+
}
98+
99+
private static String describeOwner(@CheckForNull CpsFlowExecution execution) {
100+
if (execution != null) {
101+
try {
102+
return execution.getOwner().getExecutable().toString();
103+
} catch (IOException e) {
104+
// Not significant in this context.
105+
}
106+
}
107+
return "unknown";
108+
}
109+
110+
@Override
111+
public URL getResource(String name) {
112+
URL url = super.getResource(name);
113+
if (DISABLED || url == null || !endsWithIgnoreCase(name, ".groovy") || isAllowed(url)) {
114+
return url;
115+
}
116+
// 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.
117+
LOGGER.log(Level.WARNING, LOG_MESSAGE_TEMPLATE, new Object[] { url, owner });
118+
return null;
119+
}
120+
121+
@Override
122+
public Enumeration<URL> getResources(String name) throws IOException {
123+
Enumeration<URL> urls = super.getResources(name);
124+
if (DISABLED || !urls.hasMoreElements() || !endsWithIgnoreCase(name, ".groovy")) {
125+
return urls;
126+
}
127+
List<URL> filteredUrls = new ArrayList<>();
128+
while (urls.hasMoreElements()) {
129+
URL url = urls.nextElement();
130+
if (isAllowed(url)) {
131+
filteredUrls.add(url);
132+
} else {
133+
LOGGER.log(Level.WARNING, LOG_MESSAGE_TEMPLATE, new Object[] { url, owner });
134+
}
135+
}
136+
return Collections.enumeration(filteredUrls);
137+
}
138+
139+
private static boolean isAllowed(URL url) {
140+
String urlString = url.toString();
141+
for (GroovySourceFileAllowlist allowlist : GroovySourceFileAllowlist.all()) {
142+
if (allowlist.isAllowed(urlString)) {
143+
return true;
144+
}
145+
}
146+
return false;
147+
}
148+
149+
private static boolean endsWithIgnoreCase(String value, String suffix) {
150+
int suffixLength = suffix.length();
151+
return value.regionMatches(true, value.length() - suffixLength, suffix, 0, suffixLength);
152+
}
153+
}
154+
155+
/**
156+
* Allows Groovy source files used to implement DSLs in plugins that were created before
157+
* {@link GroovySourceFileAllowlist} was introduced.
158+
*/
159+
@Extension
160+
public static class DefaultAllowlist extends GroovySourceFileAllowlist {
161+
private static final Logger LOGGER = Logger.getLogger(DefaultAllowlist.class.getName());
162+
private static final String ALLOWED_SOURCE_FILES_PROPERTY = DefaultAllowlist.class.getCanonicalName() + ".ALLOWED_SOURCE_FILES";
163+
/**
164+
* A list containing suffixes of known-good Groovy source file URLs that need to be accessible to Pipeline code.
165+
*/
166+
/* Note: Actual ClassLoader resource URLs depend on environmental factors such as webroot settings and whether
167+
* we are currently testing one of the plugins in the list, so default-allowlist only contains the path
168+
* component of the resource URLs, and we allow any resource URL that ends with one of the entries in the list.
169+
*
170+
* We could try to load the exact URLs at runtime, but then we would have to account for dynamic plugin loading
171+
* (especially when a new Jenkins controller is initialized) and the fact that workflow-cps is always a
172+
* dependency of these plugins.
173+
*/
174+
static final List<String> ALLOWED_SOURCE_FILES = new ArrayList<>();
175+
176+
public DefaultAllowlist() throws IOException {
177+
// We load custom entries first to improve performance in case .groovy is used for the property.
178+
String propertyValue = SystemProperties.getString(ALLOWED_SOURCE_FILES_PROPERTY, "");
179+
for (String groovyFile : propertyValue.split(",")) {
180+
groovyFile = StringUtils.trimToNull(groovyFile);
181+
if (groovyFile != null) {
182+
if (groovyFile.endsWith(".groovy")) {
183+
ALLOWED_SOURCE_FILES.add(groovyFile);
184+
LOGGER.log(Level.INFO, "Allowing Pipelines to access {0}", groovyFile);
185+
} else {
186+
LOGGER.log(Level.WARNING, "Ignoring invalid Groovy source file: {0}", groovyFile);
187+
}
188+
}
189+
}
190+
loadDefaultAllowlist(ALLOWED_SOURCE_FILES);
191+
// Some plugins use test-specific Groovy DSLs.
192+
if (Main.isUnitTest) {
193+
ALLOWED_SOURCE_FILES.addAll(Arrays.asList(
194+
// pipeline-model-definition
195+
"/org/jenkinsci/plugins/pipeline/modeldefinition/agent/impl/LabelAndOtherFieldAgentScript.groovy",
196+
"/org/jenkinsci/plugins/pipeline/modeldefinition/parser/GlobalStageNameTestConditionalScript.groovy",
197+
"/org/jenkinsci/plugins/pipeline/modeldefinition/parser/GlobalStepCountTestConditionalScript.groovy"
198+
));
199+
}
200+
}
201+
202+
private static void loadDefaultAllowlist(List<String> allowlist) throws IOException {
203+
try (InputStream is = GroovySourceFileAllowlist.class.getResourceAsStream("GroovySourceFileAllowlist/default-allowlist");
204+
BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8));) {
205+
String line;
206+
while ((line = reader.readLine()) != null) {
207+
line = line.trim();
208+
if (!line.isEmpty() && !line.startsWith("#")) {
209+
allowlist.add(line);
210+
}
211+
}
212+
}
213+
}
214+
215+
@Override
216+
public boolean isAllowed(String groovySourceFileUrl) {
217+
for (String sourceFile : ALLOWED_SOURCE_FILES) {
218+
if (groovySourceFileUrl.endsWith(sourceFile)) {
219+
return true;
220+
}
221+
}
222+
return false;
223+
}
224+
}
225+
226+
}

src/main/resources/org/jenkinsci/plugins/workflow/cps/CpsFlowDefinition/config.jelly

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
-->
2525

2626
<j:jelly xmlns:j="jelly:core" xmlns:f="/lib/form" xmlns:st="jelly:stapler" xmlns:wfe="/org/jenkinsci/plugins/workflow/editor">
27+
<input type="hidden" name="oldScript" value="${instance.script}"/>
2728
<f:entry title="${%Script}" field="script">
2829
<wfe:workflow-editor />
2930
</f:entry>

0 commit comments

Comments
 (0)