Skip to content

Commit f2cea76

Browse files
committed
http: add mTLS for client authentication
Add configuration field to enable Mutual TLS client authentication. Signed-off-by: Romain Beuque <[email protected]>
1 parent 7b37ee7 commit f2cea76

File tree

4 files changed

+163
-20
lines changed

4 files changed

+163
-20
lines changed

pkg/plugins/builtin/http/README.md

+10-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ This plugin permorms an HTTP request.
1313
| `body` | a string representing the payload to be sent with the request |
1414
| `headers` | a list of headers, represented as (`name`, `value`) pairs |
1515
| `timeout` | timeout expressed as a duration (e.g. `30s`) |
16-
| `auth` | a single object composed of either a `basic` object with `user` and `password` fields to enable HTTP basic auth, or `bearer` field to enable Bearer Token Authorization |
16+
| `auth` | a single object composed of either a `basic` object with `user` and `password` fields to enable HTTP basic auth, or a `bearer` field to enable Bearer Token Authorization, or a `mutual_tls` object to enable Mutual TLS authentication |
1717
| `follow_redirect` | if `true` (string) the plugin will follow up to 10 redirects (302, ...) |
1818
| `query_parameters` | a list of query parameters, represented as (`name`, `value`) pairs; these will appended the query parameters present in the `url` field; parameters can be repeated (in either `url` or `query_parameters`) which will produce e.g. `?param=value1&param=value2` |
1919
| `trim_prefix` | prefix in the response that must be removed before unmarshalling (optional) |
@@ -39,8 +39,17 @@ action:
3939
user: {{.config.basicAuth.user}}
4040
password: {{.config.basicAuth.password}}
4141
bearer: {{.config.auth.token}}
42+
mutual_tls:
43+
# a chain of certificates to identify the caller, first certificate in the chain is considered as the leaf, followed by intermediates
44+
client_cert: {{.config.mtls.clientCert}}
45+
# private key corresponding to the certificate
46+
client_key: {{.config.mtls.clientKey}}
4247
# optional, string as boolean
4348
follow_redirect: "true"
49+
# optional, defines additional root CAs to perform the call. can contains multiple CAs concatained together
50+
root_ca: {{.config.mtls.rootca}}
51+
# optional, string as boolean. indicates if server certificate must be validated or not.
52+
insecure_skip_verify: "false"
4453
# optional, array of name and value fields
4554
query_parameters:
4655
- name: foo

pkg/plugins/builtin/http/http.go

+54-16
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import (
1717
"github.com/ovh/utask/pkg/plugins/builtin/httputil"
1818
"github.com/ovh/utask/pkg/plugins/taskplugin"
1919
"github.com/ovh/utask/pkg/utils"
20-
"golang.org/x/net/http2"
2120
)
2221

2322
// the HTTP plugin performs an HTTP call
@@ -28,15 +27,6 @@ var (
2827
)
2928
)
3029

