CVE-2022-39205: Improper Authentication (CWE-287)
value: Onedev is an open source, self-hosted Git Server with CI/CD and Kanban. In versions of Onedev prior to 7.3.0 unauthenticated users can take over a OneDev instance if there is no properly configured reverse proxy. The /git-prereceive-callback endpoint is used by the pre-receive git hook on the server to check for branch protections during a push event. It is only intended to be accessed from localhost, but the check relies on the X-Forwarded-For header. Invoking this endpoint leads to the execution of one of various git commands. The environment variables of this command execution can be controlled via query parameters. This allows attackers to write to arbitrary files, which can in turn lead to the execution of arbitrary code. Such an attack would be very hard to detect, which increases the potential impact even more. Users are advised to upgrade. There are no known workarounds for this issue.
CWE description: When an actor claims to have a given identity, the product does not prove or insufficiently proves that the claim is correct.
CVSS v3 base score: 9.8
Published: 2022-09-13T19:15Z
Repository: https://github.com/theonedev/onedev
Language: Java
Files changed: server-core/src/main/java/io/onedev/server/entitymanager/impl/DefaultProjectManager.java, server-core/src/main/java/io/onedev/server/git/GitFilter.java, server-core/src/main/java/io/onedev/server/git/GitSshCommand.java, server-core/src/main/java/io/onedev/server/git/GitUtils.java, server-core/src/main/java/io/onedev/server/git/hookcallback/GitPostReceiveCallback.java, server-core/src/main/java/io/onedev/server/git/hookcallback/GitPreReceiveCallback.java, server-core/src/main/resources/git-receive-hook
Commit date: 2022-05-30 19:36:08 +0800
Commit message: Fix security vulnerability related to Git pre/post receive callback
Diff: diff --git a/server-core/src/main/java/io/onedev/server/entitymanager/impl/DefaultProjectManager.java b/server-core/src/main/java/io/onedev/server/entitymanager/impl/DefaultProjectManager.java
index fd6d7d8067a..ff1090b7965 100644
--- a/server-core/src/main/java/io/onedev/server/entitymanager/impl/DefaultProjectManager.java
+++ b/server-core/src/main/java/io/onedev/server/entitymanager/impl/DefaultProjectManager.java
@@ -535,11 +535,11 @@ public class DefaultProjectManager extends BaseEntityManager<Project>
         if (!hookFile.exists()) 
         	return false;
         
         try {
 			String content = FileUtils.readFileToString(hookFile, Charset.defaultCharset());
-			if (!content.contains("ENV_GIT_ALTERNATE_OBJECT_DIRECTORIES"))
+			if (!content.contains("ONEDEV_HOOK_TOKEN"))
 				return false;
 		} catch (IOException e) {
 			throw new RuntimeException(e);
 		}
 		
