Skip to content
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

[JENKINS-73825] ldap allows insecure configurations #300

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import hudson.util.Secret;
import jenkins.model.Jenkins;
import java.nio.charset.StandardCharsets;
import jenkins.security.FIPS140;
import org.apache.commons.lang.StringUtils;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
Expand Down Expand Up @@ -147,7 +148,14 @@ public class LDAPConfiguration extends AbstractDescribableImpl<LDAPConfiguration
private transient String id;

@DataBoundConstructor
public LDAPConfiguration(@NonNull String server, String rootDN, boolean inhibitInferRootDN, String managerDN, Secret managerPasswordSecret) {
public LDAPConfiguration(@NonNull String server, String rootDN, boolean inhibitInferRootDN, String managerDN, Secret managerPasswordSecret){
if(FIPS140.useCompliantAlgorithms() && !validateServerUrlIsSecure(server)){
throw new IllegalArgumentException(Messages.LDAPConfiguration_InsecureServer(server));
}
String managerPassword = Secret.toString(managerPasswordSecret);
if(isPasswordNonCompliant(managerPassword)) {
throw new IllegalArgumentException(Messages.LDAPConfiguration_passwordTooShortFIPS());
}
this.server = server.trim();
this.managerDN = fixEmpty(managerDN);
this.managerPasswordSecret = managerPasswordSecret;
Expand Down Expand Up @@ -184,6 +192,17 @@ public String getServerUrl() {
return buf.toString();
}

private Object readResolve() {
if(FIPS140.useCompliantAlgorithms() && !validateServerUrlIsSecure(server)){
throw new IllegalArgumentException(Messages.LDAPConfiguration_InsecureServer(server));
}
String managerPassword = Secret.toString(managerPasswordSecret);
if(isPasswordNonCompliant(managerPassword)) {
throw new IllegalArgumentException(Messages.LDAPConfiguration_passwordTooShortFIPS());
}
return this;
}

/**
* The root DN to connect to. Normally something like "dc=sun,dc=com"
*/
Expand Down Expand Up @@ -390,6 +409,20 @@ public boolean isConfiguration(String id) {
return getId().equals(id);
}

/**
* Validates if the LDAP server URL is secure (uses ldaps).
* Returns FALSE if the server URL is not secure.
*/
public static boolean validateServerUrlIsSecure(String server) {
String[] servers = server.split("\\s+");
for (String s : servers) {
if (!s.startsWith("ldaps://")) {
return false;
}
}
return true;
}

@Extension
public static final class LDAPConfigurationDescriptor extends Descriptor<LDAPConfiguration> {
//For jelly usage
Expand All @@ -407,10 +440,12 @@ public String getDisplayName() {
public FormValidation doCheckServer(@QueryParameter String value, @QueryParameter String managerDN, @QueryParameter Secret managerPasswordSecret,@QueryParameter String rootDN) {
String server = value;
String managerPassword = Secret.toString(managerPasswordSecret);

if(!Jenkins.get().hasPermission(Jenkins.ADMINISTER))
return FormValidation.ok();

if(FIPS140.useCompliantAlgorithms() && !validateServerUrlIsSecure(server)){
return FormValidation.error(Messages.LDAPConfiguration_InsecureServer(server));
}
Context ctx = null;
try {
Hashtable<String,Object> props = new Hashtable<>();
Expand Down Expand Up @@ -461,6 +496,19 @@ public FormValidation doCheckServer(@QueryParameter String value, @QueryParamete
}
}

@POST
public FormValidation doCheckManagerPasswordSecret(@QueryParameter Secret managerPasswordSecret) {
if(!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) {
return FormValidation.ok();
}
String managerPassword = Secret.toString(managerPasswordSecret);

if(LDAPConfiguration.isPasswordNonCompliant(managerPassword)) {
return FormValidation.error(Messages.LDAPConfiguration_passwordTooShortFIPS());
}
return FormValidation.ok();
}

private void forceClose(Context ctx){
if(ctx==null){
return;
Expand Down Expand Up @@ -656,4 +704,8 @@ public LDAPExtendedTemplate getLdapTemplate() {
return ldapTemplate;
}

private static boolean isPasswordNonCompliant(String password){
return FIPS140.useCompliantAlgorithms() && StringUtils.isNotBlank(password) &&
StringUtils.length(password) < 14;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,5 @@ UserDetails.Inactive=The user "{0}" is inactive until {1}.
UserDetails.Expired=The user "{0}" is expired since {1}.
UserDetails.CredentialsExpired=The user "{0}" has expired credentials.
UserDetails.Locked=The user "{0}" is locked and must be unlocked by an administrator.
LDAPConfiguration.InsecureServer=LDAP server URL is not secure: {0}.
LDAPConfiguration.passwordTooShortFIPS=Password is too short (< 14 characters)
137 changes: 137 additions & 0 deletions src/test/java/hudson/security/LDAPEmbeddedFIPSTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* The MIT License
*
* Copyright 2024 CloudBees,Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

package hudson.security;

import org.htmlunit.WebResponse;
import org.htmlunit.html.HtmlForm;
import org.htmlunit.html.HtmlPage;
import org.junit.Rule;
import org.junit.Test;
import org.jvnet.hudson.test.JenkinsRule;
import org.jvnet.hudson.test.RealJenkinsRule;
import org.jvnet.hudson.test.recipes.LocalData;

import java.io.ByteArrayOutputStream;
import java.io.PrintStream;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;

public class LDAPEmbeddedFIPSTest {
static final String LDAP_SERVER_ERROR_MESSAGE = "LDAP server URL is not secure";

@Rule
public RealJenkinsRule r = new RealJenkinsRule().javaOptions("-Djenkins.security.FIPS140.COMPLIANCE=true")
vwagh-dev marked this conversation as resolved.
Show resolved Hide resolved
.withDebugPort(5008);

@Test
@LocalData
public void testBlowsUpOnStart() throws Throwable {
// Create a stream to hold the log messages
ByteArrayOutputStream logStream = new ByteArrayOutputStream();
PrintStream originalOut = System.out;
System.setOut(new PrintStream(logStream));
r.startJenkins();
String logs = logStream.toString();
assertTrue(logs.contains(LDAP_SERVER_ERROR_MESSAGE));
}



@Test
public void testLdapServerUrl() throws Throwable {
r.then(LDAPEmbeddedFIPSTest::_testLdapServerUrl);
}

public static void _testLdapServerUrl(JenkinsRule j) throws Exception {
// Create and set the LDAP Security Realm configuration
LDAPSecurityRealm ldapRealm = new LDAPSecurityRealm(
"ldaps://ldap.example.com", // LDAP Server URL
"dc=example,dc=com", // Root DN
"ou=users,dc=example,dc=com", // User search base
"uid={0}", // User search filter
null, // Group search base (optional)
null, // Group search filter (optional)
null, // Manager DN (optional)
false // Manager password (optional)
);
// Set the LDAP security realm in Jenkins
j.jenkins.setSecurityRealm(ldapRealm);
try (JenkinsRule.WebClient wc = j.createWebClient()) {
// Navigate to "Manage Jenkins"
HtmlPage manageJenkinsPage = wc.goTo("manage");
// Navigate to "Configure Global Security"
HtmlPage securityPage = wc.goTo("configureSecurity");
// Find the form for global security configuration
HtmlForm securityForm = securityPage.getFormByName("config");
securityForm.getInputByName("_.server").setValue("ldap.example.com");
securityForm.getInputByName("_.server").blur();
wc.waitForBackgroundJavaScript(1000);
wc.setThrowExceptionOnFailingStatusCode(false);
HtmlPage page = j.submit(securityForm);
WebResponse webResponse = page.getWebResponse();
assertNotEquals(200, webResponse.getStatusCode());
assertThat(webResponse.getContentAsString(), containsString(LDAP_SERVER_ERROR_MESSAGE));
}
}

@Test
public void testManagerPassword() throws Throwable {
r.then(LDAPEmbeddedFIPSTest::_testManagerPassword);
}

public static void _testManagerPassword(JenkinsRule j) throws Exception {
// Create and set the LDAP Security Realm configuration
LDAPSecurityRealm ldapRealm = new LDAPSecurityRealm(
"ldaps://ldap.example.com", // LDAP Server URL
"dc=example,dc=com", // Root DN
"ou=users,dc=example,dc=com", // User search base
"uid={0}", // User search filter
null, // Group search base (optional)
null, // Group search filter (optional)
null, // Manager DN (optional)
false // Manager password (optional)
);
// Set the LDAP security realm in Jenkins
j.jenkins.setSecurityRealm(ldapRealm);
try (JenkinsRule.WebClient wc = j.createWebClient()) {
// Navigate to "Manage Jenkins"
HtmlPage manageJenkinsPage = wc.goTo("manage");
// Navigate to "Configure Global Security"
HtmlPage securityPage = wc.goTo("configureSecurity");
// Find the form for global security configuration
HtmlForm securityForm = securityPage.getFormByName("config");
wc.waitForBackgroundJavaScript(1000);
securityForm.getInputByName("_.managerPasswordSecret").setValueAttribute("short");
wc.setThrowExceptionOnFailingStatusCode(false);
HtmlPage page = j.submit(securityForm);
WebResponse webResponse = page.getWebResponse();
assertNotEquals(200, webResponse.getStatusCode());
assertThat(webResponse.getContentAsString(), containsString("Password is too short"));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ public class LDAPSecurityRealmWithFIPSTest {

@Test
public void ldapAuthenticationWithFIPSTest() throws Exception {
final String server = "localhost";
final String server = "ldaps://localhost";
final String rootDN = "ou=umich,dc=ou.edu";
final String userSearchBase = "cn=users,ou=umich,ou.edu";
final String managerDN = "cn=admin,ou=umich,ou.edu";
final String managerSecret = "secret";
final String managerSecret = "secretsecretsecret";
final LDAPSecurityRealm realm = new LDAPSecurityRealm(
server,
rootDN,
Expand Down
97 changes: 97 additions & 0 deletions src/test/java/jenkins/security/plugins/ldap/CasCFIPSTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package jenkins.security.plugins.ldap;

import hudson.security.LDAPSecurityRealm;
import hudson.util.FormValidation;
import hudson.util.Secret;
import io.jenkins.plugins.casc.ConfiguratorException;
import io.jenkins.plugins.casc.misc.ConfiguredWithCode;
import io.jenkins.plugins.casc.misc.JenkinsConfiguredWithCodeRule;
import jenkins.model.IdStrategy;
import jenkins.model.Jenkins;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.contrib.java.lang.system.EnvironmentVariables;
import org.junit.rules.ExpectedException;
import org.junit.rules.RuleChain;
import org.jvnet.hudson.test.FlagRule;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.*;

/**
* Based on {@link jenkins.security.plugins.ldap.CasCTest}
*/
public class CasCFIPSTest {
@Rule
public ExpectedException thrown = ExpectedException.none();
@ClassRule
public static FlagRule<String> fipsSystemPropertyRule =
FlagRule.systemProperty("jenkins.security.FIPS140.COMPLIANCE", "true");

@Rule
public RuleChain chain = RuleChain.outerRule(new EnvironmentVariables()
.set("LDAP_PASSWORD", "SECRET_Password_123"))
.around(new JenkinsConfiguredWithCodeRule());

@Test
@ConfiguredWithCode("casc_ldap_secure.yml")
public void configure_ldap() {
final LDAPSecurityRealm securityRealm = (LDAPSecurityRealm) Jenkins.get().getSecurityRealm();
assertEquals(1, securityRealm.getConfigurations().size());
assertTrue(securityRealm.getUserIdStrategy() instanceof IdStrategy.CaseInsensitive);
assertTrue(securityRealm.getGroupIdStrategy() instanceof IdStrategy.CaseSensitive);
final LDAPConfiguration configuration = securityRealm.getConfigurations().get(0);
assertEquals("ldaps://ldap.acme.com", configuration.getServer());
assertEquals("SECRET_Password_123", configuration.getManagerPassword());
assertEquals("manager", configuration.getManagerDN());
assertEquals("(&(objectCategory=User)(sAMAccountName={0}))", configuration.getUserSearch());
assertEquals("(&(cn={0})(objectclass=group))", configuration.getGroupSearchFilter());
final FromGroupSearchLDAPGroupMembershipStrategy strategy = ((FromGroupSearchLDAPGroupMembershipStrategy) configuration.getGroupMembershipStrategy());
assertEquals("(&(objectClass=group)(|(cn=GROUP_1)(cn=GROUP_2)))", strategy.getFilter());
}

/**
* Expect an exception when LDAP url is not secure & FIPS is enabled
*/
@Test
@ConfiguredWithCode(value = "casc.yml", expected = ConfiguratorException.class)
public void configure_ldap_for_invalid() {
// This test is expected to throw an ConfiguratorException while loading the configuration itself
// because the LDAP URL is not secure and FIPS is enabled. Hence, the code block is empty.
}

@Test
vwagh-dev marked this conversation as resolved.
Show resolved Hide resolved
public void testPasswordCheck(){
// Test with a short password
FormValidation shortPasswordValidation = new LDAPConfiguration.LDAPConfigurationDescriptor().doCheckManagerPasswordSecret(Secret.fromString("short"));
assertEquals(FormValidation.Kind.ERROR, shortPasswordValidation.kind);
assertThat(shortPasswordValidation.getMessage(), containsString("Password is too short"));

// Test with a strong password but server validation fails hence checking for 'Unknown host'
FormValidation strongPasswordValidation = new LDAPConfiguration.LDAPConfigurationDescriptor().doCheckManagerPasswordSecret(Secret.fromString("ThisIsVeryStrongPassword"));
assertEquals(FormValidation.Kind.OK, strongPasswordValidation.kind);

//Test when password is null
LDAPConfiguration configuration = new LDAPConfiguration("ldaps://ldap.example.com", "dc=example,dc=com", true, null, null);
assertNotNull(configuration);

// Test with a short password
thrown.expect(IllegalArgumentException.class);
thrown.expectMessage("Password is too short");
new LDAPConfiguration("ldaps://ldap.example.com", "dc=example,dc=com", true, null, Secret.fromString("shortString"));

//Test with a strong password
configuration = new LDAPConfiguration("ldaps://ldap.example.com", "dc=example,dc=com", true, null, Secret.fromString("ThisIsVeryStrongPassword"));
assertNotNull(configuration);
}

@Test
vwagh-dev marked this conversation as resolved.
Show resolved Hide resolved
public void testInSecureServerUrl(){
// Test with an invalid server URL
FormValidation invalidServerValidation = new LDAPConfiguration.LDAPConfigurationDescriptor().doCheckServer("invalid-url", "dc=example,dc=com", Secret.fromString("SomePwd"), null);
assertEquals(FormValidation.Kind.ERROR, invalidServerValidation.kind);
assertThat(invalidServerValidation.getMessage(), containsString("LDAP server URL is not secure"));
}
}
Loading