VYPR
Critical severityNVD Advisory· Published Nov 4, 2020· Updated Aug 4, 2024

CVE-2020-2301

CVE-2020-2301

Description

Jenkins Active Directory Plugin 2.19 and earlier allows attackers to log in as any user with any password while a successful authentication of that user is still in the optional cache when using Windows/ADSI mode.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Jenkins Active Directory Plugin 2.19 and earlier in Windows/ADSI mode allows attackers to log in as any cached user with any password due to improper cache validation.

Vulnerability

Overview

CVE-2020-2301 affects the Jenkins Active Directory Plugin (versions 2.19 and earlier) when operating in Windows/ADSI mode. The plugin caches successful authentication results for users in an optional cache. When a user's credentials are cached, subsequent login attempts for that user bypass password verification entirely, allowing an attacker to authenticate using any password [2][3]. This flaw stems from the cache key being based solely on the username, without incorporating authentication context or validating the password against the cached entry [4].

Exploitation

An attacker can exploit this vulnerability by knowing a username that has previously authenticated successfully and whose cache entry is still valid. No prior authentication or special network access is required; the attacker only needs to reach the Jenkins login page. The Windows/ADSI mode is used when Jenkins runs on a Windows machine without specifying a domain, relying on ADSI for authentication [1]. The optional cache must be enabled for the attack to succeed.

Impact

Successful exploitation grants the attacker the same Jenkins privileges as the cached user. This could lead to unauthorized access to Jenkins jobs, configurations, credentials, and potentially full compromise of the Jenkins instance and its connected systems. The severity is high, as it bypasses authentication entirely for cached users.

Mitigation

The vulnerability is fixed in Active Directory Plugin version 2.20 [2]. Users should upgrade immediately. As a workaround, disabling the optional cache in the plugin configuration prevents the attack. The fix changes the cache key from a simple username string to a CacheKey object that includes authentication details, ensuring cached entries are only reused after proper password validation [4].

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
org.jenkins-ci.plugins:active-directoryMaven
>= 2.17, < 2.202.20
org.jenkins-ci.plugins:active-directoryMaven
< 2.16.12.16.1

Affected products

2

Patches

1
57e78ea7bb96

[SECURITY-2099][SECURITY-2117][SECURITY-2121][SECURITY-2123]

