From 3a078c0c31a487e27c2b93a9b97bd8acf143e931 Mon Sep 17 00:00:00 2001 From: exceptionfactory Date: Fri, 21 Jul 2023 13:04:48 -0500 Subject: [PATCH] NIFI-11780 Added Certificate SAN Attributes to HandleHttpRequest Signed-off-by: Pierre Villard This closes #7521. --- .../standard/HandleHttpRequest.java | 22 +-- .../standard/http/CertificateAttribute.java | 44 +++++ .../http/CertificateAttributesProvider.java | 33 ++++ ...pRequestCertificateAttributesProvider.java | 131 +++++++++++++++ ...uestCertificateAttributesProviderTest.java | 151 ++++++++++++++++++ 5 files changed, 370 insertions(+), 11 deletions(-) create mode 100644 nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/http/CertificateAttribute.java create mode 100644 nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/http/CertificateAttributesProvider.java create mode 100644 nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/http/HandleHttpRequestCertificateAttributesProvider.java create mode 100644 nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/http/HandleHttpRequestCertificateAttributesProviderTest.java diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/HandleHttpRequest.java b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/HandleHttpRequest.java index 39e598f880..20c06f1305 100644 --- a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/HandleHttpRequest.java +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/HandleHttpRequest.java @@ -41,6 +41,9 @@ import org.apache.nifi.processor.ProcessSession; import org.apache.nifi.processor.Relationship; import org.apache.nifi.processor.exception.ProcessException; import org.apache.nifi.processor.util.StandardValidators; +import org.apache.nifi.processors.standard.http.CertificateAttribute; +import org.apache.nifi.processors.standard.http.CertificateAttributesProvider; +import org.apache.nifi.processors.standard.http.HandleHttpRequestCertificateAttributesProvider; import org.apache.nifi.processors.standard.http.HttpProtocolStrategy; import org.apache.nifi.processors.standard.util.HTTPUtils; import org.apache.nifi.scheduling.ExecutionNode; @@ -69,7 +72,6 @@ import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URLDecoder; import java.security.Principal; -import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Collections; import java.util.Enumeration; @@ -122,6 +124,10 @@ import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST; + "unless the Processor is configured to use an SSLContext Service"), @WritesAttribute(attribute = "http.issuer.dn", description = "The Distinguished Name of the entity that issued the Subject's certificate. " + "This value will not be populated unless the Processor is configured to use an SSLContext Service"), + @WritesAttribute(attribute = "http.certificate.sans.N.name", description = "X.509 Client Certificate Subject Alternative Name value from mutual TLS authentication. " + + "The attribute name has a zero-based index ordered according to the content of Client Certificate"), + @WritesAttribute(attribute = "http.certificate.sans.N.nameType", description = "X.509 Client Certificate Subject Alternative Name type from mutual TLS authentication. " + + "The attribute name has a zero-based index ordered according to the content of Client Certificate. The attribute value is one of the General Names from RFC 3280 Section 4.1.2.7"), @WritesAttribute(attribute = "http.headers.XXX", description = "Each of the HTTP Headers that is received in the request will be added as an " + "attribute, prefixed with \"http.headers.\" For example, if the request contains an HTTP Header named \"x-my-header\", then the value " + "will be added to an attribute named \"http.headers.x-my-header\""), @@ -343,6 +349,7 @@ public class HandleHttpRequest extends AbstractProcessor { private final AtomicBoolean initialized = new AtomicBoolean(false); private final AtomicBoolean runOnPrimary = new AtomicBoolean(false); private final AtomicReference> parameterToAttributesReference = new AtomicReference<>(null); + private final CertificateAttributesProvider certificateAttributesProvider = new HandleHttpRequestCertificateAttributesProvider(); @Override protected List getSupportedPropertyDescriptors() { @@ -756,22 +763,15 @@ public class HandleHttpRequest extends AbstractProcessor { putAttribute(attributes, "http.principal.name", principal.getName()); } - final X509Certificate[] certs = (X509Certificate[]) request.getAttribute("javax.servlet.request.X509Certificate"); - if (certs != null && certs.length > 0) { - final X509Certificate cert = certs[0]; - final String subjectDn = cert.getSubjectDN().getName(); - final String issuerDn = cert.getIssuerDN().getName(); - - putAttribute(attributes, HTTPUtils.HTTP_SSL_CERT, subjectDn); - putAttribute(attributes, "http.issuer.dn", issuerDn); - } + final Map certificateAttributes = certificateAttributesProvider.getCertificateAttributes(request); + attributes.putAll(certificateAttributes); return session.putAllAttributes(flowFile, attributes); } private void forwardFlowFile(final ProcessSession session, final long start, final HttpServletRequest request, final FlowFile flowFile) { final long receiveMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); - final String subjectDn = flowFile.getAttribute(HTTPUtils.HTTP_SSL_CERT); + final String subjectDn = flowFile.getAttribute(CertificateAttribute.HTTP_SUBJECT_DN.getName()); session.getProvenanceReporter().receive(flowFile, HTTPUtils.getURI(flowFile.getAttributes()), "Received from " + request.getRemoteAddr() + (subjectDn == null ? "" : " with DN=" + subjectDn), receiveMillis); session.transfer(flowFile, REL_SUCCESS); diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/http/CertificateAttribute.java b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/http/CertificateAttribute.java new file mode 100644 index 0000000000..1b4be1f4ad --- /dev/null +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/http/CertificateAttribute.java @@ -0,0 +1,44 @@ +/* + * 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.nifi.processors.standard.http; + +/** + * X.509 Client Certificate FlowFile Attribute Names + */ +public enum CertificateAttribute { + /** Certificate Subject Distinguished Name */ + HTTP_SUBJECT_DN("http.subject.dn"), + + /** Certificate Issuer Distinguished Name */ + HTTP_ISSUER_DN("http.issuer.dn"), + + /** Certificate Subject Distinguished Name */ + HTTP_CERTIFICATE_PARSING_EXCEPTION("http.certificate.parsing.exception"), + + /** Certificate Subject Alternative Names */ + HTTP_CERTIFICATE_SANS("http.certificate.sans"); + + private final String name; + + CertificateAttribute(final String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/http/CertificateAttributesProvider.java b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/http/CertificateAttributesProvider.java new file mode 100644 index 0000000000..78fe286079 --- /dev/null +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/http/CertificateAttributesProvider.java @@ -0,0 +1,33 @@ +/* + * 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.nifi.processors.standard.http; + +import javax.servlet.http.HttpServletRequest; +import java.util.Map; + +/** + * Provider abstraction for reading X.509 Client Certificates from an HTTP Servlet Request and returning FlowFile attributes + */ +public interface CertificateAttributesProvider { + /** + * Get X.509 Client Certificate Attributes + * + * @param request HTTP Servlet Request + * @return Map of Client Certificate Attributes or empty when no client certificate found + */ + Map getCertificateAttributes(HttpServletRequest request); +} diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/http/HandleHttpRequestCertificateAttributesProvider.java b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/http/HandleHttpRequestCertificateAttributesProvider.java new file mode 100644 index 0000000000..a3b75d7b96 --- /dev/null +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/http/HandleHttpRequestCertificateAttributesProvider.java @@ -0,0 +1,131 @@ +/* + * 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.nifi.processors.standard.http; + +import javax.servlet.http.HttpServletRequest; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Certificate Attributes Provider for HandleHttpRequest reads the first X.509 Certificate presented + */ +public class HandleHttpRequestCertificateAttributesProvider implements CertificateAttributesProvider { + protected static final String REQUEST_CERTIFICATES_ATTRIBUTE_NAME = "javax.servlet.request.X509Certificate"; + + private static final String SAN_NAME_TYPE_FORMAT = "%s.%d.nameType"; + + private static final String SAN_NAME_FORMAT = "%s.%d.name"; + + private static final Map GENERAL_NAME_TYPES = new LinkedHashMap<>(); + + static { + // General Name types defined in RFC 3280 Section 4.2.1.7 */ + GENERAL_NAME_TYPES.put("0", "otherName"); + GENERAL_NAME_TYPES.put("1", "rfc822Name"); + GENERAL_NAME_TYPES.put("2", "dNSName"); + GENERAL_NAME_TYPES.put("3", "x400Address"); + GENERAL_NAME_TYPES.put("4", "directoryName"); + GENERAL_NAME_TYPES.put("5", "ediPartyName"); + GENERAL_NAME_TYPES.put("6", "uniformResourceIdentifier"); + GENERAL_NAME_TYPES.put("7", "iPAddress"); + GENERAL_NAME_TYPES.put("8", "registeredID"); + } + + @Override + public Map getCertificateAttributes(final HttpServletRequest request) { + Objects.requireNonNull(request, "HTTP Servlet Request required"); + + final Map attributes; + + final Object requestCertificates = request.getAttribute(REQUEST_CERTIFICATES_ATTRIBUTE_NAME); + if (requestCertificates instanceof X509Certificate[]) { + final X509Certificate[] certificates = (X509Certificate[]) requestCertificates; + if (certificates.length == 0) { + attributes = Collections.emptyMap(); + } else { + final X509Certificate clientCertificate = certificates[0]; + attributes = getCertificateAttributes(clientCertificate); + } + } else { + attributes = Collections.emptyMap(); + } + + return attributes; + } + + private Map getCertificateAttributes(final X509Certificate certificate) { + final Map attributes = new LinkedHashMap<>(); + + final String subjectPrincipal = certificate.getSubjectX500Principal().getName(); + final String issuerPrincipal = certificate.getIssuerX500Principal().getName(); + + attributes.put(CertificateAttribute.HTTP_SUBJECT_DN.getName(), subjectPrincipal); + attributes.put(CertificateAttribute.HTTP_ISSUER_DN.getName(), issuerPrincipal); + + try { + final Collection> subjectAlternativeNames = certificate.getSubjectAlternativeNames(); + + if (subjectAlternativeNames != null) { + final Map subjectAlternativeNameAttributes = getSubjectAlternativeNameAttributes(subjectAlternativeNames); + attributes.putAll(subjectAlternativeNameAttributes); + } + } catch (final CertificateParsingException e) { + attributes.put(CertificateAttribute.HTTP_CERTIFICATE_PARSING_EXCEPTION.getName(), e.getMessage()); + } + + return attributes; + } + + private Map getSubjectAlternativeNameAttributes(final Collection> subjectAlternativeNames) { + final Map attributes = new LinkedHashMap<>(); + + int subjectAlternativeNameIndex = 0; + for (final List subjectAlternativeTypeName : subjectAlternativeNames) { + final String nameTypeAttributeKey = String.format(SAN_NAME_TYPE_FORMAT, CertificateAttribute.HTTP_CERTIFICATE_SANS.getName(), subjectAlternativeNameIndex); + final String nameType = subjectAlternativeTypeName.get(0).toString(); + final String generalNameType = GENERAL_NAME_TYPES.getOrDefault(nameType, nameType); + attributes.put(nameTypeAttributeKey, generalNameType); + + final String nameAttributeKey = String.format(SAN_NAME_FORMAT, CertificateAttribute.HTTP_CERTIFICATE_SANS.getName(), subjectAlternativeNameIndex); + final Object name = subjectAlternativeTypeName.get(1); + final String serializedName = getSerializedName(name); + attributes.put(nameAttributeKey, serializedName); + + subjectAlternativeNameIndex++; + } + + return attributes; + } + + private String getSerializedName(final Object name) { + final String serializedName; + if (name instanceof byte[]) { + final byte[] encodedName = (byte[]) name; + serializedName = Base64.getEncoder().encodeToString(encodedName); + } else { + serializedName = name.toString(); + } + return serializedName; + } +} diff --git a/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/http/HandleHttpRequestCertificateAttributesProviderTest.java b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/http/HandleHttpRequestCertificateAttributesProviderTest.java new file mode 100644 index 0000000000..3e4df7c283 --- /dev/null +++ b/nifi-nar-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/http/HandleHttpRequestCertificateAttributesProviderTest.java @@ -0,0 +1,151 @@ +/* + * 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.nifi.processors.standard.http; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.security.auth.x500.X500Principal; +import javax.servlet.http.HttpServletRequest; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HandleHttpRequestCertificateAttributesProviderTest { + private static final X500Principal SUBJECT_PRINCIPAL = new X500Principal("CN=subject, OU=users"); + + private static final X500Principal ISSUER_PRINCIPAL = new X500Principal("CN=issuer, OU=authorities"); + + private static final String RFC_822_NAME_GENERAL_NAME = "rfc822Name"; + + private static final String DNS_NAME_GENERAL_NAME = "dNSName"; + + private static final Integer RFC_822_NAME_TYPE = 1; + + private static final String EMAIL_ADDRESS = "username@localhost.localdomain"; + + private static final Integer DNS_NAME_TYPE = 2; + + private static final String DNS_NAME = "localhost.localdomain"; + + private static final String FIRST_SAN_NAME_ATTRIBUTE_KEY = "http.certificate.sans.0.name"; + + private static final String FIRST_SAN_NAME_TYPE_ATTRIBUTE_KEY = "http.certificate.sans.0.nameType"; + + private static final String SECOND_SAN_NAME_ATTRIBUTE_KEY = "http.certificate.sans.1.name"; + + private static final String SECOND_SAN_NAME_TYPE_ATTRIBUTE_KEY = "http.certificate.sans.1.nameType"; + + private static final String PARSING_EXCEPTION_MESSAGE = "SAN parsing failed"; + + @Mock + private HttpServletRequest request; + + @Mock + private X509Certificate certificate; + + private HandleHttpRequestCertificateAttributesProvider provider; + + @BeforeEach + void setProvider() { + provider = new HandleHttpRequestCertificateAttributesProvider(); + } + + @Test + void testCertificatesNotFound() { + final Map attributes = provider.getCertificateAttributes(request); + + assertTrue(attributes.isEmpty()); + } + + @Test + void testCertificatesFound() { + final X509Certificate[] certificates = new X509Certificate[]{certificate}; + when(request.getAttribute(eq(HandleHttpRequestCertificateAttributesProvider.REQUEST_CERTIFICATES_ATTRIBUTE_NAME))).thenReturn(certificates); + + when(certificate.getSubjectX500Principal()).thenReturn(SUBJECT_PRINCIPAL); + when(certificate.getIssuerX500Principal()).thenReturn(ISSUER_PRINCIPAL); + + final Map attributes = provider.getCertificateAttributes(request); + + assertSubjectIssuerFound(attributes); + } + + @Test + void testCertificatesFoundParsingException() throws CertificateParsingException { + final X509Certificate[] certificates = new X509Certificate[]{certificate}; + when(request.getAttribute(eq(HandleHttpRequestCertificateAttributesProvider.REQUEST_CERTIFICATES_ATTRIBUTE_NAME))).thenReturn(certificates); + + when(certificate.getSubjectX500Principal()).thenReturn(SUBJECT_PRINCIPAL); + when(certificate.getIssuerX500Principal()).thenReturn(ISSUER_PRINCIPAL); + + when(certificate.getSubjectAlternativeNames()).thenThrow(new CertificateParsingException(PARSING_EXCEPTION_MESSAGE)); + + final Map attributes = provider.getCertificateAttributes(request); + + assertSubjectIssuerFound(attributes); + + assertEquals(attributes.get(CertificateAttribute.HTTP_CERTIFICATE_PARSING_EXCEPTION.getName()), PARSING_EXCEPTION_MESSAGE); + } + + @Test + void testCertificateSubjectAlternativeNamesFound() throws CertificateParsingException { + final X509Certificate[] certificates = new X509Certificate[]{certificate}; + when(request.getAttribute(eq(HandleHttpRequestCertificateAttributesProvider.REQUEST_CERTIFICATES_ATTRIBUTE_NAME))).thenReturn(certificates); + + when(certificate.getSubjectX500Principal()).thenReturn(SUBJECT_PRINCIPAL); + when(certificate.getIssuerX500Principal()).thenReturn(ISSUER_PRINCIPAL); + + final Collection> subjectAlternativeNames = new ArrayList<>(); + + final List emailAddressName = Arrays.asList(RFC_822_NAME_TYPE, EMAIL_ADDRESS); + subjectAlternativeNames.add(emailAddressName); + + final List dnsName = Arrays.asList(DNS_NAME_TYPE, DNS_NAME); + subjectAlternativeNames.add(dnsName); + + when(certificate.getSubjectAlternativeNames()).thenReturn(subjectAlternativeNames); + + final Map attributes = provider.getCertificateAttributes(request); + + assertSubjectIssuerFound(attributes); + + assertEquals(attributes.get(FIRST_SAN_NAME_ATTRIBUTE_KEY), EMAIL_ADDRESS); + assertEquals(attributes.get(FIRST_SAN_NAME_TYPE_ATTRIBUTE_KEY), RFC_822_NAME_GENERAL_NAME); + + assertEquals(attributes.get(SECOND_SAN_NAME_ATTRIBUTE_KEY), DNS_NAME); + assertEquals(attributes.get(SECOND_SAN_NAME_TYPE_ATTRIBUTE_KEY), DNS_NAME_GENERAL_NAME); + } + + private void assertSubjectIssuerFound(final Map attributes) { + assertEquals(SUBJECT_PRINCIPAL.getName(), attributes.get(CertificateAttribute.HTTP_SUBJECT_DN.getName())); + assertEquals(ISSUER_PRINCIPAL.getName(), attributes.get(CertificateAttribute.HTTP_ISSUER_DN.getName())); + } +}