diff --git a/server-core/src/main/java/io/onedev/server/git/GitFilter.java b/server-core/src/main/java/io/onedev/server/git/GitFilter.java
index 3853b6e925d..81bcf90176e 100644
--- a/server-core/src/main/java/io/onedev/server/git/GitFilter.java
+++ b/server-core/src/main/java/io/onedev/server/git/GitFilter.java
@@ -141,10 +141,11 @@ public class GitFilter implements Filter {
 	            serverUrl = "https://localhost:" + serverConfig.getHttpsPort();
 
 	        environments.put("ONEDEV_CURL", settingManager.getSystemSetting().getCurlConfig().getExecutable());
 			environments.put("ONEDEV_URL", serverUrl);
 			environments.put("ONEDEV_USER_ID", SecurityUtils.getUserId().toString());
+			environments.put("ONEDEV_HOOK_TOKEN", GitUtils.HOOK_TOKEN);
 			environments.put("ONEDEV_REPOSITORY_ID", project.getId().toString());
 			
 			// to be compatible with old repository
 	        environments.put("GITPLEX_CURL", settingManager.getSystemSetting().getCurlConfig().getExecutable());
 			environments.put("GITPLEX_URL", serverUrl);
diff --git a/server-core/src/main/java/io/onedev/server/git/GitSshCommand.java b/server-core/src/main/java/io/onedev/server/git/GitSshCommand.java
index bed87b64ee0..ec550d7fe27 100644
--- a/server-core/src/main/java/io/onedev/server/git/GitSshCommand.java
+++ b/server-core/src/main/java/io/onedev/server/git/GitSshCommand.java
@@ -73,10 +73,11 @@ abstract class GitSshCommand implements Command, ServerSessionAware {
             serverUrl = "https://localhost:" + serverConfig.getHttpsPort();
 
         SettingManager settingManager = OneDev.getInstance(SettingManager.class);
         environments.put("ONEDEV_CURL", settingManager.getSystemSetting().getCurlConfig().getExecutable());
 		environments.put("ONEDEV_URL", serverUrl);
+		environments.put("ONEDEV_HOOK_TOKEN", GitUtils.HOOK_TOKEN);
 		environments.put("ONEDEV_USER_ID", SecurityUtils.getUserId().toString());
 		environments.put("ONEDEV_REPOSITORY_ID", project.getId().toString());
 		return environments;
     }
     
diff --git a/server-core/src/main/java/io/onedev/server/git/GitUtils.java b/server-core/src/main/java/io/onedev/server/git/GitUtils.java
index c8b71af5fbe..30ca4d8b1b6 100644
--- a/server-core/src/main/java/io/onedev/server/git/GitUtils.java
+++ b/server-core/src/main/java/io/onedev/server/git/GitUtils.java
@@ -14,10 +14,11 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 
 import javax.annotation.Nullable;
 
+import org.apache.commons.lang3.RandomStringUtils;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.bcpg.BCPGOutputStream;
 import org.bouncycastle.bcpg.HashAlgorithmTags;
 import org.bouncycastle.jce.provider.BouncyCastleProvider;
 import org.bouncycastle.openpgp.PGPCompressedData;
@@ -95,10 +96,12 @@ public class GitUtils {
 
 	public static final int SHORT_SHA_LENGTH = 8;
 	
 	private static final Logger logger = LoggerFactory.getLogger(GitUtils.class);
 	
+	public static final String HOOK_TOKEN = RandomStringUtils.randomAlphanumeric(20); 
+	
 	public static boolean isEmptyPath(String path) {
 		return Strings.isNullOrEmpty(path) || Objects.equal(path, DiffEntry.DEV_NULL);
 	}
 
 	public static String abbreviateSHA(String sha, int length) {
diff --git a/server-core/src/main/java/io/onedev/server/git/hookcallback/GitPostReceiveCallback.java b/server-core/src/main/java/io/onedev/server/git/hookcallback/GitPostReceiveCallback.java
index dc8e01e43a4..c05b6bc08b7 100644
--- a/server-core/src/main/java/io/onedev/server/git/hookcallback/GitPostReceiveCallback.java
+++ b/server-core/src/main/java/io/onedev/server/git/hookcallback/GitPostReceiveCallback.java
@@ -1,9 +1,8 @@
 package io.onedev.server.git.hookcallback;
 
 import java.io.IOException;
-import java.net.InetAddress;
 import java.util.ArrayList;
 import java.util.Enumeration;
 import java.util.List;
 
 import javax.inject.Inject;
@@ -54,21 +53,18 @@ public class GitPostReceiveCallback extends HttpServlet {
     }
 
     @Sessional
     @Override
 	protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
-        String clientIp = request.getHeader("X-Forwarded-For");
-        if (clientIp == null) clientIp = request.getRemoteAddr();
-
-        if (!InetAddress.getByName(clientIp).isLoopbackAddress()) {
+        List<String> fields = StringUtils.splitAndTrim(request.getPathInfo(), "/");
+        Preconditions.checkState(fields.size() == 3);
+        
+        if (!fields.get(2).equals(GitUtils.HOOK_TOKEN)) {
             response.sendError(HttpServletResponse.SC_FORBIDDEN,
-                    "Git hook callbacks can only be accessed from localhost.");
+                    "Git hook callbacks can only be accessed by OneDev itself");
             return;
         }
-
-        List<String> fields = StringUtils.splitAndTrim(request.getPathInfo(), "/");
-        Preconditions.checkState(fields.size() == 2);
         
         Project project = projectManager.load(Long.valueOf(fields.get(0)));
         Long userId = Long.valueOf(fields.get(1));
         ThreadContext.bind(SecurityUtils.asSubject(userId));
 
diff --git a/server-core/src/main/java/io/onedev/server/git/hookcallback/GitPreReceiveCallback.java b/server-core/src/main/java/io/onedev/server/git/hookcallback/GitPreReceiveCallback.java
index 03dbc79d0f7..95d0dbcc47d 100644
--- a/server-core/src/main/java/io/onedev/server/git/hookcallback/GitPreReceiveCallback.java
+++ b/server-core/src/main/java/io/onedev/server/git/hookcallback/GitPreReceiveCallback.java
@@ -1,9 +1,8 @@
 package io.onedev.server.git.hookcallback;
 
 import java.io.IOException;
-import java.net.InetAddress;
 import java.util.ArrayList;
 import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -69,22 +68,19 @@ public class GitPreReceiveCallback extends HttpServlet {
 	}
 	
 	@Sessional
 	@Override
 	protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
-        String clientIp = request.getHeader("X-Forwarded-For");
-        if (clientIp == null) clientIp = request.getRemoteAddr();
-
-        if (!InetAddress.getByName(clientIp).isLoopbackAddress()) {
+        List<String> fields = StringUtils.splitAndTrim(request.getPathInfo(), "/");
+        Preconditions.checkState(fields.size() == 3);
+        
+        if (!fields.get(2).equals(GitUtils.HOOK_TOKEN)) {
             response.sendError(HttpServletResponse.SC_FORBIDDEN,
-                    "Git hook callbacks can only be accessed from localhost.");
+                    "Git hook callbacks can only be accessed by OneDev itself");
             return;
         }
         
-        List<String> fields = StringUtils.splitAndTrim(request.getPathInfo(), "/");
-        Preconditions.checkState(fields.size() == 2);
-        
         SecurityUtils.getSubject().runAs(SecurityUtils.asPrincipal(Long.valueOf(fields.get(1))));
         try {
             Project project = projectManager.load(Long.valueOf(fields.get(0)));
             
             String refUpdateInfo = null;
diff --git a/server-core/src/main/resources/git-receive-hook b/server-core/src/main/resources/git-receive-hook
index fe67f18a7dd..974738d1aa5 100644
--- a/server-core/src/main/resources/git-receive-hook
+++ b/server-core/src/main/resources/git-receive-hook
@@ -1,10 +1,10 @@
 #!/usr/bin/env bash
 unset http_proxy
 unset https_proxy
 IFS=$'\r\n'; 
-lines=($(${ONEDEV_CURL} -k -s -S -f -X POST --data-urlencode "ENV_GIT_ALTERNATE_OBJECT_DIRECTORIES=${GIT_ALTERNATE_OBJECT_DIRECTORIES}" --data-urlencode "ENV_GIT_OBJECT_DIRECTORY=${GIT_OBJECT_DIRECTORY}" --data-urlencode "ENV_GIT_QUARANTINE_PATH=${GIT_QUARANTINE_PATH}" -d @- ${ONEDEV_URL}/%s/${ONEDEV_REPOSITORY_ID}/${ONEDEV_USER_ID} 2>&1))
+lines=($(${ONEDEV_CURL} -k -s -S -f -X POST --data-urlencode "ENV_GIT_ALTERNATE_OBJECT_DIRECTORIES=${GIT_ALTERNATE_OBJECT_DIRECTORIES}" --data-urlencode "ENV_GIT_OBJECT_DIRECTORY=${GIT_OBJECT_DIRECTORY}" --data-urlencode "ENV_GIT_QUARANTINE_PATH=${GIT_QUARANTINE_PATH}" -d @- ${ONEDEV_URL}/%s/${ONEDEV_REPOSITORY_ID}/${ONEDEV_USER_ID}/${ONEDEV_HOOK_TOKEN} 2>&1))
 
 returnCode=0;
 
 for i in ${lines[@]}
 do

Vulnerable code: 			if (!content.contains("ENV_GIT_ALTERNATE_OBJECT_DIRECTORIES"))
import java.net.InetAddress;
        String clientIp = request.getHeader("X-Forwarded-For");
        if (clientIp == null) clientIp = request.getRemoteAddr();

        if (!InetAddress.getByName(clientIp).isLoopbackAddress()) {
                    "Git hook callbacks can only be accessed from localhost.");

        List<String> fields = StringUtils.splitAndTrim(request.getPathInfo(), "/");
        Preconditions.checkState(fields.size() == 2);
import java.net.InetAddress;
        String clientIp = request.getHeader("X-Forwarded-For");
        if (clientIp == null) clientIp = request.getRemoteAddr();

        if (!InetAddress.getByName(clientIp).isLoopbackAddress()) {
                    "Git hook callbacks can only be accessed from localhost.");
        List<String> fields = StringUtils.splitAndTrim(request.getPathInfo(), "/");
        Preconditions.checkState(fields.size() == 2);
        
lines=($(${ONEDEV_CURL} -k -s -S -f -X POST --data-urlencode "ENV_GIT_ALTERNATE_OBJECT_DIRECTORIES=${GIT_ALTERNATE_OBJECT_DIRECTORIES}" --data-urlencode "ENV_GIT_OBJECT_DIRECTORY=${GIT_OBJECT_DIRECTORY}" --data-urlencode "ENV_GIT_QUARANTINE_PATH=${GIT_QUARANTINE_PATH}" -d @- ${ONEDEV_URL}/%s/${ONEDEV_REPOSITORY_ID}/${ONEDEV_USER_ID} 2>&1))
Fixed code: 			if (!content.contains("ONEDEV_HOOK_TOKEN"))
			environments.put("ONEDEV_HOOK_TOKEN", GitUtils.HOOK_TOKEN);
		environments.put("ONEDEV_HOOK_TOKEN", GitUtils.HOOK_TOKEN);
import org.apache.commons.lang3.RandomStringUtils;
	public static final String HOOK_TOKEN = RandomStringUtils.randomAlphanumeric(20); 
	
        List<String> fields = StringUtils.splitAndTrim(request.getPathInfo(), "/");
        Preconditions.checkState(fields.size() == 3);
        
        if (!fields.get(2).equals(GitUtils.HOOK_TOKEN)) {
                    "Git hook callbacks can only be accessed by OneDev itself");
        List<String> fields = StringUtils.splitAndTrim(request.getPathInfo(), "/");
        Preconditions.checkState(fields.size() == 3);
        
        if (!fields.get(2).equals(GitUtils.HOOK_TOKEN)) {
                    "Git hook callbacks can only be accessed by OneDev itself");
lines=($(${ONEDEV_CURL} -k -s -S -f -X POST --data-urlencode "ENV_GIT_ALTERNATE_OBJECT_DIRECTORIES=${GIT_ALTERNATE_OBJECT_DIRECTORIES}" --data-urlencode "ENV_GIT_OBJECT_DIRECTORY=${GIT_OBJECT_DIRECTORY}" --data-urlencode "ENV_GIT_QUARANTINE_PATH=${GIT_QUARANTINE_PATH}" -d @- ${ONEDEV_URL}/%s/${ONEDEV_REPOSITORY_ID}/${ONEDEV_USER_ID}/${ONEDEV_HOOK_TOKEN} 2>&1))