CVE-2021-43286 (published 2022-04-14T13:15Z)

An issue was discovered in ThoughtWorks GoCD before 21.3.0. An attacker with privileges to create a new pipeline on a GoCD server can abuse a command-line injection in the Git URL "Test Connection" feature to execute arbitrary code.

Severity: MEDIUM
CVSS v2 base score: 6.5 (AV:N/AC:L/Au:S/C:P/I:P/A:P)
CVSS v3 base score: 8.8, HIGH (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-77
cwe_name: Improper Neutralization of Special Elements used in a Command ('Command Injection')
description: The product constructs all or part of a command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended command when it is sent to a downstream component.
extended_description: 
            Many protocols and products have their own custom command language. While OS or shell command strings are frequently discovered and targeted, developers may not realize that these other command languages might also be vulnerable to attacks.
         
         

Fix information:
repo_url: https://github.com/gocd/gocd
commit_details: repo_url: https://github.com/gocd/gocd
author: Aravind SV
author_date: 2021-10-23 20:11:38+01:00
msg: #000 - Allow parameters to be used as URLs

Relates to commit 6fa9fb7a7c91e
repository: repo_url: https://github.com/gocd/gocd
repo_name: gocd/gocd
description: GoCD - Continuous Delivery server main repository
repo_language: Java
owner: gocd
file_changes: filename: UrlArgument.java
change_type: ModificationType.MODIFY
diff: @@ -28,7 +28,7 @@ import static org.apache.commons.lang3.StringUtils.isBlank;
 
 @ConfigAttributeValue(fieldName = "url")
 public class UrlArgument extends CommandArgument {
-    private static final String URL_DUMB_VALIDATION_REGEX = "^[a-zA-Z0-9/].*";
+    private static final String URL_DUMB_VALIDATION_REGEX = "^[a-zA-Z0-9/#].*";
     private static final Pattern pattern = Pattern.compile(URL_DUMB_VALIDATION_REGEX);
 
     protected String url;

programming_language: Java
filename: GitMaterialConfigTest.java
change_type: ModificationType.MODIFY
diff: @@ -275,6 +275,7 @@ class GitMaterialConfigTest {
             assertTrue(validating(git("@url-not-starting-with-an-alphanumeric-character")).errors().containsKey(GitMaterialConfig.URL));
 
             assertFalse(validating(git("url-starting-with-an-alphanumeric-character")).errors().containsKey(GitMaterialConfig.URL));
+            assertFalse(validating(git("#{url}")).errors().containsKey(GitMaterialConfig.URL));
         }
 
         private GitMaterialConfig validating(GitMaterialConfig git) {

programming_language: Java
filename: HgMaterialConfigTest.java
change_type: ModificationType.MODIFY
diff: @@ -300,6 +300,7 @@ class HgMaterialConfigTest {
             assertTrue(validating(hg("@url-not-starting-with-an-alphanumeric-character", "folder")).errors().containsKey(HgMaterialConfig.URL));
 
             assertFalse(validating(hg("url-starting-with-an-alphanumeric-character", "folder")).errors().containsKey(HgMaterialConfig.URL));
+            assertFalse(validating(hg("#{url}", "folder")).errors().containsKey(HgMaterialConfig.URL));
         }
 
         private HgMaterialConfig validating(HgMaterialConfig hg) {

programming_language: Java
filename: SvnMaterialConfigTest.java
change_type: ModificationType.MODIFY
diff: @@ -168,6 +168,7 @@ class SvnMaterialConfigTest {
             assertTrue(validating(svn("@url-not-starting-with-an-alphanumeric-character", false)).errors().containsKey(SvnMaterialConfig.URL));
 
             assertFalse(validating(svn("url-starting-with-an-alphanumeric-character", false)).errors().containsKey(SvnMaterialConfig.URL));
+            assertFalse(validating(svn("#{url}", false)).errors().containsKey(SvnMaterialConfig.URL));
         }
 
         private SvnMaterialConfig validating(SvnMaterialConfig svn) {

programming_language: Java
filename: TfsMaterialConfigTest.java
change_type: ModificationType.MODIFY
diff: @@ -176,6 +176,7 @@ class TfsMaterialConfigTest {
             assertTrue(validating(tfs("@url-not-starting-with-an-alphanumeric-character")).errors().containsKey(TfsMaterialConfig.URL));
 
             assertFalse(validating(tfs("url-starting-with-an-alphanumeric-character")).errors().containsKey(TfsMaterialConfig.URL));
+            assertFalse(validating(tfs("#{url}")).errors().containsKey(TfsMaterialConfig.URL));
         }
 
         private TfsMaterialConfig validating(TfsMaterialConfig tfs) {

programming_language: Java
repo_url: https://github.com/gocd/gocd
commit_details: repo_url: https://github.com/gocd/gocd
author: Aravind SV
author_date: 2021-10-23 12:18:28+01:00
msg: #000 - Validate URLs provided

- Disallow URLs which are obviously not URLs.
repository: repo_url: https://github.com/gocd/gocd
repo_name: gocd/gocd
description: GoCD - Continuous Delivery server main repository
repo_language: Java
owner: gocd
file_changes: filename: ScmMaterialConfig.java
change_type: ModificationType.MODIFY
diff: @@ -36,7 +36,6 @@ import static org.apache.commons.lang3.StringUtils.*;
  * @understands a source control repository and its configuration
  */
 public abstract class ScmMaterialConfig extends AbstractMaterialConfig implements ParamsAttributeAware {
-
     public static final String URL = "url";
     public static final String USERNAME = "username";
     protected GoCipher goCipher;
@@ -392,6 +391,10 @@ public abstract class ScmMaterialConfig extends AbstractMaterialConfig implement
             errors().add(URL, "URL cannot be blank");
             return;
         }
+
+        if (System.getProperty("gocd.verify.url.correctness", "y").equalsIgnoreCase("y") && !url.isValidURLOrLocalPath()) {
+            errors().add(URL, "URL does not seem to be valid.");
+        }
     }
 
     protected void validateCredentials() {

programming_language: Java
filename: P4MaterialConfig.java
change_type: ModificationType.MODIFY
diff: @@ -149,7 +149,12 @@ public class P4MaterialConfig extends ScmMaterialConfig implements ParamsAttribu
         if (StringUtils.isBlank(getServerAndPort())) {
             errors.add(SERVER_AND_PORT, "P4 port cannot be empty.");
         }
-        validateEncryptedPassword();
+
+        if (!new UrlArgument(getUrl()).isValidURLOrLocalPath()) {
+            errors.add(SERVER_AND_PORT, "P4 port seems to be invalid.");
+        }
+
+        view.validate(validationContext);
     }
 
     @Override

programming_language: Java
filename: UrlArgument.java
change_type: ModificationType.MODIFY
diff: @@ -21,12 +21,16 @@ import org.apache.http.client.utils.URIBuilder;
 
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.util.regex.Pattern;
 
 import static com.thoughtworks.go.util.ExceptionUtils.bombIfNull;
 import static org.apache.commons.lang3.StringUtils.isBlank;
 
 @ConfigAttributeValue(fieldName = "url")
 public class UrlArgument extends CommandArgument {
+    private static final String URL_DUMB_VALIDATION_REGEX = "^[a-zA-Z0-9/].*";
+    private static final Pattern pattern = Pattern.compile(URL_DUMB_VALIDATION_REGEX);
+
     protected String url;
 
     public UrlArgument(String url) {
@@ -123,4 +127,8 @@ public class UrlArgument extends CommandArgument {
             return url;
         }
     }
+
+    public boolean isValidURLOrLocalPath() {
+        return pattern.matcher(url).matches();
+    }
 }

programming_language: Java
filename: ScmMaterialConfigTest.java
change_type: ModificationType.MODIFY
diff: @@ -29,8 +29,7 @@ import java.nio.file.Paths;
 import java.util.Collections;
 import java.util.HashMap;
 
-import static com.thoughtworks.go.config.materials.ScmMaterialConfig.AUTO_UPDATE;
-import static com.thoughtworks.go.config.materials.ScmMaterialConfig.FOLDER;
+import static com.thoughtworks.go.config.materials.ScmMaterialConfig.*;
 import static org.assertj.core.api.Assertions.assertThat;
 
 class ScmMaterialConfigTest {

programming_language: Java
filename: GitMaterialConfigTest.java
change_type: ModificationType.MODIFY
diff: @@ -268,6 +268,15 @@ class GitMaterialConfigTest {
             assertFalse(validating(gitMaterialConfig).errors().containsKey(GitMaterialConfig.URL));
         }
 
+        @Test
+        void rejectsObviouslyWrongURL() {
+            assertTrue(validating(git("-url-not-starting-with-an-alphanumeric-character")).errors().containsKey(GitMaterialConfig.URL));
+            assertTrue(validating(git("_url-not-starting-with-an-alphanumeric-character")).errors().containsKey(GitMaterialConfig.URL));
+            assertTrue(validating(git("@url-not-starting-with-an-alphanumeric-character")).errors().containsKey(GitMaterialConfig.URL));
+
+            assertFalse(validating(git("url-starting-with-an-alphanumeric-character")).errors().containsKey(GitMaterialConfig.URL));
+        }
+
         private GitMaterialConfig validating(GitMaterialConfig git) {
             git.validate(new ConfigSaveValidationContext(null));
             return git;

programming_language: Java
filename: HgMaterialConfigTest.java
change_type: ModificationType.MODIFY
diff: @@ -31,8 +31,11 @@ import java.util.Map;
 import static com.thoughtworks.go.config.materials.AbstractMaterialConfig.MATERIAL_NAME;
 import static com.thoughtworks.go.config.materials.ScmMaterialConfig.FOLDER;
 import static com.thoughtworks.go.config.materials.ScmMaterialConfig.URL;
+import static com.thoughtworks.go.helper.MaterialConfigsMother.git;
 import static com.thoughtworks.go.helper.MaterialConfigsMother.hg;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.*;
 
 class HgMaterialConfigTest {
@@ -289,6 +292,20 @@ class HgMaterialConfigTest {
 
             assertThat(hgMaterialConfig.errors().on(HgMaterialConfig.URL)).isEqualTo("Ambiguous branch, must be provided either in URL or as an attribute.");
         }
+
+        @Test
+        void rejectsObviouslyWrongURL() {
+            assertTrue(validating(hg("-url-not-starting-with-an-alphanumeric-character", "folder")).errors().containsKey(HgMaterialConfig.URL));
+            assertTrue(validating(hg("_url-not-starting-with-an-alphanumeric-character", "folder")).errors().containsKey(HgMaterialConfig.URL));
+            assertTrue(validating(hg("@url-not-starting-with-an-alphanumeric-character", "folder")).errors().containsKey(HgMaterialConfig.URL));
+
+            assertFalse(validating(hg("url-starting-with-an-alphanumeric-character", "folder")).errors().containsKey(HgMaterialConfig.URL));
+        }
+
+        private HgMaterialConfig validating(HgMaterialConfig hg) {
+            hg.validate(new ConfigSaveValidationContext(null));
+            return hg;
+        }
     }
 
     @Nested

programming_language: Java
filename: P4MaterialConfigTest.java
change_type: ModificationType.MODIFY
diff: @@ -20,7 +20,6 @@ import com.thoughtworks.go.config.materials.AbstractMaterialConfig;
 import com.thoughtworks.go.config.materials.Filter;
 import com.thoughtworks.go.config.materials.IgnoredFiles;
 import com.thoughtworks.go.config.materials.ScmMaterialConfig;
-import com.thoughtworks.go.config.materials.svn.SvnMaterialConfig;
 import com.thoughtworks.go.domain.materials.MaterialConfig;
 import com.thoughtworks.go.security.GoCipher;
 import com.thoughtworks.go.util.ReflectionUtil;
@@ -33,6 +32,8 @@ import java.util.Map;
 
 import static com.thoughtworks.go.helper.MaterialConfigsMother.p4;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.*;
 
 class P4MaterialConfigTest {
@@ -111,8 +112,8 @@ class P4MaterialConfigTest {
         assertThat(materialConfig.getEncryptedPassword()).isEqualTo(new GoCipher().encrypt("secret"));
 
         //Dont change
-        map.put(SvnMaterialConfig.PASSWORD, "Hehehe");
-        map.put(SvnMaterialConfig.PASSWORD_CHANGED, "0");
+        map.put(P4MaterialConfig.PASSWORD, "Hehehe");
+        map.put(P4MaterialConfig.PASSWORD_CHANGED, "0");
         materialConfig.setConfigAttributes(map);
 
         assertThat(ReflectionUtil.getField(materialConfig, "password")).isNull();
@@ -120,8 +121,8 @@ class P4MaterialConfigTest {
         assertThat(materialConfig.getEncryptedPassword()).isEqualTo(new GoCipher().encrypt("secret"));
 
         //Dont change
-        map.put(SvnMaterialConfig.PASSWORD, "");
-        map.put(SvnMaterialConfig.PASSWORD_CHANGED, "1");
+        map.put(P4MaterialConfig.PASSWORD, "");
+        map.put(P4MaterialConfig.PASSWORD_CHANGED, "1");
         materialConfig.setConfigAttributes(map);
 
         assertThat(materialConfig.getPassword()).isNull();
@@ -141,6 +142,23 @@ class P4MaterialConfigTest {
         assertThat(p4MaterialConfig.getUseTickets()).isFalse();
     }
 
+    @Nested
+    class Validate {
+        @Test
+        void rejectsObviouslyWrongURL() {
+            assertTrue(validating(p4("-url-not-starting-with-an-alphanumeric-character", "view")).errors().containsKey(P4MaterialConfig.SERVER_AND_PORT));
+            assertTrue(validating(p4("_url-not-starting-with-an-alphanumeric-character", "view")).errors().containsKey(P4MaterialConfig.SERVER_AND_PORT));
+            assertTrue(validating(p4("@url-not-starting-with-an-alphanumeric-character", "view")).errors().containsKey(P4MaterialConfig.SERVER_AND_PORT));
+
+            assertFalse(validating(p4("url-starting-with-an-alphanumeric-character", "view")).errors().containsKey(P4MaterialConfig.SERVER_AND_PORT));
+        }
+
+        private P4MaterialConfig validating(P4MaterialConfig p4) {
+            p4.validate(new ConfigSaveValidationContext(null));
+            return p4;
+        }
+
+    }
     @Nested
     class ValidateTree {
         @Test

programming_language: Java
filename: SvnMaterialConfigTest.java
change_type: ModificationType.MODIFY
diff: @@ -34,6 +34,8 @@ import java.util.Map;
 
 import static com.thoughtworks.go.helper.MaterialConfigsMother.svn;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.*;
 
 class SvnMaterialConfigTest {
@@ -158,6 +160,20 @@ class SvnMaterialConfigTest {
 
             assertThat(svnMaterialConfig.errors().on(SvnMaterialConfig.FOLDER)).isEqualTo("Dest folder '../a' is not valid. It must be a sub-directory of the working folder.");
         }
+
+        @Test
+        void rejectsObviouslyWrongURL() {
+            assertTrue(validating(svn("-url-not-starting-with-an-alphanumeric-character", false)).errors().containsKey(SvnMaterialConfig.URL));
+            assertTrue(validating(svn("_url-not-starting-with-an-alphanumeric-character", false)).errors().containsKey(SvnMaterialConfig.URL));
+            assertTrue(validating(svn("@url-not-starting-with-an-alphanumeric-character", false)).errors().containsKey(SvnMaterialConfig.URL));
+
+            assertFalse(validating(svn("url-starting-with-an-alphanumeric-character", false)).errors().containsKey(SvnMaterialConfig.URL));
+        }
+
+        private SvnMaterialConfig validating(SvnMaterialConfig svn) {
+            svn.validate(new ConfigSaveValidationContext(null));
+            return svn;
+        }
     }
 
     @Nested

programming_language: Java
filename: TfsMaterialConfigTest.java
change_type: ModificationType.MODIFY
diff: @@ -38,6 +38,8 @@ import static com.thoughtworks.go.config.materials.ScmMaterialConfig.URL;
 import static com.thoughtworks.go.helper.MaterialConfigsMother.tfs;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.fail;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.*;
 
 class TfsMaterialConfigTest {
@@ -166,6 +168,20 @@ class TfsMaterialConfigTest {
 
             assertThat(tfsMaterialConfig.errors().on(URL)).isEqualTo("URL cannot be blank");
         }
+
+        @Test
+        void rejectsObviouslyWrongURL() {
+            assertTrue(validating(tfs("-url-not-starting-with-an-alphanumeric-character")).errors().containsKey(TfsMaterialConfig.URL));
+            assertTrue(validating(tfs("_url-not-starting-with-an-alphanumeric-character")).errors().containsKey(TfsMaterialConfig.URL));
+            assertTrue(validating(tfs("@url-not-starting-with-an-alphanumeric-character")).errors().containsKey(TfsMaterialConfig.URL));
+
+            assertFalse(validating(tfs("url-starting-with-an-alphanumeric-character")).errors().containsKey(TfsMaterialConfig.URL));
+        }
+
+        private TfsMaterialConfig validating(TfsMaterialConfig tfs) {
+            tfs.validate(new ConfigSaveValidationContext(null));
+            return tfs;
+        }
     }
 
     @Test

programming_language: Java
filename: MaterialConfigsMother.java
change_type: ModificationType.MODIFY
diff: @@ -168,6 +168,10 @@ public class MaterialConfigsMother {
         return tfs(goCipher, new UrlArgument(url), userName, domain, null, projectPath);
     }
 
+    public static TfsMaterialConfig tfs(String url) {
+        return tfs(new GoCipher(), new UrlArgument(url), null, null, "password");
+    }
+
     public static TfsMaterialConfig tfs(UrlArgument urlArgument, String password, String encryptedPassword, GoCipher goCipher) {
         TfsMaterialConfig tfsMaterialConfig = tfs(goCipher, urlArgument, null, null, password);
         tfsMaterialConfig.setEncryptedPassword(encryptedPassword);

programming_language: Java
filename: UrlArgumentTest.java
change_type: ModificationType.MODIFY
diff: @@ -279,4 +279,31 @@ class UrlArgumentTest {
             assertThat(result).doesNotContain("cce:password");
         }
     }
+
+    @Nested
+    @TestInstance(PER_CLASS)
+    class isValidURL {
+        Stream<Arguments> urls() {
+            return Stream.of(
+                    Arguments.of("http://my-site.com/abc", true),
+                    Arguments.of("svn+ssh://my-site.com/def", true),
+                    Arguments.of("file://my-site.com/def", true),
+                    Arguments.of("/path/in/file/system", true),
+                    Arguments.of("git@github.com:org/repo.git", true),
+                    Arguments.of("user@my-site.com:org/repo.git", true),
+                    Arguments.of("1a2b3c://abc", true),
+                    Arguments.of("-xyz", false),
+                    Arguments.of("_xyz", false),
+                    Arguments.of("@xyz", false)
+            );
+        }
+
+        @ParameterizedTest
+        @MethodSource("urls")
+        void shouldValidateURLs(String input, boolean expectedValidity) {
+            final UrlArgument url = new UrlArgument(input);
+
+            assertThat(url.isValidURLOrLocalPath()).as("Verify validity of '%s' as a URL", url.originalArgument()).isEqualTo(expectedValidity);
+        }
+    }
 }
\ No newline at end of file

programming_language: Java
filename: GitCommand.java
change_type: ModificationType.MODIFY
diff: @@ -215,7 +215,7 @@ public class GitCommand extends SCMCommand {
 
     public void checkConnection(UrlArgument repoUrl) {
         final String ref = fullUpstreamRef();
-        final CommandLine commandLine = git().withArgs("ls-remote").withArg(repoUrl).withArg(ref);
+        final CommandLine commandLine = git().withArgs("ls-remote", "--").withArg(repoUrl).withArg(ref);
         final ConsoleResult result = commandLine.runOrBomb(new NamedProcessTag(repoUrl.forDisplay()));
 
         if (!hasExactlyOneMatchingBranch(result)) {
@@ -232,7 +232,7 @@ public class GitCommand extends SCMCommand {
 
     @TestOnly
     public void add(File fileToAdd) {
-        String[] args = new String[]{"add", fileToAdd.getName()};
+        String[] args = new String[]{"add", "--", fileToAdd.getName()};
         CommandLine gitAdd = gitWd().withArgs(args);
         runOrBomb(gitAdd);
     }
@@ -299,7 +299,7 @@ public class GitCommand extends SCMCommand {
 
     @TestOnly
     public void submoduleAdd(String repoUrl, String submoduleNameToPutInGitSubmodules, String folder) {
-        String[] addSubmoduleWithSameNameArgs = new String[]{"submodule", "add", repoUrl, folder};
+        String[] addSubmoduleWithSameNameArgs = new String[]{"submodule", "add", "--", repoUrl, folder};
         String[] changeSubmoduleNameInGitModules = new String[]{"config", "--file", ".gitmodules", "--rename-section", "submodule." + folder, "submodule." + submoduleNameToPutInGitSubmodules};
         String[] addGitModules = new String[]{"add", ".gitmodules"};
 

programming_language: Java