-
Notifications
You must be signed in to change notification settings - Fork 811
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
feat(webhook): Single-identity mTLS webhook configuration #4790
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,6 +17,9 @@ | |
|
||
package com.netflix.spinnaker.orca.webhook.config; | ||
|
||
import com.netflix.spinnaker.kork.crypto.TrustStores; | ||
import com.netflix.spinnaker.kork.crypto.X509Identity; | ||
import com.netflix.spinnaker.kork.crypto.X509IdentitySource; | ||
import com.netflix.spinnaker.okhttp.OkHttpClientConfigurationProperties; | ||
import com.netflix.spinnaker.orca.config.UserConfiguredUrlRestrictions; | ||
import com.netflix.spinnaker.orca.webhook.util.UnionX509TrustManager; | ||
|
@@ -25,13 +28,14 @@ | |
import java.io.UnsupportedEncodingException; | ||
import java.net.URLEncoder; | ||
import java.nio.charset.Charset; | ||
import java.nio.file.Path; | ||
import java.security.KeyManagementException; | ||
import java.security.KeyStore; | ||
import java.security.KeyStoreException; | ||
import java.security.NoSuchAlgorithmException; | ||
import java.security.cert.CertificateException; | ||
import java.security.cert.X509Certificate; | ||
import java.util.ArrayList; | ||
import java.util.List; | ||
import java.util.Map; | ||
import java.util.Optional; | ||
import javax.net.ssl.*; | ||
|
@@ -51,7 +55,6 @@ | |
import org.springframework.http.client.ClientHttpRequestFactory; | ||
import org.springframework.http.client.OkHttp3ClientHttpRequestFactory; | ||
import org.springframework.http.converter.AbstractHttpMessageConverter; | ||
import org.springframework.http.converter.HttpMessageConverter; | ||
import org.springframework.http.converter.HttpMessageNotReadableException; | ||
import org.springframework.http.converter.HttpMessageNotWritableException; | ||
import org.springframework.http.converter.StringHttpMessageConverter; | ||
|
@@ -73,9 +76,9 @@ public WebhookConfiguration(WebhookProperties webhookProperties) { | |
@Bean | ||
@ConditionalOnMissingBean(RestTemplate.class) | ||
public RestTemplate restTemplate(ClientHttpRequestFactory webhookRequestFactory) { | ||
RestTemplate restTemplate = new RestTemplate(webhookRequestFactory); | ||
var restTemplate = new RestTemplate(webhookRequestFactory); | ||
|
||
List<HttpMessageConverter<?>> converters = restTemplate.getMessageConverters(); | ||
var converters = restTemplate.getMessageConverters(); | ||
converters.add(new ObjectStringHttpMessageConverter()); | ||
converters.add(new MapToStringHttpMessageConverter()); | ||
restTemplate.setMessageConverters(converters); | ||
|
@@ -86,10 +89,11 @@ public RestTemplate restTemplate(ClientHttpRequestFactory webhookRequestFactory) | |
@Bean | ||
public ClientHttpRequestFactory webhookRequestFactory( | ||
OkHttpClientConfigurationProperties okHttpClientConfigurationProperties, | ||
UserConfiguredUrlRestrictions userConfiguredUrlRestrictions) { | ||
X509TrustManager trustManager = webhookX509TrustManager(); | ||
SSLSocketFactory sslSocketFactory = getSSLSocketFactory(trustManager); | ||
OkHttpClient client = | ||
UserConfiguredUrlRestrictions userConfiguredUrlRestrictions) | ||
throws IOException { | ||
var trustManager = webhookX509TrustManager(); | ||
var sslSocketFactory = getSSLSocketFactory(trustManager); | ||
var builder = | ||
new OkHttpClient.Builder() | ||
.sslSocketFactory(sslSocketFactory, trustManager) | ||
.addNetworkInterceptor( | ||
|
@@ -105,9 +109,14 @@ public ClientHttpRequestFactory webhookRequestFactory( | |
} | ||
|
||
return response; | ||
}) | ||
.build(); | ||
OkHttp3ClientHttpRequestFactory requestFactory = new OkHttp3ClientHttpRequestFactory(client); | ||
}); | ||
|
||
if (webhookProperties.isInsecureSkipHostnameVerification()) { | ||
builder.hostnameVerifier((hostname, session) -> true); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hostname verification needs to be disabled for testing, as OkHttp does validate certificate hosts by default |
||
} | ||
|
||
var client = builder.build(); | ||
var requestFactory = new OkHttp3ClientHttpRequestFactory(client); | ||
requestFactory.setReadTimeout( | ||
Math.toIntExact(okHttpClientConfigurationProperties.getReadTimeoutMs())); | ||
requestFactory.setConnectTimeout( | ||
|
@@ -116,58 +125,123 @@ public ClientHttpRequestFactory webhookRequestFactory( | |
} | ||
|
||
private X509TrustManager webhookX509TrustManager() { | ||
List<X509TrustManager> trustManagers = new ArrayList<>(); | ||
var trustManagers = new ArrayList<X509TrustManager>(); | ||
|
||
trustManagers.add(getTrustManager(null)); | ||
getCustomKeyStore().ifPresent(keyStore -> trustManagers.add(getTrustManager(keyStore))); | ||
getCustomTrustStore().ifPresent(keyStore -> trustManagers.add(getTrustManager(keyStore))); | ||
|
||
if (webhookProperties.isInsecureTrustSelfSigned()) { | ||
trustManagers.add( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another testing-only option to disable trust checks for situations that cannot or do not provide custom CA files. |
||
new X509TrustManager() { | ||
@Override | ||
public void checkClientTrusted(X509Certificate[] chain, String authType) {} | ||
|
||
@Override | ||
public void checkServerTrusted(X509Certificate[] chain, String authType) {} | ||
|
||
@Override | ||
public X509Certificate[] getAcceptedIssuers() { | ||
return new X509Certificate[0]; | ||
} | ||
}); | ||
} | ||
|
||
return new UnionX509TrustManager(trustManagers); | ||
} | ||
|
||
private SSLSocketFactory getSSLSocketFactory(X509TrustManager trustManager) { | ||
private SSLSocketFactory getSSLSocketFactory(X509TrustManager trustManager) throws IOException { | ||
try { | ||
SSLContext sslContext = SSLContext.getInstance("TLS"); | ||
sslContext.init(null, new X509TrustManager[] {trustManager}, null); | ||
return sslContext.getSocketFactory(); | ||
var identityOpt = getCustomIdentity(); | ||
if (identityOpt.isPresent()) { | ||
var identity = identityOpt.get(); | ||
return identity.createSSLContext(trustManager).getSocketFactory(); | ||
} else { | ||
var sslContext = SSLContext.getInstance("TLS"); | ||
sslContext.init(null, new X509TrustManager[] {trustManager}, null); | ||
return sslContext.getSocketFactory(); | ||
} | ||
} catch (KeyManagementException | NoSuchAlgorithmException e) { | ||
throw new RuntimeException(e); | ||
} | ||
} | ||
|
||
private X509TrustManager getTrustManager(KeyStore keyStore) { | ||
try { | ||
TrustManagerFactory trustManagerFactory = | ||
var trustManagerFactory = | ||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); | ||
trustManagerFactory.init(keyStore); | ||
TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); | ||
var trustManagers = trustManagerFactory.getTrustManagers(); | ||
return (X509TrustManager) trustManagers[0]; | ||
} catch (KeyStoreException | NoSuchAlgorithmException e) { | ||
throw new RuntimeException(e); | ||
} | ||
} | ||
|
||
private Optional<KeyStore> getCustomKeyStore() { | ||
WebhookProperties.TrustSettings trustSettings = webhookProperties.getTrust(); | ||
if (trustSettings == null | ||
|| !trustSettings.isEnabled() | ||
|| StringUtils.isEmpty(trustSettings.getTrustStore())) { | ||
private Optional<X509Identity> getCustomIdentity() throws IOException { | ||
var identitySettings = webhookProperties.getIdentity(); | ||
if (identitySettings == null || !identitySettings.isEnabled()) { | ||
return Optional.empty(); | ||
} | ||
|
||
KeyStore keyStore; | ||
try { | ||
keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); | ||
} catch (KeyStoreException e) { | ||
throw new RuntimeException(e); | ||
if (StringUtils.isNotEmpty(identitySettings.getIdentityStore())) { | ||
var identity = | ||
X509IdentitySource.fromKeyStore( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Constructs a client identity from a keystore. The keystore must be password protected, and the private key entry in the store also must be password protected. For unencrypted keys, use PEM config instead. |
||
Path.of(identitySettings.getIdentityStore()), | ||
identitySettings.getIdentityStoreType(), | ||
() -> { | ||
var password = identitySettings.getIdentityStorePassword(); | ||
return password == null ? new char[0] : password.toCharArray(); | ||
}, | ||
() -> { | ||
var password = identitySettings.getIdentityStoreKeyPassword(); | ||
return password == null ? new char[0] : password.toCharArray(); | ||
}); | ||
return Optional.of(identity.load()); | ||
} else if (StringUtils.isNotEmpty(identitySettings.getIdentityKeyPem())) { | ||
return Optional.of( | ||
X509IdentitySource.fromPEM( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Constructs a client identity from PEM key and PEM cert. No encryption or password-protection supported. |
||
Path.of(identitySettings.getIdentityKeyPem()), | ||
Path.of(identitySettings.getIdentityCertPem())) | ||
.load()); | ||
} | ||
|
||
try (FileInputStream file = new FileInputStream(trustSettings.getTrustStore())) { | ||
keyStore.load(file, trustSettings.getTrustStorePassword().toCharArray()); | ||
} catch (CertificateException | IOException | NoSuchAlgorithmException e) { | ||
throw new RuntimeException(e); | ||
return Optional.empty(); | ||
} | ||
|
||
private Optional<KeyStore> getCustomTrustStore() { | ||
var trustSettings = webhookProperties.getTrust(); | ||
if (trustSettings == null || !trustSettings.isEnabled()) { | ||
return Optional.empty(); | ||
} | ||
|
||
// Use keystore first if set, then try PEM | ||
if (StringUtils.isNotEmpty(trustSettings.getTrustStore())) { | ||
KeyStore keyStore; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Loads CA trust material from a keystore (preexisting code) |
||
try { | ||
keyStore = KeyStore.getInstance(trustSettings.getTrustStoreType()); | ||
} catch (KeyStoreException e) { | ||
throw new RuntimeException(e); | ||
} | ||
|
||
try (FileInputStream file = new FileInputStream(trustSettings.getTrustStore())) { | ||
keyStore.load(file, trustSettings.getTrustStorePassword().toCharArray()); | ||
} catch (CertificateException | IOException | NoSuchAlgorithmException e) { | ||
throw new RuntimeException(e); | ||
} | ||
|
||
return Optional.of(keyStore); | ||
} else if (StringUtils.isNotEmpty(trustSettings.getTrustPem())) { | ||
try { | ||
return Optional.of(TrustStores.loadPEM(Path.of(trustSettings.getTrustPem()))); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Loads CA trust material from a PEM certificate list (new code) |
||
} catch (CertificateException | ||
| IOException | ||
| NoSuchAlgorithmException | ||
| KeyStoreException e) { | ||
throw new RuntimeException(e); | ||
} | ||
} | ||
|
||
return Optional.of(keyStore); | ||
return Optional.empty(); | ||
} | ||
|
||
public class ObjectStringHttpMessageConverter extends StringHttpMessageConverter { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -53,18 +53,39 @@ public class WebhookProperties { | |
.collect(Collectors.toList()); | ||
|
||
private List<PreconfiguredWebhook> preconfigured = new ArrayList<>(); | ||
private TrustSettings trust; | ||
private TrustSettings trust = new TrustSettings(); | ||
private IdentitySettings identity = new IdentitySettings(); | ||
|
||
private boolean verifyRedirects = true; | ||
|
||
private List<Integer> defaultRetryStatusCodes = List.of(429); | ||
|
||
// For testing *only* | ||
private boolean insecureSkipHostnameVerification = false; | ||
private boolean insecureTrustSelfSigned = false; | ||
|
||
@Data | ||
@NoArgsConstructor | ||
public static class TrustSettings { | ||
private boolean enabled; | ||
private String trustStore; | ||
private String trustStorePassword; | ||
// Default as JKS instead of PKCS12 for backward compatibility | ||
private String trustStoreType = "JKS"; | ||
private String trustPem; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For the record, a curl command like
corresponds to these pem properties. |
||
} | ||
|
||
@Data | ||
@NoArgsConstructor | ||
public static class IdentitySettings { | ||
private boolean enabled; | ||
private String identityStore; | ||
private String identityStorePassword; | ||
private String identityStoreKeyPassword; | ||
private String identityStoreType = "PKCS12"; | ||
|
||
private String identityKeyPem; | ||
private String identityCertPem; | ||
} | ||
|
||
@Data | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
/* | ||
* Copyright 2024 Apple, Inc. | ||
* | ||
* Licensed 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 com.netflix.spinnaker.orca.webhook.config; | ||
|
||
import static org.junit.jupiter.api.Assertions.assertEquals; | ||
|
||
import com.netflix.spinnaker.config.OkHttp3ClientConfiguration; | ||
import com.netflix.spinnaker.config.OkHttpClientComponents; | ||
import com.netflix.spinnaker.orca.pipeline.model.StageExecutionImpl; | ||
import com.netflix.spinnaker.orca.webhook.service.WebhookService; | ||
import java.util.HashMap; | ||
import java.util.Map; | ||
import lombok.SneakyThrows; | ||
import org.junit.jupiter.api.Test; | ||
import org.springframework.beans.factory.annotation.Autowired; | ||
import org.springframework.boot.test.context.SpringBootTest; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Primary; | ||
import org.springframework.http.HttpMethod; | ||
import org.springframework.http.HttpStatus; | ||
|
||
@SpringBootTest( | ||
classes = { | ||
MtlsConfigurationKeystoreTest.KeyStoreTestConfiguration.class, | ||
MtlsConfigurationTestBase.TestConfigurationBase.class, | ||
OkHttp3ClientConfiguration.class, | ||
OkHttpClientComponents.class, | ||
WebhookConfiguration.class, | ||
WebhookService.class | ||
}) | ||
public class MtlsConfigurationKeystoreTest extends MtlsConfigurationTestBase { | ||
static class KeyStoreTestConfiguration { | ||
@Bean | ||
@Primary | ||
WebhookProperties webhookProperties() { | ||
// Set up identity and trust properties | ||
var props = new WebhookProperties(); | ||
|
||
var identity = new WebhookProperties.IdentitySettings(); | ||
identity.setEnabled(true); | ||
identity.setIdentityStore(clientIdentityStoreFile.getAbsolutePath()); | ||
identity.setIdentityStorePassword(password); | ||
identity.setIdentityStoreKeyPassword(password); | ||
props.setIdentity(identity); | ||
|
||
var trust = new WebhookProperties.TrustSettings(); | ||
trust.setEnabled(true); | ||
trust.setTrustStore(caStoreFile.getAbsolutePath()); | ||
trust.setTrustStorePassword(password); | ||
props.setTrust(trust); | ||
|
||
// Tell okhttp to skip hostname verification, since all this is made up | ||
props.setInsecureSkipHostnameVerification(true); | ||
|
||
return props; | ||
} | ||
} | ||
|
||
@Autowired WebhookService service; | ||
|
||
@Test | ||
@SneakyThrows | ||
public void mTLSConnectivityTest() { | ||
var context = | ||
new HashMap<String, Object>() { | ||
{ | ||
put("url", mockWebServer.url("/").toString()); | ||
put("method", HttpMethod.POST); | ||
put("payload", "{ \"foo\": \"bar\" }"); | ||
} | ||
}; | ||
|
||
var stageExecution = new StageExecutionImpl(null, null, null, context); | ||
var response = service.callWebhook(stageExecution); | ||
|
||
assertEquals(HttpStatus.OK, response.getStatusCode()); | ||
var body = mapper.readValue(response.getBody().toString(), Map.class); | ||
assertEquals("yep", body.get("mtls")); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Converted a bunch of types to
var
for legibility