Repository: jooby-project/jooby
Pull request #2078 (2020-10-26 03:28:45)

SSL: configure protocol
- Set default protocol to `TLSv1.2, TLSv1.3`
- If a listed protocol is not supported, it is ignored; however, if you specify a list of protocols, none of which are supported, an exception will be thrown.
- Turn off TLSv1 and TLSv1.1 in Jetty
- Fix date tests failure


Patch:
diff --git a/docs/asciidoc/servers.adoc b/docs/asciidoc/servers.adoc
index 7d5774d6f..ba5b6aeb2 100644
--- a/docs/asciidoc/servers.adoc
+++ b/docs/asciidoc/servers.adoc
@@ -4,9 +4,9 @@ Jooby comes with three server implementations:
 
 - javadoc:Jetty[]
 - javadoc:Netty[]
-- javadoc:Undertow[]
+- javadoc:Utow[]
 
-Server are automatically registered based on their presence on the project classpath.
+Servers are automatically registered based on their presence on the project classpath.
 
 To use Jetty, add the dependency:
 
@@ -291,3 +291,41 @@ server {
   }
 }
 ----
+
+==== TLS protocol
+
+Default protocol is `TLSv1.3, TLSv1.2`. To override, just do:
+
+.TLS example
+[source,java,role="primary"]
+----
+{
+  setServerOptions(new ServerOptions()
+      .setSsl(new SslOptions().setProtocol("TLSv1.3", "TLSv1.2"))
+  ); 
+}
+----
+
+.Kotlin
+[source,kotlin,role="secondary"]
+----
+{
+  serverOptions {
+    ssl = SslOptions().apply {
+      protocol = listOf("TLSv1.3", "TLSv1.2")
+    }
+  }
+}
+----
+
+If a listed protocol is not supported, it is ignored; however, if you specify a list of protocols,
+none of which are supported, an exception will be thrown.
+
+[NOTE]
+====
+*TLSv1.3 protocol is available in*
+
+- 8u261-b12 from Oracle JDK
+- TLS 1.3 support in OpenJDK is (beside Azul's OpenJSSE) expected to come into 8u272.
+- Java 11.0.3 or higher.
+====
diff --git a/jooby/src/main/java/io/jooby/ServerOptions.java b/jooby/src/main/java/io/jooby/ServerOptions.java
index 08c6e43c6..74158f9c0 100644
--- a/jooby/src/main/java/io/jooby/ServerOptions.java
+++ b/jooby/src/main/java/io/jooby/ServerOptions.java
@@ -5,17 +5,22 @@
  */
 package io.jooby;
 
-import com.typesafe.config.Config;
-import io.jooby.internal.SslContextProvider;
-
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-import javax.net.ssl.SSLContext;
 import java.io.IOException;
 import java.net.ServerSocket;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
 import java.util.Optional;
+import java.util.Set;
 import java.util.stream.Stream;
 
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.net.ssl.SSLContext;
+
+import com.typesafe.config.Config;
+import io.jooby.internal.SslContextProvider;
+
 /**
  * Available server options.
  *
@@ -480,7 +485,17 @@ public class ServerOptions {
           .findFirst()
           .orElseThrow(
               () -> new UnsupportedOperationException("SSL Type: " + options.getType()));
-      return provider.create(loader, options);
+      SSLContext sslContext = provider.create(loader, options);
+      // validate TLS protocol, at least one protocol must be supported
+      Set<String> supportedProtocols = new LinkedHashSet<>(Arrays
+          .asList(sslContext.getDefaultSSLParameters().getProtocols()));
+      Set<String> protocols = new LinkedHashSet<>(options.getProtocol());
+      protocols.retainAll(supportedProtocols);
+      if (protocols.isEmpty()) {
+        throw new IllegalArgumentException("Unsupported protocol: " + options.getProtocol());
+      }
+      ssl.setProtocol(new ArrayList<>(protocols));
+      return sslContext;
     }
     return null;
   }
diff --git a/jooby/src/main/java/io/jooby/SslOptions.java b/jooby/src/main/java/io/jooby/SslOptions.java
index 69cdecd86..50564c2e4 100644
--- a/jooby/src/main/java/io/jooby/SslOptions.java
+++ b/jooby/src/main/java/io/jooby/SslOptions.java
@@ -5,19 +5,22 @@
  */
 package io.jooby;
 
-import com.typesafe.config.Config;
+import static io.jooby.SneakyThrows.throwingFunction;
 
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.List;
 import java.util.Optional;
 import java.util.stream.Stream;
 
-import static io.jooby.SneakyThrows.throwingFunction;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.typesafe.config.Config;
 
 /**
  * SSL options for enabling HTTPs in Jooby. Jooby supports two certificate formats:
@@ -52,6 +55,17 @@ public final class SslOptions {
     REQUIRED
   }
 
+  /** TLSv1.2. Default TLS protocol. */
+  public static final String TLS_V1_2 = "TLSv1.2";
+
+  /**
+   * TLSv1.3 protocol. Available in:
+   * - 8u261-b12 from Oracle JDK
+   * - TLS 1.3 support in OpenJDK is (beside Azul's OpenJSSE) expected to come into 8u272.
+   * - Java 11.0.3 or higher.
+   */
+  public static final String TLS_V1_3 = "TLSv1.3";
+
   /** X509 constant. */
   public static final String X509 = "X509";
 
@@ -72,6 +86,8 @@ public final class SslOptions {
 
   private ClientAuth clientAuth = ClientAuth.NONE;
 
+  private List<String> protocol = Arrays.asList(TLS_V1_3, TLS_V1_2);
+
   /**
    * Certificate type. Default is {@link #PKCS12}.
    *
@@ -253,6 +269,49 @@ public final class SslOptions {
     return this;
   }
 
+  /**
+   * Specify the enabled protocols for an SSL/TLS session. Default is: <code>TLSv1.2</code> and
+   * <code>TLSv1.3</code>.
+   *
+   * If a listed protocol is not supported, it is ignored; however, if you specify a list of
+   * protocols, none of which are supported, an exception will be thrown.
+   *
+   * Please note TLSv1.3 protocol is available in:
+   *  - 8u261-b12 from Oracle JDK
+   *  - TLS 1.3 support in OpenJDK is (beside Azul's OpenJSSE) expected to come into 8u272.
+   *  - Java 11.0.3 or higher.
+   *
+   * @return TLS protocols. Default is: <code>TLSv1.2</code> and <code>TLSv1.3</code>.
+   */
+  public @Nonnull List<String> getProtocol() {
+    return protocol;
+  }
+
+  /**
+   * Specify the enabled protocols for an SSL/TLS session. If a listed protocol is not supported,
+   * it is ignored; however, if you specify a list of protocols, none of which are supported,
+   * an exception will be thrown.
+   *
+   * @param protocol TLS protocols.
+   * @return This options.
+   */
+  public SslOptions setProtocol(@Nonnull String... protocol) {
+    return setProtocol(Arrays.asList(protocol));
+  }
+
+  /**
+   * Specify the enabled protocols for an SSL/TLS session. If a listed protocol is not supported,
+   * it is ignored; however, if you specify a list of protocols, none of which are supported,
+   * an exception will be thrown.
+   *
+   * @param protocol TLS protocols.
+   * @return This options.
+   */
+  public SslOptions setProtocol(@Nonnull List<String> protocol) {
+    this.protocol = protocol;
+    return this;
+  }
+
   @Override public String toString() {
     return type;
   }
@@ -428,6 +487,14 @@ public final class SslOptions {
           if (conf.hasPath(path + ".trust.password")) {
             options.setTrustPassword(conf.getString(path + ".trust.password"));
           }
+          if (conf.hasPath(path + ".protocol")) {
+            Object value = conf.getAnyRef(path + ".protocol");
+            if (value instanceof List) {
+              options.setProtocol((List) value);
+            } else {
+              options.setProtocol(value.toString());
+            }
+          }
           return options;
         });
   }
