diff --git a/core/src/main/cfml/context/admin/services.certificates.cfm b/core/src/main/cfml/context/admin/services.certificates.cfm index 8925ce97b89..64b30b029bd 100755 --- a/core/src/main/cfml/context/admin/services.certificates.cfm +++ b/core/src/main/cfml/context/admin/services.certificates.cfm @@ -6,9 +6,7 @@ - - - @@ -24,34 +22,33 @@ Defaults ---> - LuceeTrustStore = false; - if ((server.system.properties["lucee.use.lucee.SSL.TrustStore"]?: false) - || (server.system.environment["lucee_use_lucee_SSL_TrustStore"]?: false)){ - LuceeTrustStore = true; - }; - + customCaCertsEnabled = !(server.system.properties["lucee.ssl.customcacerts.enabled"]?: "true").equalsIgnoreCase("false"); - +

- As Lucee is currently using the JVM TrustStore/cacerts file, this functionality isn't available. + Custom CA certificates are disabled.

- Set the following System or Environment variables to enable: lucee.use.lucee.SSL.TrustStore = true; + Set the following System or Environment variable to enable: lucee.ssl.customcacerts.enabled=true

- - + - - - - + + + + + @@ -61,13 +58,13 @@ Defaults ---> - - @@ -104,9 +101,55 @@ Error Output ---> + + + + + +

Installed Certificates

+ + + + + + + + + + + + + + + + + + + + +
#stText.services.certificate.subject##stText.services.certificate.issuer#Alias
#installedCerts.subject##installedCerts.issuer##installedCerts.alias# +
+ + + +
+
+ +

No certificates installed in custom-cacerts.

