Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTTP protocol version reported by the server is HTTP/1.1 even for HTTP/2-only servers #3475

Closed
NiccoMlt opened this issue Oct 19, 2024 · 2 comments · Fixed by #3487
Closed
Assignees
Labels
type/bug A general bug
Milestone

Comments

@NiccoMlt
Copy link

NiccoMlt commented Oct 19, 2024

Expected Behavior

A HTTP server should return a representation of the "HTTP/2 version" when invoking HttpServerRequest#protocol or HttpServerRequest#version if it handles a request that came over HTTP/2.

See the example code below

Actual Behavior

The HttpClient call makes the server log

Server detected HTTP protocol HTTP/1.1
Server detected HTTP version HTTP/1.1

even if I use cURL:

curl --http2-prior-knowledge --cacert certificate.crt -v https://localhost:8443

* Host localhost:8443 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8443...
* connect to ::1 port 8443 from ::1 port 53698 failed: Connection refused
*   Trying 127.0.0.1:8443...
* Connected to localhost (127.0.0.1) port 8443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
*  CAfile: certificate.crt
*  CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=localhost
*  start date: Oct 17 08:10:06 2024 GMT
*  expire date: Oct 17 08:10:06 2025 GMT
*  subjectAltName: host "localhost" matched cert's "localhost"
*  issuer: CN=Common Name; OU=Organisational Unit name; O=Organisation; L=Locality name; ST=State or Province name; C=it
*  SSL certificate verify ok.
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://localhost:8443/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: localhost:8443]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.7.1]
* [HTTP/2] [1] [accept: */*]
> GET / HTTP/2
> Host: localhost:8443
> User-Agent: curl/8.7.1
> Accept: */*
> 
* Request completely sent off
< HTTP/2 200 
< content-length: 12
< 
* Connection #0 to host localhost left intact
Hello World!

I get the same logs:

Server detected HTTP protocol HTTP/1.1
Server detected HTTP version HTTP/1.1

Steps to Reproduce

Here is a self-contained example that:

  1. generates a root CA and a HTTPS certificate using BouncyCastle
    • it stores them in a certificate.crt and a ca.crt to use with other tools to test the server
  2. spins-up an HTTPS server using Reactor Netty, with only HTTP/2 enabled
    • the server handles the / route for HTTP methods GET and POST
  3. builds an HTTPS client that will execute a POST to the server over HTTP/2
  4. blocks over onDispose to allow additional manual test, i.e. with cURL
///usr/bin/env jbang "$0" "$@" ; exit $?
//DEPS io.projectreactor:reactor-tools:3.6.10
//DEPS io.projectreactor.netty:reactor-netty-core:1.1.22
//DEPS io.projectreactor.netty:reactor-netty-http:1.1.22
//DEPS io.netty:netty-tcnative-boringssl-static:2.0.66.Final
//DEPS io.netty:netty-resolver-dns-native-macos:4.1.112.Final
//DEPS org.bouncycastle:bcpkix-jdk18on:1.78.1
//DEPS org.bouncycastle:bcprov-jdk18on:1.78.1
//DEPS org.bouncycastle:bctls-jdk18on:1.78.1
//DEPS org.slf4j:slf4j-api:1.7.33
//DEPS org.slf4j:slf4j-jdk14:1.7.33
//DEPS io.micrometer:micrometer-registry-prometheus:1.13.5

package com.diennea.carapace;

import io.netty.handler.codec.http2.Http2SecurityUtil;
import io.netty.handler.ssl.ApplicationProtocolConfig;
import io.netty.handler.ssl.ApplicationProtocolNames;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.SslProvider;
import io.netty.handler.ssl.SupportedCipherSuiteFilter;
import java.io.ByteArrayInputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.math.BigInteger;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.Provider;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.Security;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.time.ZonedDateTime;
import java.util.Date;
import java.util.function.Function;
import javax.net.ssl.TrustManagerFactory;
import org.bouncycastle.asn1.ASN1InputStream;
import org.bouncycastle.asn1.ASN1Sequence;
import org.bouncycastle.asn1.DERUTF8String;
import org.bouncycastle.asn1.x500.AttributeTypeAndValue;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.X500NameBuilder;
import org.bouncycastle.asn1.x500.style.BCStyle;
import org.bouncycastle.asn1.x509.BasicConstraints;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.asn1.x509.SubjectKeyIdentifier;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.bc.BcX509ExtensionUtils;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import reactor.core.publisher.Mono;
import reactor.netty.ByteBufFlux;
import reactor.netty.DisposableServer;
import reactor.netty.http.HttpProtocol;
import reactor.netty.http.client.HttpClient;
import reactor.netty.http.client.HttpClientResponse;
import reactor.netty.http.server.HttpServer;
import reactor.netty.http.server.HttpServerRequest;
import reactor.netty.http.server.HttpServerResponse;

public class HelloReactorNetty {

    private static final String LOCALHOST = "localhost";
    private static final int PORT = 8443;

    private static final Provider BC_PROVIDER = new BouncyCastleProvider();
    private static final Provider BC_JSSE_PROVIDER = new BouncyCastleJsseProvider();
    private static final SecureRandom PRNG = new SecureRandom();

    private static final String TRUST_MANAGER_ALGORITHM = "PKIX";
    private static final String KEYSTORE_TYPE = "PKCS12";
    private static final String KEY_ALGORITHM = "RSA";
    private static final int KEY_SIZE = 4096;
    private static final String SIGNATURE_ALGORITHM = "SHA256With" + KEY_ALGORITHM;

    public static void main(final String... args) throws Exception {
        Security.insertProviderAt(BC_PROVIDER, 1);
        Security.insertProviderAt(BC_JSSE_PROVIDER, 2);

        final KeyPair keyPair = getKeyPair();
        final var contentSigner = new JcaContentSignerBuilder(SIGNATURE_ALGORITHM)
                .setProvider(BC_PROVIDER)
                .build(keyPair.getPrivate());
        final X509Certificate caCertificate = generateCaCertificate(contentSigner, keyPair.getPublic());

        final X509Certificate httpsCertificate = generateHttpsCertificate(caCertificate, contentSigner);
        final SslContext serverSslContext = SslContextBuilder
                .forServer(keyPair.getPrivate(), httpsCertificate)
                .sslProvider(SslProvider.OPENSSL)
                .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE)
                .applicationProtocolConfig(new ApplicationProtocolConfig(
                        ApplicationProtocolConfig.Protocol.ALPN,
                        ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE,
                        ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT,
                        ApplicationProtocolNames.HTTP_2
                ))
                .build();

        final HttpServer httpServer = HttpServer
                .create()
                .host(LOCALHOST)
                .port(PORT)
                .secure(sslContextSpec -> sslContextSpec.sslContext(serverSslContext))
                .protocol(HttpProtocol.H2)
                .metrics(true, Function.identity())
                .route(routes -> routes
                        .get("/", (final HttpServerRequest request, final HttpServerResponse response) -> {
                            // It always fails here!!!
                            /* if (HttpVersion.valueOf(request.protocol()).majorVersion() != 2) {
                                throw new RuntimeException("Unsupported HTTP version: " + request.protocol());
                            } */
                            System.out.println("Server detected HTTP protocol " + request.protocol());
                            System.out.println("Server detected HTTP version " + request.version());
                            return response.sendString(Mono.just("Hello World!"));
                        })
                        .post("/", (final HttpServerRequest request, final HttpServerResponse response) -> {
                            // It always fails here!!!
                            /* if (HttpVersion.valueOf(request.protocol()).majorVersion() != 2) {
                                throw new RuntimeException("Unsupported HTTP version: " + request.protocol());
                            } */
                            System.out.println("Server detected HTTP protocol " + request.protocol());
                            System.out.println("Server detected HTTP version " + request.version());
                            return response.send(request.receive().retain());
                        })
                );
        final DisposableServer disposableServer = httpServer.bindNow();

        final TrustManagerFactory trustManagerFactory = getTrustManagerFactory(keyPair, caCertificate);
        final SslContext clientSslContext = SslContextBuilder
                .forClient()
                .sslProvider(SslProvider.OPENSSL)
                .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE)
                .applicationProtocolConfig(new ApplicationProtocolConfig(
                        ApplicationProtocolConfig.Protocol.ALPN,
                        ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE,
                        ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT,
                        ApplicationProtocolNames.HTTP_2
                ))
                .trustManager(trustManagerFactory)
                .build();

        final HttpClient httpClient = HttpClient.create()
                .host(disposableServer.host())
                .port(disposableServer.port())
                .secure(sslContextSpec -> sslContextSpec.sslContext(clientSslContext))
                .metrics(true, Function.identity())
                .protocol(HttpProtocol.H2);

        final HttpClientResponse response = httpClient.post()
                .send(ByteBufFlux.fromString(Mono.just("hello")))
                .response()
                .blockOptional()
                .orElseThrow();

        System.out.println("Response status: " + response.status());
        System.out.println("Response HTTP version: " + response.version());

        disposableServer.onDispose().block();
    }

    private static KeyPair getKeyPair() throws NoSuchAlgorithmException {
        final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(KEY_ALGORITHM, BC_PROVIDER);
        keyPairGenerator.initialize(KEY_SIZE, PRNG);
        return keyPairGenerator.generateKeyPair();
    }

    private static X509Certificate generateCaCertificate(final ContentSigner contentSigner, final PublicKey publicKey) throws IOException, CertificateException {
        final byte[] keyPublicEncoded = publicKey.getEncoded();

        // Generate the Subject (Public-) Key Identifier
        // See: <https://stackoverflow.com/a/77292916/7907339>
        final SubjectKeyIdentifier subjectKeyIdentifier;
        try (
                final ByteArrayInputStream ist = new ByteArrayInputStream(keyPublicEncoded);
                final ASN1InputStream ais = new ASN1InputStream(ist)
        ) {
            final ASN1Sequence asn1Sequence = (ASN1Sequence) ais.readObject();
            final SubjectPublicKeyInfo subjectPublicKeyInfo = SubjectPublicKeyInfo.getInstance(asn1Sequence);
            subjectKeyIdentifier = new BcX509ExtensionUtils().createSubjectKeyIdentifier(subjectPublicKeyInfo);
        }

        final X500Name subject = new X500NameBuilder()
                .addRDN(new AttributeTypeAndValue(BCStyle.CN, new DERUTF8String("Common Name")))
                .addRDN(new AttributeTypeAndValue(BCStyle.OU, new DERUTF8String("Organisational Unit name")))
                .addRDN(new AttributeTypeAndValue(BCStyle.O, new DERUTF8String("Organisation")))
                .addRDN(new AttributeTypeAndValue(BCStyle.L, new DERUTF8String("Locality name")))
                .addRDN(new AttributeTypeAndValue(BCStyle.ST, new DERUTF8String("State or Province name")))
                .addRDN(new AttributeTypeAndValue(BCStyle.C, new DERUTF8String("it")))
                .build();

        final ZonedDateTime now = ZonedDateTime.now();
        final X509v3CertificateBuilder certBuilder = new X509v3CertificateBuilder(
                subject,
                generateSerialNumber(),
                Date.from(now.toInstant()),
                Date.from(now.plusYears(1).toInstant()),
                subject,
                SubjectPublicKeyInfo.getInstance(keyPublicEncoded)
        );
        final X509CertificateHolder certHolder = certBuilder
                .addExtension(Extension.basicConstraints, true, new BasicConstraints(true))
                .addExtension(Extension.subjectKeyIdentifier, false, subjectKeyIdentifier)
                .build(contentSigner);
        final X509Certificate certificate = new JcaX509CertificateConverter()
                .setProvider(BC_PROVIDER)
                .getCertificate(certHolder);
        writePemFile("ca.crt", certificate);
        return certificate;
    }

    private static BigInteger generateSerialNumber() {
        return new BigInteger(Long.SIZE, PRNG);
    }

    private static X509Certificate generateHttpsCertificate(final X509Certificate issuer, final ContentSigner contentSigner) throws IOException, CertificateException {
        final JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
                issuer,
                generateSerialNumber(),
                issuer.getNotBefore(),
                issuer.getNotAfter(),
                new X500NameBuilder().addRDN(BCStyle.CN, new DERUTF8String(LOCALHOST)).build(),
                issuer.getPublicKey()
        );
        final X509CertificateHolder certHolder = certBuilder
                /*
                 * The Subject Alternative Names (SAN) seems to be required.
                 */
                .addExtension(Extension.subjectAlternativeName, false, new GeneralNames(new GeneralName[]{
                        new GeneralName(GeneralName.dNSName, LOCALHOST),
                        new GeneralName(GeneralName.iPAddress, "127.0.0.1")
                }))
                .build(contentSigner);
        final X509Certificate certificate = new JcaX509CertificateConverter()
                .setProvider(BC_PROVIDER)
                .getCertificate(certHolder);
        writePemFile("certificate.crt", certificate);
        return certificate;
    }

    private static void writePemFile(final String filename, final X509Certificate certificate) throws IOException {
        try (final JcaPEMWriter pemWriter = new JcaPEMWriter(new FileWriter(filename))) {
            pemWriter.writeObject(certificate);
        }
    }

    private static TrustManagerFactory getTrustManagerFactory(final KeyPair keyPair, final X509Certificate x509Cert) throws KeyStoreException, CertificateException, IOException, NoSuchAlgorithmException {
        final KeyStore keyStore = getKeyStore(keyPair, x509Cert);
        final TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TRUST_MANAGER_ALGORITHM, BC_JSSE_PROVIDER);
        trustManagerFactory.init(keyStore);
        return trustManagerFactory;
    }

    private static KeyStore getKeyStore(final KeyPair keyPair, final X509Certificate x509Cert) throws KeyStoreException, CertificateException, IOException, NoSuchAlgorithmException {
        final char[] password = "password".toCharArray();
        final KeyStore keyStore = KeyStore.getInstance(KEYSTORE_TYPE, BC_PROVIDER);
        keyStore.load(null, password);
        keyStore.setKeyEntry("alias", keyPair.getPrivate(), password, new X509Certificate[]{x509Cert});
        return keyStore;
    }
}