diff --git a/modules/jooby-jetty/src/main/java/io/jooby/jetty/Jetty.java b/modules/jooby-jetty/src/main/java/io/jooby/jetty/Jetty.java
index 561bc9210..16340b0c6 100644
--- a/modules/jooby-jetty/src/main/java/io/jooby/jetty/Jetty.java
+++ b/modules/jooby-jetty/src/main/java/io/jooby/jetty/Jetty.java
@@ -5,14 +5,15 @@
  */
 package io.jooby.jetty;
 
-import com.typesafe.config.Config;
-import io.jooby.Jooby;
-import io.jooby.ServerOptions;
-import io.jooby.SneakyThrows;
-import io.jooby.SslOptions;
-import io.jooby.WebSocket;
-import io.jooby.internal.jetty.JettyHandler;
-import io.jooby.internal.jetty.JettyWebSocket;
+import java.net.BindException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+import javax.annotation.Nonnull;
+
 import org.eclipse.jetty.server.HttpConfiguration;
 import org.eclipse.jetty.server.HttpConnectionFactory;
 import org.eclipse.jetty.server.MultiPartFormDataCompliance;
@@ -29,12 +30,14 @@ import org.eclipse.jetty.websocket.api.WebSocketBehavior;
 import org.eclipse.jetty.websocket.api.WebSocketPolicy;
 import org.eclipse.jetty.websocket.server.WebSocketServerFactory;
 
