diff --git a/src/main/java/org/jenkinsci/plugins/gitclient/JGitAPIImpl.java b/src/main/java/org/jenkinsci/plugins/gitclient/JGitAPIImpl.java index 94e63ed330..7e9d6497ce 100644 --- a/src/main/java/org/jenkinsci/plugins/gitclient/JGitAPIImpl.java +++ b/src/main/java/org/jenkinsci/plugins/gitclient/JGitAPIImpl.java @@ -15,8 +15,11 @@ import static org.jenkinsci.plugins.gitclient.CliGitAPIImpl.TIMEOUT_LOG_PREFIX; import com.cloudbees.jenkins.plugins.sshcredentials.SSHUserPrivateKey; +import com.cloudbees.plugins.credentials.CredentialsDescriptor; +import com.cloudbees.plugins.credentials.CredentialsScope; import com.cloudbees.plugins.credentials.common.StandardCredentials; import com.cloudbees.plugins.credentials.common.StandardUsernameCredentials; +import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; import com.cloudbees.plugins.credentials.common.UsernameCredentials; import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; @@ -53,6 +56,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Predicate; @@ -727,6 +731,143 @@ private boolean unsupportedProtocol(URIish url) { return url != null && unsupportedProtocol(url.toString()); } + /** + * Self contained descriptor so that {@code getDescriptor()} is safe to call on agents, where no + * Jenkins instance is available. + */ + private static class EmbeddedCredentialsDescriptor extends CredentialsDescriptor { + + EmbeddedCredentialsDescriptor() { + super(EmbeddedCredentials.class); + } + + @Override + @NonNull + public String getDisplayName() { + return "Embedded URL credentials"; + } + } + + /** + * Static inner class to hold embedded credentials extracted from URLs. + * This avoids SpotBugs warnings about serializable inner classes. + */ + private static class EmbeddedCredentials implements StandardUsernamePasswordCredentials { + @Serial + private static final long serialVersionUID = 1L; + + private static final CredentialsDescriptor DESCRIPTOR = new EmbeddedCredentialsDescriptor(); + + private final String username; + private final Secret password; + private final String host; + + EmbeddedCredentials(String username, String password, String host) { + this.username = username; + this.password = Secret.fromString(password); + this.host = host; + } + + @Override + @NonNull + public String getDescription() { + return "Credentials extracted from repository URL"; + } + + @Override + @NonNull + public String getId() { + return "embedded-url-credentials-" + username + "@" + host; + } + + @Override + public CredentialsScope getScope() { + return CredentialsScope.GLOBAL; + } + + @Override + @NonNull + public CredentialsDescriptor getDescriptor() { + return DESCRIPTOR; + } + + @Override + @NonNull + public String getUsername() { + return username; + } + + @Override + @NonNull + public Secret getPassword() { + return password; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof EmbeddedCredentials other)) { + return false; + } + return Objects.equals(username, other.username) + && Objects.equals(password, other.password) + && Objects.equals(host, other.host); + } + + @Override + public int hashCode() { + return Objects.hash(username, password, host); + } + } + + /** + * Returns true if the value is a bare remote name such as {@code origin} rather than a + * repository location. + */ + private static boolean isRemoteName(URIish url) { + if (url.getScheme() != null || url.getHost() != null) { + return false; + } + String value = url.toString(); + return !value.isEmpty() && !value.contains("/") && !value.contains("\\"); + } + + /** + * Adds credentials embedded in an http or https URL to the credentials provider, since JGit does + * not use credentials embedded in a URL resolved from git config (JENKINS-69507). Other + * protocols are ignored. + * + * @param url the URL which may contain embedded credentials + */ + private void extractAndAddEmbeddedCredentials(URIish url) { + if (url == null) { + return; + } + + String scheme = url.getScheme(); + if (scheme == null || !(scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) { + return; + } + + String user = url.getUser(); + String pass = url.getPass(); + if (user == null || user.isEmpty() || pass == null || pass.isEmpty()) { + return; + } + + String host = url.getHost(); + if (host == null || host.isEmpty()) { + host = "unknown-host"; + } + + StandardUsernamePasswordCredentials embeddedCredentials = new EmbeddedCredentials(user, pass, host); + + addCredentials(url.toString(), embeddedCredentials); + addCredentials(url.setUser(null).setPass(null).toString(), embeddedCredentials); + } + /** * fetch_. * @@ -816,6 +957,21 @@ public void execute() throws GitException { if (unsupportedProtocol(url)) { throw new GitException("unsupported protocol in URL " + url); } + + /* JENKINS-69507 */ + URIish urlForCredentials = url; + if (isRemoteName(url)) { + String resolvedUrl = repo.getConfig().getString("remote", url.toString(), "url"); + if (resolvedUrl != null) { + try { + urlForCredentials = new URIish(resolvedUrl); + } catch (URISyntaxException e) { + LOGGER.log(Level.FINE, e, () -> "Could not parse the URL configured for remote " + url); + } + } + } + extractAndAddEmbeddedCredentials(urlForCredentials); + fetch.setRemote(url.toString()); fetch.setCredentialsProvider(getProvider()); fetch.setTransportConfigCallback(getTransportConfigCallback()); diff --git a/src/test/java/org/jenkinsci/plugins/gitclient/CredentialsTest.java b/src/test/java/org/jenkinsci/plugins/gitclient/CredentialsTest.java index 07b65878ad..bb058ae900 100644 --- a/src/test/java/org/jenkinsci/plugins/gitclient/CredentialsTest.java +++ b/src/test/java/org/jenkinsci/plugins/gitclient/CredentialsTest.java @@ -334,15 +334,15 @@ static List gitRepoUrls() throws Exception { false, lfsSpecificTest); repos.add(repo); - /* Add embedded credentials test case if valid username, valid password, CLI git, and http protocol */ + /* Add embedded credentials test case if valid username, valid password, CLI git or JGit, and http protocol */ if (username != null && !username.matches(".*[@:].*") && // Skip special cases of username password != null && !password.matches(".*[@:].*") && // Skip special cases of password - implementation.equals("git") - && // Embedded credentials only implemented for CLI git + (implementation.equals("git") || implementation.equals("jgit")) + && // Embedded credentials implemented for both CLI git and JGit (JENKINS-69507) repoURL.startsWith("http")) { /* Use existing username and password to create an embedded credentials test case */ String repoURLwithCredentials = repoURL.replaceAll( diff --git a/src/test/java/org/jenkinsci/plugins/gitclient/JGitEmbeddedCredentialsTest.java b/src/test/java/org/jenkinsci/plugins/gitclient/JGitEmbeddedCredentialsTest.java new file mode 100644 index 0000000000..6d58f51231 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/gitclient/JGitEmbeddedCredentialsTest.java @@ -0,0 +1,282 @@ +package org.jenkinsci.plugins.gitclient; + +import static org.junit.jupiter.api.Assertions.*; + +import com.cloudbees.plugins.credentials.CredentialsDescriptor; +import com.cloudbees.plugins.credentials.common.StandardCredentials; +import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; +import hudson.model.TaskListener; +import hudson.plugins.git.GitException; +import hudson.util.StreamTaskListener; +import java.io.File; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.eclipse.jgit.transport.CredentialItem; +import org.eclipse.jgit.transport.RefSpec; +import org.eclipse.jgit.transport.URIish; +import org.jenkinsci.plugins.gitclient.jgit.SmartCredentialsProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.jvnet.hudson.test.Issue; + +/** + * Test that verifies JENKINS-69507 fix: JGit should extract and use + * credentials embedded in URLs (like https://user:pass@host/repo.git) + * for subsequent fetch operations. + * + * @author Akash Manna + */ +@Issue("JENKINS-69507") +class JGitEmbeddedCredentialsTest { + + /** Refuses connections immediately, so a fetch fails without reaching the network. */ + private static final String UNREACHABLE_AUTHORITY = "localhost:1"; + + @TempDir + private File tempDir; + + private JGitAPIImpl newClient(String name) throws Exception { + TaskListener listener = StreamTaskListener.fromStdout(); + File workspace = new File(tempDir, name); + assertTrue(workspace.mkdirs(), "Failed to create " + workspace); + return new JGitAPIImpl(workspace, listener); + } + + private JGitAPIImpl newInitializedClient(String name) throws Exception { + JGitAPIImpl gitClient = newClient(name); + gitClient.init_().workspace(gitClient.getWorkTree().getRemote()).execute(); + return gitClient; + } + + private static void extractCredentials(JGitAPIImpl gitClient, URIish url) throws Exception { + Method method = JGitAPIImpl.class.getDeclaredMethod("extractAndAddEmbeddedCredentials", URIish.class); + method.setAccessible(true); + method.invoke(gitClient, new Object[] {url}); + } + + private static Map registeredCredentials(JGitAPIImpl gitClient) { + Map credentials = + new HashMap<>(gitClient.getProvider().getCredentials()); + credentials.remove(""); + return credentials; + } + + private static StandardUsernamePasswordCredentials assertRegistered( + JGitAPIImpl gitClient, String key, String expectedUsername, String expectedPassword) { + Map credentials = registeredCredentials(gitClient); + StandardCredentials registered = credentials.get(key); + assertNotNull(registered, "No credentials registered for " + key + ", registered " + credentials.keySet()); + StandardUsernamePasswordCredentials usernamePassword = + assertInstanceOf(StandardUsernamePasswordCredentials.class, registered); + assertEquals(expectedUsername, usernamePassword.getUsername()); + assertEquals(expectedPassword, usernamePassword.getPassword().getPlainText()); + return usernamePassword; + } + + private static void assertProviderAuthenticates( + SmartCredentialsProvider provider, String url, String expectedUsername, String expectedPassword) + throws Exception { + CredentialItem.Username username = new CredentialItem.Username(); + CredentialItem.Password password = new CredentialItem.Password(); + assertTrue(provider.get(new URIish(url), username, password), "No credentials provided for " + url); + assertEquals(expectedUsername, username.getValue()); + assertEquals(expectedPassword, new String(password.getValue())); + } + + @Test + void testExtractEmbeddedCredentials() throws Exception { + JGitAPIImpl gitClient = newClient("extract"); + + extractCredentials(gitClient, new URIish("https://testuser:testpass@example.com/repo.git")); + + assertEquals(2, registeredCredentials(gitClient).size()); + assertRegistered(gitClient, "https://testuser@example.com/repo", "testuser", "testpass"); + assertRegistered(gitClient, "https://example.com/repo", "testuser", "testpass"); + assertProviderAuthenticates(gitClient.getProvider(), "https://example.com/repo.git", "testuser", "testpass"); + assertProviderAuthenticates( + gitClient.getProvider(), "https://testuser@example.com/repo.git", "testuser", "testpass"); + } + + @Test + void testExtractPercentEncodedCredentials() throws Exception { + JGitAPIImpl gitClient = newClient("encoded"); + + extractCredentials( + gitClient, new URIish("https://user%40domain.com:p%40ss%3Aword@example.com:8443/team/repo.git")); + + assertEquals(2, registeredCredentials(gitClient).size()); + assertRegistered( + gitClient, "https://user%40domain.com@example.com:8443/team/repo", "user@domain.com", "p@ss:word"); + assertRegistered(gitClient, "https://example.com:8443/team/repo", "user@domain.com", "p@ss:word"); + } + + @Test + void testExtractCredentialsFromIPv6UrlWithPort() throws Exception { + JGitAPIImpl gitClient = newClient("ipv6"); + + extractCredentials(gitClient, new URIish("https://ipv6user:ipv6pass@[fe80::1]:8443/repo.git")); + + assertEquals(2, registeredCredentials(gitClient).size()); + assertRegistered(gitClient, "https://ipv6user@[fe80::1]:8443/repo", "ipv6user", "ipv6pass"); + assertRegistered(gitClient, "https://[fe80::1]:8443/repo", "ipv6user", "ipv6pass"); + } + + @Test + void testUrlWithOnlyUsername() throws Exception { + JGitAPIImpl gitClient = newClient("user-only"); + + extractCredentials(gitClient, new URIish("https://testuser@example.com/repo.git")); + + assertTrue(registeredCredentials(gitClient).isEmpty(), "Credentials registered without a password"); + } + + @Test + void testUrlWithoutCredentials() throws Exception { + JGitAPIImpl gitClient = newClient("no-credentials"); + + extractCredentials(gitClient, new URIish("https://example.com/repo.git")); + + assertTrue(registeredCredentials(gitClient).isEmpty(), "Credentials registered for an anonymous URL"); + } + + @Test + void testNonHttpUrlsAreIgnored() throws Exception { + JGitAPIImpl gitClient = newClient("other-protocols"); + + extractCredentials(gitClient, new URIish("ssh://sshuser:sshpass@example.com/repo.git")); + extractCredentials(gitClient, new URIish("git@example.com:jenkinsci/git-client-plugin.git")); + extractCredentials( + gitClient, + new URIish(new File(tempDir, "other-protocols").toURI().toString())); + extractCredentials(gitClient, null); + + assertTrue(registeredCredentials(gitClient).isEmpty(), "Credentials registered for a non http URL"); + } + + @Test + void testRepeatedExtractionDoesNotGrowProvider() throws Exception { + JGitAPIImpl gitClient = newClient("repeated"); + URIish url = new URIish("https://testuser:testpass@example.com/repo.git"); + + for (int i = 0; i < 5; i++) { + extractCredentials(gitClient, url); + } + + assertEquals(2, registeredCredentials(gitClient).size()); + } + + @Test + void testEmbeddedCredentialsProvideDescriptor() throws Exception { + JGitAPIImpl gitClient = newClient("descriptor"); + + extractCredentials(gitClient, new URIish("https://testuser:testpass@example.com/repo.git")); + + StandardUsernamePasswordCredentials credentials = + assertRegistered(gitClient, "https://example.com/repo", "testuser", "testpass"); + CredentialsDescriptor descriptor = credentials.getDescriptor(); + assertNotNull(descriptor, "Embedded credentials have no descriptor"); + assertEquals("Embedded URL credentials", descriptor.getDisplayName()); + } + + @Test + void testEmbeddedCredentialsEqualsAndHashCode() throws Exception { + JGitAPIImpl first = newClient("equals-first"); + JGitAPIImpl second = newClient("equals-second"); + JGitAPIImpl third = newClient("equals-third"); + + extractCredentials(first, new URIish("https://testuser:testpass@example.com/repo.git")); + extractCredentials(second, new URIish("https://testuser:testpass@example.com/other.git")); + extractCredentials(third, new URIish("https://testuser:different@example.com/repo.git")); + + StandardCredentials firstCredentials = registeredCredentials(first).get("https://example.com/repo"); + StandardCredentials secondCredentials = registeredCredentials(second).get("https://example.com/other"); + StandardCredentials thirdCredentials = registeredCredentials(third).get("https://example.com/repo"); + + assertEquals(firstCredentials, secondCredentials); + assertEquals(firstCredentials.hashCode(), secondCredentials.hashCode()); + assertNotEquals(firstCredentials, thirdCredentials); + assertNotEquals(firstCredentials, null); + assertNotEquals(firstCredentials, "not a credential"); + } + + @Test + void testFetchWithEmbeddedCredentialsPopulatesProvider() throws Exception { + JGitAPIImpl gitClient = newInitializedClient("fetch-url"); + URIish url = new URIish("https://fetchuser:fetchpass@" + UNREACHABLE_AUTHORITY + "/repo.git"); + List refSpecs = List.of(new RefSpec("+refs/heads/*:refs/remotes/origin/*")); + + assertTrue(registeredCredentials(gitClient).isEmpty()); + assertThrows( + GitException.class, + () -> gitClient + .fetch_() + .from(url, refSpecs) + .tags(false) + .timeout(1) + .execute()); + + assertRegistered(gitClient, "https://fetchuser@" + UNREACHABLE_AUTHORITY + "/repo", "fetchuser", "fetchpass"); + assertRegistered(gitClient, "https://" + UNREACHABLE_AUTHORITY + "/repo", "fetchuser", "fetchpass"); + assertProviderAuthenticates( + gitClient.getProvider(), "https://" + UNREACHABLE_AUTHORITY + "/repo.git", "fetchuser", "fetchpass"); + } + + @Test + void testFetchByRemoteNameResolvesEmbeddedCredentials() throws Exception { + JGitAPIImpl gitClient = newInitializedClient("fetch-remote-name"); + String urlWithCredentials = "https://remoteuser:remotepass@" + UNREACHABLE_AUTHORITY + "/repo.git"; + gitClient.setRemoteUrl("origin", urlWithCredentials); + assertEquals(urlWithCredentials, gitClient.getRemoteUrl("origin")); + + List refSpecs = List.of(new RefSpec("+refs/heads/*:refs/remotes/origin/*")); + URIish remoteName = new URIish("origin"); + + assertTrue(registeredCredentials(gitClient).isEmpty()); + assertThrows( + GitException.class, + () -> gitClient + .fetch_() + .from(remoteName, refSpecs) + .tags(false) + .timeout(1) + .execute()); + + assertRegistered( + gitClient, "https://remoteuser@" + UNREACHABLE_AUTHORITY + "/repo", "remoteuser", "remotepass"); + assertRegistered(gitClient, "https://" + UNREACHABLE_AUTHORITY + "/repo", "remoteuser", "remotepass"); + } + + @Test + void testFetchByRemoteNameWithoutEmbeddedCredentials() throws Exception { + JGitAPIImpl gitClient = newInitializedClient("fetch-remote-name-anonymous"); + gitClient.setRemoteUrl("origin", "https://" + UNREACHABLE_AUTHORITY + "/repo.git"); + + List refSpecs = List.of(new RefSpec("+refs/heads/*:refs/remotes/origin/*")); + URIish remoteName = new URIish("origin"); + + assertThrows( + GitException.class, + () -> gitClient + .fetch_() + .from(remoteName, refSpecs) + .tags(false) + .timeout(1) + .execute()); + + assertTrue(registeredCredentials(gitClient).isEmpty(), "Credentials registered for an anonymous remote"); + } + + @Test + void testFetchFromLocalRepositoryRegistersNoCredentials() throws Exception { + JGitAPIImpl bare = newInitializedClient("local-source"); + JGitAPIImpl gitClient = newInitializedClient("local-destination"); + + URIish source = new URIish(bare.getWorkTree().getRemote()); + List refSpecs = List.of(new RefSpec("+refs/heads/*:refs/remotes/origin/*")); + gitClient.fetch_().from(source, refSpecs).tags(false).execute(); + + assertTrue(registeredCredentials(gitClient).isEmpty(), "Credentials registered for a local fetch"); + } +}