31-
var defaultUnsecureTransport http.RoundTripper
32-
33-
func init() {
34-
tr := http.DefaultTransport.(*http.Transport).Clone()
35-
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
36-
_ = http2.ConfigureTransport(tr)
37-
defaultUnsecureTransport = tr
38-
}
39-
4030
const (
4131
// TimeoutDefault represents the default value that will be used for HTTP call, if not defined in configuration
4232
TimeoutDefault = "30s"
@@ -56,6 +46,7 @@ type HTTPConfig struct {
5646
QueryParameters []parameter `json:"query_parameters,omitempty"`
5747
TrimPrefix string `json:"trim_prefix,omitempty"`
5848
InsecureSkipVerify string `json:"insecure_skip_verify,omitempty"`
49+
RootCA string `json:"root_ca,omitempty"`
5950
}
6051

6152
// parameter represents either headers, query parameters, ...
@@ -66,8 +57,9 @@ type parameter struct {
6657

6758
// auth represents HTTP authentication
6859
type auth struct {
69-
Basic authBasic `json:"basic"`
70-
Bearer string `json:"bearer"`
60+
Basic *authBasic `json:"basic"`
61+
Bearer *string `json:"bearer"`
62+
MutualTLS *mTLS `json:"mutual_tls"`
7163
}
7264

7365
// authBasic represents the embedded basic auth inside Auth struct
@@ -76,6 +68,11 @@ type authBasic struct {
7668
Password string `json:"password"`
7769
}
7870

71+
type mTLS struct {
72+
ClientCert string `json:"client_cert"`
73+
ClientKey string `json:"client_key"`
74+
}
75+
7976
func validConfig(config interface{}) error {
8077
cfg := config.(*HTTPConfig)
8178
if !strings.HasPrefix(cfg.Method, "{{") && !strings.HasSuffix(cfg.Method, "}}") {
@@ -110,6 +107,26 @@ func validConfig(config interface{}) error {
110107
}
111108
}
112109

110+
if cfg.Auth.Basic != nil && cfg.Auth.Bearer != nil {
111+
return fmt.Errorf("basic auth and bearer auth are mutually exclusive")
112+
}
113+
114+
if cfg.Auth.Basic != nil {
115+
if cfg.Auth.Basic.User == "" || cfg.Auth.Basic.Password == "" {
116+
return fmt.Errorf("missing either user or password for basic auth")
117+
}
118+
}
119+
120+
if cfg.Auth.Bearer != nil && *cfg.Auth.Bearer == "" {
121+
return fmt.Errorf("missing bearer token value")
122+
}
123+
124+
if cfg.Auth.MutualTLS != nil {
125+
if cfg.Auth.MutualTLS.ClientCert == "" || cfg.Auth.MutualTLS.ClientKey == "" {
126+
return fmt.Errorf("missing either client_cert or client_key for mTLS")
127+
}
128+
}
129+
113130
return nil
114131
}
115132

@@ -170,10 +187,10 @@ func exec(stepName string, config interface{}, ctx interface{}) (interface{}, in
170187
}
171188
req.URL.RawQuery = q.Encode()
172189

173-
if cfg.Auth.Bearer != "" {
174-
var bearer = "Bearer " + cfg.Auth.Bearer
190+
if cfg.Auth.Bearer != nil {
191+
var bearer = "Bearer " + *cfg.Auth.Bearer
175192
req.Header.Add("Authorization", bearer)
176-
} else if cfg.Auth.Basic.User != "" && cfg.Auth.Basic.Password != "" {
193+
} else if cfg.Auth.Basic != nil {
177194
req.SetBasicAuth(cfg.Auth.Basic.User, cfg.Auth.Basic.Password)
178195
}
179196

@@ -219,9 +236,30 @@ func exec(stepName string, config interface{}, ctx interface{}) (interface{}, in
219236
Timeout: td,
220237
FollowRedirect: fr,
221238
}
239+
opts := []func(*http.Transport) error{}
222240
if insecureSkipVerify {
223-
httpClientConfig.Transport = defaultUnsecureTransport
241+
opts = append(opts, httputil.WithTLSInsecureSkipVerify(true))
224242
}
243+
244+
if cfg.Auth.MutualTLS != nil {
245+
cert, err := tls.X509KeyPair([]byte(cfg.Auth.MutualTLS.ClientCert), []byte(cfg.Auth.MutualTLS.ClientKey))
246+
if err != nil {
247+
return nil, nil, fmt.Errorf("failed to parse x509 mTLS certificate or key: %s", err)
248+
}
249+
opts = append(opts, httputil.WithTLSClientAuth(cert))
250+
}
251+
252+
if cfg.RootCA != "" {
253+
opts = append(opts, httputil.WithTLSRootCA([]byte(cfg.RootCA)))
254+
}
255+
256+
if len(opts) > 0 {
257+
httpClientConfig.Transport, err = httputil.GetTransport(opts...)
258+
if err != nil {
259+
return nil, nil, fmt.Errorf("failed to craft a new http transport: %s", err)
260+
}
261+
}
262+
225263
httpClient := httputil.NewHTTPClient(httpClientConfig)
226264

227265
resp, err := httpClient.Do(req)

pkg/plugins/builtin/http/http_test.go

+41-3
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ import (
1414
)
1515

1616
func Test_validConfig(t *testing.T) {
17+
bearerToken := "my_token"
1718
cfg := HTTPConfig{
1819
URL: "http://lolcat.host/stuff",
1920
Method: "GET",
2021
Timeout: "10s",
2122
FollowRedirect: "false",
2223
Auth: auth{
23-
Bearer: "my_token",
24-
Basic: authBasic{
24+
Basic: &authBasic{
2525
User: "foo",
2626
Password: "bar",
2727
},
@@ -74,6 +74,43 @@ func Test_validConfig(t *testing.T) {
7474
},
7575
}
7676

77+
// wrong auth: exclusive auth added
78+
cfg.Auth.Bearer = &bearerToken
79+
cfgJSON, err = json.Marshal(cfg)
80+
assert.NoError(t, err)
81+
assert.Errorf(t, Plugin.ValidConfig(json.RawMessage(""), json.RawMessage(cfgJSON)), "basic auth and bearer auth are mutually exclusive")
82+
cfg.Auth.Bearer = nil
83+
84+
// wrong auth: invalid basic auth
85+
cfg.Auth.Basic.Password = ""
86+
cfgJSON, err = json.Marshal(cfg)
87+
assert.NoError(t, err)
88+
assert.Errorf(t, Plugin.ValidConfig(json.RawMessage(""), json.RawMessage(cfgJSON)), "missing either user or password for basic auth")
89+
cfg.Auth.Basic.Password = "bar"
90+
91+
// wrong auth: invalid bearer auth
92+
cfg.Auth.Basic = nil
93+
empty := ""
94+
cfg.Auth.Bearer = &empty
95+
cfgJSON, err = json.Marshal(cfg)
96+
assert.NoError(t, err)
97+
assert.Errorf(t, Plugin.ValidConfig(json.RawMessage(""), json.RawMessage(cfgJSON)), "missing bearer token value")
98+
cfg.Auth.Basic = &authBasic{
99+
User: "foo",
100+
Password: "bar",
101+
}
102+
cfg.Auth.Bearer = nil
103+
104+
// wrong auth: invalid mTLS auth
105+
cfg.Auth.MutualTLS = &mTLS{
106+
ClientCert: "foo",
107+
ClientKey: "",
108+
}
109+
cfgJSON, err = json.Marshal(cfg)
110+
assert.NoError(t, err)
111+
assert.Errorf(t, Plugin.ValidConfig(json.RawMessage(""), json.RawMessage(cfgJSON)), "missing either client_cert or client_key for mTLS")
112+
cfg.Auth.MutualTLS = nil
113+
77114
// no URL
78115
cfg.URL = ""
79116
cfgJSON, err = json.Marshal(cfg)
@@ -128,6 +165,7 @@ func Test_exec(t *testing.T) {
128165
}
129166
}
130167

168+
bearerToken := "my_token"
131169
cfg := HTTPConfig{
132170
URL: "http://lolcat.host/stuff",
133171
Method: "GET",
@@ -140,7 +178,7 @@ func Test_exec(t *testing.T) {
140178
Timeout: "10s",
141179
FollowRedirect: "false",
142180
Auth: auth{
143-
Bearer: "my_token",
181+
Bearer: &bearerToken,
144182
},
145183
}
146184

pkg/plugins/builtin/httputil/httputil.go

+58
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ package httputil
22

33
import (
44
"bytes"
5+
"crypto/tls"
6+
"crypto/x509"
57
"fmt"
68
"io/ioutil"
79
"net/http"
810
"strings"
911
"time"
1012

1113
"github.com/juju/errors"
14+
"golang.org/x/net/http2"
1215

1316
"github.com/ovh/utask/pkg/plugins/taskplugin"
1417
"github.com/ovh/utask/pkg/utils"
@@ -110,3 +113,58 @@ func defaultHTTPClientFactory(cfg HTTPClientConfig) HTTPClient {
110113
}
111114
return c
112115
}
116+
117+
func GetTransport(opts ...func(*http.Transport) error) (http.RoundTripper, error) {
118+
tr := http.DefaultTransport.(*http.Transport).Clone()
119+
for _, o := range opts {
120+
if err := o(tr); err != nil {
121+
return tr, err
122+
}
123+
}
124+
125+
_ = http2.ConfigureTransport(tr)
126+
return tr, nil
127+
}
128+
129+
func WithTLSInsecureSkipVerify(v bool) func(*http.Transport) error {
130+
return func(t *http.Transport) error {
131+
if t.TLSClientConfig == nil {
132+
t.TLSClientConfig = &tls.Config{}
133+
}
134+
135+
t.TLSClientConfig.InsecureSkipVerify = v
136+
return nil
137+
}
138+
}
139+
140+
func WithTLSClientAuth(cert tls.Certificate) func(*http.Transport) error {
141+
return func(t *http.Transport) error {
142+
if t.TLSClientConfig == nil {
143+
t.TLSClientConfig = &tls.Config{}
144+
}
145+
146+
t.TLSClientConfig.Certificates = append(t.TLSClientConfig.Certificates, cert)
147+
return nil
148+
}
149+
}
150+
151+
// WithTLSRootCA should be called only once, with multiple PEM encoded certificates as input if needed.
152+
func WithTLSRootCA(caCert []byte) func(*http.Transport) error {
153+
return func(t *http.Transport) error {
154+
if t.TLSClientConfig == nil {
155+
t.TLSClientConfig = &tls.Config{}
156+
}
157+
caCertPool, err := x509.SystemCertPool()
158+
if err != nil {
159+
fmt.Println("http: tls: failed to load default system cert pool, fallback to an empty cert pool")
160+
caCertPool = x509.NewCertPool()
161+
}
162+
163+
if ok := caCertPool.AppendCertsFromPEM(caCert); !ok {
164+
return errors.New("WithTLSRootCA: failed to add a certificate to the cert pool")
165+
}
166+
167+
t.TLSClientConfig.RootCAs = caCertPool
168+
return nil
169+
}
170+
}

0 commit comments

Comments
 (0)