Skip to content

Commit

Permalink
proxytest: proxy HTTPS request using MITM
Browse files Browse the repository at this point in the history
The proxytest now can proxy HTTPS requests using a men in the middle (MITM) approach to allow to fully control the requests between the proxy and the target server.
  • Loading branch information
AndersonQ committed Oct 16, 2024
1 parent fc05e0d commit fd3a299
Show file tree
Hide file tree
Showing 7 changed files with 685 additions and 162 deletions.
4 changes: 2 additions & 2 deletions NOTICE.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1264,11 +1264,11 @@ SOFTWARE

--------------------------------------------------------------------------------
Dependency : github.com/elastic/elastic-agent-libs
Version: v0.12.1
Version: v0.13.0
Licence type (autodetected): Apache-2.0
--------------------------------------------------------------------------------

Contents of probable licence file $GOMODCACHE/github.com/elastic/elastic-agent-libs@v0.12.1/LICENSE:
Contents of probable licence file $GOMODCACHE/github.com/elastic/elastic-agent-libs@v0.13.0/LICENSE:

Apache License
Version 2.0, January 2004
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ require (
github.com/dolmen-go/contextio v0.0.0-20200217195037-68fc5150bcd5
github.com/elastic/elastic-agent-autodiscover v0.9.0
github.com/elastic/elastic-agent-client/v7 v7.16.0
github.com/elastic/elastic-agent-libs v0.12.1
github.com/elastic/elastic-agent-libs v0.13.0
github.com/elastic/elastic-agent-system-metrics v0.11.3
github.com/elastic/elastic-transport-go/v8 v8.6.0
github.com/elastic/go-elasticsearch/v8 v8.15.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -264,8 +264,8 @@ github.com/elastic/elastic-agent-autodiscover v0.9.0 h1:+iWIKh0u3e8I+CJa3FfWe9h0
github.com/elastic/elastic-agent-autodiscover v0.9.0/go.mod h1:5iUxLHhVdaGSWYTveSwfJEY4RqPXTG13LPiFoxcpFd4=
github.com/elastic/elastic-agent-client/v7 v7.16.0 h1:yKGq2+CxAuW8Kh0EoNl202tqAyQKfBcPRawVKs2Jve0=
github.com/elastic/elastic-agent-client/v7 v7.16.0/go.mod h1:6h+f9QdIr3GO2ODC0Y8+aEXRwzbA5W4eV4dd/67z7nI=
github.com/elastic/elastic-agent-libs v0.12.1 h1:5jkxMx15Bna8cq7/Sz/XUIVUXfNWiJ80iSk4ICQ7KJ0=
github.com/elastic/elastic-agent-libs v0.12.1/go.mod h1:5CR02awPrBr+tfmjBBK+JI+dMmHNQjpVY24J0wjbC7M=
github.com/elastic/elastic-agent-libs v0.13.0 h1:I0ZKvjIqT8ka7d2gX6Ta3nXrCZKysZ+N8VIKIuqqir0=
github.com/elastic/elastic-agent-libs v0.13.0/go.mod h1:5CR02awPrBr+tfmjBBK+JI+dMmHNQjpVY24J0wjbC7M=
github.com/elastic/elastic-agent-system-metrics v0.11.3 h1:LDzRwP8kxvsYEtMDgMSKZs1TgPcSEukit+/EAP5Y28A=
github.com/elastic/elastic-agent-system-metrics v0.11.3/go.mod h1:saqLKe9fuyuAo6IADAnnuy1kaBI7VNlxfwMo8KzSRyQ=
github.com/elastic/elastic-transport-go/v8 v8.6.0 h1:Y2S/FBjx1LlCv5m6pWAF2kDJAHoSjSRSJCApolgfthA=
Expand Down
220 changes: 220 additions & 0 deletions testing/proxytest/https.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License 2.0;
// you may not use this file except in compliance with the Elastic License 2.0.

package proxytest

import (
"bufio"
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"

"github.com/elastic/elastic-agent-libs/testing/certutil"
)

func (p *Proxy) serveHTTPS(w http.ResponseWriter, r *http.Request) {
log := loggerFromReqCtx(r)
log.Debug("handling CONNECT")

clientCon, err := hijack(w)
if err != nil {
p.http500Error(clientCon, "cannot handle request", err, log)
return
}
defer clientCon.Close()

// Hijack successful, w is now useless, let's make sure it isn't used by
// mistake ;)
w = nil //nolint:ineffassign,wastedassign // w is now useless, let's make sure it isn't used by mistake ;)
log.Debug("hijacked request")

// ==================== CONNECT accepted, let the client know
_, err = clientCon.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n"))
if err != nil {
p.http500Error(clientCon, "failed to send 200-OK after CONNECT", err, log)
return
}

// ==================== TLS handshake
// client will proceed to perform the TLS handshake with the "target",
// which we're impersonating.

// generate a TLS certificate matching the target's host
cert, err := p.newTLSCert(r.URL)
if err != nil {
p.http500Error(clientCon, "failed generating certificate", err, log)
return
}

tlscfg := p.TLS.Clone()
tlscfg.Certificates = []tls.Certificate{*cert}
clientTLSConn := tls.Server(clientCon, tlscfg)
defer clientTLSConn.Close()
err = clientTLSConn.Handshake()
if err != nil {
p.http500Error(clientCon, "failed TLS handshake with client", err, log)
return
}

clientTLSReader := bufio.NewReader(clientTLSConn)

notEOF := func(r *bufio.Reader) bool {
_, err = r.Peek(1)
return !errors.Is(err, io.EOF)
}
// ==================== Handle the actual request
for notEOF(clientTLSReader) {
// read request from the client sent after the 1s CONNECT request
req, err := http.ReadRequest(clientTLSReader)
if err != nil {
p.http500Error(clientTLSConn, "failed reading client request", err, log)
return
}

// carry over the original remote addr
req.RemoteAddr = r.RemoteAddr

// the read request is relative to the host from the original CONNECT
// request and without scheme. Therefore, set them in the new request.
req.URL, err = url.Parse("https://" + r.Host + req.URL.String())
if err != nil {
p.http500Error(clientTLSConn, "failed reading request URL from client", err, log)
return
}
cleanUpHeaders(req.Header)

// now the request is ready, it can be altered just as an HTTP request
// can.
resp, err := p.processRequest(req)
if err != nil {
p.http500Error(clientTLSConn, "failed performing request to target", err, log)
return
}

// Send response from target to client
// 1st - the status code
_, err = clientTLSConn.Write([]byte("HTTP/1.1 " + resp.Status + "\r\n"))
if err != nil {
p.http500Error(clientTLSConn, "failed writing response status line", err, log)
return
}

// 2nd - the headers
if err = resp.Header.Write(clientTLSConn); err != nil {
p.http500Error(clientTLSConn, "failed writing TLS response header", err, log)
return
}

// 3rd - indicates the headers are done and the body will follow
if _, err = clientTLSConn.Write([]byte("\r\n")); err != nil {
p.http500Error(clientTLSConn, "failed writing TLS header/body separator", err, log)
return
}

// copy the body else
_, err = io.CopyBuffer(clientTLSConn, resp.Body, make([]byte, 4096))
if err != nil {
p.http500Error(clientTLSConn, "failed writing response body", err, log)
return
}

_ = resp.Body.Close()
}

log.Debug("EOF reached, finishing HTTPS handler")
}

func (p *Proxy) newTLSCert(u *url.URL) (*tls.Certificate, error) {
// generate the certificate key - it needs to be RSA because Elastic Defend
// do not support EC :/
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("could not create RSA private key: %w", err)
}
host := u.Hostname()

var name string
var ips []net.IP
ip := net.ParseIP(host)
if ip == nil { // host isn't an IP, therefore it must be an DNS
name = host
} else {
ips = append(ips, ip)
}

cert, _, err := certutil.GenerateGenericChildCert(
name,
ips,
priv,
&priv.PublicKey,
p.ca.capriv,
p.ca.cacert)
if err != nil {
return nil, fmt.Errorf("could not generate TLS certificate for %s: %w",
host, err)
}

return cert, nil
}