Your Environment

  • Reactor version(s) used: Reactor-Netty 1.1.22
  • Other relevant libraries versions (eg. netty, ...):
    • Netty 4.1.112.Final,
    • BouncyCastle 1.78.1,
    • Micrometer 1.13.5
  • JVM version (java -version):
    ❯ java -version                                                                                                      
    openjdk version "21.0.2" 2024-01-16 LTS
    OpenJDK Runtime Environment Temurin-21.0.2+13 (build 21.0.2+13-LTS)
    OpenJDK 64-Bit Server VM Temurin-21.0.2+13 (build 21.0.2+13-LTS, mixed mode)
  • OS and version (eg. uname -a):
    ❯ uname -a
    Darwin s3macbook23.local 24.0.0 Darwin Kernel Version 24.0.0: Tue Sep 24 23:37:36 PDT 2024; 
    root:xnu-11215.1.12~1/RELEASE_ARM64_T6020 arm64
@NiccoMlt NiccoMlt added status/need-triage A new issue that still need to be evaluated as a whole type/bug A general bug labels Oct 19, 2024
@violetagg violetagg self-assigned this Oct 22, 2024
@violetagg violetagg removed the status/need-triage A new issue that still need to be evaluated as a whole label Oct 22, 2024
@violetagg
Copy link
Member

@NiccoMlt Thanks for the reproducible example!

@NiccoMlt
Copy link
Author

You're welcome, hope it helps!

@violetagg violetagg added this to the 1.1.24 milestone Oct 28, 2024
@violetagg violetagg linked a pull request Oct 28, 2024 that will close this issue
NiccoMlt added a commit to diennea/carapaceproxy that referenced this issue Dec 10, 2024
NiccoMlt added a commit to diennea/carapaceproxy that referenced this issue Dec 10, 2024
NiccoMlt added a commit to diennea/carapaceproxy that referenced this issue Dec 10, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type/bug A general bug
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants