HTTP binding: add TLS config options for client cert verification (#2399)

* adding client cert for client verification

Signed-off-by: Pravin Pushkar <ppushkar@microsoft.com>

* logic for getting certs is provided as pem encoded strings

Signed-off-by: Pravin Pushkar <ppushkar@microsoft.com>

* adding test for https endpoint

Signed-off-by: Pravin Pushkar <ppushkar@microsoft.com>

* tls min version

Signed-off-by: Pravin Pushkar <ppushkar@microsoft.com>

* adding tests

Signed-off-by: Pravin Pushkar <ppushkar@microsoft.com>

* added more tests and some test refactoring

Signed-off-by: Pravin Pushkar <ppushkar@microsoft.com>

* modify err check for file not exist

Signed-off-by: Pravin Pushkar <ppushkar@microsoft.com>

* review comments

Signed-off-by: Pravin Pushkar <ppushkar@microsoft.com>

Signed-off-by: Pravin Pushkar <ppushkar@microsoft.com>
This commit is contained in:
Pravin Pushkar 2023-01-11 10:48:48 +05:30 committed by GitHub
parent 46652b69d2
commit 1ef514df3f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 318 additions and 12 deletions

View File

@ -16,10 +16,15 @@ package http
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
@ -32,6 +37,13 @@ import (
"github.com/dapr/kit/logger"
)
const (
MTLSEnable = "MTLSEnable"
MTLSRootCA = "MTLSRootCA"
MTLSClientCert = "MTLSClientCert"
MTLSClientKey = "MTLSClientKey"
)
// HTTPSource is a binding for an http url endpoint invocation
//
//revive:disable-next-line
@ -43,7 +55,10 @@ type HTTPSource struct {
}
type httpMetadata struct {
URL string `mapstructure:"url"`
URL string `mapstructure:"url"`
MTLSClientCert string `mapstructure:"mtlsClientCert"`
MTLSClientKey string `mapstructure:"mtlsClientKey"`
MTLSRootCA string `mapstructure:"mtlsRootCA"`
}
// NewHTTP returns a new HTTPSource.
@ -53,9 +68,17 @@ func NewHTTP(logger logger.Logger) bindings.OutputBinding {
// Init performs metadata parsing.
func (h *HTTPSource) Init(metadata bindings.Metadata) error {
if err := mapstructure.Decode(metadata.Properties, &h.metadata); err != nil {
var err error
if err = mapstructure.Decode(metadata.Properties, &h.metadata); err != nil {
return err
}
var tlsConfig *tls.Config
if h.metadata.MTLSClientCert != "" && h.metadata.MTLSClientKey != "" {
tlsConfig, err = h.readMTLSCertificates()
if err != nil {
return err
}
}
// See guidance on proper HTTP client settings here:
// https://medium.com/@nate510/don-t-use-go-s-default-http-client-4804cb19f779
@ -66,6 +89,9 @@ func (h *HTTPSource) Init(metadata bindings.Metadata) error {
Dial: dialer.Dial,
TLSHandshakeTimeout: 5 * time.Second,
}
if tlsConfig != nil && len(tlsConfig.Certificates) > 0 && tlsConfig.RootCAs != nil {
netTransport.TLSClientConfig = tlsConfig
}
h.client = &http.Client{
Timeout: time.Second * 30,
Transport: netTransport,
@ -81,6 +107,66 @@ func (h *HTTPSource) Init(metadata bindings.Metadata) error {
return nil
}
// readMTLSCertificates reads the certificates and key from the metadata and returns a tls.Config.
func (h *HTTPSource) readMTLSCertificates() (*tls.Config, error) {
clientCertBytes, err := h.getPemBytes(MTLSClientCert, h.metadata.MTLSClientCert)
if err != nil {
return nil, err
}
clientKeyBytes, err := h.getPemBytes(MTLSClientKey, h.metadata.MTLSClientKey)
if err != nil {
return nil, err
}
cert, err := tls.X509KeyPair(clientCertBytes, clientKeyBytes)
if err != nil {
return nil, fmt.Errorf("failed to load client certificate: %w", err)
}
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cert},
}
if h.metadata.MTLSRootCA != "" {
caCertBytes, err := h.getPemBytes(MTLSRootCA, h.metadata.MTLSRootCA)
if err != nil {
return nil, err
}
caCertPool := x509.NewCertPool()
ok := caCertPool.AppendCertsFromPEM(caCertBytes)
if !ok {
return nil, errors.New("failed to add root certificate to certpool")
}
tlsConfig.RootCAs = caCertPool
}
return tlsConfig, nil
}
// getPemBytes returns the PEM encoded bytes from the provided certName and certData.
// If the certData is a file path, it reads the file and returns the bytes.
// Else if the certData is a PEM encoded string, it returns the bytes.
// Else it returns an error.
func (h *HTTPSource) getPemBytes(certName, certData string) ([]byte, error) {
// Read the file
pemBytes, err := os.ReadFile(certData)
// If there is an error assume it is already PEM encoded string not a file path.
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("failed to read %q file: %w", certName, err)
}
if !isValidPEM(certData) {
return nil, fmt.Errorf("provided %q value is neither a valid file path or nor a valid pem encoded string", certName)
}
return []byte(certData), nil
}
return pemBytes, nil
}
// isValidPEM validates the provided input has PEM formatted block.
func isValidPEM(val string) bool {
block, _ := pem.Decode([]byte(val))
return block != nil
}
// Operations returns the supported operations for this binding.
func (h *HTTPSource) Operations() []bindings.OperationKind {
return []bindings.OperationKind{

View File

@ -15,9 +15,14 @@ package http_test
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
@ -142,7 +147,160 @@ func TestDefaultBehavior(t *testing.T) {
hs, err := InitBinding(s, nil)
require.NoError(t, err)
verifyDefaultBehaviors(t, hs, handler)
}
func TestNon2XXErrorsSuppressed(t *testing.T) {
handler := NewHTTPHandler()
s := httptest.NewServer(handler)
defer s.Close()
hs, err := InitBinding(s, map[string]string{"errorIfNot2XX": "false"})
require.NoError(t, err)
verifyNon2XXErrorsSuppressed(t, hs, handler)
}
func InitBindingForHTTPS(s *httptest.Server, extraProps map[string]string) (bindings.OutputBinding, error) {
m := bindings.Metadata{Base: metadata.Base{
Properties: map[string]string{
"url": s.URL,
},
}}
for k, v := range extraProps {
m.Properties[k] = v
}
hs := bindingHttp.NewHTTP(logger.NewLogger("test"))
err := hs.Init(m)
return hs, err
}
func httpsHandler(w http.ResponseWriter, r *http.Request) {
// r.TLS gets ignored by HTTP handlers.
// in case where client auth is not required, r.TLS.PeerCertificates will be empty.
res := fmt.Sprintf("%v", len(r.TLS.PeerCertificates))
io.WriteString(w, res)
}
func TestDefaultBehaviorHTTPS(t *testing.T) {
handler := NewHTTPHandler()
server := setupHTTPSServer(t, true, handler)
defer server.Close()
certMap := map[string]string{
"MTLSRootCA": filepath.Join(".", "testdata", "ca.pem"),
"MTLSClientCert": filepath.Join(".", "testdata", "client.pem"),
"MTLSClientKey": filepath.Join(".", "testdata", "client.key"),
}
hs, err := InitBindingForHTTPS(server, certMap)
require.NoError(t, err)
verifyDefaultBehaviors(t, hs, handler)
}
func TestNon2XXErrorsSuppressedHTTPS(t *testing.T) {
handler := NewHTTPHandler()
server := setupHTTPSServer(t, true, handler)
defer server.Close()
certMap := map[string]string{
"MTLSRootCA": filepath.Join(".", "testdata", "ca.pem"),
"MTLSClientCert": filepath.Join(".", "testdata", "client.pem"),
"MTLSClientKey": filepath.Join(".", "testdata", "client.key"),
"errorIfNot2XX": "false",
}
hs, err := InitBindingForHTTPS(server, certMap)
require.NoError(t, err)
verifyNon2XXErrorsSuppressed(t, hs, handler)
}
func TestHTTPSBinding(t *testing.T) {
handler := http.NewServeMux()
handler.HandleFunc("/testhttps", httpsHandler)
server := setupHTTPSServer(t, true, handler)
defer server.Close()
t.Run("get with https with valid client cert and clientAuthEnabled true", func(t *testing.T) {
certMap := map[string]string{
"MTLSRootCA": filepath.Join(".", "testdata", "ca.pem"),
"MTLSClientCert": filepath.Join(".", "testdata", "client.pem"),
"MTLSClientKey": filepath.Join(".", "testdata", "client.key"),
}
hs, err := InitBindingForHTTPS(server, certMap)
require.NoError(t, err)
req := TestCase{
input: "GET",
operation: "get",
metadata: map[string]string{"path": "/testhttps"},
path: "/testhttps",
err: "",
statusCode: 200,
}.ToInvokeRequest()
response, err := hs.Invoke(context.Background(), &req)
assert.NoError(t, err)
peerCerts, err := strconv.Atoi(string(response.Data))
assert.NoError(t, err)
assert.True(t, peerCerts > 0)
req = TestCase{
input: "EXPECTED",
operation: "post",
metadata: map[string]string{"path": "/testhttps"},
path: "/testhttps",
err: "",
statusCode: 201,
}.ToInvokeRequest()
response, err = hs.Invoke(context.Background(), &req)
assert.NoError(t, err)
peerCerts, err = strconv.Atoi(string(response.Data))
assert.NoError(t, err)
assert.True(t, peerCerts > 0)
})
t.Run("get with https with no client cert and clientAuthEnabled true", func(t *testing.T) {
certMap := map[string]string{}
hs, err := InitBindingForHTTPS(server, certMap)
require.NoError(t, err)
req := TestCase{
input: "GET",
operation: "get",
metadata: map[string]string{"path": "/testhttps"},
path: "/testhttps",
err: "",
statusCode: 200,
}.ToInvokeRequest()
_, err = hs.Invoke(context.Background(), &req)
assert.Error(t, err)
})
}
func setupHTTPSServer(t *testing.T, clientAuthEnabled bool, handler http.Handler) *httptest.Server {
server := httptest.NewUnstartedServer(handler)
caCertFile, err := os.ReadFile(filepath.Join(".", "testdata", "ca.pem"))
assert.NoError(t, err)
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCertFile)
serverCert := filepath.Join(".", "testdata", "server.pem")
serverKey := filepath.Join(".", "testdata", "server.key")
cert, err := tls.LoadX509KeyPair(serverCert, serverKey)
assert.NoError(t, err)
// Create the TLS Config with the CA pool and enable Client certificate validation
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
ClientCAs: caCertPool,
Certificates: []tls.Certificate{cert},
}
if clientAuthEnabled {
tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
}
server.TLS = tlsConfig
server.StartTLS()
return server
}
func verifyDefaultBehaviors(t *testing.T, hs bindings.OutputBinding, handler *HTTPHandler) {
tests := map[string]TestCase{
"get": {
input: "GET",
@ -269,7 +427,7 @@ func TestDefaultBehavior(t *testing.T) {
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
req := tc.ToInvokeRequest()
response, err := hs.Invoke(context.TODO(), &req)
response, err := hs.Invoke(context.Background(), &req)
if tc.err == "" {
require.NoError(t, err)
assert.Equal(t, tc.path, handler.Path)
@ -286,14 +444,7 @@ func TestDefaultBehavior(t *testing.T) {
}
}
func TestNon2XXErrorsSuppressed(t *testing.T) {
handler := NewHTTPHandler()
s := httptest.NewServer(handler)
defer s.Close()
hs, err := InitBinding(s, map[string]string{"errorIfNot2XX": "false"})
require.NoError(t, err)
func verifyNon2XXErrorsSuppressed(t *testing.T, hs bindings.OutputBinding, handler *HTTPHandler) {
tests := map[string]TestCase{
"internal server error": {
input: "internal server error",
@ -348,7 +499,7 @@ func TestNon2XXErrorsSuppressed(t *testing.T) {
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
req := tc.ToInvokeRequest()
response, err := hs.Invoke(context.TODO(), &req)
response, err := hs.Invoke(context.Background(), &req)
if tc.err == "" {
require.NoError(t, err)
assert.Equal(t, tc.path, handler.Path)

View File

@ -39,3 +39,18 @@ metadata:
# If omitted, uses the same values as "<root>.binding"
binding:
output: true
- name: MTLSRootCA
required: false
description: "The CA certificate used to generate the client certificate"
binding:
output: true
- name: MTLSClientCert
required: false
description: "The client certificate to present to server to enable client verification"
binding:
output: true
- name: MTLSClientKey
required: false
description: "The client certificate key to present to server to enable client verification"
binding:
output: true

14
bindings/http/testdata/ca.pem vendored Normal file
View File

@ -0,0 +1,14 @@
-----BEGIN CERTIFICATE-----
MIICHzCCAcWgAwIBAgIUVmulwhIO5Y2A8ACi3OcX94denFEwCgYIKoZIzj0EAwIw
UDELMAkGA1UEBhMCSU4xCzAJBgNVBAgMAlRTMQswCQYDVQQHDAJIWTETMBEGA1UE
CgwKRGFwciBXb3JsZDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIzMDEwNjEwMDI0
NVoXDTI0MDEwNjEwMDI0NVowUDELMAkGA1UEBhMCSU4xCzAJBgNVBAgMAlRTMQsw
CQYDVQQHDAJIWTETMBEGA1UECgwKRGFwciBXb3JsZDESMBAGA1UEAwwJbG9jYWxo
b3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE03ecfl+LFMzocftTCwpYrcJK
YwutP6My8ZNfELp0IUqvMyTCgG3o08niA4GrUGaP73wyUFhO6UaswvFccBI92aN9
MHswDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYI
KwYBBQUHAwEGCCsGAQUFBwMCMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATAd
BgNVHQ4EFgQUmYArK7rKcVh57kzZXYMze2RiNrMwCgYIKoZIzj0EAwIDSAAwRQIg
QtSMNNo+OWONVB4HfyV5UwjxyMxGmJwNt8qh99PWS2UCIQCyzZzBUOwNAHQv/T3D
IsCxyWZQ37yZpvS82Z/SnCIZuw==
-----END CERTIFICATE-----

5
bindings/http/testdata/client.key vendored Normal file
View File

@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIEobWR8PduGHOPY+T/Id/bNmjS8cjRQ+UID4NnCaROgmoAoGCCqGSM49
AwEHoUQDQgAEKx7xYU7DGNX+WDo5QzJGkIvrqhqbw1ZaNKe5h2QREJwwIB2X5TyT
DYaNfhqXc8IOB9si4GJ2x0JhyeoAvi/U8w==
-----END EC PRIVATE KEY-----

15
bindings/http/testdata/client.pem vendored Normal file
View File

@ -0,0 +1,15 @@
-----BEGIN CERTIFICATE-----
MIICRjCCAeugAwIBAgIUKCvur/fviGKJDitDggTtIkwQMxIwCgYIKoZIzj0EAwIw
UDELMAkGA1UEBhMCSU4xCzAJBgNVBAgMAlRTMQswCQYDVQQHDAJIWTETMBEGA1UE
CgwKRGFwciBXb3JsZDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIzMDEwNjEwMDI0
NVoXDTI0MDEwNjEwMDI0NVowUDELMAkGA1UEBhMCSU4xCzAJBgNVBAgMAlRTMQsw
CQYDVQQHDAJIWTETMBEGA1UECgwKRGFwciBXb3JsZDESMBAGA1UEAwwJbG9jYWxo
b3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKx7xYU7DGNX+WDo5QzJGkIvr
qhqbw1ZaNKe5h2QREJwwIB2X5TyTDYaNfhqXc8IOB9si4GJ2x0JhyeoAvi/U86OB
ojCBnzASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUE
FjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/
AAABMB0GA1UdDgQWBBRHcmSCVapIorD+/O1T+ySlkwEI+zAfBgNVHSMEGDAWgBSZ
gCsruspxWHnuTNldgzN7ZGI2szAKBggqhkjOPQQDAgNJADBGAiEAh+c11VmTeiv3
v4sRikMeiLkneDUl1wuLfffZbCLXsXkCIQC9bfk8RlH5aTTJ6Xrpz5oVyxU58v7E
2HVuTU281m67bw==
-----END CERTIFICATE-----

5
bindings/http/testdata/server.key vendored Normal file
View File

@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIBdj4qCqbLVvFqjcv14xthAm+YGghrMe6uHbE6nKg93EoAoGCCqGSM49
AwEHoUQDQgAEJup13iQfS72fRxN9JLJDLP0tel4F8bmxEfcHjvKMGJaupvvtHgZm
tlYlY6evzE6yIN5mUIcBqCWzpN+aCPBwqw==
-----END EC PRIVATE KEY-----

15
bindings/http/testdata/server.pem vendored Normal file
View File

@ -0,0 +1,15 @@
-----BEGIN CERTIFICATE-----
MIICRjCCAeugAwIBAgIUXpf5hT8jws5FV65ZFdVUITeldAYwCgYIKoZIzj0EAwIw
UDELMAkGA1UEBhMCSU4xCzAJBgNVBAgMAlRTMQswCQYDVQQHDAJIWTETMBEGA1UE
CgwKRGFwciBXb3JsZDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIzMDEwNjEwMDI0
NVoXDTI0MDEwNjEwMDI0NVowUDELMAkGA1UEBhMCSU4xCzAJBgNVBAgMAlRTMQsw
CQYDVQQHDAJIWTETMBEGA1UECgwKRGFwciBXb3JsZDESMBAGA1UEAwwJbG9jYWxo
b3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJup13iQfS72fRxN9JLJDLP0t
el4F8bmxEfcHjvKMGJaupvvtHgZmtlYlY6evzE6yIN5mUIcBqCWzpN+aCPBwq6OB
ojCBnzASBgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBhjAdBgNVHSUE
FjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/
AAABMB0GA1UdDgQWBBRkZKt5a3/g5ea0eVufjBT3QL5xkTAfBgNVHSMEGDAWgBSZ
gCsruspxWHnuTNldgzN7ZGI2szAKBggqhkjOPQQDAgNJADBGAiEAkO5nX2Eofa0k
R+u1zIiwJilwzgI9A2WnnvLWWmKiQkQCIQDtoS4x1bKaPyCTtLJ91fwSPxWdexhQ
CDsniMd431J38g==
-----END CERTIFICATE-----