-import javax.annotation.Nonnull;
-import java.net.BindException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Optional;
-import java.util.concurrent.TimeUnit;
+import com.typesafe.config.Config;
+import io.jooby.Jooby;
+import io.jooby.ServerOptions;
+import io.jooby.SneakyThrows;
+import io.jooby.SslOptions;
+import io.jooby.WebSocket;
+import io.jooby.internal.jetty.JettyHandler;
+import io.jooby.internal.jetty.JettyWebSocket;
 
 /**
  * Web server implementation using <a href="https://www.eclipse.org/jetty/">Jetty</a>.
@@ -105,6 +108,11 @@ public class Jetty extends io.jooby.Server.Base {
         SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
         sslContextFactory
             .setSslContext(options.getSSLContext(application.getEnvironment().getClassLoader()));
+        List<String> protocol = options.getSsl().getProtocol();
+        sslContextFactory.setIncludeProtocols(protocol.toArray(new String[protocol.size()]));
+        // exclude
+        isNotInUse(protocol, "TLSv1", sslContextFactory::addExcludeProtocols);
+        isNotInUse(protocol, "TLSv1.1", sslContextFactory::addExcludeProtocols);
 
         SslOptions.ClientAuth clientAuth = Optional.ofNullable(options.getSsl())
             .map(SslOptions::getClientAuth)
@@ -168,6 +176,12 @@ public class Jetty extends io.jooby.Server.Base {
     return this;
   }
 
+  private void isNotInUse(List<String> protocols, String protocol, Consumer<String> consumer) {
+    if (!protocols.contains(protocol)) {
+      consumer.accept(protocol);
+    }
+  }
+
   @Nonnull @Override public synchronized io.jooby.Server stop() {
     fireStop(applications);
     if (server != null) {
diff --git a/modules/jooby-netty/src/main/java/io/jooby/netty/Netty.java b/modules/jooby-netty/src/main/java/io/jooby/netty/Netty.java
index d16c4f341..9d997a5c3 100644
--- a/modules/jooby-netty/src/main/java/io/jooby/netty/Netty.java
+++ b/modules/jooby-netty/src/main/java/io/jooby/netty/Netty.java
@@ -5,6 +5,16 @@
  */
 package io.jooby.netty;
 
