Skip to content

Commit

Permalink
feat(#736): prototype of mTLS security scope isolation only to gRPC s…
Browse files Browse the repository at this point in the history
…ervice
  • Loading branch information
tpz committed Nov 7, 2024
1 parent 5af649a commit ca634f6
Show file tree
Hide file tree
Showing 8 changed files with 294 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
*
* _ _ ____ ____
* _____ _(_) |_ __ _| _ \| __ )
* / _ \ \ / / | __/ _` | | | | _ \
* | __/\ V /| | || (_| | |_| | |_) |
* \___| \_/ |_|\__\__,_|____/|____/
*
* Copyright (c) 2024
*
* Licensed under the Business Source License, Version 1.1 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/FgForrest/evitaDB/blob/master/LICENSE
*
* 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 io.evitadb.externalApi.exception;


import javax.annotation.Nonnull;
import java.io.Serial;

/**
* Exception is raised when client accesses the API endpoint secured by client certificate (mTLS) with not allowed one
* on the server side.
*
* @author Jan Novotný ([email protected]), FG Forrest a.s. (c) 2024
*/
public class ClientCertificateNotAllowedException extends ExternalApiInvalidUsageException {
@Serial private static final long serialVersionUID = 3934858455674566277L;

public ClientCertificateNotAllowedException(@Nonnull String publicMessage) {
super(publicMessage);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
*
* _ _ ____ ____
* _____ _(_) |_ __ _| _ \| __ )
* / _ \ \ / / | __/ _` | | | | _ \
* | __/\ V /| | || (_| | |_| | |_) |
* \___| \_/ |_|\__\__,_|____/|____/
*
* Copyright (c) 2024
*
* Licensed under the Business Source License, Version 1.1 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/FgForrest/evitaDB/blob/master/LICENSE
*
* 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 io.evitadb.externalApi.exception;


import javax.annotation.Nonnull;
import java.io.Serial;

