Skip to content

Commit

Permalink
SECURITY-2478
Browse files Browse the repository at this point in the history
  • Loading branch information
Pldi23 committed May 4, 2022
1 parent db4d71f commit 8c9cbb8
Show file tree
Hide file tree
Showing 5 changed files with 278 additions and 2 deletions.
6 changes: 4 additions & 2 deletions pom.xml
Expand Up @@ -3,7 +3,7 @@
<parent>
<groupId>org.jenkins-ci.plugins</groupId>
<artifactId>plugin</artifactId>
<version>3.53</version>
<version>4.31</version>
<relativePath />
</parent>

Expand All @@ -16,7 +16,9 @@
<url>https://github.com/jenkinsci/repo-plugin/blob/master/doc/README.adoc</url>

<properties>
<jenkins.version>2.60.3</jenkins.version>
<revision>1.14.1</revision>
<changelist>-SNAPSHOT</changelist>
<jenkins.version>2.277.1</jenkins.version>
<java.level>8</java.level>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
Expand Down
83 changes: 83 additions & 0 deletions src/main/java/hudson/plugins/repo/ManifestValidator.java
@@ -0,0 +1,83 @@
/*
* The MIT License
*
* Copyright (c) 2010, Brad Larson
*
* 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 hudson.plugins.repo;

import hudson.AbortException;
import jenkins.util.xml.XMLUtils;
import org.w3c.dom.Document;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.NodeList;

import org.xml.sax.SAXException;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.Locale;

/**
* to validate manifest xml file and abort when remote references a local path.
*/
public final class ManifestValidator {

private ManifestValidator() {
// to hide the implicit public constructor
}

/**
* to validate manifest xml file and abort when remote references a local path.
* @param manifestText byte representation of manifest file
* @param manifestRepositoryUrl url
* @throws IOException when remote references a local path.
*/
public static void validate(final byte[] manifestText, final String manifestRepositoryUrl)
throws IOException {
if (manifestText.length > 0) {
try {
Document doc = XMLUtils.parse(new ByteArrayInputStream(manifestText));
NodeList remote = doc.getElementsByTagName("remote");
for (int i = 0; i < remote.getLength(); i++) {
NamedNodeMap attributes = remote.item(i).getAttributes();
for (int j = 0; j < attributes.getLength(); j++) {
if ("fetch".equals(attributes.item(j).getNodeName())
&& attributes.item(j).getNodeValue()
.toLowerCase(Locale.ENGLISH).startsWith("file://")) {
// we don't need to check source using Files.exists because fetch
// attribute could resolve only local paths starting from 'file://'
throw new AbortException("Checkout of Repo url '"
+ manifestRepositoryUrl
+ "' aborted because manifest references a local "
+ "directory, which may be insecure. You can allow "
+ "local checkouts anyway"
+ " by setting the system property '"
+ RepoScm.ALLOW_LOCAL_CHECKOUT_PROPERTY + "' to true.");
}
}
}
} catch (SAXException e) {
throw new IOException("Could not validate manifest");
}
}
}
}
59 changes: 59 additions & 0 deletions src/main/java/hudson/plugins/repo/RepoScm.java
Expand Up @@ -30,16 +30,20 @@
import java.io.Serializable;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;