+import java.net.BindException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import javax.annotation.Nonnull;
+import javax.net.ssl.SSLContext;
+
 import io.jooby.Jooby;
 import io.jooby.Server;
 import io.jooby.ServerOptions;
@@ -26,16 +36,6 @@ import io.netty.handler.ssl.JdkSslContext;
 import io.netty.handler.ssl.SslContext;
 import io.netty.util.concurrent.DefaultThreadFactory;
 
-import javax.annotation.Nonnull;
-import javax.net.ssl.SSLContext;
-import java.net.BindException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Optional;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-
 /**
  * Web server implementation using <a href="https://netty.io/">Netty</a>.
  *
@@ -113,11 +113,15 @@ public class Netty extends Server.Base {
       if (options.isSSLEnabled()) {
         SSLContext javaSslContext = options
             .getSSLContext(application.getEnvironment().getClassLoader());
-        SslOptions.ClientAuth clientAuth = Optional.ofNullable(options.getSsl())
-            .map(SslOptions::getClientAuth)
-            .orElse(SslOptions.ClientAuth.NONE);
+
+        SslOptions sslOptions = options.getSsl();
+        String[] protocol = sslOptions.getProtocol().stream()
+            .toArray(String[]::new);
+
+        SslOptions.ClientAuth clientAuth = sslOptions.getClientAuth();
         ServerBootstrap https = transport.configure(acceptorloop, eventloop)
-            .childHandler(newPipeline(factory, wrap(javaSslContext, toClientAuth(clientAuth))))
+            .childHandler(
+                newPipeline(factory, wrap(javaSslContext, toClientAuth(clientAuth), protocol)))
             .childOption(ChannelOption.SO_REUSEADDR, true)
             .childOption(ChannelOption.TCP_NODELAY, true);
 
@@ -178,8 +182,8 @@ public class Netty extends Server.Base {
     return this;
   }
 
-  private SslContext wrap(SSLContext sslContext, ClientAuth clientAuth) {
+  private SslContext wrap(SSLContext sslContext, ClientAuth clientAuth, String[] protocol) {
     return new JdkSslContext(sslContext, false, null, IdentityCipherSuiteFilter.INSTANCE,
-        ApplicationProtocolConfig.DISABLED, clientAuth, null, false);
+        ApplicationProtocolConfig.DISABLED, clientAuth, protocol, false);
   }
 }
diff --git a/modules/jooby-utow/src/main/java/io/jooby/utow/Utow.java b/modules/jooby-utow/src/main/java/io/jooby/utow/Utow.java
index b0cf74836..dd6d9884f 100644
--- a/modules/jooby-utow/src/main/java/io/jooby/utow/Utow.java
+++ b/modules/jooby-utow/src/main/java/io/jooby/utow/Utow.java
@@ -5,6 +5,19 @@
  */
 package io.jooby.utow;
 
+import java.net.BindException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import javax.annotation.Nonnull;
+import javax.net.ssl.SSLContext;
+
+import org.xnio.Options;
+import org.xnio.Sequence;
+import org.xnio.SslClientAuthMode;
+
 import io.jooby.Jooby;
 import io.jooby.Server;
 import io.jooby.ServerOptions;
@@ -18,16 +31,6 @@ import io.undertow.server.handlers.encoding.ContentEncodingRepository;
 import io.undertow.server.handlers.encoding.DeflateEncodingProvider;
 import io.undertow.server.handlers.encoding.EncodingHandler;
 import io.undertow.server.handlers.encoding.GzipEncodingProvider;
