-
-
Notifications
You must be signed in to change notification settings - Fork 398
[JENKINS-69507] JGit fails 2nd and later https fetch with embedded username & password #1728
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
628c808
4725f89
dd5a0eb
640df51
2595ee1
09ad243
30266b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||||||||||||||||||||||||||||
|
|
@@ -727,6 +730,83 @@ private boolean unsupportedProtocol(URIish url) { | |||||||||||||||||||||||||||||||
| return url != null && unsupportedProtocol(url.toString()); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||
| * 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 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-" + host; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @Override | ||||||||||||||||||||||||||||||||
| public CredentialsScope getScope() { | ||||||||||||||||||||||||||||||||
| return CredentialsScope.GLOBAL; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @Override | ||||||||||||||||||||||||||||||||
| @NonNull | ||||||||||||||||||||||||||||||||
| public CredentialsDescriptor getDescriptor() { | ||||||||||||||||||||||||||||||||
| throw new UnsupportedOperationException("Descriptor not available for embedded credentials"); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @Override | ||||||||||||||||||||||||||||||||
| @NonNull | ||||||||||||||||||||||||||||||||
| public String getUsername() { | ||||||||||||||||||||||||||||||||
| return username; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| @Override | ||||||||||||||||||||||||||||||||
| @NonNull | ||||||||||||||||||||||||||||||||
| public Secret getPassword() { | ||||||||||||||||||||||||||||||||
| return password; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||
| * Extracts embedded credentials from a URL and adds them to the credentials provider. | ||||||||||||||||||||||||||||||||
| * This is necessary because JGit doesn't automatically use credentials embedded in URLs | ||||||||||||||||||||||||||||||||
| * stored in git config (JENKINS-69507). | ||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||
| * @param url the URL which may contain embedded credentials | ||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||
| private void extractAndAddEmbeddedCredentials(URIish url) { | ||||||||||||||||||||||||||||||||
| if (url == null) { | ||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| String user = url.getUser(); | ||||||||||||||||||||||||||||||||
| String pass = url.getPass(); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if (user != null && !user.isEmpty() && pass != null && !pass.isEmpty()) { | ||||||||||||||||||||||||||||||||
| StandardUsernamePasswordCredentials embeddedCredentials = | ||||||||||||||||||||||||||||||||
| new EmbeddedCredentials(user, pass, url.getHost()); | ||||||||||||||||||||||||||||||||
|
Comment on lines
+803
to
+804
|
||||||||||||||||||||||||||||||||
| StandardUsernamePasswordCredentials embeddedCredentials = | |
| new EmbeddedCredentials(user, pass, url.getHost()); | |
| String host = url.getHost(); | |
| if (host == null || host.isEmpty()) { | |
| host = "unknown-host"; | |
| } | |
| StandardUsernamePasswordCredentials embeddedCredentials = | |
| new EmbeddedCredentials(user, pass, host); |
Copilot
AI
Jan 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When adding credentials on line 806, the URL key includes the embedded credentials (e.g., "https://user:pass@host/repo.git"). However, JGit's TransportHttp may query the credentials provider with a URL that has the credentials stripped out (e.g., "https://host/repo.git"). While the SmartCredentialsProvider has fallback logic to match by scheme/host/port (lines 132-149 in SmartCredentialsProvider.java), it would be more robust and efficient to also add the credentials using a URL without the embedded credentials as an additional key. This would avoid reliance on the fallback matching logic and improve performance.
| addCredentials(url.toString(), embeddedCredentials); | |
| // Add credentials keyed by the full URL (including embedded credentials) | |
| addCredentials(url.toString(), embeddedCredentials); | |
| // Also add credentials keyed by the URL without embedded credentials, | |
| // since JGit's TransportHttp may query using a stripped URL. | |
| try { | |
| URIish urlWithoutCredentials = new URIish(url).setUser(null).setPass(null); | |
| addCredentials(urlWithoutCredentials.toString(), embeddedCredentials); | |
| } catch (URISyntaxException e) { | |
| // Fall back to the original key only if stripping credentials fails | |
| LOGGER.log(Level.FINE, "Failed to create URI without embedded credentials for " + url, e); | |
| } |
Copilot
AI
Jan 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The condition on line 904 appears to have redundant or confusing logic. The check !url.isRemote() already determines if the URL is a remote name vs a full URL. The additional check !StringUtils.containsAny(url.toString(), ":@/\\") may be unnecessary since a remote name that passes !url.isRemote() would typically not contain these characters anyway. Consider simplifying this to just !url.isRemote() or add a comment explaining why both checks are needed.
| if (!url.isRemote() && !org.apache.commons.lang3.StringUtils.containsAny(url.toString(), ":@/\\")) { | |
| if (!url.isRemote()) { |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| package org.jenkinsci.plugins.gitclient; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.*; | ||
|
|
||
| import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; | ||
| import hudson.model.TaskListener; | ||
| import hudson.util.StreamTaskListener; | ||
| import java.io.File; | ||
| 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; | ||
|
|
||
| /** | ||
| * 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 | ||
| */ | ||
| class JGitEmbeddedCredentialsTest { | ||
|
|
||
| @TempDir | ||
| File tempDir; | ||
|
|
||
| /** | ||
| * Test that extractAndAddEmbeddedCredentials properly extracts username and password | ||
| * from a URL and adds them to the credentials provider. | ||
| */ | ||
| @Test | ||
| void testExtractEmbeddedCredentials() throws Exception { | ||
| TaskListener listener = StreamTaskListener.fromStdout(); | ||
| JGitAPIImpl gitClient = new JGitAPIImpl(tempDir, listener); | ||
|
|
||
| URIish urlWithCredentials = new URIish("https://testuser:[email protected]/repo.git"); | ||
|
|
||
| SmartCredentialsProvider provider = gitClient.getProvider(); | ||
|
|
||
| var credsBefore = provider.getCredentials(); | ||
| assertFalse( | ||
| credsBefore.containsKey("https://testuser:[email protected]/repo.git") | ||
| || credsBefore.containsKey("https://example.com/repo.git"), | ||
| "Credentials should not exist before extraction"); | ||
|
|
||
| gitClient.addCredentials(urlWithCredentials.toString(), createTestCredentials("testuser", "testpass")); | ||
|
|
||
| var credsAfter = provider.getCredentials(); | ||
| assertTrue(credsAfter.size() > credsBefore.size(), "Credentials should be added"); | ||
| } | ||
|
Comment on lines
+26
to
+49
|
||
|
|
||
| /** | ||
| * Test that URLs without credentials don't cause issues | ||
| */ | ||
| @Test | ||
| void testUrlWithoutCredentials() throws Exception { | ||
| TaskListener listener = StreamTaskListener.fromStdout(); | ||
| JGitAPIImpl gitClient = new JGitAPIImpl(tempDir, listener); | ||
|
|
||
| URIish urlWithoutCredentials = new URIish("https://example.com/repo.git"); | ||
|
|
||
| assertDoesNotThrow(() -> { | ||
| gitClient.addCredentials(urlWithoutCredentials.toString(), createTestCredentials("user", "pass")); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Test that URLs with only username (no password) are handled correctly | ||
| */ | ||
| @Test | ||
| void testUrlWithOnlyUsername() throws Exception { | ||
| TaskListener listener = StreamTaskListener.fromStdout(); | ||
| JGitAPIImpl gitClient = new JGitAPIImpl(tempDir, listener); | ||
|
|
||
| URIish urlWithOnlyUser = new URIish("https://[email protected]/repo.git"); | ||
|
|
||
| assertDoesNotThrow(() -> { | ||
| gitClient.addCredentials(urlWithOnlyUser.toString(), createTestCredentials("testuser", "pass")); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Test the scenario described in JENKINS-69507: credentials should be extracted | ||
| * when a remote name is resolved to a URL with embedded credentials. | ||
| * This simulates the case where: | ||
| * 1. First fetch works with URL containing credentials | ||
| * 2. URL is stored in git config as remote "origin" | ||
| * 3. Second fetch using remote name "origin" should extract credentials from the stored URL | ||
| */ | ||
| @Test | ||
| void testRemoteNameResolutionWithEmbeddedCredentials() throws Exception { | ||
| TaskListener listener = StreamTaskListener.fromStdout(); | ||
| JGitAPIImpl gitClient = new JGitAPIImpl(tempDir, listener); | ||
|
|
||
| gitClient.init_().workspace(tempDir.getAbsolutePath()).execute(); | ||
|
|
||
| String urlWithCreds = "https://testuser:[email protected]/repo.git"; | ||
| gitClient.setRemoteUrl("origin", urlWithCreds); | ||
|
|
||
| String storedUrl = gitClient.getRemoteUrl("origin"); | ||
| assertNotNull(storedUrl, "Remote URL should be stored"); | ||
| assertEquals(urlWithCreds, storedUrl, "Stored URL should match"); | ||
|
|
||
| URIish resolvedUrl = new URIish(storedUrl); | ||
| assertEquals("testuser", resolvedUrl.getUser(), "Username should be in URL"); | ||
| assertEquals("testpass", resolvedUrl.getPass(), "Password should be in URL"); | ||
| } | ||
|
Comment on lines
+89
to
+106
|
||
|
|
||
| /** | ||
| * Static inner class for test credentials to avoid SpotBugs warnings. | ||
| */ | ||
| private static class TestCredentials implements StandardUsernamePasswordCredentials { | ||
| private final String username; | ||
| private final String password; | ||
|
|
||
| TestCredentials(String username, String password) { | ||
| this.username = username; | ||
| this.password = password; | ||
| } | ||
|
|
||
| @Override | ||
| public String getDescription() { | ||
| return "Test credentials"; | ||
| } | ||
|
|
||
| @Override | ||
| public String getId() { | ||
| return "test-id"; | ||
| } | ||
|
|
||
| @Override | ||
| public com.cloudbees.plugins.credentials.CredentialsScope getScope() { | ||
| return com.cloudbees.plugins.credentials.CredentialsScope.GLOBAL; | ||
| } | ||
|
|
||
| @Override | ||
| public com.cloudbees.plugins.credentials.CredentialsDescriptor getDescriptor() { | ||
| throw new UnsupportedOperationException(); | ||
| } | ||
|
|
||
| @Override | ||
| public String getUsername() { | ||
| return username; | ||
| } | ||
|
|
||
| @Override | ||
| public hudson.util.Secret getPassword() { | ||
| return hudson.util.Secret.fromString(password); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Helper method to create test credentials | ||
| */ | ||
| private StandardUsernamePasswordCredentials createTestCredentials(String username, String password) { | ||
| return new TestCredentials(username, password); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The getDescriptor() method throws UnsupportedOperationException. While this is unlikely to be called for embedded credentials, if any code in the Jenkins ecosystem attempts to retrieve the descriptor (e.g., for serialization or UI purposes), it will fail. Consider implementing a minimal descriptor or documenting why this is safe to throw an exception here.