+
+ +
#cfcatch.message# #cfcatch.detail#
+
+
+
+ + - @@ -143,4 +186,4 @@ Error Output ---> -
\ No newline at end of file + diff --git a/core/src/main/java/lucee/commons/net/http/HTTPDownloader.java b/core/src/main/java/lucee/commons/net/http/HTTPDownloader.java index 1abf1e57bae..609e91de209 100644 --- a/core/src/main/java/lucee/commons/net/http/HTTPDownloader.java +++ b/core/src/main/java/lucee/commons/net/http/HTTPDownloader.java @@ -42,6 +42,13 @@ public final class HTTPDownloader { public static final long DEFAULT_READ_TIMEOUT = 60000; // 60 seconds private static final String DEFAULT_USER_AGENT = "Lucee"; + // Internal downloads talk to a small fixed set of hosts (update.lucee.org, Maven Central) + // and are typically sequential. The pool here is intentionally separate from HTTPEngine4Impl's + // cfhttp pool — see LDEV-5571 plan. User cert installs invalidate cfhttp pools without affecting + // bundle/update downloads, and bundle download lifecycle is decoupled from user request traffic. + private static final int POOL_MAX_CONN = 16; + private static final int POOL_MAX_CONN_PER_ROUTE = 4; + private HTTPDownloader() { // Utility class, prevent instantiation } @@ -52,7 +59,7 @@ private HTTPDownloader() { public static void releaseSharedClient() { synchronized (CLIENT_LOCK) { if (SHARED_CLIENT != null) { - IOUtil.closeEL(SHARED_CLIENT); + IOUtil.closeEL(SHARED_CLIENT); // managerShared=false → cascades to the owned pool SHARED_CLIENT = null; } } @@ -62,8 +69,7 @@ private static CloseableHttpClient getSharedClient() throws GeneralSecurityExcep if (SHARED_CLIENT == null) { synchronized (CLIENT_LOCK) { if (SHARED_CLIENT == null) { - HttpClientBuilder builder = HTTPEngine4Impl.getHttpClientBuilder(true, null, null, "true"); - SHARED_CLIENT = builder.build(); + SHARED_CLIENT = HTTPEngine4Impl.buildUnmanagedClient(null, null, null, null, true, POOL_MAX_CONN_PER_ROUTE, POOL_MAX_CONN, "true"); } } } @@ -221,7 +227,7 @@ public static InputStream get(URL url, String username, String password, long co // Handle proxy and credentials ProxyData proxy = getProxyData(url.getHost()); - HttpClientBuilder builder = HTTPEngine4Impl.getHttpClientBuilder(true, null, null, "true"); + HttpClientBuilder builder = HTTPEngine4Impl.getHttpClientBuilder(true, null, null, null, null, true, "true"); HttpHost httpHost = new HttpHost(url.getHost(), url.getPort()); HttpContext context = HTTPEngine4Impl.setCredentials(builder, httpHost, username, password, false); HTTPEngine4Impl.setProxy(url.getHost(), builder, request, proxy); @@ -262,7 +268,7 @@ public static HTTPResponse head(URL url, long connectTimeout, long readTimeout, try { // Get configured HttpClientBuilder (with connection pooling, true = use pooling) - HttpClientBuilder builder = HTTPEngine4Impl.getHttpClientBuilder(true, null, null, "true"); + HttpClientBuilder builder = HTTPEngine4Impl.getHttpClientBuilder(true, null, null, null, null, true, "true"); // Create HTTP HEAD request HttpHead request = new HttpHead(url.toString()); @@ -305,7 +311,7 @@ public static boolean exists(URL url) { public static boolean exists(URL url, long connectTimeout, long readTimeout) { try { // Get configured HttpClientBuilder (with connection pooling, true = use pooling) - HttpClientBuilder builder = HTTPEngine4Impl.getHttpClientBuilder(true, null, null, "true"); + HttpClientBuilder builder = HTTPEngine4Impl.getHttpClientBuilder(true, null, null, null, null, true, "true"); // Create HTTP HEAD request HttpHead request = new HttpHead(url.toString()); diff --git a/core/src/main/java/lucee/commons/net/http/httpclient/HTTPEngine4Impl.java b/core/src/main/java/lucee/commons/net/http/httpclient/HTTPEngine4Impl.java index 7b3cd6c8c44..d3810683344 100644 --- a/core/src/main/java/lucee/commons/net/http/httpclient/HTTPEngine4Impl.java +++ b/core/src/main/java/lucee/commons/net/http/httpclient/HTTPEngine4Impl.java @@ -18,19 +18,13 @@ **/ package lucee.commons.net.http.httpclient; -import java.io.File; -import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Field; import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; import java.security.GeneralSecurityException; -import java.security.KeyManagementException; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.UnrecoverableKeyException; -import java.security.cert.CertificateException; import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; @@ -41,7 +35,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SSLContext; import org.apache.http.Header; @@ -73,6 +67,7 @@ import org.apache.http.conn.HttpClientConnectionManager; import org.apache.http.conn.socket.ConnectionSocketFactory; import org.apache.http.conn.socket.PlainConnectionSocketFactory; +import org.apache.http.conn.ssl.NoopHostnameVerifier; import org.apache.http.conn.ssl.SSLConnectionSocketFactory; import org.apache.http.entity.ByteArrayEntity; import org.apache.http.entity.ContentType; @@ -110,6 +105,7 @@ import lucee.runtime.PageContextImpl; import lucee.runtime.engine.ThreadLocalPageContext; import lucee.runtime.net.http.ReqRspUtil; +import lucee.runtime.net.http.SSLUtil; import lucee.runtime.net.http.sni.DefaultHostnameVerifierImpl; import lucee.runtime.net.http.sni.DefaultHttpClientConnectionOperatorImpl; import lucee.runtime.net.http.sni.SSLConnectionSocketFactoryImpl; @@ -272,10 +268,9 @@ private static Header toHeader(lucee.commons.net.http.Header header) { return new HeaderImpl(header.getName(), header.getValue()); } - public static HttpClientBuilder getHttpClientBuilder(boolean pooling, String clientCert, String clientCertPassword, String redirect) - throws GeneralSecurityException, IOException { - String key = clientCert + ":" + clientCertPassword; - Registry reg = StringUtil.isEmpty(clientCert, true) ? createRegistry() : createRegistry(clientCert, clientCertPassword); + public static HttpClientBuilder getHttpClientBuilder(boolean pooling, String clientCert, String clientCertPassword, String trustStore, String trustStorePassword, boolean sslVerify, String redirect) throws GeneralSecurityException { + String key = clientCert + ":" + clientCertPassword + ":" + trustStore + ":" + trustStorePassword + ":" + sslVerify; + Registry reg = createRegistry( clientCert, clientCertPassword, trustStore, trustStorePassword, sslVerify ); if (!pooling) { HttpClientBuilder builder = HttpClients.custom(); @@ -331,31 +326,43 @@ public static void setTimeout(HttpClientBuilder builder, TimeSpan timeout) { builder.setDefaultRequestConfig(rcBuilder.build()); } - private static Registry createRegistry() throws GeneralSecurityException { - SSLContext sslcontext = SSLContext.getInstance("TLS"); - sslcontext.init(null, null, new java.security.SecureRandom()); - SSLConnectionSocketFactory defaultsslsf = new SSLConnectionSocketFactoryImpl(sslcontext, new DefaultHostnameVerifierImpl()); - /* Register connection handlers */ - return RegistryBuilder.create().register("http", PlainConnectionSocketFactory.getSocketFactory()).register("https", defaultsslsf).build(); + private static Registry createRegistry( String clientCert, String clientCertPassword, String trustStore, String trustStorePassword, boolean sslVerify ) throws GeneralSecurityException { + SSLContext sslContext; + HostnameVerifier hostnameVerifier; - } + try { + Path clientCertPath = StringUtil.isEmpty( clientCert, true ) ? null : Paths.get( clientCert ); + char[] clientPassword = clientCertPassword != null ? clientCertPassword.toCharArray() : null; + + if ( !sslVerify ) { + // Disable all SSL verification (like curl -k) + sslContext = SSLUtil.createUnsafeSSLContext( clientCertPath, clientPassword ); + hostnameVerifier = NoopHostnameVerifier.INSTANCE; + } + else if ( !StringUtil.isEmpty( trustStore, true ) ) { + // Use custom trust store + Path trustStorePath = Paths.get( trustStore ); + char[] trustPassword = trustStorePassword != null ? trustStorePassword.toCharArray() : "changeit".toCharArray(); + List additionalTrustStores = new ArrayList<>(); + additionalTrustStores.add( new SSLUtil.TrustStoreConfig( trustStorePath, trustPassword ) ); + sslContext = SSLUtil.createSSLContext( clientCertPath, clientPassword, additionalTrustStores ); + hostnameVerifier = new DefaultHostnameVerifierImpl(); + } + else { + // Standard mode with JVM + custom-cacerts + sslContext = SSLUtil.createSSLContext( clientCertPath, clientPassword ); + hostnameVerifier = new DefaultHostnameVerifierImpl(); + } + } + catch ( IOException e ) { + throw new GeneralSecurityException( "Failed to create SSL context", e ); + } - private static Registry createRegistry(String clientCert, String clientCertPassword) - throws IOException, KeyStoreException, NoSuchAlgorithmException, CertificateException, UnrecoverableKeyException, KeyManagementException { - // Currently, clientCert force usePool to being ignored - if (clientCertPassword == null) clientCertPassword = ""; - // Load the client cert - File ksFile = new File(clientCert); - KeyStore clientStore = KeyStore.getInstance("PKCS12"); - clientStore.load(new FileInputStream(ksFile), clientCertPassword.toCharArray()); - // Prepare the keys - KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(clientStore, clientCertPassword.toCharArray()); - SSLContext sslcontext = SSLContext.getInstance("TLS"); - // Configure the socket factory - sslcontext.init(kmf.getKeyManagers(), null, new java.security.SecureRandom()); - SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactoryImpl(sslcontext, new DefaultHostnameVerifierImpl()); - return RegistryBuilder.create().register("http", PlainConnectionSocketFactory.getSocketFactory()).register("https", sslsf).build(); + SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactoryImpl( sslContext, hostnameVerifier ); + return RegistryBuilder.create() + .register( "http", PlainConnectionSocketFactory.getSocketFactory() ) + .register( "https", sslsf ) + .build(); } public static void releaseConnectionManager() { @@ -366,6 +373,29 @@ public static void releaseConnectionManager() { } } + /** + * Builds a CloseableHttpClient backed by a fresh connection pool that is NOT registered in + * the shared connectionManagers map. The client owns the pool (managerShared=false), so + * closing the client closes the pool. Use for internal infrastructure traffic whose lifecycle + * must be independent of user-driven releaseConnectionManager() calls. + */ + public static CloseableHttpClient buildUnmanagedClient(String clientCert, String clientCertPassword, String trustStore, String trustStorePassword, + boolean sslVerify, int maxPerRoute, int maxTotal, String redirect) throws GeneralSecurityException { + Registry reg = createRegistry(clientCert, clientCertPassword, trustStore, trustStorePassword, sslVerify); + PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(new DefaultHttpClientConnectionOperatorImpl(reg), null, POOL_CONN_TTL_MS, + TimeUnit.MILLISECONDS); + cm.setDefaultMaxPerRoute(maxPerRoute); + cm.setMaxTotal(maxTotal); + cm.setDefaultSocketConfig(SocketConfig.copy(SocketConfig.DEFAULT).setTcpNoDelay(true).setSoReuseAddress(true).setSoLinger(0).build()); + + HttpClientBuilder builder = HttpClients.custom().setConnectionManager(cm).setConnectionManagerShared(false) + .setConnectionTimeToLive(POOL_CONN_TTL_MS, TimeUnit.MILLISECONDS).setConnectionReuseStrategy(new DefaultClientConnectionReuseStrategy()) + .setRedirectStrategy("lax".equalsIgnoreCase(redirect) ? new LaxRedirectStrategy() : new DefaultRedirectStrategy()) + .setRetryHandler(new NoHttpResponseExceptionHttpRequestRetryHandler()); + if (!Caster.toBooleanValue(redirect, true)) builder.disableRedirectHandling(); + return builder.build(); + } + public static boolean isShutDown(PoolingHttpClientConnectionManager cm, boolean defaultValue) { if (cm != null && !cannotAccess) { try { @@ -399,7 +429,7 @@ private static HTTPResponse invoke(URL url, HttpUriRequest request, String usern CloseableHttpClient client; proxy = ProxyDataImpl.validate(proxy, url.getHost()); - HttpClientBuilder builder = getHttpClientBuilder(pooling, null, null, String.valueOf(redirect)); + HttpClientBuilder builder = getHttpClientBuilder(pooling, null, null, null, null, true, String.valueOf(redirect)); HttpHost hh = new HttpHost(url.getHost(), url.getPort()); setHeader(request, headers); diff --git a/core/src/main/java/lucee/runtime/config/ConfigFactoryImpl.java b/core/src/main/java/lucee/runtime/config/ConfigFactoryImpl.java index 23fa1d7f33f..dc9e958b40f 100644 --- a/core/src/main/java/lucee/runtime/config/ConfigFactoryImpl.java +++ b/core/src/main/java/lucee/runtime/config/ConfigFactoryImpl.java @@ -27,6 +27,7 @@ import java.net.MalformedURLException; import java.net.URL; import java.nio.charset.Charset; +import java.nio.file.Paths; import java.security.NoSuchAlgorithmException; import java.sql.SQLException; import java.util.ArrayList; @@ -157,6 +158,7 @@ import lucee.runtime.monitor.RequestMonitorProImpl; import lucee.runtime.monitor.RequestMonitorWrap; import lucee.runtime.net.http.ReqRspUtil; +import lucee.runtime.net.http.SSLUtil; import lucee.runtime.net.mail.Server; import lucee.runtime.net.mail.ServerImpl; import lucee.runtime.net.proxy.ProxyData; @@ -338,6 +340,7 @@ public static ConfigServerImpl newInstanceServer(CFMLEngineImpl engine, Map installed = new WeakHashMap(); + private static Map installed = new WeakHashMap<>(); private String host; private int port; @@ -68,20 +71,19 @@ public CertificateInstaller(Resource source, String host, int port, char[] passp this.port = port; this.passphrase = passphrase; - ks = null; - InputStream in = source.getInputStream(); - try { - ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(in, passphrase); - } - finally { - IOUtil.close(in); - } + ks = SSLUtil.loadKeyStore( Paths.get( source.getAbsolutePath() ), passphrase ); - context = SSLContext.getInstance("SSL"); + context = SSLContext.getInstance("TLS"); tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); tmf.init(ks); - X509TrustManager defaultTrustManager = (X509TrustManager) tmf.getTrustManagers()[0]; + X509TrustManager defaultTrustManager = null; + for ( TrustManager candidate : tmf.getTrustManagers() ) { + if ( candidate instanceof X509TrustManager ) { + defaultTrustManager = (X509TrustManager) candidate; + break; + } + } + if ( defaultTrustManager == null ) throw new GeneralSecurityException( "No X509TrustManager found" ); tm = new SavingTrustManager(defaultTrustManager); context.init(null, new TrustManager[] { tm }, null); @@ -152,27 +154,32 @@ public X509Certificate[] getCertificates() { return tm.chain; } - public static List getAllCertificates(Resource source) throws GeneralSecurityException, IOException { - KeyStore ks = null; - InputStream in = source.getInputStream(); - try { - ks = KeyStore.getInstance(KeyStore.getDefaultType()); - ks.load(in, "changeit".toCharArray()); - } - finally { - IOUtil.close(in); - } + public static void installToCustomCaCerts( String host, int port ) throws GeneralSecurityException, IOException { + if ( !SSLUtil.isCustomCaCertsEnabled() ) throw new GeneralSecurityException( "custom-cacerts is disabled. Set lucee.ssl.customcacerts.enabled=true or provide a custom keystore path." ); + Path customCaCertsPath = SSLUtil.getCustomCaCertsPath(); + if ( customCaCertsPath == null ) throw new GeneralSecurityException( "Could not determine custom-cacerts path. Lucee config may not be initialized." ); + SSLUtil.initCustomCaCerts(); + Resource keystore = ResourcesImpl.getFileResourceProvider().getResource( customCaCertsPath.toString() ); + new CertificateInstaller( keystore, host, port ).installAll( true ); + } + + public static List getAllCertificates(Resource source) throws GeneralSecurityException { + return new ArrayList<>( getAllCertificatesWithAliases( source ).values() ); + } + + public static Map getAllCertificatesWithAliases(Resource source) throws GeneralSecurityException { + KeyStore ks = SSLUtil.loadKeyStore( Paths.get( source.getAbsolutePath() ), "changeit".toCharArray() ); - List list = new ArrayList<>(); + Map map = new LinkedHashMap<>(); Enumeration aliases = ks.aliases(); - while (aliases.hasMoreElements()) { + while ( aliases.hasMoreElements() ) { String alias = aliases.nextElement(); - Certificate cert = ks.getCertificate(alias); - if (cert instanceof X509Certificate) { - list.add((X509Certificate) cert); + Certificate cert = ks.getCertificate( alias ); + if ( cert instanceof X509Certificate ) { + map.put( alias, (X509Certificate) cert ); } } - return list; // Adjust return based on method implementation + return map; } private static class SavingTrustManager implements X509TrustManager { diff --git a/core/src/main/java/lucee/runtime/net/http/SSLUtil.java b/core/src/main/java/lucee/runtime/net/http/SSLUtil.java new file mode 100644 index 00000000000..0d456073dce --- /dev/null +++ b/core/src/main/java/lucee/runtime/net/http/SSLUtil.java @@ -0,0 +1,336 @@ +package lucee.runtime.net.http; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import lucee.commons.io.SystemUtil; +import lucee.commons.io.log.LogUtil; + +/** + * SSL utility class for creating SSLContexts that combine multiple trust sources. + * Combines JVM cacerts with user-installed certificates in custom-cacerts. + */ +public final class SSLUtil { + + private static final String CUSTOM_CACERTS_ENABLED_PROP = "lucee.ssl.customcacerts.enabled"; + private static final String CUSTOM_CACERTS_FILENAME = "custom-cacerts"; + private static final char[] DEFAULT_PASSWORD = "changeit".toCharArray(); + + // Cache the enabled flag - read once at startup + private static final boolean customCaCertsEnabled; + static { + customCaCertsEnabled = !"false".equalsIgnoreCase( SystemUtil.getSystemPropOrEnvVar( CUSTOM_CACERTS_ENABLED_PROP, "true" ) ); + } + + private static volatile Path customCaCertsPath = null; + + private SSLUtil() { + // utility class + } + + /** + * Returns a fresh copy of the default keystore password ("changeit"). + */ + public static char[] getDefaultPassword() { + return DEFAULT_PASSWORD.clone(); + } + + /** + * Returns true if custom-cacerts is enabled (default: true). + * Can be disabled by setting lucee.ssl.customcacerts.enabled=false + */ + public static boolean isCustomCaCertsEnabled() { + return customCaCertsEnabled; + } + + /** + * Called once at server startup to set the security directory path. + * After this, getCustomCaCertsPath() works from any thread. + */ + public static void init( Path securityDir ) { + if ( !customCaCertsEnabled ) return; + try { + if ( !Files.exists( securityDir ) ) { + Files.createDirectories( securityDir ); + } + customCaCertsPath = securityDir.resolve( CUSTOM_CACERTS_FILENAME ); + } + catch ( IOException e ) { + LogUtil.log( "ssl", e ); + } + } + + /** + * Gets the path to the custom-cacerts keystore file. + * Returns null if custom-cacerts is disabled or init() has not been called yet. + */ + public static Path getCustomCaCertsPath() { + return customCaCertsPath; + } + + /** + * Creates an empty custom-cacerts keystore if it doesn't exist. + */ + public static void initCustomCaCerts() throws GeneralSecurityException, IOException { + Path path = getCustomCaCertsPath(); + if ( path != null && !Files.exists( path ) ) { + KeyStore ks = KeyStore.getInstance( KeyStore.getDefaultType() ); + ks.load( null, DEFAULT_PASSWORD ); // Initialize empty keystore + try ( OutputStream os = Files.newOutputStream( path ) ) { + ks.store( os, DEFAULT_PASSWORD ); + } + } + } + + /** + * Creates an SSLContext using JVM cacerts + custom-cacerts (if enabled and exists). + */ + public static SSLContext createSSLContext() throws GeneralSecurityException, IOException { + return createSSLContext( null, null, null ); + } + + /** + * Creates an SSLContext with optional client certificate (identity material). + * Automatically includes custom-cacerts if enabled. + * + * @param clientCertPath Path to client certificate keystore (PKCS12 or JKS) + * @param clientCertPassword Password for the client certificate keystore + */ + public static SSLContext createSSLContext( Path clientCertPath, char[] clientCertPassword ) throws GeneralSecurityException, IOException { + return createSSLContext( clientCertPath, clientCertPassword, null ); + } + + /** + * Creates an SSLContext with optional client certificate and additional trust stores. + * Automatically includes JVM cacerts and custom-cacerts (if enabled). + * + * @param clientCertPath Path to client certificate keystore (PKCS12 or JKS), may be null + * @param clientCertPassword Password for the client certificate keystore + * @param additionalTrustStores Additional trust stores to combine with JVM default, may be null + */ + public static SSLContext createSSLContext( Path clientCertPath, char[] clientCertPassword, List additionalTrustStores ) + throws GeneralSecurityException, IOException { + + // Build list of trust managers + List trustManagers = new ArrayList<>(); + + // Always add JVM default trust material + TrustManagerFactory defaultTmf = TrustManagerFactory.getInstance( TrustManagerFactory.getDefaultAlgorithm() ); + defaultTmf.init( (KeyStore) null ); // null = use JVM default cacerts + for ( TrustManager tm : defaultTmf.getTrustManagers() ) { + if ( tm instanceof X509TrustManager ) { + trustManagers.add( (X509TrustManager) tm ); + } + } + + // Add custom-cacerts if enabled and exists + Path customCaCertsPath = getCustomCaCertsPath(); + if ( customCaCertsPath != null && Files.exists( customCaCertsPath ) ) { + try { + KeyStore ks = loadKeyStore( customCaCertsPath, DEFAULT_PASSWORD ); + TrustManagerFactory tmf = TrustManagerFactory.getInstance( TrustManagerFactory.getDefaultAlgorithm() ); + tmf.init( ks ); + for ( TrustManager tm : tmf.getTrustManagers() ) { + if ( tm instanceof X509TrustManager ) { + trustManagers.add( (X509TrustManager) tm ); + } + } + } + catch ( Exception e ) { + // Log but don't fail - custom-cacerts is optional + LogUtil.log( "ssl", e ); + } + } + + // Add any additional trust stores (e.g., per-request trustStore attribute) + if ( additionalTrustStores != null ) { + for ( TrustStoreConfig config : additionalTrustStores ) { + if ( config.path == null ) continue; + if ( !Files.exists( config.path ) ) { + throw new GeneralSecurityException( "trustStore file not found: " + config.path ); + } + KeyStore ks = loadKeyStore( config.path, config.password ); + TrustManagerFactory tmf = TrustManagerFactory.getInstance( TrustManagerFactory.getDefaultAlgorithm() ); + tmf.init( ks ); + for ( TrustManager tm : tmf.getTrustManagers() ) { + if ( tm instanceof X509TrustManager ) { + trustManagers.add( (X509TrustManager) tm ); + } + } + } + } + + // Create composite trust manager + X509TrustManager compositeTm = new CompositeX509TrustManager( trustManagers ); + + // Load client certificate (identity material) if provided + KeyManager[] keyManagers = null; + if ( clientCertPath != null && Files.exists( clientCertPath ) ) { + KeyStore clientKs = loadKeyStore( clientCertPath, clientCertPassword ); + KeyManagerFactory kmf = KeyManagerFactory.getInstance( KeyManagerFactory.getDefaultAlgorithm() ); + kmf.init( clientKs, clientCertPassword != null ? clientCertPassword : new char[0] ); + keyManagers = kmf.getKeyManagers(); + } + + // Create and initialize SSL context + SSLContext sslContext = SSLContext.getInstance( "TLS" ); + sslContext.init( keyManagers, new TrustManager[] { compositeTm }, null ); + return sslContext; + } + + /** + * Creates an SSLContext that trusts all certificates (UNSAFE - for development/testing only). + * Equivalent to curl -k or sslVerify="false". + */ + public static SSLContext createUnsafeSSLContext() throws GeneralSecurityException, IOException { + return createUnsafeSSLContext( null, null ); + } + + /** + * Creates an SSLContext that trusts all certificates with optional client cert. + */ + public static SSLContext createUnsafeSSLContext( Path clientCertPath, char[] clientCertPassword ) throws GeneralSecurityException, IOException { + X509TrustManager unsafeTm = new X509TrustManager() { + @Override + public void checkClientTrusted( X509Certificate[] chain, String authType ) { + // Trust all + } + + @Override + public void checkServerTrusted( X509Certificate[] chain, String authType ) { + // Trust all + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + + KeyManager[] keyManagers = null; + if ( clientCertPath != null && Files.exists( clientCertPath ) ) { + KeyStore clientKs = loadKeyStore( clientCertPath, clientCertPassword ); + KeyManagerFactory kmf = KeyManagerFactory.getInstance( KeyManagerFactory.getDefaultAlgorithm() ); + kmf.init( clientKs, clientCertPassword != null ? clientCertPassword : new char[0] ); + keyManagers = kmf.getKeyManagers(); + } + + SSLContext sslContext = SSLContext.getInstance( "TLS" ); + sslContext.init( keyManagers, new TrustManager[] { unsafeTm }, null ); + return sslContext; + } + + /** + * Loads a KeyStore from a file, auto-detecting the type (JKS or PKCS12). + */ + public static KeyStore loadKeyStore( Path path, char[] password ) throws GeneralSecurityException { + // Try PKCS12 first (more common for client certs), then JKS + KeyStoreException lastException = null; + for ( String type : new String[] { "PKCS12", "JKS" } ) { + try ( InputStream is = Files.newInputStream( path ) ) { + KeyStore ks = KeyStore.getInstance( type ); + ks.load( is, password ); + return ks; + } + catch ( KeyStoreException | IOException e ) { + lastException = new KeyStoreException( "Failed to load keystore as " + type + ": " + e.getMessage(), e ); + } + } + throw lastException; + } + + /** + * Configuration for an additional trust store. + */ + public static class TrustStoreConfig { + public final Path path; + public final char[] password; + + public TrustStoreConfig( Path path, char[] password ) { + this.path = path; + this.password = password; + } + + public TrustStoreConfig( Path path ) { + this( path, "changeit".toCharArray() ); + } + } + + /** + * A TrustManager that delegates to multiple underlying X509TrustManagers. + * If any trust manager accepts the certificate chain, the chain is trusted. + */ + private static class CompositeX509TrustManager implements X509TrustManager { + private final List trustManagers; + + public CompositeX509TrustManager( List trustManagers ) { + this.trustManagers = new ArrayList<>( trustManagers ); + } + + @Override + public void checkClientTrusted( X509Certificate[] chain, String authType ) throws CertificateException { + CertificateException lastException = null; + for ( X509TrustManager tm : trustManagers ) { + try { + tm.checkClientTrusted( chain, authType ); + return; // If any trust manager accepts, we're done + } + catch ( CertificateException e ) { + lastException = e; + } + } + if ( lastException != null ) { + throw lastException; + } + throw new CertificateException( "No trust managers available" ); + } + + @Override + public void checkServerTrusted( X509Certificate[] chain, String authType ) throws CertificateException { + CertificateException lastException = null; + for ( X509TrustManager tm : trustManagers ) { + try { + tm.checkServerTrusted( chain, authType ); + return; // If any trust manager accepts, we're done + } + catch ( CertificateException e ) { + lastException = e; + } + } + if ( lastException != null ) { + throw lastException; + } + throw new CertificateException( "No trust managers available" ); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + List issuers = new ArrayList<>(); + for ( X509TrustManager tm : trustManagers ) { + X509Certificate[] accepted = tm.getAcceptedIssuers(); + if ( accepted != null ) { + issuers.addAll( Arrays.asList( accepted ) ); + } + } + return issuers.toArray( new X509Certificate[0] ); + } + } +} diff --git a/core/src/main/java/lucee/runtime/tag/Admin.java b/core/src/main/java/lucee/runtime/tag/Admin.java index ad9e2b5cf87..6b1b594502c 100755 --- a/core/src/main/java/lucee/runtime/tag/Admin.java +++ b/core/src/main/java/lucee/runtime/tag/Admin.java @@ -22,6 +22,8 @@ import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.cert.X509Certificate; import java.sql.Connection; import java.util.ArrayList; @@ -61,6 +63,7 @@ import lucee.commons.io.log.LogUtil; import lucee.commons.io.log.LoggerAndSourceData; import lucee.commons.io.res.Resource; +import lucee.commons.io.res.ResourcesImpl; import lucee.commons.io.res.filter.DirectoryResourceFilter; import lucee.commons.io.res.filter.ExtensionResourceFilter; import lucee.commons.io.res.filter.NotResourceFilter; @@ -72,6 +75,7 @@ import lucee.commons.lang.IDGenerator; import lucee.commons.lang.StringUtil; import lucee.commons.lang.types.RefBooleanImpl; +import lucee.commons.net.http.httpclient.HTTPEngine4Impl; import lucee.commons.surveillance.HeapDumper; import lucee.loader.engine.CFMLEngine; import lucee.loader.osgi.BundleCollection; @@ -150,8 +154,10 @@ import lucee.runtime.monitor.IntervallMonitor; import lucee.runtime.monitor.Monitor; import lucee.runtime.monitor.RequestMonitor; +import lucee.runtime.functions.other.SSLCertificateRemove; import lucee.runtime.net.http.CertificateInstaller; import lucee.runtime.net.http.ReqRspUtil; +import lucee.runtime.net.http.SSLUtil; // import lucee.runtime.net.mail.SMTPVerifier; // removed with mail functionality import lucee.runtime.net.mail.Server; import lucee.runtime.net.mail.ServerImpl; @@ -697,6 +703,8 @@ else if (check("getLoggedDebugData", ACCESS_FREE)) // no password necessary for else if (check("updateLogSettings", ACCESS_FREE) && check2(ACCESS_WRITE)) doUpdateLogSettings(); else if (check("updateJar", ACCESS_FREE) && check2(ACCESS_WRITE)) doUpdateJar(); else if (check("updateSSLCertificate", ACCESS_NOT_WHEN_WEB) && check2(ACCESS_WRITE)) doUpdateSSLCertificate(); + else if (check("removeSSLCertificate", ACCESS_NOT_WHEN_WEB) && check2(ACCESS_WRITE)) doRemoveSSLCertificate(); + else if (check("getAllSSLCertificate", ACCESS_NOT_WHEN_WEB) && check2(ACCESS_READ)) doGetAllSSLCertificate(); else if (check("updateMonitorEnabled", ACCESS_NOT_WHEN_WEB) && check2(ACCESS_WRITE)) doUpdateMonitorEnabled(); else if (check("updateTLD", ACCESS_FREE) && check2(ACCESS_WRITE)) doUpdateTLD(); else if (check("updateFLD", ACCESS_FREE) && check2(ACCESS_WRITE)) doUpdateFLD(); @@ -4872,18 +4880,37 @@ private void doUpdateSSLCertificate() throws PageException { updateSSLCertificate(config, host, port); } - public static void updateSSLCertificate(Config config, String host, int port) throws PageException { - Resource cacerts = config.getSecurityDirectory(); + private void doRemoveSSLCertificate() throws PageException { + String alias = getString("admin", "RemoveSSLCertificate", "alias"); + removeSSLCertificate(config, alias); + } + + public static void removeSSLCertificate(Config config, String alias) throws PageException { + try { + SSLCertificateRemove.call( null, alias ); + } + catch (PageException pe) { + throw pe; + } + catch (Exception e) { + throw Caster.toPageException(e); + } + } + public static void updateSSLCertificate(Config config, String host, int port) throws PageException { try { - CertificateInstaller installer = new CertificateInstaller(cacerts, host, port); - installer.installAll(true); + CertificateInstaller.installToCustomCaCerts( host, port ); + HTTPEngine4Impl.releaseConnectionManager(); } catch (Exception e) { throw Caster.toPageException(e); } } + private void doGetAllSSLCertificate() throws PageException { + pageContext.setVariable(getString("admin", action, "returnVariable"), getAllSSLCertificate(config)); + } + private void doGetSSLCertificate() throws PageException { String host = getString("admin", "GetSSLCertificate", "host"); int port = getInt("port", 443); @@ -4913,22 +4940,27 @@ public static Query getSSLCertificate(Config config, String host, int port) thro } public static Query getAllSSLCertificate(Config config) throws PageException { - List certs; try { - certs = CertificateInstaller.getAllCertificates(config.getSecurityDirectory()); + Path customCaCertsPath = SSLUtil.getCustomCaCertsPath(); + if ( customCaCertsPath == null || !Files.exists( customCaCertsPath ) ) { + return new QueryImpl(new Key[] { KeyConstants._alias, KeyConstants._subject, KeyConstants._issuer, KeyConstants._raw }, 0, "certificates"); + } + Resource res = ResourcesImpl.getFileResourceProvider().getResource( customCaCertsPath.toString() ); + Map certs = CertificateInstaller.getAllCertificatesWithAliases( res ); + Query qry = new QueryImpl(new Key[] { KeyConstants._alias, KeyConstants._subject, KeyConstants._issuer, KeyConstants._raw }, certs.size(), "certificates"); + int row = 0; + for ( Map.Entry entry : certs.entrySet() ) { + row++; + qry.setAtEL(KeyConstants._alias, row, entry.getKey()); + qry.setAtEL(KeyConstants._subject, row, entry.getValue().getSubjectDN().getName()); + qry.setAtEL(KeyConstants._issuer, row, entry.getValue().getIssuerDN().getName()); + qry.setAtEL(KeyConstants._raw, row, entry.getValue()); + } + return qry; } catch (Exception e) { throw Caster.toPageException(e); } - Query qry = new QueryImpl(new Key[] { KeyConstants._subject, KeyConstants._issuer, KeyConstants._raw }, certs.size(), "certificates"); - int row = 0; - for (X509Certificate cert: certs) { - row++; - qry.setAtEL(KeyConstants._subject, row, cert.getSubjectDN().getName()); - qry.setAtEL(KeyConstants._issuer, row, cert.getIssuerDN().getName()); - qry.setAtEL(KeyConstants._raw, row, cert); - } - return qry; } private void doRemoveBundle() throws PageException { diff --git a/core/src/main/java/lucee/runtime/tag/Http.java b/core/src/main/java/lucee/runtime/tag/Http.java index fadbf485843..1e85454ae8f 100644 --- a/core/src/main/java/lucee/runtime/tag/Http.java +++ b/core/src/main/java/lucee/runtime/tag/Http.java @@ -335,6 +335,13 @@ public final class Http extends BodyTagImpl { private String clientCertPassword; private boolean autoCert = false; + /** Path to a custom trust store (JKS or PKCS12) for SSL certificate validation. */ + private String trustStore; + /** Password for the custom trust store. */ + private String trustStorePassword; + /** When false, disables SSL certificate and hostname verification (like curl -k). */ + private boolean sslVerify = true; + @Override public void release() { super.release(); @@ -383,6 +390,9 @@ public void release() { cachedWithin = null; usePool = true; autoCert = false; + trustStore = null; + trustStorePassword = null; + sslVerify = true; } /** @@ -730,7 +740,7 @@ private void _doEndTag() throws PageException, IOException, GeneralSecurityExcep long start = System.nanoTime(); boolean safeToMemory = !StringUtil.isEmpty(result, true); - HttpClientBuilder builder = HTTPEngine4Impl.getHttpClientBuilder(this.usePool, this.clientCert, this.clientCertPassword, this.redirect); + HttpClientBuilder builder = HTTPEngine4Impl.getHttpClientBuilder(this.usePool, this.clientCert, this.clientCertPassword, this.trustStore, this.trustStorePassword, this.sslVerify, this.redirect); // cookies BasicCookieStore cookieStore = new BasicCookieStore(); @@ -1782,6 +1792,27 @@ public void setClientcertpassword(String clientCertPassword) { this.clientCertPassword = clientCertPassword; } + /** + * @param trustStore path to custom trust store (JKS or PKCS12) + */ + public void setTruststore(String trustStore) { + this.trustStore = trustStore; + } + + /** + * @param trustStorePassword password for the custom trust store + */ + public void setTruststorepassword(String trustStorePassword) { + this.trustStorePassword = trustStorePassword; + } + + /** + * @param sslVerify when false, disables SSL certificate verification + */ + public void setSslverify(boolean sslVerify) { + this.sslVerify = sslVerify; + } + /** * checks if status code is a redirect * diff --git a/core/src/main/java/resource/fld/core-base.fld b/core/src/main/java/resource/fld/core-base.fld index e24c7d9a0eb..fc13189a2ab 100755 --- a/core/src/main/java/resource/fld/core-base.fld +++ b/core/src/main/java/resource/fld/core-base.fld @@ -14133,6 +14133,24 @@ You can find a list of all available timezones in the Lucee administrator (Setti void + + + + SSLCertificateRemove + lucee.runtime.functions.other.SSLCertificateRemove + Removes a certificate from the custom-cacerts keystore by alias. + + alias + string + Yes + The alias of the certificate to remove from custom-cacerts. + + + boolean + Returns true if the certificate was successfully removed. + + + stripCr diff --git a/core/src/main/java/resource/setting/sysprop-envvar.json b/core/src/main/java/resource/setting/sysprop-envvar.json index b7afe4fc253..65e14bab916 100644 --- a/core/src/main/java/resource/setting/sysprop-envvar.json +++ b/core/src/main/java/resource/setting/sysprop-envvar.json @@ -1139,6 +1139,17 @@ "type": "boolean", "default": true }, + { + "sysprop": "lucee.ssl.customcacerts.enabled", + "envvar": "LUCEE_SSL_CUSTOMCACERTS_ENABLED", + "desc": "Controls whether Lucee uses a custom CA certificates keystore (custom-cacerts) in addition to the JVM's default cacerts. When enabled, certificates installed via SSLCertificateInstall() are stored in {lucee-server}/context/security/custom-cacerts and automatically trusted for all HTTPS connections. Set to `false` to disable and use only the JVM's default trust store", + "category": "security", + "tags": [ "cfhttp" ], + "functions": [ "SSLCertificateInstall", "SSLCertificateList", "SSLCertificateRemove" ], + "type": "boolean", + "introduced": "7.1", + "default": true + }, { "sysprop": "lucee.status.code", "envvar": "LUCEE_STATUS_CODE", diff --git a/core/src/main/java/resource/tld/core-base.tld b/core/src/main/java/resource/tld/core-base.tld index 1663d8745c6..69d70576db3 100644 --- a/core/src/main/java/resource/tld/core-base.tld +++ b/core/src/main/java/resource/tld/core-base.tld @@ -2536,6 +2536,30 @@ If not specified, falls back to the `timeout` attribute value. 5.0.0.0 Password used to decrypt the client certificate. + + string + trustStore + false + true + 7.1 + Path to a custom trust store (JKS or PKCS12) for SSL certificate validation. When specified, certificates in this store are trusted in addition to the JVM's default cacerts. + + + string + trustStorePassword + false + true + 7.1 + Password for the custom trust store. Defaults to 'changeit' if not specified. + + + boolean + sslVerify + false + true + 7.1 + When set to false, disables SSL certificate and hostname verification (equivalent to curl -k). Default is true. Use with caution as this makes connections vulnerable to man-in-the-middle attacks. + boolean pooling diff --git a/test/functions/SSLCertificateInstall.cfc b/test/functions/SSLCertificateInstall.cfc index fcc865de985..d2543b5793a 100644 --- a/test/functions/SSLCertificateInstall.cfc +++ b/test/functions/SSLCertificateInstall.cfc @@ -6,7 +6,7 @@ component extends="org.lucee.cfml.test.LuceeTestCase" labels="ssl" { it("should install SSL certificates for google.com without error", function() { expect(function() { SSLCertificateInstall("google.com"); - }).toThrow(); // disabled since LDEV-917 - use jvm cacerts + }).notToThrow(); // LDEV-5571 - now uses custom-cacerts store }); it("should install SSL certificates for google.com into custom caerts path, bad password to error", function() { diff --git a/test/tickets/LDEV5571.cfc b/test/tickets/LDEV5571.cfc new file mode 100644 index 00000000000..01845a3ddd8 --- /dev/null +++ b/test/tickets/LDEV5571.cfc @@ -0,0 +1,142 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="cfhttp,ssl" { + + variables.ssl = nullValue(); + + function beforeAll() { + variables.ssl = new test.tools.SSLTestServer(); + variables.ssl.start(); + } + + function afterAll() { + if ( !isNull( variables.ssl ) ) variables.ssl.stop(); + variables.ssl = nullValue(); + } + + function run( testResults, testBox ) { + describe( "LDEV-5571: Custom Truststore for SSL Certs", function() { + + it( "SSL test server should be running", function() { + expect( variables.ssl.isRunning() ).toBeTrue( "SSL test server failed to start" ); + }); + + it( "should fail connecting to self-signed server without cert installed", function() { + var result = {}; + cfhttp( url="https://localhost:#variables.ssl.getPort()#/", result="result", timeout=10, pooling=false ); + expect( result.error ).toBeTrue(); + expect( result.statusCode ).toInclude( "Connection Failure" ); + }); + + it( "should connect using trustStore cfhttp attribute", function() { + var result = {}; + cfhttp( + url = "https://localhost:#variables.ssl.getPort()#/", + result = "result", + timeout = 10, + pooling = false, + trustStore = variables.ssl.getTruststorePath(), + trustStorePassword = variables.ssl.getTruststorePassword() + ); + expect( result.statusCode ).toInclude( "200" ); + }); + + it( "should install cert into custom-cacerts and connect without trustStore attribute", function() { + SSLCertificateInstall( "localhost", variables.ssl.getPort() ); + + var result = {}; + cfhttp( url="https://localhost:#variables.ssl.getPort()#/", result="result", timeout=10, pooling=false ); + expect( result.statusCode ).toInclude( "200" ); + }); + + it( "should list installed cert via SSLCertificateList", function() { + var certs = SSLCertificateList(); + var found = false; + for ( var row in certs ) { + if ( findNoCase( "localhost", row.subject ) ) { + found = true; + break; + } + } + expect( found ).toBeTrue(); + }); + + it( "should remove cert and connection fails again", function() { + var certs = SSLCertificateList(); + var alias = ""; + for ( var row in certs ) { + if ( findNoCase( "localhost", row.subject ) ) { + alias = row.alias; + break; + } + } + expect( alias ).notToBeEmpty(); + + SSLCertificateRemove( alias ); + + var result = {}; + cfhttp( url="https://localhost:#variables.ssl.getPort()#/", result="result", timeout=10, pooling=false ); + expect( result.error ).toBeTrue(); + expect( result.statusCode ).toInclude( "Connection Failure" ); + }); + + it( "should install cert via cfadmin updatesslcertificate action", function() { + admin + action = "updatesslcertificate" + type = "server" + password = "#request.SERVERADMINPASSWORD#" + host = "localhost" + port = "#variables.ssl.getPort()#"; + + var result = {}; + cfhttp( url="https://localhost:#variables.ssl.getPort()#/", result="result", timeout=10, pooling=false ); + expect( result.statusCode ).toInclude( "200" ); + }); + + it( "should list installed cert via cfadmin getallsslcertificate action", function() { + admin + action = "getallsslcertificate" + type = "server" + password = "#request.SERVERADMINPASSWORD#" + returnVariable = "local.certs"; + + expect( isQuery( certs ) ).toBeTrue(); + expect( certs ).toHaveKey( "alias" ); + expect( certs ).toHaveKey( "subject" ); + expect( certs ).toHaveKey( "issuer" ); + + var found = false; + for ( var row in certs ) { + if ( findNoCase( "localhost", row.subject ) ) { + found = true; + break; + } + } + expect( found ).toBeTrue(); + }); + + it( "should remove cert via cfadmin removesslcertificate action and connection fails again", function() { + var certs = SSLCertificateList(); + var alias = ""; + for ( var row in certs ) { + if ( findNoCase( "localhost", row.subject ) ) { + alias = row.alias; + break; + } + } + expect( alias ).notToBeEmpty(); + + admin + action = "removesslcertificate" + type = "server" + password = "#request.SERVERADMINPASSWORD#" + alias = "#alias#"; + + var result = {}; + cfhttp( url="https://localhost:#variables.ssl.getPort()#/", result="result", timeout=10, pooling=false ); + expect( result.error ).toBeTrue(); + expect( result.statusCode ).toInclude( "Connection Failure" ); + }); + + }); + } + +} diff --git a/test/tickets/LDEV6005.cfc b/test/tickets/LDEV6005.cfc new file mode 100644 index 00000000000..45a302cdaf6 --- /dev/null +++ b/test/tickets/LDEV6005.cfc @@ -0,0 +1,42 @@ +component extends="org.lucee.cfml.test.LuceeTestCase" labels="cfhttp,ssl" { + + function run() { + describe( "LDEV-6005 cfhttp trustStore and sslVerify attributes", function() { + + it( "should connect to HTTPS with default sslVerify=true", function() { + var result = {}; + cfhttp( url="https://www.google.com", result="result", timeout=30 ); + expect( result.statusCode ).toInclude( "200" ); + }); + + it( "should connect with sslVerify=false (disables cert verification)", function() { + var result = {}; + cfhttp( url="https://www.google.com", result="result", timeout=30, sslVerify=false ); + expect( result.statusCode ).toInclude( "200" ); + }); + + it( "should fail with invalid trustStore path", function() { + expect( function() { + var result = {}; + cfhttp( url="https://www.google.com", result="result", timeout=30, trustStore="/nonexistent/path/to/truststore.jks" ); + }).toThrow(); + }); + + it( "should fail with invalid trustStore password", function() { + // Create a temp keystore file + var tempFile = getTempFile( getTempDirectory(), "truststore", ".jks" ); + // Write empty file - will fail to load as keystore + fileWrite( tempFile, "" ); + + expect( function() { + var result = {}; + cfhttp( url="https://www.google.com", result="result", timeout=30, trustStore=tempFile, trustStorePassword="wrongpassword" ); + }).toThrow(); + + if ( fileExists( tempFile ) ) fileDelete( tempFile ); + }); + + }); + } + +} diff --git a/test/tools/SSLTestServer.cfc b/test/tools/SSLTestServer.cfc new file mode 100644 index 00000000000..889558bae23 --- /dev/null +++ b/test/tools/SSLTestServer.cfc @@ -0,0 +1,146 @@ +/** + * Self-contained HTTPS test server for SSL integration tests. + * + * Generates a self-signed cert via BouncyCastle, starts an SSLServerSocket + * on a random port, and exports a JKS truststore for tests to use. + * + * Usage: + * ssl = new test.tools.SSLTestServer(); + * ssl.start(); + * // ... cfhttp url="https://localhost:#ssl.getPort()#/" trustStore=ssl.getTruststorePath() ... + * ssl.stop(); + */ +component javaSettings='{ "maven": ["org.bouncycastle:bcpkix-jdk18on:1.78.1"] }' { + + import java.io.FileOutputStream; + import java.math.BigInteger; + import java.security.KeyPairGenerator; + import java.security.KeyStore; + import java.security.Security; + import java.time.Instant; + import java.util.Date; + import javax.net.ssl.KeyManagerFactory; + import javax.net.ssl.SSLContext; + import org.bouncycastle.asn1.x500.X500Name; + import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; + import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; + import org.bouncycastle.jce.provider.BouncyCastleProvider; + import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + + variables.password = "changeit"; + variables.serverSocket = nullValue(); + variables.port = 0; + variables.truststorePath = ""; + variables.threadName = ""; + + function start() { + // Register BC provider if not already present + if ( isNull( Security::getProvider( "BC" ) ) ) { + Security::addProvider( new java:org.bouncycastle.jce.provider.BouncyCastleProvider() ); + } + + // 1. Generate RSA keypair + var keyGen = KeyPairGenerator::getInstance( "RSA" ); + keyGen.initialize( 2048 ); + var kp = keyGen.generateKeyPair(); + + // 2. Build self-signed cert + var issuer = new java:org.bouncycastle.asn1.x500.X500Name( "CN=localhost" ); + var notBefore = new java:java.util.Date(); + var notAfter = Date::from( Instant::now().plusSeconds( javaCast( "long", 365 * 86400 ) ) ); + var certBuilder = new java:org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder( + issuer, + BigInteger::valueOf( javaCast( "long", getTickCount() ) ), + notBefore, + notAfter, + issuer, + kp.getPublic() + ); + var signer = new java:org.bouncycastle.operator.jcajce.JcaContentSignerBuilder( "SHA256WithRSA" ).build( kp.getPrivate() ); + var cert = new java:org.bouncycastle.cert.jcajce.JcaX509CertificateConverter().getCertificate( certBuilder.build( signer ) ); + + // 3. Server keystore — holds private key + cert chain + var serverKs = KeyStore::getInstance( "JKS" ); + serverKs.load( nullValue(), variables.password.toCharArray() ); + serverKs.setKeyEntry( "test", kp.getPrivate(), variables.password.toCharArray(), [ cert ] ); + + // 4. Trust store — cert only, exported for tests + var trustKs = KeyStore::getInstance( "JKS" ); + trustKs.load( nullValue(), variables.password.toCharArray() ); + trustKs.setCertificateEntry( "test", cert ); + variables.truststorePath = getTempDirectory() & "ssl-test-#createUUID()#.jks"; + var fos = new java:java.io.FileOutputStream( variables.truststorePath ); + try { + trustKs.store( fos, variables.password.toCharArray() ); + } finally { + fos.close(); + } + + // 5. SSLContext from server keystore + var kmf = KeyManagerFactory::getInstance( KeyManagerFactory::getDefaultAlgorithm() ); + kmf.init( serverKs, variables.password.toCharArray() ); + var sslCtx = SSLContext::getInstance( "TLS" ); + sslCtx.init( kmf.getKeyManagers(), nullValue(), nullValue() ); + + // 6. SSLServerSocket on a random port + variables.serverSocket = sslCtx.getServerSocketFactory().createServerSocket( 0 ); + variables.port = variables.serverSocket.getLocalPort(); + + // 7. Accept loop in background thread + variables.threadName = "ssl-test-server-#createUUID()#"; + thread action="run" name=variables.threadName serverSocket=variables.serverSocket { + try { + while ( true ) { + var conn = attributes.serverSocket.accept(); + try { + var os = conn.getOutputStream(); + var response = "HTTP/1.1 200 OK#chr(13)##chr(10)#Content-Length: 2#chr(13)##chr(10)#Connection: close#chr(13)##chr(10)##chr(13)##chr(10)#OK"; + os.write( response.getBytes( "UTF-8" ) ); + os.flush(); + } catch ( any e ) { + // SSL handshake failures are expected when testing without a trusted cert + var isExpectedError = findNoCase( "SSLHandshakeException", e.type ) + || findNoCase( "SSLException", e.type ) + || findNoCase( "SocketException", e.type ); + if ( !isExpectedError ) systemOutput( "SSLTestServer connection error: #e.stacktrace#", true ); + } finally { + conn.close(); + } + } + } catch ( any e ) { + // Socket closed is expected when stop() is called + if ( !findNoCase( "SocketException", e.type ) ) systemOutput( "SSLTestServer accept loop exit: #e.stacktrace#", true ); + } + } + + return this; + } + + function getPort() { + return variables.port; + } + + function getTruststorePath() { + return variables.truststorePath; + } + + function getTruststorePassword() { + return variables.password; + } + + function isRunning() { + return !isNull( variables.serverSocket ) + && variables.serverSocket.isBound() + && !variables.serverSocket.isClosed(); + } + + function stop() { + if ( !isNull( variables.serverSocket ) ) { + variables.serverSocket.close(); + } + if ( len( variables.truststorePath ) && fileExists( variables.truststorePath ) ) { + fileDelete( variables.truststorePath ); + } + } + +}