-import org.xnio.Options;
-import org.xnio.SslClientAuthMode;
-
-import javax.annotation.Nonnull;
-import javax.net.ssl.SSLContext;
-import java.net.BindException;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
 
 /**
  * Web server implementation using <a href="http://undertow.io/">Undertow</a>.
@@ -99,6 +102,8 @@ public class Utow extends Server.Base {
       SSLContext sslContext = options.getSSLContext(application.getEnvironment().getClassLoader());
       if (sslContext != null) {
         builder.addHttpsListener(options.getSecurePort(), options.getHost(), sslContext);
+        SslOptions ssl = options.getSsl();
+        builder.setSocketOption(Options.SSL_ENABLED_PROTOCOLS, Sequence.of(ssl.getProtocol()));
         Optional.ofNullable(options.getSsl())
             .map(SslOptions::getClientAuth)
             .map(this::toSslClientAuthMode)


Test patch:
diff --git a/jooby/src/test/java/io/jooby/SslOptionsTest.java b/jooby/src/test/java/io/jooby/SslOptionsTest.java
index 00cb8d973..e98304149 100644
--- a/jooby/src/test/java/io/jooby/SslOptionsTest.java
+++ b/jooby/src/test/java/io/jooby/SslOptionsTest.java
@@ -1,13 +1,17 @@
 package io.jooby;
 
-import com.typesafe.config.Config;
-import com.typesafe.config.ConfigFactory;
-import org.junit.jupiter.api.Test;
-
 import static com.typesafe.config.ConfigValueFactory.fromAnyRef;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
+import java.util.Arrays;
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+import com.typesafe.config.Config;
+import com.typesafe.config.ConfigFactory;
+
 public class SslOptionsTest {
 
   @Test
@@ -85,4 +89,30 @@ public class SslOptionsTest {
     assertEquals("ssl/test.key", options.getPrivateKey());
     assertEquals("changeit", options.getPassword());
   }
+
+  @Test
+  public void shouldParseSingleProtocol() {
+    Config config = ConfigFactory.empty()
+        .withValue("ssl.protocol", fromAnyRef("TLSv1.2"))
+        .withValue("ssl.cert", fromAnyRef("ssl/test.crt"))
+        .withValue("ssl.key", fromAnyRef("ssl/test.key"))
+        .withValue("ssl.password", fromAnyRef("changeit"))
+        .resolve();
+
+    SslOptions options = SslOptions.from(config).get();
+    assertEquals(Collections.singletonList("TLSv1.2"), options.getProtocol());
+  }
+
+  @Test
+  public void shouldParseProtocols() {
+    Config config = ConfigFactory.empty()
+        .withValue("ssl.protocol", fromAnyRef(Arrays.asList("TLSv1.2", "TLSv1.3")))
+        .withValue("ssl.cert", fromAnyRef("ssl/test.crt"))
+        .withValue("ssl.key", fromAnyRef("ssl/test.key"))
+        .withValue("ssl.password", fromAnyRef("changeit"))
+        .resolve();
+
+    SslOptions options = SslOptions.from(config).get();
+    assertEquals(Arrays.asList("TLSv1.2", "TLSv1.3"), options.getProtocol());
+  }
 }
diff --git a/modules/jooby-freemarker/src/test/java/io/jooby/freemarker/FreemarkerModuleTest.java b/modules/jooby-freemarker/src/test/java/io/jooby/freemarker/FreemarkerModuleTest.java
index 3fb3ff442..f1a442e71 100644
--- a/modules/jooby-freemarker/src/test/java/io/jooby/freemarker/FreemarkerModuleTest.java
+++ b/modules/jooby-freemarker/src/test/java/io/jooby/freemarker/FreemarkerModuleTest.java
@@ -1,24 +1,25 @@
 package io.jooby.freemarker;
 
-import com.typesafe.config.ConfigFactory;
-import com.typesafe.config.ConfigValueFactory;
-import freemarker.template.Configuration;
-import io.jooby.Environment;
-import io.jooby.Jooby;
-import io.jooby.MockContext;
-import io.jooby.ModelAndView;
-import org.junit.jupiter.api.Test;
+import static java.util.Collections.singletonList;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 
 import java.time.DayOfWeek;
-import java.time.LocalDateTime;
-import java.time.ZoneOffset;
+import java.time.LocalDate;
+import java.time.ZoneId;
 import java.time.temporal.TemporalAdjusters;
 import java.util.Arrays;
 import java.util.Date;
 import java.util.Locale;
 
-import static java.util.Collections.singletonList;
-import static org.junit.jupiter.api.Assertions.assertEquals;
+import org.junit.jupiter.api.Test;
+
+import com.typesafe.config.ConfigFactory;
+import com.typesafe.config.ConfigValueFactory;
+import freemarker.template.Configuration;
+import io.jooby.Environment;
+import io.jooby.Jooby;
+import io.jooby.MockContext;
+import io.jooby.ModelAndView;
 
 public class FreemarkerModuleTest {
 
@@ -58,7 +59,8 @@ public class FreemarkerModuleTest {
         .build(new Environment(getClass().getClassLoader(), ConfigFactory.empty(), "test"));
     FreemarkerTemplateEngine engine = new FreemarkerTemplateEngine(freemarker,
         Arrays.asList(".ftl"));
-    MockContext ctx = new MockContext().setRouter(new Jooby().setLocales(singletonList(Locale.ENGLISH)));
+    MockContext ctx = new MockContext()
+        .setRouter(new Jooby().setLocales(singletonList(Locale.ENGLISH)));
     ctx.getAttributes().put("local", "var");
     String output = engine
         .render(ctx, new ModelAndView("index.ftl")
@@ -73,10 +75,14 @@ public class FreemarkerModuleTest {
         .build(new Environment(getClass().getClassLoader(), ConfigFactory.empty(), "test"));
     FreemarkerTemplateEngine engine = new FreemarkerTemplateEngine(freemarker,
         Arrays.asList(".ftl"));
-    MockContext ctx = new MockContext().setRouter(new Jooby().setLocales(singletonList(Locale.ENGLISH)));
+    MockContext ctx = new MockContext()
+        .setRouter(new Jooby().setLocales(singletonList(Locale.ENGLISH)));
 
-    Date nextFriday = Date.from(LocalDateTime.now()
-        .with(TemporalAdjusters.next(DayOfWeek.FRIDAY)).toInstant(ZoneOffset.UTC));
+    Date nextFriday = java.util.Date
+        .from(LocalDate.now().with(TemporalAdjusters.next(DayOfWeek.FRIDAY)).atStartOfDay()
+            .atZone(ZoneId.systemDefault())
+            .toInstant()
+        );
 
     assertEquals("friday", engine.render(ctx, new ModelAndView("locales.ftl")
         .put("someDate", nextFriday)).trim().toLowerCase());
@@ -96,7 +102,8 @@ public class FreemarkerModuleTest {
         .build(new Environment(getClass().getClassLoader(), ConfigFactory.empty(), "test"));
     FreemarkerTemplateEngine engine = new FreemarkerTemplateEngine(freemarker,
         Arrays.asList(".ftl"));
-    MockContext ctx = new MockContext().setRouter(new Jooby().setLocales(singletonList(Locale.ENGLISH)));
+    MockContext ctx = new MockContext()
+        .setRouter(new Jooby().setLocales(singletonList(Locale.ENGLISH)));
     ctx.getAttributes().put("local", "var");
     String output = engine
         .render(ctx, new ModelAndView("index.ftl")
@@ -113,7 +120,8 @@ public class FreemarkerModuleTest {
                 ConfigValueFactory.fromAnyRef("foo"))));
     FreemarkerTemplateEngine engine = new FreemarkerTemplateEngine(freemarker,
         Arrays.asList(".ftl"));
-    MockContext ctx = new MockContext().setRouter(new Jooby().setLocales(singletonList(Locale.ENGLISH)));
+    MockContext ctx = new MockContext()
+        .setRouter(new Jooby().setLocales(singletonList(Locale.ENGLISH)));
     ctx.getAttributes().put("local", "var");
     String output = engine
         .render(ctx, new ModelAndView("index.ftl"));