/**
* Exception is raised when client accesses the API endpoint secured by client certificate (mTLS) without providing it.
*
* @author Jan Novotný ([email protected]), FG Forrest a.s. (c) 2024
*/
public class ClientCertificateNotProvidedException extends ExternalApiInvalidUsageException {
@Serial private static final long serialVersionUID = 3934858455674566277L;

public ClientCertificateNotProvidedException(@Nonnull String publicMessage) {
super(publicMessage);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ public static CertificatePath initCertificate(
);
}

if (necessaryFiles.stream().noneMatch(File::exists)) {
if (necessaryFiles.stream().anyMatch(it -> !it.exists())) {
try {
serverCertificateManager.generateSelfSignedCertificate(certificateTypes);
} catch (Exception e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,34 @@
import com.linecorp.armeria.server.RpcService;
import com.linecorp.armeria.server.ServiceRequestContext;
import io.evitadb.externalApi.configuration.AbstractApiConfiguration;
import io.evitadb.externalApi.configuration.ApiConfigurationWithMutualTls;
import io.evitadb.externalApi.configuration.HostDefinition;
import io.evitadb.externalApi.configuration.MtlsConfiguration;
import io.evitadb.externalApi.configuration.TlsMode;
import io.evitadb.externalApi.exception.ClientCertificateNotAllowedException;
import io.evitadb.externalApi.exception.ClientCertificateNotProvidedException;
import io.evitadb.externalApi.exception.InvalidPortException;
import io.evitadb.externalApi.exception.InvalidSchemeException;
import io.evitadb.utils.CollectionUtils;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.net.ssl.SSLPeerUnverifiedException;
import java.io.FileInputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.Principal;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;

/**
* This HTTP service verifies input scheme against allowed {@link TlsMode} in configuration and returns appropriate
Expand All @@ -61,6 +78,7 @@ public class HttpServiceSecurityDecorator implements DecoratingHttpServiceFuncti
private final String[] schemes;
private final int[] ports;
private final int peak;
private final MtlsConfiguration mtlsConfiguration;

public HttpServiceSecurityDecorator(@Nonnull AbstractApiConfiguration... configurations) {
final int hostsConfigs = Arrays.stream(configurations)
Expand All @@ -70,6 +88,13 @@ public HttpServiceSecurityDecorator(@Nonnull AbstractApiConfiguration... configu
this.schemes = new String[hostsConfigs];
this.ports = new int[hostsConfigs];
this.peak = prepareConfigurations(configurations);
Optional<ApiConfigurationWithMutualTls> mtlsApiConfiguration = Arrays.stream(configurations)
.filter(x -> x instanceof ApiConfigurationWithMutualTls)
.map(x -> (ApiConfigurationWithMutualTls) x)
.findFirst();
this.mtlsConfiguration = mtlsApiConfiguration
.map(ApiConfigurationWithMutualTls::getMtlsConfiguration)
.orElseGet(() -> new MtlsConfiguration(false, new ArrayList<>(0)));
}

@Nonnull
Expand All @@ -83,6 +108,25 @@ public HttpResponse serve(@Nonnull HttpService delegate, @Nonnull ServiceRequest
for (int i = 0; i < peak; i++) {
if (port == ports[i] && (hosts[i] == null || address.getAddress().getHostAddress().equals(hosts[i]))) {
if (schemes[i] == null || scheme.equals(schemes[i])) {
final MediaType mediaType = req.contentType();
if (mediaType != null && "application/grpc+proto".equals(mediaType.toString())) {
if (mtlsConfiguration.enabled()) {
final Set<X509Certificate> allowedCertificates = getAllowedClientCertificatesFromPaths(mtlsConfiguration);
try {
final Certificate[] clientCerts = ctx.sslSession().getPeerCertificates();
if (clientCerts.length == 0) {
return HttpResponse.ofFailure(new ClientCertificateNotProvidedException("Client certificate not provided."));
}
final Certificate clientCert = clientCerts[0];
if (!isClientCertificateAllowed(clientCert, allowedCertificates)) {
return HttpResponse.ofFailure(new ClientCertificateNotAllowedException("Client certificate not allowed."));
}
} catch (SSLPeerUnverifiedException e) {
return HttpResponse.ofFailure(new ClientCertificateNotProvidedException("Client certificate not provided."));
}

}
}
return delegate.serve(ctx, req);
} else {
hostAndPortMatching = true;
Expand All @@ -107,6 +151,17 @@ public RpcResponse serve(@Nonnull RpcService delegate, @Nonnull ServiceRequestCo
for (int i = 0; i < peak; i++) {
if (port == ports[i] && (hosts[i] == null || address.getAddress().getHostAddress().equals(hosts[i]))) {
if (schemes[i] == null || scheme.equals(schemes[i])) {
if (mtlsConfiguration.enabled()) {
final Set<X509Certificate> allowedCertificates = getAllowedClientCertificatesFromPaths(mtlsConfiguration);
final Certificate[] clientCerts = ctx.sslSession().getPeerCertificates();
if (clientCerts.length == 0) {
return RpcResponse.ofFailure(new InvalidSchemeException("Client certificate not provided."));
}
final Certificate clientCert = clientCerts[0];
if (!isClientCertificateAllowed(clientCert, allowedCertificates)) {
return RpcResponse.ofFailure(new InvalidSchemeException("Client certificate not allowed."));
}
}
return delegate.serve(ctx, req);
} else {
portMatching = true;
Expand All @@ -120,6 +175,34 @@ public RpcResponse serve(@Nonnull RpcService delegate, @Nonnull ServiceRequestCo
}
}

private static Set<X509Certificate> getAllowedClientCertificatesFromPaths(@Nonnull MtlsConfiguration mtlsConfig) {
final Set<X509Certificate> certificates = CollectionUtils.createHashSet(mtlsConfig.allowedClientCertificatePaths().size());

try {
mtlsConfig.allowedClientCertificatePaths().stream()
.filter(Objects::nonNull)
.filter(path -> path.endsWith(".crt"))
.forEach(certPath -> {
try (FileInputStream fis = new FileInputStream(certPath)) {
final CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
final X509Certificate cert = (X509Certificate) certFactory.generateCertificate(fis);
certificates.add(cert);
} catch (Exception e) {
System.err.println("Failed to load certificate from " + certPath + ": " + e.getMessage());
}
});
} catch (Exception e) {
System.err.println("Error loading certificates: " + e.getMessage());
}

return certificates;
}

private static boolean isClientCertificateAllowed(@Nonnull Certificate clientCert, @Nonnull Set<X509Certificate> allowedCertificates) {
// Check if any allowed certificate matches the client certificate
return allowedCertificates.stream().anyMatch(allowedCert -> allowedCert.equals(clientCert));
}

/**
* Prepares security configurations based on the provided API configurations.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,8 +289,10 @@ public EvitaClient(
);
}
},
tlsCustomizer
));
tlsCustomizer,
clientFactoryBuilder
)
);
} else {
uriScheme = "http";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ public static GrpcClientBuilder getBuilder(@Nonnull ClientSessionInterceptor int

final String uriScheme;
if (grpcConfig.getTlsMode() != TlsMode.FORCE_NO_TLS) {
clientFactoryBuilder.tlsCustomizer(tlsCustomizer -> builder.build().buildClientSslContext(null, tlsCustomizer));
clientFactoryBuilder.tlsCustomizer(tlsCustomizer -> builder.build().buildClientSslContext(null, tlsCustomizer, clientFactoryBuilder));
uriScheme = "https";
} else {
uriScheme = "http";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
import io.evitadb.test.EvitaTestSupport;
import io.evitadb.test.TestConstants;
import io.evitadb.utils.ArrayUtils;
import io.evitadb.utils.CertificateUtils;
import io.evitadb.utils.CollectionUtils.Property;
import io.evitadb.utils.NetworkUtils;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -54,7 +55,9 @@

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -403,6 +406,117 @@ void shouldRestrictAccessViaNonSupportedProtocolsAndPortsExceptLab() {
}
}

@Test
void shouldRestrictAccessWhenMtlsConfiguredOnlyOnTlsServicesOfRpcType() {
final Map<String, Integer> servicePorts = new HashMap<>();
final EvitaServer evitaServer = new EvitaServer(
getPathInTargetDirectory(DIR_EVITA_SERVER_TEST),
constructTestArguments(
servicePorts,
List.of(
property("api.endpoints.rest.tlsMode", "RELAXED"),
property("api.endpoints.graphQL.tlsMode", "RELAXED"),
property("api.endpoints.gRPC.tlsMode", "RELAXED"),
property("api.endpoints.gRPC.mTLS.enabled", "true"),
property("api.endpoints.gRPC.mTLS.allowedClientCertificatesPaths", "[\"evita-server-certificates" + File.separator + "client.crt\"]"),
property("api.endpoints.lab.tlsMode", "RELAXED")
),
Set.of(
RestProvider.CODE, GraphQLProvider.CODE, SystemProvider.CODE
)
)
);

try {
evitaServer.run();

/*// attempt to access the system API via correct scheme and port
NetworkUtils.fetchContent(
"http://localhost:" + servicePorts.get(SystemProvider.CODE) + "/system/server-name",
"GET",
"text/plain",
null,
TIMEOUT_IN_MILLIS,
error -> fail("The system API should be accessible via correct scheme and port: " + error),
timeout -> assertEquals("Error fetching content from URL: http://localhost:" + servicePorts.get(ObservabilityProvider.CODE) + "/system/server-name HTTP status 404 - Not Found: Service not available.", timeout)
).ifPresent(
content -> assertTrue(content.contains("evitaDB-"), "The system API should be accessible via correct scheme and port: " + content)
);
// attempt to access the system API via correct scheme and Lab port
NetworkUtils.fetchContent(
"https://localhost:" + servicePorts.get(LabProvider.CODE) + "/system/server-name",
"GET",
"text/plain",
null,
TIMEOUT_IN_MILLIS,
error -> fail("The system API should be accessible via Lab scheme and port: " + error),
timeout -> assertEquals("Error fetching content from URL: http://localhost:" + servicePorts.get(ObservabilityProvider.CODE) + "/system/server-name HTTP status 404 - Not Found: Service not available.", timeout)
).ifPresent(
content -> assertTrue(content.contains("evitaDB-"), "The system API should be accessible via Lab scheme and port: " + content)
);
// attempt to access the system API via correct scheme and Lab port
NetworkUtils.fetchContent(
"https://localhost:" + servicePorts.get(LabProvider.CODE) + "/system/server-name",
"GET",
"text/plain",
null,
TIMEOUT_IN_MILLIS,
error -> fail("The system API should be accessible via Lab scheme and port: " + error),
timeout -> assertEquals("Error fetching content from URL: http://localhost:" + servicePorts.get(ObservabilityProvider.CODE) + "/system/server-name HTTP status 404 - Not Found: Service not available.", timeout)
).ifPresent(
content -> assertTrue(content.contains("evitaDB-"), "The system API should be accessible via Lab scheme and port: " + content)
);*/

final EvitaClient notAllowedCertificate = new EvitaClient(
EvitaClientConfiguration.builder()
.host("localhost")
.port(servicePorts.get(GrpcProvider.CODE))
.systemApiPort(servicePorts.get(SystemProvider.CODE))
.mtlsEnabled(true)
.certificateFolderPath(Path.of("evita-server-certificates"))
.rootCaCertificatePath(Path.of(CertificateUtils.getGeneratedRootCaCertificateFileName()))
.certificateFileName(Path.of(CertificateUtils.getGeneratedRootCaCertificateFileName()))
.certificateKeyFileName(Path.of(CertificateUtils.getGeneratedClientCertificatePrivateKeyFileName()))
.useGeneratedCertificate(false)
.trustCertificate(false)
.build()
);

try {
notAllowedCertificate.getCatalogNames();
fail("gRPC call should should be made with allowed client certificate!");
} catch (Exception ex) {
assertEquals("UNIMPLEMENTED: HTTP status code 403", ex.getMessage());
}

// we should be able to access gRCP via correct scheme and port
final EvitaClient correctEvitaClient = new EvitaClient(
EvitaClientConfiguration.builder()
.host("localhost")
.port(servicePorts.get(GrpcProvider.CODE))
.systemApiPort(servicePorts.get(SystemProvider.CODE))
.tlsEnabled(true)
.mtlsEnabled(true)
.certificateFolderPath(Path.of("evita-server-certificates"))
.certificateFileName(Path.of(CertificateUtils.getGeneratedClientCertificateFileName()))
.certificateKeyFileName(Path.of(CertificateUtils.getGeneratedClientCertificatePrivateKeyFileName()))
.useGeneratedCertificate(false)
.trustCertificate(false)
.build()
);

assertEquals(0, correctEvitaClient.getCatalogNames().size());
} finally {
try {
getPortManager().releasePortsOnCompletion(DIR_EVITA_SERVER_TEST, evitaServer.stop());
} catch (Exception ex) {
fail(ex.getMessage(), ex);
}
}
}

@Test
void shouldBeAbleToGetGrpcWebResponse() {
final Map<String, Integer> servicePorts = new HashMap<>();
Expand Down
Loading

0 comments on commit ca634f6

Please sign in to comment.