diff --git a/http/README.md b/http/README.md index 55aabffcea..1782c4a076 100644 --- a/http/README.md +++ b/http/README.md @@ -398,6 +398,7 @@ properties can be used (some legacy property names still exist but are not docum | `org.apache.felix.http.timeout` | Connection timeout in milliseconds. The default is `60000` (60 seconds). | | `org.apache.felix.http.session.timeout` | Allows for the specification of the Session life time as a number of minutes. This property serves the same purpose as the `session-timeout` element in a Web Application descriptor. The default is "0" (zero) for no timeout at all. | | `org.apache.felix.http.enable` | Flag to enable the use of HTTP. The default is `true`. | +| `org.apache.felix.http.require.config` | If `true`, the server does not start until an OSGi configuration (via Configuration Admin) has been received, rather than starting immediately with the OSGi environment properties. The default is `false`. | | `org.apache.felix.https.enable` | Flag to enable the user of HTTPS. The default is `false`. | | `org.apache.felix.https.keystore` | The name of the file containing the keystore. | | `org.apache.felix.https.keystore.password` | The password for the keystore. | @@ -410,6 +411,9 @@ properties can be used (some legacy property names still exist but are not docum | `org.apache.felix.https.jetty.protocols.excluded` | Configures comma-separated list of SSL protocols (e.g. SSLv3, TLSv1.0, TLSv1.1, TLSv1.2) to *exclude*. Default is `null`, meaning that no protocol is excluded. | | `org.apache.felix.https.jetty.protocols.included` | Configures comma-separated list of SSL protocols to *include*. Default is `null`, meaning that the default protocols are used. | | `org.apache.felix.https.clientcertificate` | Flag to determine if the HTTPS protocol requires, wants or does not use client certificates. Legal values are `needs`, `wants` and `none`. The default is `none`. | +| `org.apache.felix.https.sslContext.sniRequired` | Whether SNI is required at the TLS level. When `true`, clients that don't send a valid SNI receive a TLS failure. Default is `false`. See [Jetty SNI docs](https://jetty.org/docs/jetty/12/operations-guide/protocols/index.html#ssl-sni). Added in Jetty12 2.0.2 / 1.2.2. | +| `org.apache.felix.https.ssl.sniRequired` | Whether SNI is required at the HTTP level. When `true`, clients without a valid SNI receive a `400 Bad Request`. Default is `false`. See [Jetty SNI docs](https://jetty.org/docs/jetty/12/operations-guide/protocols/index.html#ssl-sni). Added in Jetty12 2.0.2 / 1.2.2. | +| `org.apache.felix.https.ssl.sniHostCheck` | Whether the SNI hostname must match the `Host` header. Default is `true`. See [Jetty SNI docs](https://jetty.org/docs/jetty/12/operations-guide/protocols/index.html#ssl-sni). Added in Jetty12 2.0.2 / 1.2.2. | | `org.apache.felix.http.jetty.headerBufferSize` | Size of the buffer for request and response headers, in bytes. Default is 16 KB. | | `org.apache.felix.http.jetty.requestBufferSize` | Size of the buffer for requests not fitting the header buffer, in bytes. Default is 8 KB. | | `org.apache.felix.http.jetty.responseBufferSize` | Size of the buffer for responses, in bytes. Default is 24 KB. | diff --git a/http/jetty12/src/main/java/org/apache/felix/http/jetty/internal/ConfigMetaTypeProvider.java b/http/jetty12/src/main/java/org/apache/felix/http/jetty/internal/ConfigMetaTypeProvider.java index dd629973ba..669f8f2593 100644 --- a/http/jetty12/src/main/java/org/apache/felix/http/jetty/internal/ConfigMetaTypeProvider.java +++ b/http/jetty12/src/main/java/org/apache/felix/http/jetty/internal/ConfigMetaTypeProvider.java @@ -225,6 +225,24 @@ public ObjectClassDefinition getObjectClassDefinition( String id, String locale -1, bundle.getBundleContext().getProperty(JettyConfig.FELIX_JETTY_ACCEPT_QUEUE_SIZE))); + adList.add(new AttributeDefinitionImpl(JettyConfig.FELIX_HTTPS_SNI_CONTEXT_REQUIRED, + "SNI required at TLS level", + "Whether SNI is required at the TLS level. Clients without a valid SNI receive a TLS failure. Defaults to false.", + false, + bundle.getBundleContext().getProperty(JettyConfig.FELIX_HTTPS_SNI_CONTEXT_REQUIRED))); + + adList.add(new AttributeDefinitionImpl(JettyConfig.FELIX_HTTPS_SNI_REQUIRED, + "SNI required at HTTP level", + "Whether SNI is required at the HTTP level. Clients without a valid SNI receive a 400 Bad Request. Defaults to false.", + false, + bundle.getBundleContext().getProperty(JettyConfig.FELIX_HTTPS_SNI_REQUIRED))); + + adList.add(new AttributeDefinitionImpl(JettyConfig.FELIX_HTTPS_SNI_HOST_CHECK, + "SNI host check", + "Whether the SNI hostname must match the Host header. Defaults to true.", + true, + bundle.getBundleContext().getProperty(JettyConfig.FELIX_HTTPS_SNI_HOST_CHECK))); + adList.add(new AttributeDefinitionImpl(JettyConfig.FELIX_JETTY_ERROR_PAGE_CUSTOM_HEADERS, "Custom headers to add to error pages", "Felix specific property to configure the custom headers to add to all error pages served by Jetty. Separate key-value pairs with ##.", diff --git a/http/jetty12/src/main/java/org/apache/felix/http/jetty/internal/JettyConfig.java b/http/jetty12/src/main/java/org/apache/felix/http/jetty/internal/JettyConfig.java index d344447acd..8795c605c3 100644 --- a/http/jetty12/src/main/java/org/apache/felix/http/jetty/internal/JettyConfig.java +++ b/http/jetty12/src/main/java/org/apache/felix/http/jetty/internal/JettyConfig.java @@ -153,6 +153,15 @@ public final class JettyConfig /** Felix specific properties to be able to disable renegotiation protocol for TLSv1 */ public static final String FELIX_JETTY_RENEGOTIATION_ALLOWED = "org.apache.felix.https.jetty.renegotiateAllowed"; + /** Felix specific property to require SNI at the TLS level. Defaults to false. See https://jetty.org/docs/jetty/12/operations-guide/protocols/index.html#ssl-sni */ + public static final String FELIX_HTTPS_SNI_CONTEXT_REQUIRED = "org.apache.felix.https.sslContext.sniRequired"; + + /** Felix specific property to require SNI at the HTTP level (returns 400 on mismatch). Defaults to false. See https://jetty.org/docs/jetty/12/operations-guide/protocols/index.html#ssl-sni */ + public static final String FELIX_HTTPS_SNI_REQUIRED = "org.apache.felix.https.ssl.sniRequired"; + + /** Felix specific property to check that the SNI hostname matches the Host header. Defaults to true. See https://jetty.org/docs/jetty/12/operations-guide/protocols/index.html#ssl-sni */ + public static final String FELIX_HTTPS_SNI_HOST_CHECK = "org.apache.felix.https.ssl.sniHostCheck"; + /** Felix specific property to control whether to enable Proxy/Load Balancer Connection */ public static final String FELIX_PROXY_LOAD_BALANCER_CONNECTION_ENABLE = "org.apache.felix.proxy.load.balancer.connection.enable"; @@ -618,6 +627,18 @@ public boolean isRenegotiationAllowed() { return getBooleanProperty(FELIX_JETTY_RENEGOTIATION_ALLOWED, false); } + public boolean isSniContextRequired() { + return getBooleanProperty(FELIX_HTTPS_SNI_CONTEXT_REQUIRED, false); + } + + public boolean isSniRequired() { + return getBooleanProperty(FELIX_HTTPS_SNI_REQUIRED, false); + } + + public boolean isSniHostCheck() { + return getBooleanProperty(FELIX_HTTPS_SNI_HOST_CHECK, true); + } + public String getHttpServiceName() { return (String) getProperty(FELIX_HTTP_SERVICE_NAME); diff --git a/http/jetty12/src/main/java/org/apache/felix/http/jetty/internal/JettyService.java b/http/jetty12/src/main/java/org/apache/felix/http/jetty/internal/JettyService.java index b14504bab5..a94634133c 100644 --- a/http/jetty12/src/main/java/org/apache/felix/http/jetty/internal/JettyService.java +++ b/http/jetty12/src/main/java/org/apache/felix/http/jetty/internal/JettyService.java @@ -578,7 +578,10 @@ private boolean initializeHttps() ); HttpConfiguration httpConfiguration = connFactory.getHttpConfiguration(); - httpConfiguration.addCustomizer(new SecureRequestCustomizer()); + SecureRequestCustomizer secureRequestCustomizer = new SecureRequestCustomizer(); + secureRequestCustomizer.setSniRequired(this.config.isSniRequired()); + secureRequestCustomizer.setSniHostCheck(this.config.isSniHostCheck()); + httpConfiguration.addCustomizer(secureRequestCustomizer); if (this.config.isProxyLoadBalancerConnection()) { @@ -751,6 +754,7 @@ else if ("needs".equalsIgnoreCase(this.config.getClientcert())) } connector.setRenegotiationAllowed(this.config.isRenegotiationAllowed()); + connector.setSniRequired(this.config.isSniContextRequired()); } private void configureConnector(final ServerConnector connector, int port) diff --git a/http/jetty12/src/test/java/org/apache/felix/http/jetty/it/JettySniContextRequiredIT.java b/http/jetty12/src/test/java/org/apache/felix/http/jetty/it/JettySniContextRequiredIT.java new file mode 100644 index 0000000000..83172938ce --- /dev/null +++ b/http/jetty12/src/test/java/org/apache/felix/http/jetty/it/JettySniContextRequiredIT.java @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.felix.http.jetty.it; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; +import static org.ops4j.pax.exam.CoreOptions.mavenBundle; +import static org.ops4j.pax.exam.cm.ConfigurationAdminOptions.newConfiguration; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.Hashtable; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import javax.inject.Inject; +import jakarta.servlet.Servlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP; +import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.awaitility.Awaitility; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.ops4j.pax.exam.Option; +import org.ops4j.pax.exam.junit.PaxExam; +import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; +import org.ops4j.pax.exam.spi.reactors.PerClass; +import org.ops4j.pax.exam.util.PathUtils; +import org.osgi.framework.BundleContext; +import org.osgi.service.http.HttpService; +import org.osgi.service.servlet.whiteboard.HttpWhiteboardConstants; + +/** + * Integration test for org.apache.felix.https.sslContext.sniRequired (FELIX-6846). + * + * This property requires SNI at the TLS level. Unlike the HTTP-level sniRequired + * (which returns a 400 Bad Request), a missing SNI here causes the TLS handshake + * itself to fail. + * + * - A client that sends SNI matching the certificate completes the handshake (200 OK). + * - A client that sends no SNI fails the TLS handshake (an exception is thrown). + */ +@RunWith(PaxExam.class) +@ExamReactorStrategy(PerClass.class) +public class JettySniContextRequiredIT extends AbstractJettyTestSupport { + + @Inject + protected BundleContext bundleContext; + + @Override + protected Option[] additionalOptions() throws IOException { + String jettyVersion = System.getProperty("jetty.version", JETTY_VERSION); + return new Option[] { + spifly(), + + // bundles for the server side + mavenBundle().groupId("org.eclipse.jetty.ee11").artifactId("jetty-ee11-webapp").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.ee11").artifactId("jetty-ee11-servlet").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-xml").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.compression").artifactId("jetty-compression-common").version(jettyVersion), + + // additional bundles for the client side + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-alpn-client").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-client").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.compression").artifactId("jetty-compression-gzip").version(jettyVersion) + }; + } + + @Override + protected Option felixHttpConfig(int httpPort) { + String keystorePath = PathUtils.getBaseDir() + "/src/test/resources/test-keystore.p12"; + return newConfiguration("org.apache.felix.http") + .put("org.osgi.service.http.port", httpPort) + .put("org.osgi.service.http.port.secure", findFreePort()) + .put("org.apache.felix.https.enable", "true") + .put("org.apache.felix.https.keystore", keystorePath) + .put("org.apache.felix.https.keystore.password", "testpassword") + .put("org.apache.felix.https.keystore.key.password", "testpassword") + .put("org.apache.felix.https.sslContext.sniRequired", "true") + .asOption(); + } + + @Before + public void setup() { + assertNotNull(bundleContext); + bundleContext.registerService(Servlet.class, new OkServlet(), new Hashtable<>(Map.of( + HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN, "/*" + ))); + } + + @Test + public void testHandshakeWithSniSucceeds() throws Exception { + // Force the client to send SNI for "localhost" so the TLS handshake can complete. + try (HttpClient httpClient = newTrustAllHttpsClient(true)) { + httpClient.start(); + ContentResponse response = httpClient.GET(new URI(String.format("https://localhost:%d/test", getHttpsPort()))); + assertEquals(200, response.getStatus()); + } + } + + @Test + public void testHandshakeWithoutSniFails() throws Exception { + // No SNI is sent, so the TLS handshake fails (rather than returning an HTTP 400). + try (HttpClient httpClient = newTrustAllHttpsClient(false)) { + httpClient.start(); + httpClient.GET(new URI(String.format("https://localhost:%d/test", getHttpsPort()))); + fail("Expected the TLS handshake to fail when no SNI is sent and sslContext.sniRequired=true"); + } catch (ExecutionException expected) { + // Expected: the handshake is aborted by the server. + } + } + + private HttpClient newTrustAllHttpsClient(boolean sendNonDomainSni) { + SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); + sslContextFactory.setTrustAll(true); + if (sendNonDomainSni) { + sslContextFactory.setSNIProvider(SslContextFactory.Client.SniProvider.NON_DOMAIN_SNI_PROVIDER); + } + ClientConnector clientConnector = new ClientConnector(); + clientConnector.setSslContextFactory(sslContextFactory); + return new HttpClient(new HttpClientTransportOverHTTP(clientConnector)); + } + + private int getHttpsPort() { + // HTTPS is enabled via ConfigAdmin after initial startup, which restarts Jetty + // and briefly unregisters the HttpService. Wait for it to come back. + Awaitility.await("httpServiceRegistered") + .atMost(Duration.ofSeconds(30)) + .until(() -> bundleContext.getServiceReference(HttpService.class) != null); + Object value = bundleContext.getServiceReference(HttpService.class).getProperty("org.osgi.service.http.port.secure"); + return Integer.parseInt((String) value); + } + + static final class OkServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setStatus(200); + resp.getWriter().write("OK"); + } + } +} diff --git a/http/jetty12/src/test/java/org/apache/felix/http/jetty/it/JettySniHostCheckIT.java b/http/jetty12/src/test/java/org/apache/felix/http/jetty/it/JettySniHostCheckIT.java new file mode 100644 index 0000000000..071feb67e7 --- /dev/null +++ b/http/jetty12/src/test/java/org/apache/felix/http/jetty/it/JettySniHostCheckIT.java @@ -0,0 +1,159 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.felix.http.jetty.it; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.ops4j.pax.exam.CoreOptions.mavenBundle; +import static org.ops4j.pax.exam.cm.ConfigurationAdminOptions.newConfiguration; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.Hashtable; +import java.util.Map; + +import javax.inject.Inject; +import jakarta.servlet.Servlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.awaitility.Awaitility; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.ops4j.pax.exam.Option; +import org.ops4j.pax.exam.junit.PaxExam; +import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; +import org.ops4j.pax.exam.spi.reactors.PerClass; +import org.ops4j.pax.exam.util.PathUtils; +import org.osgi.framework.BundleContext; +import org.osgi.service.http.HttpService; +import org.osgi.service.servlet.whiteboard.HttpWhiteboardConstants; + +/** + * Integration test for org.apache.felix.https.ssl.sniHostCheck (FELIX-6846). + * + * With sniHostCheck=true (the default), Jetty checks that the certificate + * presented to the client matches the Host header. A Host header that does not + * match the certificate CN/SAN results in a 400 Bad Request. + * + * With sniHostCheck=false that check is skipped and every request is accepted. + */ +@RunWith(PaxExam.class) +@ExamReactorStrategy(PerClass.class) +public class JettySniHostCheckIT extends AbstractJettyTestSupport { + + @Inject + protected BundleContext bundleContext; + + @Override + protected Option[] additionalOptions() throws IOException { + String jettyVersion = System.getProperty("jetty.version", JETTY_VERSION); + return new Option[] { + spifly(), + + // bundles for the server side + mavenBundle().groupId("org.eclipse.jetty.ee11").artifactId("jetty-ee11-webapp").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.ee11").artifactId("jetty-ee11-servlet").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-xml").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.compression").artifactId("jetty-compression-common").version(jettyVersion), + + // additional bundles for the client side + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-alpn-client").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-client").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.compression").artifactId("jetty-compression-gzip").version(jettyVersion) + }; + } + + @Override + protected Option felixHttpConfig(int httpPort) { + String keystorePath = PathUtils.getBaseDir() + "/src/test/resources/test-keystore.p12"; + return newConfiguration("org.apache.felix.http") + .put("org.osgi.service.http.port", httpPort) + .put("org.osgi.service.http.port.secure", findFreePort()) + .put("org.apache.felix.https.enable", "true") + .put("org.apache.felix.https.keystore", keystorePath) + .put("org.apache.felix.https.keystore.password", "testpassword") + .put("org.apache.felix.https.keystore.key.password", "testpassword") + // sniHostCheck defaults to true — no explicit property needed + .asOption(); + } + + @Before + public void setup() { + assertNotNull(bundleContext); + bundleContext.registerService(Servlet.class, new OkServlet(), new Hashtable<>(Map.of( + HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN, "/*" + ))); + } + + @Test + public void testMatchingHostHeaderIsAccepted() throws Exception { + try (HttpClient httpClient = newTrustAllHttpsClient()) { + httpClient.start(); + // Host header matches the certificate CN/SAN — should be accepted + ContentResponse response = httpClient.GET(new URI(String.format("https://localhost:%d/test", getHttpsPort()))); + assertEquals(200, response.getStatus()); + } + } + + @Test + public void testMismatchedHostHeaderIsRejected() throws Exception { + try (HttpClient httpClient = newTrustAllHttpsClient()) { + httpClient.start(); + // Override Host header so it does not match the certificate (CN=localhost) + ContentResponse response = httpClient.newRequest(new URI(String.format("https://localhost:%d/test", getHttpsPort()))) + .headers(h -> h.put(HttpHeader.HOST, "wrong.example.org")) + .send(); + assertEquals(400, response.getStatus()); + } + } + + private HttpClient newTrustAllHttpsClient() { + SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); + sslContextFactory.setTrustAll(true); + ClientConnector clientConnector = new ClientConnector(); + clientConnector.setSslContextFactory(sslContextFactory); + return new HttpClient(new HttpClientTransportOverHTTP(clientConnector)); + } + + private int getHttpsPort() { + // HTTPS is enabled via ConfigAdmin after initial startup, which restarts Jetty + // and briefly unregisters the HttpService. Wait for it to come back. + Awaitility.await("httpServiceRegistered") + .atMost(Duration.ofSeconds(30)) + .until(() -> bundleContext.getServiceReference(HttpService.class) != null); + Object value = bundleContext.getServiceReference(HttpService.class).getProperty("org.osgi.service.http.port.secure"); + return Integer.parseInt((String) value); + } + + static final class OkServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setStatus(200); + resp.getWriter().write("OK"); + } + } +} diff --git a/http/jetty12/src/test/java/org/apache/felix/http/jetty/it/JettySniIT.java b/http/jetty12/src/test/java/org/apache/felix/http/jetty/it/JettySniIT.java new file mode 100644 index 0000000000..7be6f6546a --- /dev/null +++ b/http/jetty12/src/test/java/org/apache/felix/http/jetty/it/JettySniIT.java @@ -0,0 +1,160 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.felix.http.jetty.it; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.ops4j.pax.exam.CoreOptions.mavenBundle; +import static org.ops4j.pax.exam.cm.ConfigurationAdminOptions.newConfiguration; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.Hashtable; +import java.util.Map; + +import javax.inject.Inject; +import jakarta.servlet.Servlet; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP; +import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.awaitility.Awaitility; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.ops4j.pax.exam.Option; +import org.ops4j.pax.exam.junit.PaxExam; +import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; +import org.ops4j.pax.exam.spi.reactors.PerClass; +import org.ops4j.pax.exam.util.PathUtils; +import org.osgi.framework.BundleContext; +import org.osgi.service.http.HttpService; +import org.osgi.service.servlet.whiteboard.HttpWhiteboardConstants; + +/** + * Integration test for org.apache.felix.https.ssl.sniRequired (FELIX-6846). + * + * With sniRequired=true (sniHostCheck disabled so it does not interfere): + * - A client that sends SNI matching the certificate is accepted (200 OK). + * - A client that sends no SNI is rejected (400 Bad Request). + */ +@RunWith(PaxExam.class) +@ExamReactorStrategy(PerClass.class) +public class JettySniIT extends AbstractJettyTestSupport { + + @Inject + protected BundleContext bundleContext; + + @Override + protected Option[] additionalOptions() throws IOException { + String jettyVersion = System.getProperty("jetty.version", JETTY_VERSION); + return new Option[] { + spifly(), + + // bundles for the server side + mavenBundle().groupId("org.eclipse.jetty.ee11").artifactId("jetty-ee11-webapp").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.ee11").artifactId("jetty-ee11-servlet").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-xml").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.compression").artifactId("jetty-compression-common").version(jettyVersion), + + // additional bundles for the client side + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-alpn-client").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty").artifactId("jetty-client").version(jettyVersion), + mavenBundle().groupId("org.eclipse.jetty.compression").artifactId("jetty-compression-gzip").version(jettyVersion) + }; + } + + @Override + protected Option felixHttpConfig(int httpPort) { + String keystorePath = PathUtils.getBaseDir() + "/src/test/resources/test-keystore.p12"; + return newConfiguration("org.apache.felix.http") + .put("org.osgi.service.http.port", httpPort) + .put("org.osgi.service.http.port.secure", findFreePort()) + .put("org.apache.felix.https.enable", "true") + .put("org.apache.felix.https.keystore", keystorePath) + .put("org.apache.felix.https.keystore.password", "testpassword") + .put("org.apache.felix.https.keystore.key.password", "testpassword") + .put("org.apache.felix.https.ssl.sniRequired", "true") + .put("org.apache.felix.https.ssl.sniHostCheck", "false") + .asOption(); + } + + @Before + public void setup() { + assertNotNull(bundleContext); + bundleContext.registerService(Servlet.class, new OkServlet(), new Hashtable<>(Map.of( + HttpWhiteboardConstants.HTTP_WHITEBOARD_SERVLET_PATTERN, "/*" + ))); + } + + @Test + public void testRequestWithSniIsAccepted() throws Exception { + // Force the client to send SNI for "localhost". By default the JDK does not send + // SNI for non-domain names, so an explicit non-domain SNI provider is required. + try (HttpClient httpClient = newTrustAllHttpsClient(true)) { + httpClient.start(); + ContentResponse response = httpClient.GET(new URI(String.format("https://localhost:%d/test", getHttpsPort()))); + assertEquals(200, response.getStatus()); + } + } + + @Test + public void testRequestWithoutSniIsRejected() throws Exception { + // Default JDK behaviour: no SNI is sent for the non-domain name "localhost", + // so with sniRequired=true the request is rejected with 400 Bad Request. + try (HttpClient httpClient = newTrustAllHttpsClient(false)) { + httpClient.start(); + ContentResponse response = httpClient.GET(new URI(String.format("https://localhost:%d/test", getHttpsPort()))); + assertEquals(400, response.getStatus()); + } + } + + private HttpClient newTrustAllHttpsClient(boolean sendNonDomainSni) { + SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(); + sslContextFactory.setTrustAll(true); + if (sendNonDomainSni) { + sslContextFactory.setSNIProvider(SslContextFactory.Client.SniProvider.NON_DOMAIN_SNI_PROVIDER); + } + ClientConnector clientConnector = new ClientConnector(); + clientConnector.setSslContextFactory(sslContextFactory); + return new HttpClient(new HttpClientTransportOverHTTP(clientConnector)); + } + + private int getHttpsPort() { + // HTTPS is enabled via ConfigAdmin after initial startup, which restarts Jetty + // and briefly unregisters the HttpService. Wait for it to come back. + Awaitility.await("httpServiceRegistered") + .atMost(Duration.ofSeconds(30)) + .until(() -> bundleContext.getServiceReference(HttpService.class) != null); + Object value = bundleContext.getServiceReference(HttpService.class).getProperty("org.osgi.service.http.port.secure"); + return Integer.parseInt((String) value); + } + + static final class OkServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setStatus(200); + resp.getWriter().write("OK"); + } + } +} diff --git a/http/jetty12/src/test/resources/test-keystore.p12 b/http/jetty12/src/test/resources/test-keystore.p12 new file mode 100644 index 0000000000..41e17dcd44 Binary files /dev/null and b/http/jetty12/src/test/resources/test-keystore.p12 differ