import hudson.AbortException;
import hudson.EnvVars;
import hudson.Extension;
import hudson.FilePath;
Expand Down Expand Up @@ -84,6 +88,17 @@ public class RepoScm extends SCM implements Serializable {
private static Logger debug = Logger
.getLogger("hudson.plugins.repo.RepoScm");

/**
* escape hatch name.
*/
static final String ALLOW_LOCAL_CHECKOUT_PROPERTY =
RepoScm.class.getName() + ".ALLOW_LOCAL_CHECKOUT";
/**
* escape hatch.
*/
//CS IGNORE VisibilityModifier FOR NEXT 1 LINES. REASON: escape hatch property.
static /* not final */ boolean ALLOW_LOCAL_CHECKOUT = Boolean.parseBoolean(System.getProperty(ALLOW_LOCAL_CHECKOUT_PROPERTY, "false"));

private final String manifestRepositoryUrl;

// Advanced Fields:
Expand Down Expand Up @@ -880,6 +895,10 @@ public void checkout(
@CheckForNull final File changelogFile, @CheckForNull final SCMRevisionState baseline)
throws IOException, InterruptedException {

if (!ALLOW_LOCAL_CHECKOUT && !workspace.isRemote()) {
abortIfUrlLocal();
}

Job<?, ?> job = build.getParent();
EnvVars env = build.getEnvironment(listener);
env = getEnvVars(env, job);
Expand Down Expand Up @@ -931,6 +950,18 @@ public void checkout(
build.addAction(manifestAction);
}

private void abortIfUrlLocal() throws AbortException {
if (StringUtils.isNotEmpty(manifestRepositoryUrl)
&& (manifestRepositoryUrl.toLowerCase(Locale.ENGLISH).startsWith("file://")
|| Files.exists(Paths.get(manifestRepositoryUrl)))) {
throw new AbortException("Checkout of Repo url '" + manifestRepositoryUrl
+ "' aborted because it references a local directory, "
+ "which may be insecure. "
+ "You can allow local checkouts anyway by setting the system property '"
+ ALLOW_LOCAL_CHECKOUT_PROPERTY + "' to true.");
}
}

private int doSync(final Launcher launcher, @Nonnull final FilePath workspace,
final OutputStream logger, final EnvVars env)
throws IOException, InterruptedException {
Expand Down Expand Up @@ -1082,6 +1113,10 @@ private boolean checkoutCode(final Launcher launcher,
}
//}

if (!ALLOW_LOCAL_CHECKOUT && !workspace.isRemote()) {
abortIfManifestReferencesLocalUrl(launcher, workspace, logger, env);
}

returnCode = doSync(launcher, workspace, logger, env);
if (returnCode != 0) {
debug.log(Level.WARNING, "Sync failed. Resetting repository");
Expand All @@ -1100,6 +1135,30 @@ private boolean checkoutCode(final Launcher launcher,
return true;
}

private byte[] getManifestAsBytes(final Launcher launcher,
final FilePath workspace,
final OutputStream logger,
final EnvVars env)
throws IOException, InterruptedException {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
final List<String> commands = new ArrayList<>();
commands.add(getDescriptor().getExecutable());
commands.add("manifest");
launcher.launch().stderr(logger).stdout(byteArrayOutputStream).pwd(workspace)
.cmds(commands).envs(env).join();
return byteArrayOutputStream.toByteArray();
}

private void abortIfManifestReferencesLocalUrl(final Launcher launcher,
final FilePath workspace,
final OutputStream logger,
final EnvVars env)
throws IOException, InterruptedException {
byte[] manifestText = getManifestAsBytes(launcher, workspace, logger, env);

ManifestValidator.validate(manifestText, manifestRepositoryUrl);
}

private String getStaticManifest(final Launcher launcher,
final FilePath workspace, final OutputStream logger,
final EnvVars env)
Expand Down
56 changes: 56 additions & 0 deletions src/test/java/hudson/plugins/repo/ManifestValidatorTest.java
@@ -0,0 +1,56 @@
package hudson.plugins.repo;

import org.junit.Test;
import org.jvnet.hudson.test.Issue;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.fail;

public class ManifestValidatorTest {

@Issue("SECURITY-2478")
@Test
public void validateWhenFetchAttributeReferencesLocalPathThenAbort() {
String manifest = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<manifest>\n" +
" <remote name=\"local\"\n" +
" fetch=\"file:///Users/d.platonov/workdir/\"\n" +
" revision=\"master\"\n" +
" review=\"\" />\n" +
"\n" +
" <project name=\"localProject\" path=\"localProject\" groups=\"lib\" remote=\"local\" />\n" +
"</manifest>";
try {
ManifestValidator.validate(manifest.getBytes(StandardCharsets.UTF_8), "repoUrl");
fail("should fail because fetch attribute in remote tag references a local path");
} catch (IOException e) {
assertThat(e.getMessage(), is("Checkout of Repo url 'repoUrl' aborted because manifest references a local directory, " +
"which may be insecure. You can allow local checkouts anyway by setting the system property '" +
RepoScm.ALLOW_LOCAL_CHECKOUT_PROPERTY + "' to true."));
}
}

@Issue("SECURITY-2478")
@Test
public void validateWhenValidManifestThenDoNotAbort() {
String manifest = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<manifest>\n" +
" <remote name=\"origin\"\n" +
" fetch=\"..\"\n" + // https://stackoverflow.com/questions/18251358/repo-manifest-xml-what-does-the-fetch-mean
" revision=\"master\"\n" +
" review=\"https://github.com\" />\n" +
"\n" +
" <project name=\"any\" path=\"any\" groups=\"gr\" remote=\"origin\" />\n" +
" </manifest>";

try {
ManifestValidator.validate(manifest.getBytes(StandardCharsets.UTF_8), "repoUrl");
} catch (Exception e) {
fail("fail because input is valid and no exception expected");
}
}
}
76 changes: 76 additions & 0 deletions src/test/java/hudson/plugins/repo/Security2478Test.java
@@ -0,0 +1,76 @@
package hudson.plugins.repo;

import hudson.model.FreeStyleBuild;
import hudson.model.FreeStyleProject;
import hudson.model.Result;
import hudson.slaves.DumbSlave;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.jvnet.hudson.test.Issue;
import org.jvnet.hudson.test.JenkinsRule;

public class Security2478Test {

@Rule
public JenkinsRule rule = new JenkinsRule();

@Rule
public TemporaryFolder testFolder = new TemporaryFolder();

@Issue("SECURITY-2478")
@Test
public void checkoutShouldAbortWhenUrlIsNonRemoteAndBuildOnController() throws Exception {
FreeStyleProject freeStyleProject = rule.createFreeStyleProject();
String manifestRepositoryUrl = testFolder.newFolder().toString();
RepoScm scm = new RepoScm(manifestRepositoryUrl);
freeStyleProject.setScm(scm);
FreeStyleBuild freeStyleBuild = rule.assertBuildStatus(Result.FAILURE, freeStyleProject.scheduleBuild2(0));
rule.assertLogContains("Checkout of Repo url '" + manifestRepositoryUrl +
"' aborted because it references a local directory, " +
"which may be insecure. You can allow local checkouts anyway by setting the system property '" +
RepoScm.ALLOW_LOCAL_CHECKOUT_PROPERTY + "' to true.", freeStyleBuild);
}

@Issue("SECURITY-2478")
@Test
public void checkoutShouldNotAbortWhenUrlIsNonRemoteAndEscapeHatchTrue() throws Exception {
try {
RepoScm.ALLOW_LOCAL_CHECKOUT = true;
FreeStyleProject freeStyleProject = rule.createFreeStyleProject();
String manifestRepositoryUrl = testFolder.newFolder().toString();
RepoScm scm = new RepoScm(manifestRepositoryUrl);
freeStyleProject.setScm(scm);
FreeStyleBuild freeStyleBuild = rule.assertBuildStatus(Result.FAILURE, freeStyleProject.scheduleBuild2(0));

// build fails because of manifestRepositoryUrl is not a repo(git) repository, but we don't care,
// we verify that build was not aborted because of RepoScm uses local path.
rule.assertLogNotContains("Checkout of Repo url '" + manifestRepositoryUrl +
"' aborted because it references a local directory, " +
"which may be insecure. You can allow local checkouts anyway by setting the system property '" +
RepoScm.ALLOW_LOCAL_CHECKOUT_PROPERTY + "' to true.", freeStyleBuild);
} finally {
RepoScm.ALLOW_LOCAL_CHECKOUT = false;
}
}

@Issue("SECURITY-2478")
@Test
public void checkoutShouldNotAbortWhenUrlIsNonRemoteAndBuildOnAgent() throws Exception {
DumbSlave agent = rule.createOnlineSlave();
FreeStyleProject freeStyleProject = rule.createFreeStyleProject();

String manifestRepositoryUrl = testFolder.newFolder().toString();

RepoScm scm = new RepoScm(manifestRepositoryUrl);
freeStyleProject.setScm(scm);
freeStyleProject.setAssignedLabel(agent.getSelfLabel());

// build fails because of manifestRepositoryUrl is not a repo(git) repository, but we don't care,
// we verify that build was not aborted because of RepoScm uses local path.
rule.assertLogNotContains("Checkout of Repo url '" + manifestRepositoryUrl +
"' aborted because it references a local directory, " +
"which may be insecure. You can allow local checkouts anyway by setting the system property '" +
RepoScm.ALLOW_LOCAL_CHECKOUT_PROPERTY + "' to true.", freeStyleProject.scheduleBuild2(0).get());
}
}

0 comments on commit 8c9cbb8

Please sign in to comment.