func (p *Proxy) http500Error(clientCon net.Conn, msg string, err error, log *slog.Logger) {
p.httpError(clientCon, http.StatusInternalServerError, msg, err, log)
}

func (p *Proxy) httpError(clientCon net.Conn, status int, msg string, err error, log *slog.Logger) {
log.Error(msg, "err", err)

_, err = clientCon.Write(generateHTTPResponse(status, []byte(msg)))
if err != nil {
log.Error("failed writing response", "err", err)
}
}

func hijack(w http.ResponseWriter) (net.Conn, error) {
hijacker, ok := w.(http.Hijacker)
if !ok {
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprint(w, "cannot handle request")
return nil, errors.New("http.ResponseWriter does not support hijacking")
}

clientCon, _, err := hijacker.Hijack()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, err = fmt.Fprint(w, "cannot handle request")

return nil, fmt.Errorf("could not Hijack HTTPS CONNECT request: %w", err)
}

return clientCon, err
}

func cleanUpHeaders(h http.Header) {
h.Del("Proxy-Connection")
h.Del("Proxy-Authenticate")
h.Del("Proxy-Authorization")
h.Del("Connection")
}

func generateHTTPResponse(statusCode int, body []byte) []byte {
resp := bytes.Buffer{}
resp.WriteString(fmt.Sprintf("HTTP/1.1 %d %s\r\n",
statusCode, http.StatusText(statusCode)))
resp.WriteString("Content-Type: text/plain\r\n")
resp.WriteString(fmt.Sprintf("Content-Length: %d\r\n", len(body)))
resp.WriteString("\r\n")
if len(body) > 0 {
resp.Write(body)
}

return resp.Bytes()
}
Loading

0 comments on commit fd3a299

Please sign in to comment.