From ce7f539f1c60fb659f7d1bd834dcd6f15ece94b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20R=C3=BCtter?= Date: Wed, 24 Jun 2026 23:08:01 +0200 Subject: [PATCH 1/8] FELIX-6846 Add SNI configuration support for Jetty 12 - Add three new config properties: org.apache.felix.https.sslContext.sniRequired (TLS level, default false) org.apache.felix.https.ssl.sniRequired (HTTP level, default false) org.apache.felix.https.ssl.sniHostCheck (HTTP level, default true) - Wire sslContext.sniRequired into SslContextFactory.Server.setSniRequired() - Wire ssl.sniRequired and ssl.sniHostCheck into SecureRequestCustomizer - Register all three in ConfigMetaTypeProvider - Document all three in http/README.md Co-Authored-By: Claude Sonnet 4.6 --- http/README.md | 3 +++ .../internal/ConfigMetaTypeProvider.java | 18 +++++++++++++++ .../http/jetty/internal/JettyConfig.java | 21 ++++++++++++++++++ .../http/jetty/internal/JettyService.java | 6 ++++- .../src/test/resources/test-keystore.p12 | Bin 0 -> 2718 bytes 5 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 http/jetty12/src/test/resources/test-keystore.p12 diff --git a/http/README.md b/http/README.md index 55aabffcea..48829c13c2 100644 --- a/http/README.md +++ b/http/README.md @@ -410,6 +410,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). | +| `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). | +| `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). | | `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/resources/test-keystore.p12 b/http/jetty12/src/test/resources/test-keystore.p12 new file mode 100644 index 0000000000000000000000000000000000000000..a254cf7aa4e44386a8e6137bbd3cf21ff3fb58d8 GIT binary patch literal 2718 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 literal 0 HcmV?d00001 From 97540bfa184285837d6d6c334135d510701703cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20R=C3=BCtter?= Date: Wed, 24 Jun 2026 23:12:05 +0200 Subject: [PATCH 2/8] FELIX-6846 Add SNI integration test and update jetty12 README - JettySniIT verifies org.apache.felix.https.ssl.sniRequired=true: hostname connections send SNI and receive 200 OK, IP address connections omit SNI (RFC 6066) and receive 400 Bad Request - Add SNI property table to http/jetty12/README Co-Authored-By: Claude Sonnet 4.6 --- http/jetty12/README | 12 ++ .../felix/http/jetty/it/JettySniIT.java | 146 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 http/jetty12/src/test/java/org/apache/felix/http/jetty/it/JettySniIT.java diff --git a/http/jetty12/README b/http/jetty12/README index cf468c0e19..2f05fb63a5 100644 --- a/http/jetty12/README +++ b/http/jetty12/README @@ -2,3 +2,15 @@ This directory contains an implementation of the Apache Felix Http Service. It is based on Servlet API 6.1, Eclipse Jetty 12 and requires Java 17. +## SNI Configuration (FELIX-6846) + +Jetty 12 supports Server Name Indication (SNI) at two levels. See the +[Jetty SNI documentation](https://jetty.org/docs/jetty/12/operations-guide/protocols/index.html#ssl-sni) +for details. + +| Property | Default | Description | +|---|---|---| +| `org.apache.felix.https.sslContext.sniRequired` | `false` | Require SNI at the TLS level. Clients without a valid SNI receive a TLS handshake failure. | +| `org.apache.felix.https.ssl.sniRequired` | `false` | Require SNI at the HTTP level. Clients without a valid SNI receive a `400 Bad Request`. | +| `org.apache.felix.https.ssl.sniHostCheck` | `true` | Check that the SNI hostname matches the `Host` header. Mismatches receive a `400 Bad Request`. | + 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..8b8ed5c121 --- /dev/null +++ b/http/jetty12/src/test/java/org/apache/felix/http/jetty/it/JettySniIT.java @@ -0,0 +1,146 @@ +/* + * 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.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.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 SNI configuration (FELIX-6846). + * + * With org.apache.felix.https.ssl.sniRequired=true: + * - Connections with a hostname send SNI and are accepted (200 OK). + * - Connections using an IP address do not send SNI and are 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") + .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 testRequestWithHostnameSendsSniAndIsAccepted() throws Exception { + try (HttpClient httpClient = newTrustAllHttpsClient()) { + httpClient.start(); + ContentResponse response = httpClient.GET(new URI(String.format("https://localhost:%d/test", getHttpsPort()))); + assertEquals(200, response.getStatus()); + } + } + + @Test + public void testRequestWithIpAddressHasNoSniAndIsRejected() throws Exception { + try (HttpClient httpClient = newTrustAllHttpsClient()) { + httpClient.start(); + // IP addresses do not trigger SNI in the TLS handshake (RFC 6066) + ContentResponse response = httpClient.GET(new URI(String.format("https://127.0.0.1:%d/test", getHttpsPort()))); + 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() { + 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"); + } + } +} From fbd8afc88e84de2c17a4c278d499e9df82b4a6fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20R=C3=BCtter?= Date: Wed, 24 Jun 2026 23:25:03 +0200 Subject: [PATCH 3/8] FELIX-6846 Fix and extend SNI integration tests - Regenerate test-keystore.p12 with SAN (dns:localhost, ip:127.0.0.1) so Jetty's sniHostCheck can validate the certificate properly - JettySniIT: disable sniHostCheck to isolate sniRequired behaviour; hostname connection (SNI sent) -> 200, IP connection (no SNI) -> 400 - JettySniHostCheckIT: tests sniHostCheck=true (default); matching Host header -> 200, mismatched Host header -> 400 Co-Authored-By: Claude Sonnet 4.6 --- .../http/jetty/it/JettySniHostCheckIT.java | 152 ++++++++++++++++++ .../felix/http/jetty/it/JettySniIT.java | 1 + .../src/test/resources/test-keystore.p12 | Bin 2718 -> 2750 bytes 3 files changed, 153 insertions(+) create mode 100644 http/jetty12/src/test/java/org/apache/felix/http/jetty/it/JettySniHostCheckIT.java 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..4e8463c6a1 --- /dev/null +++ b/http/jetty12/src/test/java/org/apache/felix/http/jetty/it/JettySniHostCheckIT.java @@ -0,0 +1,152 @@ +/* + * 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.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.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() { + 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 index 8b8ed5c121..c712a5b993 100644 --- 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 @@ -93,6 +93,7 @@ protected Option felixHttpConfig(int httpPort) { .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(); } diff --git a/http/jetty12/src/test/resources/test-keystore.p12 b/http/jetty12/src/test/resources/test-keystore.p12 index a254cf7aa4e44386a8e6137bbd3cf21ff3fb58d8..41e17dcd44847d04c4a37a3693420418f061784d 100644 GIT binary patch delta 2532 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 Date: Wed, 24 Jun 2026 23:34:55 +0200 Subject: [PATCH 4/8] FELIX-6846 Fix SNI test and add TLS-level sniRequired test The OpenJDK TLS stack does not send SNI for non-domain names such as "localhost", so the previous JettySniIT hostname case never sent SNI and was rejected. Use Jetty's NON_DOMAIN_SNI_PROVIDER to force SNI when a positive (SNI present) result is expected. - JettySniIT (org.apache.felix.https.ssl.sniRequired, HTTP level): SNI sent -> 200, no SNI -> 400 Bad Request - JettySniContextRequiredIT (org.apache.felix.https.sslContext.sniRequired, TLS level): SNI sent -> 200, no SNI -> TLS handshake failure Co-Authored-By: Claude Sonnet 4.6 --- .../jetty/it/JettySniContextRequiredIT.java | 157 ++++++++++++++++++ .../felix/http/jetty/it/JettySniIT.java | 28 ++-- 2 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 http/jetty12/src/test/java/org/apache/felix/http/jetty/it/JettySniContextRequiredIT.java 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..808ac6fc3c --- /dev/null +++ b/http/jetty12/src/test/java/org/apache/felix/http/jetty/it/JettySniContextRequiredIT.java @@ -0,0 +1,157 @@ +/* + * 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.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.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() { + 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 index c712a5b993..6bdb574e8d 100644 --- 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 @@ -50,11 +50,11 @@ import org.osgi.service.servlet.whiteboard.HttpWhiteboardConstants; /** - * Integration test for SNI configuration (FELIX-6846). + * Integration test for org.apache.felix.https.ssl.sniRequired (FELIX-6846). * - * With org.apache.felix.https.ssl.sniRequired=true: - * - Connections with a hostname send SNI and are accepted (200 OK). - * - Connections using an IP address do not send SNI and are rejected (400 Bad Request). + * 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) @@ -106,8 +106,10 @@ public void setup() { } @Test - public void testRequestWithHostnameSendsSniAndIsAccepted() throws Exception { - try (HttpClient httpClient = newTrustAllHttpsClient()) { + 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()); @@ -115,18 +117,22 @@ public void testRequestWithHostnameSendsSniAndIsAccepted() throws Exception { } @Test - public void testRequestWithIpAddressHasNoSniAndIsRejected() throws Exception { - try (HttpClient httpClient = newTrustAllHttpsClient()) { + 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(); - // IP addresses do not trigger SNI in the TLS handshake (RFC 6066) - ContentResponse response = httpClient.GET(new URI(String.format("https://127.0.0.1:%d/test", getHttpsPort()))); + ContentResponse response = httpClient.GET(new URI(String.format("https://localhost:%d/test", getHttpsPort()))); assertEquals(400, response.getStatus()); } } - private HttpClient newTrustAllHttpsClient() { + 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)); From 66315659488b91520fb4fdaa4924192b8c3acc08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20R=C3=BCtter?= Date: Wed, 24 Jun 2026 23:38:57 +0200 Subject: [PATCH 5/8] FELIX-6846 Wait for HttpService registration in SNI tests Enabling HTTPS via ConfigAdmin happens after initial startup and restarts Jetty, briefly unregistering the HttpService. Reading the secure port from the service reference could hit a null reference (NPE). Await the service with Awaitility before reading the port in all three SNI integration tests. Co-Authored-By: Claude Sonnet 4.6 --- .../felix/http/jetty/it/JettySniContextRequiredIT.java | 7 +++++++ .../apache/felix/http/jetty/it/JettySniHostCheckIT.java | 7 +++++++ .../java/org/apache/felix/http/jetty/it/JettySniIT.java | 7 +++++++ 3 files changed, 21 insertions(+) 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 index 808ac6fc3c..83172938ce 100644 --- 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 @@ -24,6 +24,7 @@ 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; @@ -39,6 +40,7 @@ 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; @@ -143,6 +145,11 @@ private HttpClient newTrustAllHttpsClient(boolean sendNonDomainSni) { } 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); } 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 index 4e8463c6a1..071feb67e7 100644 --- 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 @@ -23,6 +23,7 @@ import java.io.IOException; import java.net.URI; +import java.time.Duration; import java.util.Hashtable; import java.util.Map; @@ -38,6 +39,7 @@ 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; @@ -138,6 +140,11 @@ private HttpClient newTrustAllHttpsClient() { } 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); } 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 index 6bdb574e8d..7be6f6546a 100644 --- 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 @@ -23,6 +23,7 @@ import java.io.IOException; import java.net.URI; +import java.time.Duration; import java.util.Hashtable; import java.util.Map; @@ -37,6 +38,7 @@ 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; @@ -139,6 +141,11 @@ private HttpClient newTrustAllHttpsClient(boolean sendNonDomainSni) { } 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); } From 10a99af719142f09601f9fad2df8d89ed9481dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20R=C3=BCtter?= Date: Thu, 25 Jun 2026 09:06:34 +0200 Subject: [PATCH 6/8] FELIX-6846 Document org.apache.felix.http.require.config in README This existing property (default false) was missing from the properties table. When true, the server waits for a Configuration Admin configuration before starting instead of starting immediately. Co-Authored-By: Claude Sonnet 4.6 --- http/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/http/README.md b/http/README.md index 48829c13c2..ff8875df8c 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. | From fe5012f804851d5ba764bd7923c0ddca74248245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20R=C3=BCtter?= Date: Thu, 25 Jun 2026 09:10:31 +0200 Subject: [PATCH 7/8] FELIX-6846 Revert jetty12 README SNI section The SNI properties are already documented in the main http/README.md properties table; the jetty12 README addition was unnecessary. Co-Authored-By: Claude Sonnet 4.6 --- http/jetty12/README | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/http/jetty12/README b/http/jetty12/README index 2f05fb63a5..cf468c0e19 100644 --- a/http/jetty12/README +++ b/http/jetty12/README @@ -2,15 +2,3 @@ This directory contains an implementation of the Apache Felix Http Service. It is based on Servlet API 6.1, Eclipse Jetty 12 and requires Java 17. -## SNI Configuration (FELIX-6846) - -Jetty 12 supports Server Name Indication (SNI) at two levels. See the -[Jetty SNI documentation](https://jetty.org/docs/jetty/12/operations-guide/protocols/index.html#ssl-sni) -for details. - -| Property | Default | Description | -|---|---|---| -| `org.apache.felix.https.sslContext.sniRequired` | `false` | Require SNI at the TLS level. Clients without a valid SNI receive a TLS handshake failure. | -| `org.apache.felix.https.ssl.sniRequired` | `false` | Require SNI at the HTTP level. Clients without a valid SNI receive a `400 Bad Request`. | -| `org.apache.felix.https.ssl.sniHostCheck` | `true` | Check that the SNI hostname matches the `Host` header. Mismatches receive a `400 Bad Request`. | - From d3eccde21d0eb1f85624a08073ba484462ffa064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20R=C3=BCtter?= Date: Thu, 25 Jun 2026 09:19:56 +0200 Subject: [PATCH 8/8] FELIX-6846 Note version the SNI properties were added in Co-Authored-By: Claude Sonnet 4.6 --- http/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/http/README.md b/http/README.md index ff8875df8c..1782c4a076 100644 --- a/http/README.md +++ b/http/README.md @@ -411,9 +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). | -| `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). | -| `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). | +| `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. |