14 files changed · +882 57
  • pom.xml+11 2 modified
    @@ -75,6 +75,11 @@
           <artifactId>mailer</artifactId>
           <version>1.5</version>
         </dependency>
    +    <dependency>
    +      <groupId>com.github.spotbugs</groupId>
    +      <artifactId>spotbugs-annotations</artifactId>
    +      <version>4.1.4</version>
    +    </dependency>
         <dependency>
           <groupId>org.mockito</groupId>
           <artifactId>mockito-core</artifactId>
    @@ -154,8 +159,8 @@
              <groupId>org.apache.maven.plugins</groupId>
              <artifactId>maven-surefire-plugin</artifactId>
              <configuration>
    -           <forkCount>2C</forkCount>
    -           <reuseForks>true</reuseForks>
    +           <!--<forkCount>2C</forkCount>-->
    +           <!--<reuseForks>false</reuseForks>-->
                <argLine>-Xms2048m -Xmx2048m</argLine>
              </configuration>
            </plugin>
    @@ -175,6 +180,10 @@
                 <configuration>
                   <excludes>
                     <exclude>**/TheFlintstonesTest.java</exclude>
    +                <exclude>**/EntoEndUserCacheLookupEnabledTest.java</exclude>
    +                <exclude>**/EntoEndUserCacheLookupDisabledTest.java</exclude>
    +                <exclude>**/WindowsAdsiModeUserCacheDisabledTest.java</exclude>
    +                <exclude>**/WindowsAdsiModeUserCacheEnabledTest.java</exclude>
                   </excludes>
                 </configuration>
               </plugin>
    
  • src/main/java/hudson/plugins/active_directory/AbstractActiveDirectoryAuthenticationProvider.java+24 0 modified
    @@ -28,6 +28,8 @@
     import org.acegisecurity.userdetails.UserDetails;
     import org.acegisecurity.userdetails.UserDetailsService;
     import org.acegisecurity.userdetails.UsernameNotFoundException;
    +import org.kohsuke.accmod.Restricted;
    +import org.kohsuke.accmod.restrictions.NoExternalUse;
     import org.springframework.dao.DataAccessException;
     
     /**
    @@ -50,4 +52,26 @@ protected void additionalAuthenticationChecks(UserDetails userDetails, UsernameP
             // so there's nothing to do here.
         }
     
    +    @Restricted(NoExternalUse.class)
    +    interface Password {
    +
    +    }
    +
    +    @Restricted(NoExternalUse.class)
    +    final static class UserPassword implements Password {
    +
    +        private final String password;
    +
    +        public UserPassword(String password) {
    +            this.password = password;
    +        }
    +        public String getPassword() {
    +            return password;
    +        }
    +    }
    +
    +    @Restricted(NoExternalUse.class)
    +    public enum NoAuthentication implements Password {
    +        INSTANCE
    +    }
     }
    
  • src/main/java/hudson/plugins/active_directory/ActiveDirectoryAuthenticationProvider.java+29 11 modified
    @@ -39,6 +39,7 @@
     import com4j.typelibs.ado20._Connection;
     import com4j.typelibs.ado20._Recordset;
     import com4j.util.ComObjectCollector;
    +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
     import hudson.security.GroupDetails;
     import hudson.security.SecurityRealm;
     import hudson.security.UserMayOrMayNotExistException;
    @@ -58,6 +59,8 @@
     import java.util.concurrent.Callable;
     import java.util.logging.Level;
     import java.util.logging.Logger;
    +
    +import org.apache.commons.lang.StringUtils;
     import org.springframework.dao.DataAccessException;
     import org.springframework.dao.DataAccessResourceFailureException;
     
    @@ -67,6 +70,9 @@
      * @author Kohsuke Kawaguchi
      */
     public class ActiveDirectoryAuthenticationProvider extends AbstractActiveDirectoryAuthenticationProvider {
    +    @SuppressFBWarnings("MS_SHOULD_BE_FINAL")
    +    private static /* non-final for Groovy */ boolean ALLOW_EMPTY_PASSWORD = Boolean.getBoolean(ActiveDirectoryAuthenticationProvider.class.getName() + ".ALLOW_EMPTY_PASSWORD");
    +
         private final String defaultNamingContext;
         /**
          * ADO connection for searching Active Directory.
    @@ -81,7 +87,7 @@ public class ActiveDirectoryAuthenticationProvider extends AbstractActiveDirecto
         /**
          * The {@link UserDetails} cache.
          */
    -    private final Cache<String, UserDetails> userCache;
    +    private final Cache<CacheKey, UserDetails> userCache;
     
         /**
          * The {@link ActiveDirectoryGroupDetails} cache.
    @@ -141,13 +147,21 @@ static String dnToLdapUrl(String dn) {
     
         protected UserDetails retrieveUser(final String username,final  UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
             try {
    -            return  userCache.get(username, new Callable<UserDetails>() {
    -                public UserDetails call() {
    -                    String password = null;
    -                    if(authentication!=null) {
    -                        password = (String) authentication.getCredentials();
    -                    }
    +            Password password;
    +            if (authentication == null) {
    +                password = NoAuthentication.INSTANCE;
    +            } else {
    +                final String userPassword = (String) authentication.getCredentials();
    +                if (!ALLOW_EMPTY_PASSWORD && StringUtils.isEmpty(userPassword)) {
    +                    LOGGER.log(Level.FINE, "Empty password not allowed was tried by user {0}", username);
    +                    throw new BadCredentialsException("Empty password not allowed");
    +                }
    +                password = new UserPassword(userPassword);
    +            }
    +            final CacheKey cacheKey = CacheUtil.computeCacheKey(username, password, userCache.asMap().keySet());
     
    +            final Callable<UserDetails> callable = new Callable<UserDetails>() {
    +                public UserDetails call() {
                         String dn = getDnOfUserOrGroup(username);
     
                         ComObjectCollector col = new ComObjectCollector();
    @@ -165,7 +179,7 @@ public UserDetails call() {
                             try {
                                 usr = (authentication==null
                                         ? dso.openDSObject(dnToLdapUrl(dn), null, null, ADS_READONLY_SERVER)
    -                                    : dso.openDSObject(dnToLdapUrl(dn), dn, password, ADS_READONLY_SERVER))
    +                                    : dso.openDSObject(dnToLdapUrl(dn), dn, ((UserPassword)password).getPassword(), ADS_READONLY_SERVER))
                                         .queryInterface(IADsUser.class);
                             } catch (ComException e) {
                                 // this is failing
    @@ -190,7 +204,7 @@ public UserDetails call() {
                             LOGGER.log(Level.FINE, "Login successful: {0} dn={1}", new Object[] {username, dn});
     
                             return new ActiveDirectoryUserDetail(
    -                                username, password,
    +                                username, "redacted",
                                     !isAccountDisabled(usr),
                                     true, true, true,
                                     groups.toArray(new GrantedAuthority[0]),
    @@ -201,7 +215,8 @@ public UserDetails call() {
                             COM4J.removeListener(col);
                         }
                     }
    -            });
    +            };
    +            return cacheKey == null ? callable.call() : userCache.get(cacheKey, callable);
             } catch (UncheckedExecutionException e) {
                 Throwable t = e.getCause();
                 if (t instanceof AuthenticationException) {
    @@ -210,7 +225,10 @@ public UserDetails call() {
                 } else {
                     throw new CacheAuthenticationException("Authentication failed because there was a problem caching user " + username, e);
                 }
    -        } catch (java.util.concurrent.ExecutionException e) {
    +        } catch (Exception e) {
    +            if (e instanceof AuthenticationException) {
    +                throw (AuthenticationException)e;
    +            }
                 LOGGER.log(Level.SEVERE, String.format("There was a problem caching user %s", username), e);
                 throw new CacheAuthenticationException("Authentication failed because there was a problem caching user " + username, e);
             }
    
  • src/main/java/hudson/plugins/active_directory/ActiveDirectorySecurityRealm.java+1 1 modified
    @@ -411,7 +411,7 @@ public void doAuthTest(StaplerRequest req, StaplerResponse rsp, @QueryParameter
     	                    for (SocketInfo ldapServer : ldapServers) {
     	                        pw.println("Trying a domain controller at "+ldapServer);
     	                        try {
    -	                            UserDetails d = p.retrieveUser(username, password, domain, Collections.singletonList(ldapServer));
    +	                            UserDetails d = p.retrieveUser(username, new ActiveDirectoryUnixAuthenticationProvider.UserPassword(password), domain, Collections.singletonList(ldapServer));
     	                            pw.println("Authenticated as "+d);
     	                        } catch (AuthenticationException e) {
     	                            e.printStackTrace(pw);
    
  • src/main/java/hudson/plugins/active_directory/ActiveDirectoryUnixAuthenticationProvider.java+35 38 modified
    @@ -47,7 +47,6 @@
     import org.acegisecurity.providers.UsernamePasswordAuthenticationToken;
     import org.acegisecurity.userdetails.UserDetails;
     import org.acegisecurity.userdetails.UsernameNotFoundException;
    -import org.apache.commons.codec.digest.DigestUtils;
     import org.apache.commons.lang.StringUtils;
     
     import javax.naming.NamingEnumeration;
    @@ -65,6 +64,7 @@
     import java.util.Hashtable;
     import java.util.List;
     import java.util.Map;
    +import java.util.Objects;
     import java.util.Set;
     import java.util.Stack;
     import java.util.concurrent.ArrayBlockingQueue;
    @@ -107,7 +107,7 @@ public class ActiveDirectoryUnixAuthenticationProvider extends AbstractActiveDir
         /**
          * The {@link UserDetails} cache.
          */
    -    private final Cache<String, UserDetails> userCache;
    +    private final Cache<CacheKey, UserDetails> userCache;
     
         /**
          * The {@link ActiveDirectoryGroupDetails} cache.
    @@ -224,7 +224,7 @@ protected UserDetails retrieveUser(final String username, final UsernamePassword
                     try {
                         return retrieveUser(username, authentication, domain);
                     } catch (NamingException ne) {
    -                    if (activeDirectoryInternalUser != null && activeDirectoryInternalUser.getJenkinsInternalUser() != null && username.equals(activeDirectoryInternalUser.getJenkinsInternalUser())) {
    +                    if (userMatchesInternalDatabaseUser(username)) {
                             LOGGER.log(Level.WARNING, String.format("Looking into Jenkins Internal Users Database for user %s", username));
                             User internalUser = hudson.model.User.get(username);
                             HudsonPrivateSecurityRealm.Details hudsonPrivateSecurityRealm = internalUser.getProperty(HudsonPrivateSecurityRealm.Details.class);
    @@ -234,7 +234,7 @@ protected UserDetails retrieveUser(final String username, final UsernamePassword
                             }
                             if (hudsonPrivateSecurityRealm.isPasswordCorrect(password)) {
                                 LOGGER.log(Level.INFO, String.format("Falling back into the internal user %s", username));
    -                            return new ActiveDirectoryUserDetail(username, password, true, true, true, true, hudsonPrivateSecurityRealm.getAuthorities(), internalUser.getDisplayName(), "", "");
    +                            return new ActiveDirectoryUserDetail(username, "redacted", true, true, true, true, hudsonPrivateSecurityRealm.getAuthorities(), internalUser.getDisplayName(), "", "");
                             } else {
                                 LOGGER.log(Level.WARNING, String.format("Credential exception trying to authenticate against %s domain", domain.getName()), ne);
                                 errors.add(new MultiCauseUserMayOrMayNotExistException("We can't tell if the user exists or not: " + username, notFound));
    @@ -292,9 +292,9 @@ private UserDetails retrieveUser(String username, UsernamePasswordAuthentication
             ClassLoader ccl = Thread.currentThread().getContextClassLoader();
             Thread.currentThread().setContextClassLoader(getClass().getClassLoader());
             try {
    -            String password = NO_AUTHENTICATION;
    +            Password password = NoAuthentication.INSTANCE;
                 if (authentication!=null)
    -                password = (String) authentication.getCredentials();
    +                password = new UserPassword((String) authentication.getCredentials());
     
                 return retrieveUser(username, password, domain, obtainLDAPServers(domain));
             } finally {
    @@ -319,27 +319,30 @@ private List<SocketInfo> obtainLDAPServers(ActiveDirectoryDomain domain) throws
          * Authenticates and retrieves the user by using the given list of available AD LDAP servers.
          * 
          * @param password
    -     *      If this is {@link #NO_AUTHENTICATION}, the authentication is not performed, and just the retrieval
    +     *      If this is {@link AbstractActiveDirectoryAuthenticationProvider.NoAuthentication}, the authentication is not performed, and just the retrieval
          *      would happen.
          * @throws UsernameNotFoundException
          *      The user didn't exist.
          * @return never null
          */
         @SuppressFBWarnings(value = "ES_COMPARING_PARAMETER_STRING_WITH_EQ", justification = "Intentional instance check.")
    -    public UserDetails retrieveUser(final String username, final String password, final ActiveDirectoryDomain domain, final List<SocketInfo> ldapServers) throws NamingException {
    +    public UserDetails retrieveUser(final String username, final Password password, final ActiveDirectoryDomain domain, final List<SocketInfo> ldapServers) throws NamingException {
    +        Objects.requireNonNull(password);
             UserDetails userDetails;
    -        String hashKey = username + "@@" + DigestUtils.sha1Hex(password);
    +        final CacheKey cacheKey = CacheUtil.computeCacheKey(username, password, userCache.asMap().keySet());
    +
             final String bindName = domain.getBindName();
             final String bindPassword = Secret.toString(domain.getBindPassword());
    +
             try {
                 final ActiveDirectoryUserDetail[] cacheMiss = new ActiveDirectoryUserDetail[1];
    -            userDetails = userCache.get(hashKey, new Callable<UserDetails>() {
    +            final Callable<UserDetails> callable = new Callable<UserDetails>() {
                     public UserDetails call() throws AuthenticationException, NamingException {
                         DirContext context;
                         boolean anonymousBind = false;    // did we bind anonymously?
     
                         // LDAP treats empty password as anonymous bind, so we need to reject it
    -                    if (StringUtils.isEmpty(password)) {
    +                    if (password instanceof UserPassword && StringUtils.isEmpty(((UserPassword) password).getPassword())) {
                             throw new BadCredentialsException("Empty password");
                         }
     
    @@ -353,20 +356,18 @@ public UserDetails call() throws AuthenticationException, NamingException {
                                 context = descriptor.bind(bindName, bindPassword, ldapServers, props, domain.getTlsConfiguration());
                                 anonymousBind = false;
                             } catch (NamingException e) {
    -                            if (activeDirectoryInternalUser !=null) {
    +                            if (activeDirectoryInternalUser != null) {
                                     throw e;
                                 }
                                 throw new AuthenticationServiceException("Failed to bind to LDAP server with the bind name/password", e);
                             }
                         } else {
    -                        if (password.equals(NO_AUTHENTICATION)) {
    -                            anonymousBind = true;
    -                        }
    +                        anonymousBind = password instanceof NoAuthentication;
     
                             try {
                                 // if we are just retrieving the user, try using anonymous bind by empty password (see RFC 2829 5.1)
                                 // but if that fails, that's not BadCredentialException but UserMayOrMayNotExistException
    -                            context = descriptor.bind(userPrincipalName, anonymousBind ? "" : password, ldapServers, props, domain.getTlsConfiguration());
    +                            context = descriptor.bind(userPrincipalName, anonymousBind ? "" : ((UserPassword) password).getPassword(), ldapServers, props, domain.getTlsConfiguration());
                             } catch (BadCredentialsException e) {
                                 if (anonymousBind)
                                     // in my observation, if we attempt an anonymous bind and AD doesn't allow it, it still passes the bind method
    @@ -402,11 +403,11 @@ public UserDetails call() throws AuthenticationException, NamingException {
                             LdapName ldapName = new LdapName(dn);
                             String dnFormatted = ldapName.toString();
     
    -                        if (bindName != null && !password.equals(NO_AUTHENTICATION)) {
    +                        if (bindName != null && password instanceof UserPassword) {
                                 // if we've used the credential specifically for the bind, we
                                 // need to verify the provided password to do authentication
                                 LOGGER.log(Level.FINE, "Attempting to validate password for DN={0}", dn);
    -                            DirContext test = descriptor.bind(dnFormatted, password, ldapServers, props, domain.getTlsConfiguration());
    +                            DirContext test = descriptor.bind(dnFormatted, ((UserPassword) password).getPassword(), ldapServers, props, domain.getTlsConfiguration());
                                 // Binding alone is not enough to test the credential. Need to actually perform some query operation.
                                 // but if the authentication fails this throws an exception
                                 try {
    @@ -419,7 +420,7 @@ public UserDetails call() throws AuthenticationException, NamingException {
                             Set<GrantedAuthority> groups = resolveGroups(domainDN, dnFormatted, context);
                             groups.add(SecurityRealm.AUTHENTICATED_AUTHORITY);
     
    -                        cacheMiss[0] = new ActiveDirectoryUserDetail(username, password, true, true, true, true, groups.toArray(new GrantedAuthority[0]),
    +                        cacheMiss[0] = new ActiveDirectoryUserDetail(username, "redacted", true, true, true, true, groups.toArray(new GrantedAuthority[0]),
                                     getStringAttribute(user, "displayName"),
                                     getStringAttribute(user, "mail"),
                                     getStringAttribute(user, "telephoneNumber")
    @@ -448,8 +449,9 @@ public UserDetails call() throws AuthenticationException, NamingException {
                             closeQuietly(context);
                         }
                     }
    -            });
    -            if (cacheMiss[0] != null) {
    +            };
    +            userDetails = cacheKey == null ? callable.call() : userCache.get(cacheKey, callable);
    +            if (cacheMiss[0] != null || cacheKey == null) { // If a lookup was performed
                     threadPoolExecutor.execute(new Runnable() {
                         @Override
                         public void run() {
    @@ -467,7 +469,7 @@ public void run() {
                             }
                         }
                     });
    -                if (activeDirectoryInternalUser != null && activeDirectoryInternalUser.getJenkinsInternalUser() != null&& username.equals(activeDirectoryInternalUser.getJenkinsInternalUser())) {
    +                if (userMatchesInternalDatabaseUser(username) && password instanceof UserPassword) {
                         threadPoolExecutor.execute(new Runnable() {
                             @Override
                             public void run() {
    @@ -476,7 +478,7 @@ public void run() {
                                 LOGGER.log(Level.FINEST, "Starting the Jenkins Internal Database update {0}", new Date());
                                 try {
                                     long t0 = System.currentTimeMillis();
    -                                cacheMiss[0].updatePasswordInJenkinsInternalDatabase(username, password);
    +                                cacheMiss[0].updatePasswordInJenkinsInternalDatabase(username, ((UserPassword)password).getPassword());
                                     LOGGER.log(Level.FINEST, "Finished the password update {0}", new Date());
                                     long t1 = System.currentTimeMillis();
                                     LOGGER.log(Level.FINE, "The password update for user {0} took {1} msec", new Object[]{cacheMiss[0].getUsername(), String.valueOf(t1-t0)});
    @@ -491,26 +493,27 @@ public void run() {
             } catch (UncheckedExecutionException e) {
                Throwable t = e.getCause();
                 if (t instanceof AuthenticationException) {
    -                AuthenticationException authenticationException= (AuthenticationException)t;
    -                throw authenticationException;
    +                throw (AuthenticationException)t;
                 } else {
                     throw new CacheAuthenticationException("Authentication failed because there was a problem caching user " +  username, e);
                 }
    -        } catch (ExecutionException e) {
    +        } catch (Exception e) {
    +            if (e instanceof NamingException) {
    +                throw (NamingException)e;
    +            }
                 if (e.getCause() instanceof NamingException) {
    -                throw new NamingException();
    +                throw new NamingException(); // TODO why not re-throw e.getCause() ?
                 }
                 LOGGER.log(Level.SEVERE, "There was a problem caching user "+ username, e);
                 throw new CacheAuthenticationException("Authentication failed because there was a problem caching user " +  username, e);
             }
    -        // We need to check the password when the user is cached so it doesn't get automatically authenticated
    -        // without verifying the credentials
    -        if (password != null && !password.equals(NO_AUTHENTICATION) && userDetails != null && !password.equals(userDetails.getPassword())) {
    -            throw new BadCredentialsException("Failed to retrieve user information from the cache for "+ username);
    -        }
             return userDetails;
         }
     
    +    private boolean userMatchesInternalDatabaseUser(String username) {
    +        return activeDirectoryInternalUser != null && activeDirectoryInternalUser.getJenkinsInternalUser() != null && username.equals(activeDirectoryInternalUser.getJenkinsInternalUser());
    +    }
    +
         public GroupDetails loadGroupByGroupname(final String groupname) {
             try {
                 return groupCache.get(groupname, new Callable<ActiveDirectoryGroupDetails>() {
    @@ -829,10 +832,4 @@ private void parseMembers(String userDN, Set<GrantedAuthority> groups, NamingEnu
         }
     
         private static final Logger LOGGER = Logger.getLogger(ActiveDirectoryUnixAuthenticationProvider.class.getName());
    -
    -    /**
    -     * We use this as the password value if we are calling retrieveUser to retrieve the user information
    -     * without authentication.
    -     */
    -    private static final String NO_AUTHENTICATION = "\u0000\u0000\u0000\u0000\u0000\u0000";
     }
    
  • src/main/java/hudson/plugins/active_directory/ActiveDirectoryUserDetail.java+3 3 modified
    @@ -53,15 +53,15 @@ public class ActiveDirectoryUserDetail extends User {
     
         private String toStringValue;
     
    +    // TODO Remove 'password' argument from constructor
     	public ActiveDirectoryUserDetail(String username, String password,
     			boolean enabled, boolean accountNonExpired,
     			boolean credentialsNonExpired, boolean accountNonLocked,
     			GrantedAuthority[] authorities,
     			String displayName, String mail, String telephoneNumber)
     			throws IllegalArgumentException {
    -		// Acegi doesn't like null password, but during remember-me processing
    -		// we don't know the password so we need to set some dummy. See #1229
    -		super(username, password != null ? password : "PASSWORD", enabled,
    +		// We cannot just set a null password, so we need to set some dummy. See JENKINS-1229
    +		super(username, "redacted", enabled,
     				accountNonExpired, credentialsNonExpired, accountNonLocked,
     				authorities);
     
    
  • src/main/java/hudson/plugins/active_directory/CacheConfiguration.java+2 2 modified
    @@ -17,7 +17,7 @@ public class CacheConfiguration<K,V,E extends Exception> {
         /**
          * The {@link UserDetails} cache.
          */
    -    private transient final Cache<String, UserDetails> userCache;
    +    private transient final Cache<CacheKey, UserDetails> userCache;
     
         /**
          * The {@link ActiveDirectoryGroupDetails} cache.
    @@ -69,7 +69,7 @@ public int getTtl() {
          *
          * @return the cache for users
          */
    -    public Cache<String, UserDetails> getUserCache() {
    +    public Cache<CacheKey, UserDetails> getUserCache() {
             return userCache;
         }
     
    
  • src/main/java/hudson/plugins/active_directory/CacheKey.java+52 0 added
    @@ -0,0 +1,52 @@
    +package hudson.plugins.active_directory;
    +
    +import org.kohsuke.accmod.Restricted;
    +import org.kohsuke.accmod.restrictions.NoExternalUse;
    +
    +import java.util.Objects;
    +
    +@Restricted(NoExternalUse.class)
    +public final class CacheKey {
    +    private final String username;
    +    private final String salt;
    +    private final String passwordHash;
    +
    +    public CacheKey(String username, String salt, String passwordHash) {
    +        this.username = username;
    +        this.salt = salt;
    +        this.passwordHash = passwordHash;
    +    }
    +
    +    public CacheKey(String username) {
    +        this.username = username;
    +        this.salt = null;
    +        this.passwordHash = null;
    +    }
    +
    +    public String getUsername() {
    +        return username;
    +    }
    +
    +    public String getSalt() {
    +        return salt;
    +    }
    +
    +    public String getPasswordHash() {
    +        return passwordHash;
    +    }
    +
    +    @Override
    +    public boolean equals(Object o) {
    +        if (this == o) return true;
    +        if (o == null || getClass() != o.getClass()) return false;
    +        CacheKey cacheKey = (CacheKey) o;
    +        return username.equals(cacheKey.username) &&
    +                Objects.equals(salt, cacheKey.salt) &&
    +                Objects.equals(passwordHash, cacheKey.passwordHash);
    +    }
    +
    +    @Override
    +    public int hashCode() {
    +        return Objects.hash(username, salt, passwordHash);
    +    }
    +}
    
  • src/main/java/hudson/plugins/active_directory/CacheUtil.java+76 0 added
    @@ -0,0 +1,76 @@
    +package hudson.plugins.active_directory;
    +
    +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
    +import org.kohsuke.accmod.Restricted;
    +import org.kohsuke.accmod.restrictions.NoExternalUse;
    +import org.mindrot.jbcrypt.BCrypt;
    +
    +import javax.annotation.CheckForNull;
    +import javax.annotation.Nonnull;
    +import java.security.SecureRandom;
    +import java.util.Objects;
    +import java.util.Set;
    +
    +@Restricted(NoExternalUse.class)
    +public final class CacheUtil {
    +    @SuppressFBWarnings("MS_SHOULD_BE_FINAL")
    +    public static /* non-final for Groovy */ boolean NO_CACHE_AUTH = Boolean.getBoolean(CacheUtil.class.getName() + ".noCacheAuth"); // Groovy console: hudson.plugins.active_directory.CacheUtil.NO_CACHE_AUTH = true
    +    @SuppressFBWarnings("MS_SHOULD_BE_FINAL")
    +    public static /* non-final for Groovy */ int BCRYPT_ROUNDS = Integer.getInteger(CacheUtil.class.getName() + ".bcryptLogRounds", 10); // Groovy console: hudson.plugins.active_directory.CacheUtil.BCRYPT_ROUNDS = 20
    +
    +    private static final SecureRandom RANDOM = new SecureRandom();
    +
    +    private CacheUtil() {
    +        // prevent instantiation
    +    }
    +
    +    public static @CheckForNull
    +    CacheKey computeCacheKey(@Nonnull String username, @Nonnull AbstractActiveDirectoryAuthenticationProvider.Password password, Set<CacheKey> existingKeys) {
    +        Objects.requireNonNull(username);
    +        Objects.requireNonNull(password);
    +
    +        if (password instanceof AbstractActiveDirectoryAuthenticationProvider.UserPassword) {
    +            if (NO_CACHE_AUTH) {
    +                // we're not caching authentications
    +                return null;
    +            }
    +
    +            CacheKey existingKey = findExistingKeyForUserAndPasswordInSet(username, ((AbstractActiveDirectoryAuthenticationProvider.UserPassword) password).getPassword(), existingKeys);
    +            if (existingKey != null) {
    +                return existingKey;
    +            }
    +
    +            String salt = computeSalt();
    +            final String passwordHash = computeHash(((AbstractActiveDirectoryAuthenticationProvider.UserPassword) password).getPassword(), salt);
    +            return new CacheKey(username, salt, passwordHash);
    +        }
    +
    +        // password is null, this isn't authentication
    +        return new CacheKey(username);
    +    }
    +
    +    private static String computeHash(@Nonnull String password, String salt) {
    +        return BCrypt.hashpw(password, salt);
    +    }
    +
    +    private static CacheKey findExistingKeyForUserAndPasswordInSet(String username, String password, Set<CacheKey> existingKeys) {
    +        for (CacheKey key : existingKeys) {
    +            if (key.getSalt() == null || key.getPasswordHash() == null) {
    +                // lookup cache key only
    +                continue;
    +            }
    +            if (!Objects.equals(key.getUsername(), username)) {
    +                continue;
    +            }
    +            // username matches
    +            if (BCrypt.checkpw(password, key.getPasswordHash())) {
    +                return key;
    +            }
    +        }
    +        return null;
    +    }
    +
    +    private static String computeSalt() {
    +        return BCrypt.gensalt(BCRYPT_ROUNDS);
    +    }
    +}
    
  • src/test/java/hudson/plugins/active_directory/docker/EntoEndUserCacheLookupDisabledTest.java+150 0 added
    @@ -0,0 +1,150 @@
    +package hudson.plugins.active_directory.docker;
    +
    +import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException;
    +import hudson.plugins.active_directory.ActiveDirectoryDomain;
    +import hudson.plugins.active_directory.ActiveDirectoryInternalUsersDatabase;
    +import hudson.plugins.active_directory.ActiveDirectorySecurityRealm;
    +import hudson.plugins.active_directory.CacheConfiguration;
    +import hudson.plugins.active_directory.GroupLookupStrategy;
    +import org.acegisecurity.AuthenticationServiceException;
    +import org.acegisecurity.userdetails.UserDetails;
    +import org.apache.commons.io.FileUtils;
    +import org.jenkinsci.test.acceptance.docker.DockerContainer;
    +import org.jenkinsci.test.acceptance.docker.DockerFixture;
    +import org.jenkinsci.test.acceptance.docker.DockerRule;
    +import org.junit.Rule;
    +import org.junit.Test;
    +import org.jvnet.hudson.test.JenkinsRule;
    +import org.jvnet.hudson.test.LoggerRule;
    +
    +import java.util.ArrayList;
    +import java.util.List;
    +import java.util.logging.Level;
    +
    +import static org.hamcrest.Matchers.containsString;
    +import static org.junit.Assert.assertThat;
    +import static org.junit.Assert.assertTrue;
    +import static org.junit.Assert.fail;
    +
    +public class EntoEndUserCacheLookupDisabledTest {
    +
    +    @Rule
    +    public DockerRule<TheFlintstonesTest.TheFlintstones> docker = new DockerRule<>(TheFlintstonesTest.TheFlintstones.class);
    +
    +    @Rule
    +    public JenkinsRule j = new JenkinsRule();
    +
    +    @Rule
    +    public LoggerRule l = new LoggerRule();
    +
    +    private final static String AD_DOMAIN = "samdom.example.com";
    +    private final static String AD_MANAGER_DN = "CN=Administrator,CN=Users,DC=SAMDOM,DC=EXAMPLE,DC=COM";
    +    private final static String AD_MANAGER_DN_PASSWORD = "ia4uV1EeKait";
    +    private final static int MAX_RETRIES = 30;
    +    private String dockerIp;
    +    private int dockerPort;
    +
    +    public void customSingleADSetup(ActiveDirectoryDomain activeDirectoryDomain, String site, String bindName, String bindPassword,
    +                                    GroupLookupStrategy groupLookupStrategy, boolean removeIrrelevantGroups, Boolean customDomain,
    +                                    CacheConfiguration cache, Boolean startTls, ActiveDirectoryInternalUsersDatabase internalUsersDatabase) throws Exception {
    +        TheFlintstonesTest.TheFlintstones d = docker.get();
    +        dockerIp = d.ipBound(3268);
    +        dockerPort = d.port(3268);
    +
    +        activeDirectoryDomain.servers = dockerIp + ":" + dockerPort;
    +        List<ActiveDirectoryDomain> domains = new ArrayList<>(1);
    +        domains.add(activeDirectoryDomain);
    +
    +        ActiveDirectorySecurityRealm activeDirectorySecurityRealm = new ActiveDirectorySecurityRealm(null, domains, site, bindName, bindPassword, null, groupLookupStrategy, removeIrrelevantGroups, customDomain, cache, startTls, internalUsersDatabase);
    +        j.getInstance().setSecurityRealm(activeDirectorySecurityRealm);
    +        while(!FileUtils.readFileToString(d.getLogfile()).contains("custom (exit status 0; expected)")) {
    +            Thread.sleep(1000);
    +        }
    +        UserDetails userDetails = null;
    +        int i = 0;
    +        while (i < MAX_RETRIES && userDetails == null) {
    +            try {
    +                userDetails = j.jenkins.getSecurityRealm().loadUserByUsername("Fred");
    +            } catch (AuthenticationServiceException e) {
    +                Thread.sleep(1000);
    +            }
    +            i ++;
    +        }
    +    }
    +
    +    @Test
    +    public void testEndtoEndManagerDnCacheEnabled() throws Exception {
    +        List<String> messages;
    +        l.record(hudson.plugins.active_directory.ActiveDirectoryUnixAuthenticationProvider.class, Level.FINE).capture(20);
    +        // Configure AD servers with Manager DN and the Cache enabled
    +        ActiveDirectoryDomain activeDirectoryDomain = new ActiveDirectoryDomain(AD_DOMAIN, null, null, AD_MANAGER_DN, AD_MANAGER_DN_PASSWORD, null);
    +        CacheConfiguration cacheConfiguration = new CacheConfiguration(500,30);
    +        customSingleADSetup(activeDirectoryDomain, null, null, null, GroupLookupStrategy.RECURSIVE, false, null, cacheConfiguration, false, null );
    +        // Try to login as Fred with correct password
    +        JenkinsRule.WebClient wc = j.createWebClient().login("Fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>Fred</name>"));
    +        // Move to $JENKINS_URL/user/Fred to perform an internal lookup which will be cached
    +        wc.goTo("user/Fred");
    +        //Logout
    +        j.createWebClient().goTo("logout");
    +        // Try to login as Fred with blank password
    +        try {
    +            wc.login("Fred", "");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Failed to retrieve user Fred")));
    +        // Try to login as Fred with incorrect password
    +        try {
    +            wc.login("Fred", "Fred");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Failed to retrieve user Fred")));
    +        // Try to login as Fred with correct password
    +        wc.login("Fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>Fred</name>"));
    +    }
    +
    +    @Test
    +    public void testEndtoEndManagerDnCacheDisabled() throws Exception {
    +        List<String> messages;
    +        l.record(hudson.plugins.active_directory.ActiveDirectoryUnixAuthenticationProvider.class, Level.FINE).capture(20);
    +        ActiveDirectoryDomain activeDirectoryDomain = new ActiveDirectoryDomain(AD_DOMAIN, null, null, AD_MANAGER_DN, AD_MANAGER_DN_PASSWORD, null);
    +        customSingleADSetup(activeDirectoryDomain, null, null, null, GroupLookupStrategy.RECURSIVE, false, null, null, false, null );
    +        // Try to login as Fred with correct password
    +        JenkinsRule.WebClient wc = j.createWebClient().login("Fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>Fred</name>"));
    +        // Move to $JENKINS_URL/user/Fred to perform an internal lookup which will be cached
    +        wc.goTo("user/Fred");
    +        //Logout
    +        j.createWebClient().goTo("logout");
    +        // Try to login as Fred with blank password
    +        try {
    +            wc.login("Fred", "");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Failed to retrieve user Fred")));
    +        // Try to login as Fred with incorrect password
    +        try {
    +            wc.login("Fred", "Fred");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Failed to retrieve user Fred")));
    +        // Try to login as Fred with correct password
    +        wc.login("Fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>Fred</name>"));
    +
    +    }
    +
    +    @DockerFixture(id = "ad-dc", ports= {135, 138, 445, 39, 464, 389, 3268}, udpPorts = {53}, matchHostPorts = true, dockerfileFolder="docker/TheFlintstonesTest/TheFlintstones")
    +    public static class TheFlintstones extends DockerContainer {
    +
    +    }
    +}
    
  • src/test/java/hudson/plugins/active_directory/docker/EntoEndUserCacheLookupEnabledTest.java+170 0 added
    @@ -0,0 +1,170 @@
    +package hudson.plugins.active_directory.docker;
    +
    +import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException;
    +import hudson.plugins.active_directory.ActiveDirectoryDomain;
    +import hudson.plugins.active_directory.ActiveDirectoryInternalUsersDatabase;
    +import hudson.plugins.active_directory.ActiveDirectorySecurityRealm;
    +import hudson.plugins.active_directory.CacheConfiguration;
    +import hudson.plugins.active_directory.CacheUtil;
    +import hudson.plugins.active_directory.GroupLookupStrategy;
    +import org.acegisecurity.AuthenticationServiceException;
    +import org.acegisecurity.userdetails.UserDetails;
    +import org.apache.commons.io.FileUtils;
    +import org.jenkinsci.test.acceptance.docker.DockerContainer;
    +import org.jenkinsci.test.acceptance.docker.DockerFixture;
    +import org.jenkinsci.test.acceptance.docker.DockerRule;
    +import org.junit.AfterClass;
    +import org.junit.BeforeClass;
    +import org.junit.Rule;
    +import org.junit.Test;
    +import org.jvnet.hudson.test.JenkinsRule;
    +import org.jvnet.hudson.test.LoggerRule;
    +
    +import java.util.ArrayList;
    +import java.util.List;
    +import java.util.logging.Level;
    +
    +import static org.hamcrest.Matchers.containsString;
    +import static org.junit.Assert.assertThat;
    +import static org.junit.Assert.assertTrue;
    +import static org.junit.Assert.fail;
    +
    +public class EntoEndUserCacheLookupEnabledTest {
    +
    +    @Rule
    +    public DockerRule<TheFlintstonesTest.TheFlintstones> docker = new DockerRule<>(TheFlintstonesTest.TheFlintstones.class);
    +
    +    @Rule
    +    public JenkinsRule j = new JenkinsRule();
    +
    +    @Rule
    +    public LoggerRule l = new LoggerRule();
    +
    +    private final static String AD_DOMAIN = "samdom.example.com";
    +    private final static String AD_MANAGER_DN = "CN=Administrator,CN=Users,DC=SAMDOM,DC=EXAMPLE,DC=COM";
    +    private final static String AD_MANAGER_DN_PASSWORD = "ia4uV1EeKait";
    +    private final static int MAX_RETRIES = 30;
    +    private String dockerIp;
    +    private int dockerPort;
    +
    +    private static String CACHE_AUTH;
    +
    +    @BeforeClass
    +    public static void enableHealthMetrics() {
    +        CACHE_AUTH = System.getProperty(CacheUtil.class.getName() + ".cacheAuth");
    +        System.setProperty(CacheUtil.class.getName() + ".cacheAuth", "true");
    +    }
    +
    +    @AfterClass
    +    public static void disableHealthMetrics() {
    +        // Put back the previous value before the test was executed
    +        if (CACHE_AUTH != null) {
    +            System.setProperty(CacheUtil.class.getName() + ".cacheAuth", CACHE_AUTH);
    +        } else {
    +            System.clearProperty(CacheUtil.class.getName() + ".cacheAuth");
    +        }
    +    }
    +
    +    public void customSingleADSetup(ActiveDirectoryDomain activeDirectoryDomain, String site, String bindName, String bindPassword,
    +                                    GroupLookupStrategy groupLookupStrategy, boolean removeIrrelevantGroups, Boolean customDomain,
    +                                    CacheConfiguration cache, Boolean startTls, ActiveDirectoryInternalUsersDatabase internalUsersDatabase) throws Exception {
    +        TheFlintstonesTest.TheFlintstones d = docker.get();
    +        dockerIp = d.ipBound(3268);
    +        dockerPort = d.port(3268);
    +
    +        activeDirectoryDomain.servers = dockerIp + ":" + dockerPort;
    +        List<ActiveDirectoryDomain> domains = new ArrayList<>(1);
    +        domains.add(activeDirectoryDomain);
    +
    +        ActiveDirectorySecurityRealm activeDirectorySecurityRealm = new ActiveDirectorySecurityRealm(null, domains, site, bindName, bindPassword, null, groupLookupStrategy, removeIrrelevantGroups, customDomain, cache, startTls, internalUsersDatabase);
    +        j.getInstance().setSecurityRealm(activeDirectorySecurityRealm);
    +        while(!FileUtils.readFileToString(d.getLogfile()).contains("custom (exit status 0; expected)")) {
    +            Thread.sleep(1000);
    +        }
    +        UserDetails userDetails = null;
    +        int i = 0;
    +        while (i < MAX_RETRIES && userDetails == null) {
    +            try {
    +                userDetails = j.jenkins.getSecurityRealm().loadUserByUsername("Fred");
    +            } catch (AuthenticationServiceException e) {
    +                Thread.sleep(1000);
    +            }
    +            i ++;
    +        }
    +    }
    +
    +    @Test
    +    public void testEndtoEndManagerDnCacheEnabled() throws Exception {
    +        List<String> messages;
    +        l.record(hudson.plugins.active_directory.ActiveDirectoryUnixAuthenticationProvider.class, Level.FINE).capture(20);
    +        // Configure AD servers with Manager DN and the Cache enabled
    +        ActiveDirectoryDomain activeDirectoryDomain = new ActiveDirectoryDomain(AD_DOMAIN, null, null, AD_MANAGER_DN, AD_MANAGER_DN_PASSWORD, null);
    +        CacheConfiguration cacheConfiguration = new CacheConfiguration(500,30);
    +        customSingleADSetup(activeDirectoryDomain, null, null, null, GroupLookupStrategy.RECURSIVE, false, null, cacheConfiguration, false, null );
    +        // Try to login as Fred with correct password
    +        JenkinsRule.WebClient wc = j.createWebClient().login("Fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>Fred</name>"));
    +        // Move to $JENKINS_URL/user/Fred to perform an internal lookup which will be cached
    +        wc.goTo("user/Fred");
    +        //Logout
    +        j.createWebClient().goTo("logout");
    +        // Try to login as Fred with blank password
    +        try {
    +            wc.login("Fred", "");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Failed to retrieve user Fred")));
    +        // Try to login as Fred with incorrect password
    +        try {
    +            wc.login("Fred", "Fred");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Failed to retrieve user Fred")));
    +        // Try to login as Fred with correct password
    +        wc.login("Fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>Fred</name>"));
    +    }
    +
    +    @Test
    +    public void testEndtoEndManagerDnCacheDisabled() throws Exception {
    +        List<String> messages;
    +        l.record(hudson.plugins.active_directory.ActiveDirectoryUnixAuthenticationProvider.class, Level.FINE).capture(20);
    +        ActiveDirectoryDomain activeDirectoryDomain = new ActiveDirectoryDomain(AD_DOMAIN, null, null, AD_MANAGER_DN, AD_MANAGER_DN_PASSWORD, null);
    +        customSingleADSetup(activeDirectoryDomain, null, null, null, GroupLookupStrategy.RECURSIVE, false, null, null, false, null );
    +        // Try to login as Fred with correct password
    +        JenkinsRule.WebClient wc = j.createWebClient().login("Fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>Fred</name>"));
    +        // Move to $JENKINS_URL/user/Fred to perform an internal lookup which will be cached
    +        wc.goTo("user/Fred");
    +        //Logout
    +        j.createWebClient().goTo("logout");
    +        // Try to login as Fred with blank password
    +        try {
    +            wc.login("Fred", "");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Failed to retrieve user Fred")));
    +        // Try to login as Fred with incorrect password
    +        try {
    +            wc.login("Fred", "Fred");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Failed to retrieve user Fred")));
    +        // Try to login as Fred with correct password
    +        wc.login("Fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>Fred</name>"));
    +    }
    +
    +    @DockerFixture(id = "ad-dc", ports= {135, 138, 445, 39, 464, 389, 3268}, udpPorts = {53}, matchHostPorts = true, dockerfileFolder="docker/TheFlintstonesTest/TheFlintstones")
    +    public static class TheFlintstones extends DockerContainer {
    +
    +    }
    +}
    
  • src/test/java/hudson/plugins/active_directory/docker/TheFlintstonesTest.java+52 0 modified
    @@ -24,8 +24,11 @@
     
     package hudson.plugins.active_directory.docker;
     
    +import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException;
     import hudson.plugins.active_directory.ActiveDirectoryDomain;
    +import hudson.plugins.active_directory.ActiveDirectoryInternalUsersDatabase;
     import hudson.plugins.active_directory.ActiveDirectorySecurityRealm;
    +import hudson.plugins.active_directory.CacheConfiguration;
     import hudson.plugins.active_directory.GroupLookupStrategy;
     import hudson.util.Secret;
     import jenkins.model.Jenkins;
    @@ -51,10 +54,13 @@
     import java.util.List;
     
     import static junit.framework.Assert.assertEquals;
    +import static org.hamcrest.Matchers.contains;
    +import static org.hamcrest.Matchers.containsStringIgnoringCase;
     import static org.junit.Assert.assertThat;
     import static org.hamcrest.Matchers.is;
     import static org.hamcrest.Matchers.containsString;
     import static org.junit.Assert.assertTrue;
    +import static org.junit.Assert.fail;
     
     import java.util.logging.Formatter;
     import java.util.logging.Level;
    @@ -67,6 +73,7 @@
     
     import hudson.security.GroupDetails;
     import hudson.util.RingBufferLogHandler;
    +import org.jvnet.hudson.test.LoggerRule;
     import org.jvnet.hudson.test.recipes.LocalData;
     
     /**
    @@ -80,6 +87,9 @@ public class TheFlintstonesTest {
         @Rule
         public JenkinsRule j = new JenkinsRule();
     
    +    @Rule
    +    public LoggerRule l = new LoggerRule();
    +
         public final static String AD_DOMAIN = "samdom.example.com";
         public final static String AD_MANAGER_DN = "CN=Administrator,CN=Users,DC=SAMDOM,DC=EXAMPLE,DC=COM";
         public final static String AD_MANAGER_DN_PASSWORD = "ia4uV1EeKait";
    @@ -156,6 +166,20 @@ public void actualLogin() throws Exception {
             */
         }
     
    +    @Issue("SECURITY-2099")
    +    @Test
    +    public void shouldNotAllowEmptyPassword() throws Exception {
    +        l.record(hudson.plugins.active_directory.ActiveDirectoryUnixAuthenticationProvider.class, Level.FINE).capture(20);
    +        dynamicSetUp();
    +        try {
    +            j.createWebClient().login("Fred", "");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        final List<String> messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Failed to retrieve user Fred")));
    +    }
    +
         @Test
         public void simpleLoginFails() throws Exception {
             dynamicSetUp();
    @@ -301,6 +325,34 @@ public void testSimpleLoginFailsTrustingJDKTrustStore() throws Exception {
             }
         }
     
    +    @Issue("SECURITY-2117")
    +    @Test
    +    public void testNullBytesInPasswordMustFail() throws Exception {
    +        l.record(hudson.plugins.active_directory.ActiveDirectoryUnixAuthenticationProvider.class, Level.FINE).capture(20);
    +        dynamicSetUp();
    +        try {
    +            JenkinsRule.WebClient wc = j.createWebClient().login("Fred", "\u0000\u0000\u0000\u0000\u0000\u0000");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        final List<String> messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Failed to retrieve user Fred")));
    +    }
    +
    +    @Issue("SECURITY-2117")
    +    @Test
    +    public void testIncorrectPasswordMustFail() throws Exception {
    +        l.record(hudson.plugins.active_directory.ActiveDirectoryUnixAuthenticationProvider.class, Level.FINE).capture(20);
    +        dynamicSetUp();
    +        try {
    +            JenkinsRule.WebClient wc = j.createWebClient().login("Fred", "Fred");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        final List<String> messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Failed to retrieve user Fred")));
    +    }
    +
         @DockerFixture(id = "ad-dc", ports= {135, 138, 445, 39, 464, 389, 3268}, udpPorts = {53}, matchHostPorts = true)
         public static class TheFlintstones extends DockerContainer {
     
    
  • src/test/java/hudson/plugins/active_directory/WindowsAdsiModeUserCacheDisabledTest.java+129 0 added
    @@ -0,0 +1,129 @@
    +package hudson.plugins.active_directory;
    +
    +import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException;
    +import org.junit.BeforeClass;
    +import org.junit.Rule;
    +import org.junit.Test;
    +import org.jvnet.hudson.test.JenkinsRule;
    +import org.jvnet.hudson.test.LoggerRule;
    +
    +import java.io.File;
    +import java.util.List;
    +import java.util.logging.Level;
    +
    +import static org.hamcrest.Matchers.containsString;
    +import static org.junit.Assert.assertThat;
    +import static org.junit.Assert.assertTrue;
    +import static org.junit.Assert.fail;
    +import static org.junit.Assume.assumeTrue;
    +
    +
    +public class WindowsAdsiModeUserCacheDisabledTest {
    +
    +    @BeforeClass
    +    public static void setUp() {
    +        assumeTrue(isWindows());
    +    }
    +
    +    @Rule
    +    public JenkinsRule j = new JenkinsRule();
    +
    +    @Rule
    +    public LoggerRule l = new LoggerRule();
    +
    +    public void dynamicCacheEnableSetUp() throws Exception {
    +        CacheConfiguration cacheConfiguration = new CacheConfiguration(500,30);
    +        ActiveDirectorySecurityRealm activeDirectorySecurityRealm = new ActiveDirectorySecurityRealm(null, null, null, null,
    +                null, null, null, false, null, cacheConfiguration, null, (ActiveDirectoryInternalUsersDatabase) null);
    +        j.jenkins.setSecurityRealm(activeDirectorySecurityRealm);
    +    }
    +
    +
    +    public void dynamicCacheDisabledSetUp() throws Exception {
    +        ActiveDirectorySecurityRealm activeDirectorySecurityRealm = new ActiveDirectorySecurityRealm(null, null, null, null,
    +                null, null, null, false, null, null, null, (ActiveDirectoryInternalUsersDatabase) null);
    +        j.jenkins.setSecurityRealm(activeDirectorySecurityRealm);
    +    }
    +
    +    @Test
    +    public void actualLogin() throws Exception {
    +        dynamicCacheDisabledSetUp();
    +        JenkinsRule.WebClient wc = j.createWebClient().login("fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>fred</name>"));
    +    }
    +
    +    @Test
    +    public void testEndtoEndCacheEnabled() throws Exception {
    +        dynamicCacheEnableSetUp();
    +        List<String> messages;
    +        l.record(hudson.plugins.active_directory.ActiveDirectoryAuthenticationProvider.class, Level.FINE).capture(20);
    +        // Try to login as fred with correct password
    +        JenkinsRule.WebClient wc = j.createWebClient().login("fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>fred</name>"));
    +        // Move to $JENKINS_URL/user/Fred to perform an internal lookup which will be cached
    +        wc.goTo("user/fred");
    +        //Logout
    +        j.createWebClient().goTo("logout");
    +        // Try to login as fred with blank password
    +        try {
    +            wc.login("fred", "");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Empty password not allowed was tried by user fred")));
    +        // Try to login as fred with incorrect password
    +        try {
    +            wc.login("fred", "fred");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Login failure: Incorrect password for fred")));
    +        // Try to login as fred with correct password
    +        wc.login("fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>fred</name>"));
    +    }
    +
    +    @Test
    +    public void testEndtoEndCacheDisabled() throws Exception {
    +        dynamicCacheDisabledSetUp();
    +        List<String> messages;
    +        l.record(hudson.plugins.active_directory.ActiveDirectoryAuthenticationProvider.class, Level.FINE).capture(20);
    +        // Try to login as Fred with correct password
    +        JenkinsRule.WebClient wc = j.createWebClient().login("fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>fred</name>"));
    +        // Move to $JENKINS_URL/user/fred to perform an internal lookup which will be cached
    +        wc.goTo("user/fred");
    +        //Logout
    +        j.createWebClient().goTo("logout");
    +        // Try to login as Fred with blank password
    +        try {
    +            wc.login("fred", "");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Empty password not allowed was tried by user fred")));
    +        // Try to login as fred with incorrect password
    +        try {
    +            wc.login("fred", "fred");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Login failure: Incorrect password for fred")));
    +        // Try to login as fred with correct password
    +        wc.login("fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>fred</name>"));
    +
    +    }
    +
    +    /**
    +     * inline ${@link hudson.Functions#isWindows()} to prevent a transient
    +     * remote classloader issue
    +     */
    +    private static boolean isWindows() {
    +        return File.pathSeparatorChar == ';';
    +    }
    +}
    
  • src/test/java/hudson/plugins/active_directory/WindowsAdsiModeUserCacheEnabledTest.java+148 0 added
    @@ -0,0 +1,148 @@
    +package hudson.plugins.active_directory;
    +
    +import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException;
    +import org.junit.AfterClass;
    +import org.junit.BeforeClass;
    +import org.junit.Rule;
    +import org.junit.Test;
    +import org.jvnet.hudson.test.JenkinsRule;
    +import org.jvnet.hudson.test.LoggerRule;
    +
    +import java.io.File;
    +import java.util.List;
    +import java.util.logging.Level;
    +
    +import static org.hamcrest.Matchers.containsString;
    +import static org.junit.Assert.assertThat;
    +import static org.junit.Assert.assertTrue;
    +import static org.junit.Assert.fail;
    +import static org.junit.Assume.assumeTrue;
    +
    +
    +public class WindowsAdsiModeUserCacheEnabledTest {
    +
    +    @BeforeClass
    +    public static void setUp() {
    +        assumeTrue(isWindows());
    +    }
    +
    +    @Rule
    +    public JenkinsRule j = new JenkinsRule();
    +
    +    @Rule
    +    public LoggerRule l = new LoggerRule();
    +
    +    private static String CACHE_AUTH;
    +
    +    @BeforeClass
    +    public static void enableHealthMetrics() {
    +        CACHE_AUTH = System.getProperty(CacheUtil.class.getName() + ".cacheAuth");
    +        System.setProperty(CacheUtil.class.getName() + ".cacheAuth", "true");
    +    }
    +
    +    @AfterClass
    +    public static void disableHealthMetrics() {
    +        // Put back the previous value before the test was executed
    +        if (CACHE_AUTH != null) {
    +            System.setProperty(CacheUtil.class.getName() + ".cacheAuth", CACHE_AUTH);
    +        } else {
    +            System.clearProperty(CacheUtil.class.getName() + ".cacheAuth");
    +        }
    +    }
    +
    +    public void dynamicCacheEnableSetUp() throws Exception {
    +        CacheConfiguration cacheConfiguration = new CacheConfiguration(500,30);
    +        ActiveDirectorySecurityRealm activeDirectorySecurityRealm = new ActiveDirectorySecurityRealm(null, null, null, null,
    +                null, null, null, false, null, cacheConfiguration, null, (ActiveDirectoryInternalUsersDatabase) null);
    +        j.jenkins.setSecurityRealm(activeDirectorySecurityRealm);
    +    }
    +
    +
    +    public void dynamicCacheDisabledSetUp() throws Exception {
    +        ActiveDirectorySecurityRealm activeDirectorySecurityRealm = new ActiveDirectorySecurityRealm(null, null, null, null,
    +                null, null, null, false, null, null, null, (ActiveDirectoryInternalUsersDatabase) null);
    +        j.jenkins.setSecurityRealm(activeDirectorySecurityRealm);
    +    }
    +
    +    @Test
    +    public void actualLogin() throws Exception {
    +        dynamicCacheDisabledSetUp();
    +        JenkinsRule.WebClient wc = j.createWebClient().login("fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>fred</name>"));
    +    }
    +
    +    @Test
    +    public void testEndtoEndCacheEnabled() throws Exception {
    +        dynamicCacheEnableSetUp();
    +        List<String> messages;
    +        l.record(hudson.plugins.active_directory.ActiveDirectoryAuthenticationProvider.class, Level.FINE).capture(20);
    +        // Try to login as fred with correct password
    +        JenkinsRule.WebClient wc = j.createWebClient().login("fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>fred</name>"));
    +        // Move to $JENKINS_URL/user/Fred to perform an internal lookup which will be cached
    +        wc.goTo("user/fred");
    +        //Logout
    +        j.createWebClient().goTo("logout");
    +        // Try to login as fred with blank password
    +        try {
    +            wc.login("fred", "");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Empty password not allowed was tried by user fred")));
    +        // Try to login as fred with incorrect password
    +        try {
    +            wc.login("fred", "fred");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Login failure: Incorrect password for fred")));
    +        // Try to login as fred with correct password
    +        wc.login("fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>fred</name>"));
    +    }
    +
    +    @Test
    +    public void testEndtoEndCacheDisabled() throws Exception {
    +        dynamicCacheDisabledSetUp();
    +        List<String> messages;
    +        l.record(hudson.plugins.active_directory.ActiveDirectoryAuthenticationProvider.class, Level.FINE).capture(20);
    +        // Try to login as Fred with correct password
    +        JenkinsRule.WebClient wc = j.createWebClient().login("fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>fred</name>"));
    +        // Move to $JENKINS_URL/user/fred to perform an internal lookup which will be cached
    +        wc.goTo("user/fred");
    +        //Logout
    +        j.createWebClient().goTo("logout");
    +        // Try to login as Fred with blank password
    +        try {
    +            wc.login("fred", "");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Empty password not allowed was tried by user fred")));
    +        // Try to login as fred with incorrect password
    +        try {
    +            wc.login("fred", "fred");
    +            fail();
    +        } catch (FailingHttpStatusCodeException ex) {
    +        }
    +        messages = l.getMessages();
    +        assertTrue(messages.stream().anyMatch(s -> s.contains("Login failure: Incorrect password for fred")));
    +        // Try to login as fred with correct password
    +        wc.login("fred", "ia4uV1EeKait");
    +        assertThat(wc.goToXml("whoAmI/api/xml").asXml().replaceAll("\\s+", ""), containsString("<name>fred</name>"));
    +
    +    }
    +
    +    /**
    +     * inline ${@link hudson.Functions#isWindows()} to prevent a transient
    +     * remote classloader issue
    +     */
    +    private static boolean isWindows() {
    +        return File.pathSeparatorChar == ';';
    +    }
    +}
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

1