diff --git a/docs/checks/plugins/check_http.md b/docs/checks/plugins/check_http.md index 15544fba..4341035f 100644 --- a/docs/checks/plugins/check_http.md +++ b/docs/checks/plugins/check_http.md @@ -67,15 +67,16 @@ Application Options: -s, --string= String to expect in the content --base64-string= Base64 Encoded string to expect the content -A, --useragent= UserAgent to be sent (default: check_http) - -a, --authorization= username:password on sites with basic authentication + -a, --authorization= Pass '[username]:[password]' formatted string to be + used as basic authorization header -k, --header= Any other tags to be sent in http header. Use multiple times for additional headers - -C, --certificate= check certificates instead of content. Specified in + -C, --certificate= Check certificates instead of content. Specified in mandatory days left to warn and optional days to crit with a comma: warn_days[,] - --tls-min=[1.0|1.0+|1.1|1.1+|1.2|1.2+|1.3] minimum supported TLS version. Values with plus set + --tls-min=[1.0|1.0+|1.1|1.1+|1.2|1.2+|1.3] Minimum supported TLS version. Values with plus set the max tls version as well to latest version: 1.3 - --tls-max=[1.0|1.1|1.2|1.3] maximum supported TLS version + --tls-max=[1.0|1.1|1.2|1.3] Maximum supported TLS version --proxy= Proxy that should be used -r, --regex= Search page for case-sensitive regex string -R, --regexi= Search page for case-insensitive regex string @@ -98,26 +99,26 @@ Application Options: unit is given at the end, default of seconds is assumed. Value is truncated to milliseconds. (default: 60) - --wait-for-interval= retry interval (default: 2s) - --wait-for-max= time to wait for success (max.: 180s) - --interim= interval time after successful request for + --wait-for-interval= Retry interval (default: 2s) + --wait-for-max= Time to wait for success (max.: 180s) + --interim= Interval time after successful request for consecutive mode (default: 1s) - --consecutive= number of consecutive successful requests required + --consecutive= Number of consecutive successful requests required (max.: 5) (default: 1) -p, --port= Port number --max-redirs= Maximum redirects before giving up on following - --no-discard raise error when the response body is larger then + --no-discard Raise error when the response body is larger then max-buffer-size - --wait-for retry until successful when enabled - -S, --ssl use https - --sni enable SNI - -4 use tcp4 only - -6 use tcp6 only + --wait-for Retry until successful when enabled + -S, --ssl Use https + --sni Enable SNI + -4 Use tcp4 only + -6 Use tcp6 only -v, --verbose Show verbose output --show-body Print body content below status line - --ignore-certificate-chain by default all certificates are checked in many - aspects. Toggle this option to only check the leaf - (final) certificate. + --ignore-certificate-chain During certificate check, all certificates are + checked in many aspects. Toggle this option to only + check the leaf (final) certificate. --check-cn Subject Common Name of leaf certificate can be checked to match hostname exactly. Common Name field is now largely unused in modern web, with Subject diff --git a/pkg/check_http/check_http.go b/pkg/check_http/check_http.go index 18bd8ace..d1471772 100644 --- a/pkg/check_http/check_http.go +++ b/pkg/check_http/check_http.go @@ -67,11 +67,11 @@ type commandOpts struct { ExpectContent string `short:"s" long:"string" description:"String to expect in the content"` Base64ExpectContent string ` long:"base64-string" description:"Base64 Encoded string to expect the content"` UserAgent string `short:"A" long:"useragent" default:"check_http" description:"UserAgent to be sent"` - Authorization string `short:"a" long:"authorization" description:"username:password on sites with basic authentication"` + Authorization string `short:"a" long:"authorization" description:"Pass '[username]:[password]' formatted string to be used as basic authorization header"` Header []string `short:"k" long:"header" description:"Any other tags to be sent in http header. Use multiple times for additional headers"` - Certificate string `short:"C" long:"certificate" description:"check certificates instead of content. Specified in mandatory days left to warn and optional days to crit with a comma: warn_days[,]" ` - TLSMinVersion string ` long:"tls-min" description:"minimum supported TLS version. Values with plus set the max tls version as well to latest version: 1.3" choice:"1.0" choice:"1.0+" choice:"1.1" choice:"1.1+" choice:"1.2" choice:"1.2+" choice:"1.3"` - TLSMaxVersion string ` long:"tls-max" description:"maximum supported TLS version" choice:"1.0" choice:"1.1" choice:"1.2" choice:"1.3"` + Certificate string `short:"C" long:"certificate" description:"Check certificates instead of content. Specified in mandatory days left to warn and optional days to crit with a comma: warn_days[,]" ` + TLSMinVersion string ` long:"tls-min" description:"Minimum supported TLS version. Values with plus set the max tls version as well to latest version: 1.3" choice:"1.0" choice:"1.0+" choice:"1.1" choice:"1.1+" choice:"1.2" choice:"1.2+" choice:"1.3"` + TLSMaxVersion string ` long:"tls-max" description:"Maximum supported TLS version" choice:"1.0" choice:"1.1" choice:"1.2" choice:"1.3"` Proxy string ` long:"proxy" description:"Proxy that should be used"` RegexStr string `short:"r" long:"regex" description:"Search page for case-sensitive regex string"` RegexiStr string `short:"R" long:"regexi" description:"Search page for case-insensitive regex string"` @@ -80,21 +80,21 @@ type commandOpts struct { TimeoutStr string `short:"t" long:"timeout" default:"10" description:"Timeout to wait for connection. If no time unit is given at the end, default of seconds is assumed"` WarningThresholdStr string `short:"w" long:"warning" default:"30" description:"If the request+response takes longer specified warning threshold, raises a warning. If no time unit is given at the end, default of seconds is assumed. Value is truncated to milliseconds."` CriticalThresholdStr string `short:"c" long:"critical" default:"60" description:"If the request+response takes longer specified critical threshold, raises a critical. If no time unit is given at the end, default of seconds is assumed. Value is truncated to milliseconds."` - WaitForInterval time.Duration ` long:"wait-for-interval" default:"2s" description:"retry interval"` - WaitForMax time.Duration ` long:"wait-for-max" description:"time to wait for success (max.: 180s)"` - Interim time.Duration ` long:"interim" default:"1s" description:"interval time after successful request for consecutive mode"` - Consecutive int ` long:"consecutive" default:"1" description:"number of consecutive successful requests required (max.: 5)"` + WaitForInterval time.Duration ` long:"wait-for-interval" default:"2s" description:"Retry interval"` + WaitForMax time.Duration ` long:"wait-for-max" description:"Time to wait for success (max.: 180s)"` + Interim time.Duration ` long:"interim" default:"1s" description:"Interval time after successful request for consecutive mode"` + Consecutive int ` long:"consecutive" default:"1" description:"Number of consecutive successful requests required (max.: 5)"` Port int `short:"p" long:"port" description:"Port number"` MaxRedirects int ` long:"max-redirs" description:"Maximum redirects before giving up on following"` - NoDiscard bool ` long:"no-discard" description:"raise error when the response body is larger then max-buffer-size"` - WaitFor bool ` long:"wait-for" description:"retry until successful when enabled"` - SSL bool `short:"S" long:"ssl" description:"use https"` - SNI bool ` long:"sni" description:"enable SNI"` - TCP4 bool `short:"4" description:"use tcp4 only"` - TCP6 bool `short:"6" description:"use tcp6 only"` + NoDiscard bool ` long:"no-discard" description:"Raise error when the response body is larger then max-buffer-size"` + WaitFor bool ` long:"wait-for" description:"Retry until successful when enabled"` + SSL bool `short:"S" long:"ssl" description:"Use https"` + SNI bool ` long:"sni" description:"Enable SNI"` + TCP4 bool `short:"4" description:"Use tcp4 only"` + TCP6 bool `short:"6" description:"Use tcp6 only"` Verbose bool `short:"v" long:"verbose" description:"Show verbose output"` ShowBody bool ` long:"show-body" description:"Print body content below status line"` - IgnoreCertificateChain bool ` long:"ignore-certificate-chain" description:"by default all certificates are checked in many aspects. Toggle this option to only check the leaf (final) certificate."` + IgnoreCertificateChain bool ` long:"ignore-certificate-chain" description:"During certificate check, all certificates are checked in many aspects. Toggle this option to only check the leaf (final) certificate."` CheckCN bool ` long:"check-cn" description:"Subject Common Name of leaf certificate can be checked to match hostname exactly. Common Name field is now largely unused in modern web, with Subject Alternative Name fields being more prevalent and used instead of Common Name when present. It is not checked by default, use this flag to enable it."` CheckSAN bool ` long:"check-san" description:"Subject Alternative Names can be checked against the hostname. SANs contain the hostnames and IP addresses this certificate is valid for. They are ignored if the certificate is a Certificate Authority type, meaning they are used to sign other certificates and not for proving security for a hostname. It is not checked by default, use this flag to enable it."` IgnoreNotAfter bool ` long:"ignore-not-after" description:"Certificates are invalid after the timestamp in their NotAfter has passed. This field can be ignored with this flag."` @@ -115,6 +115,18 @@ func (opts *commandOpts) tracef(format string, args ...any) { } } +func (opts *commandOpts) debugf(format string, args ...any) { + if !opts.flags.Verbose { + return + } + + if opts.log != nil { + opts.log.Debugf(format, args...) + } else { + log.Printf(format, args...) + } +} + func makeTLSConfig(opts *commandOpts) (conf *tls.Config) { //nolint:gosec // TLS check is deliberately skipped, certificate checks are done in its separate function conf = &tls.Config{ @@ -158,10 +170,17 @@ func makeDialer(opts *commandOpts) func(ctx context.Context, _ string, _ string) tcpMode = "tcp6" } - dialFunc := func(ctx context.Context, _, _ string) (net.Conn, error) { - addr := net.JoinHostPort(opts.flags.IPAddress, strconv.Itoa(opts.flags.Port)) + dialFunc := func(ctx context.Context, _ string, addr string) (net.Conn, error) { + // when a proxy is configured, the http transport passes the proxy address as addr, need to dial the proxy instead of the target + if opts.flags.Proxy != "" && addr != "" { + return baseDialFunc(ctx, tcpMode, addr) + } + + // otherwise it according to -I/-p + // also used by the -C certificate check which calls dialFunc with an empty addr + targetAddr := net.JoinHostPort(opts.flags.IPAddress, strconv.Itoa(opts.flags.Port)) - return baseDialFunc(ctx, tcpMode, addr) + return baseDialFunc(ctx, tcpMode, targetAddr) } return dialFunc @@ -171,16 +190,40 @@ func makeDialer(opts *commandOpts) func(ctx context.Context, _ string, _ string) func makeTransport(opts *commandOpts, dialFunc func(ctx context.Context, _ string, _ string) (net.Conn, error), tlsConfig *tls.Config) (http.RoundTripper, error) { proxy := http.ProxyFromEnvironment + var parsedURL *url.URL + proxyScheme := "" + if opts.flags.Proxy != "" { - parsedURL, err := url.Parse(opts.flags.Proxy) + var err error + parsedURL, err = url.Parse(opts.flags.Proxy) if err != nil { return nil, fmt.Errorf("Error while parsing Proxy URL. Error was: %s", err.Error()) } + opts.debugf("Proxy used: %q", parsedURL) + + proxyScheme = parsedURL.Scheme + if proxyScheme == "" { + proxyScheme = "http" + } + + opts.debugf("Proxy is using scheme: %q", proxyScheme) + + switch proxyScheme { + case "https": + opts.debugf("This means a TLS connection will be established to the proxy") + case "socks5", "socks5h": + opts.debugf("This means the proxy will resolve the target hostname") + case "socks4a": + opts.debugf("socks4a is not supported by the go http client, only socks5/socks5h is supported") + default: + opts.debugf("Using proxy with unsupported scheme: %q", proxyScheme) + } + proxy = http.ProxyURL(parsedURL) } - return &http.Transport{ + transport := &http.Transport{ // inherited http.DefaultTransport Proxy: proxy, DialContext: dialFunc, @@ -191,7 +234,32 @@ func makeTransport(opts *commandOpts, dialFunc func(ctx context.Context, _ strin ResponseHeaderTimeout: opts.TimeoutParsed, TLSClientConfig: tlsConfig, ForceAttemptHTTP2: true, - }, nil + } + + if proxyScheme == "https" { + opts.debugf("The proxy certificate will be verified") + + proxyTLSConfig := makeProxyTLSConfig(opts, parsedURL) + transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := dialFunc(ctx, network, addr) + if err != nil { + return nil, err + } + + return tls.Client(conn, proxyTLSConfig), nil + } + } + + return transport, nil +} + +// makeProxyTLSConfig returns a tls config that verifies the certificate of an https proxy. +func makeProxyTLSConfig(opts *commandOpts, proxyURL *url.URL) *tls.Config { + return &tls.Config{ + ServerName: proxyURL.Hostname(), + MinVersion: opts.tlsMinVersion, + MaxVersion: opts.tlsMaxVersion, + } } func buildRequest(ctx context.Context, opts *commandOpts) (*http.Request, error) { diff --git a/pkg/check_http/check_http_test.go b/pkg/check_http/check_http_test.go index 020e28be..48c35372 100644 --- a/pkg/check_http/check_http_test.go +++ b/pkg/check_http/check_http_test.go @@ -2,11 +2,20 @@ package check_http import ( "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" "strings" + "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) const ( @@ -232,3 +241,204 @@ func TestHTTPExpectStatusCode(t *testing.T) { expectedStr := `matched option '200'` assert.Containsf(t, output.String(), expectedStr, "expected output to contain '%s'", expectedStr) } + +func TestHTTPProxyPlain(t *testing.T) { + if testing.Short() { + t.Skip("skipping network test in short mode") + } + + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, "direct-target-response") + })) + defer target.Close() + + targetURL, err := url.Parse(target.URL) + require.NoError(t, err) + + var proxied atomic.Bool + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + proxied.Store(true) + fmt.Fprint(w, "proxied-response") + })) + defer proxy.Close() + + var output strings.Builder + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + code := Check(ctx, &output, []string{ + "check_http", "-H", targetURL.Host, "-p", "80", + "--proxy", proxy.URL, "-s", "proxied-response", + }) + + // it should connect to the proxy first + // the mock proxy just returns a text and does not actually proxy the request + assert.Equalf(t, OK, code, "expected exit code OK (0), got %d, output: %s", code, output.String()) + assert.Truef(t, proxied.Load(), "request did not go through the proxy, output: %s", output.String()) + assert.Containsf(t, output.String(), "proxied-response", "expected output to contain 'proxied-response'") + assert.NotContainsf(t, output.String(), "direct-target-response", "response should come from the proxy, not the target") +} + +func TestHTTPProxySSL(t *testing.T) { + if testing.Short() { + t.Skip("skipping network test in short mode") + } + + // the target is a TLS address on a TLS server + target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, "proxied-target-response") + })) + defer target.Close() + + targetURL, err := url.Parse(target.URL) + require.NoError(t, err) + + var proxied atomic.Int64 + // this is a more realistic proxy server, which actually proxies the requests + // it is an http server and not HTTPS + proxy := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + // only accept the HTTP CONNECT method. This should be set by the http client + if req.Method != http.MethodConnect { + http.Error(writer, "expected CONNECT request", http.StatusBadRequest) + + return + } + proxied.Add(1) + + // connect to the target + dest, err := net.Dial("tcp", req.Host) + if err != nil { + http.Error(writer, err.Error(), http.StatusBadGateway) + + return + } + defer dest.Close() + + // hijacker stops the default handling and lifecycle management over the connection + hijacker, ok := writer.(http.Hijacker) + if !ok { + http.Error(writer, "hijacking not supported", http.StatusInternalServerError) + + return + } + + clientConn, _, err := hijacker.Hijack() + if err != nil { + return + } + defer clientConn.Close() + + // this is the header for the proxy connection establishment + fmt.Fprint(clientConn, "HTTP/1.1 200 Connection Established\r\n\r\n") + + // continuously copy the proxy client request to the target + go func() { + _, _ = io.Copy(dest, clientConn) + }() + + // continuously copy the target responses to client + _, _ = io.Copy(clientConn, dest) + })) + defer proxy.Close() + + var output strings.Builder + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // since the proxy actually does proxying, this should get the result of the target + // the proxy is using http, there are no certificates to verify about proxy. + // the target is using https, but we are not in the --certificate mode where we perform certificate checks + code := Check(ctx, &output, []string{ + "check_http", "-H", targetURL.Host, "-S", "--sni", + "--proxy", proxy.URL, "-s", "proxied-target-response", + }) + + assert.Equalf(t, OK, code, "expected exit code OK (0), got %d, output: %s", code, output.String()) + assert.GreaterOrEqualf(t, proxied.Load(), int64(1), "expected a CONNECT request to go through the proxy, output: %s", output.String()) + assert.Containsf(t, output.String(), "proxied-target-response", "expected output to contain 'proxied-target-response'") +} + +func TestMakeProxyTLSConfig(t *testing.T) { + opts := &commandOpts{} + opts.tlsMinVersion = tls.VersionTLS12 + opts.tlsMaxVersion = tls.VersionTLS13 + + proxyURL, err := url.Parse("https://proxy.example.com:8080") + require.NoError(t, err) + + // the default config to the proxy, when its using TLS is to + // NOT skip TLS verification, and PERFORM the TLS verification + conf := makeProxyTLSConfig(opts, proxyURL) + assert.Equal(t, "proxy.example.com", conf.ServerName) + assert.False(t, conf.InsecureSkipVerify) + assert.Equal(t, uint16(tls.VersionTLS12), conf.MinVersion) + assert.Equal(t, uint16(tls.VersionTLS13), conf.MaxVersion) +} + +func TestHTTPProxySSLSelfSignedProxy(t *testing.T) { + if testing.Short() { + t.Skip("skipping network test in short mode") + } + + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, "target-response") + })) + defer target.Close() + + targetURL, err := url.Parse(target.URL) + require.NoError(t, err) + + // a TLS proxy with a self-signed certificate (httptest.NewTLSServer default). + // The CONNECT-tunnel handler is never reached in this test: the client + // fails to verify the proxy certificate during the TLS handshake. + proxy := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodConnect { + http.Error(writer, "expected CONNECT request", http.StatusBadRequest) + + return + } + + dest, err := net.Dial("tcp", req.Host) + if err != nil { + http.Error(writer, err.Error(), http.StatusBadGateway) + + return + } + defer dest.Close() + + hijacker, ok := writer.(http.Hijacker) + if !ok { + http.Error(writer, "hijacking not supported", http.StatusInternalServerError) + + return + } + + clientConn, _, err := hijacker.Hijack() + if err != nil { + return + } + defer clientConn.Close() + + fmt.Fprint(clientConn, "HTTP/1.1 200 Connection Established\r\n\r\n") + + go func() { + _, _ = io.Copy(dest, clientConn) + }() + _, _ = io.Copy(clientConn, dest) + })) + defer proxy.Close() + + var output strings.Builder + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // the proxy is using HTTPS, so the proxy's HTTPS connection should be verified + // the target is using HTTP, but before reaching the target, the proxy TLS check fails + code := Check(ctx, &output, []string{ + "check_http", "-H", targetURL.Host, "--proxy", proxy.URL, "-u", "/", + }) + + assert.Equalf(t, CRITICAL, code, "expected exit code CRITICAL (2), got %d, output: %s", code, output.String()) + assert.Containsf(t, output.String(), "failed to verify certificate", "expected a proxy certificate verification error, output: %s", output.String()) + assert.Containsf(t, output.String(), "unknown authority", "expected an untrusted certificate error, output: %s", output.String()) +}