|
| 1 | +// https://github.com/eliben/code-for-blog/blob/master/2022/go-and-proxies/connect-mitm-proxy.go |
| 2 | +package main |
| 3 | + |
| 4 | +import ( |
| 5 | + "bufio" |
| 6 | + "crypto" |
| 7 | + "crypto/ecdsa" |
| 8 | + "crypto/elliptic" |
| 9 | + "crypto/rand" |
| 10 | + "crypto/tls" |
| 11 | + "crypto/x509" |
| 12 | + "crypto/x509/pkix" |
| 13 | + "encoding/pem" |
| 14 | + "flag" |
| 15 | + "io" |
| 16 | + "io/ioutil" |
| 17 | + "log" |
| 18 | + "math/big" |
| 19 | + "net" |
| 20 | + "net/http" |
| 21 | + "net/url" |
| 22 | + "runtime/debug" |
| 23 | + "strings" |
| 24 | + "time" |
| 25 | +) |
| 26 | + |
| 27 | +// createCert creates a new certificate/private key pair for the given domains, |
| 28 | +// signed by the parent/parentKey certificate. hoursValid is the duration of |
| 29 | +// the new certificate's validity. |
| 30 | +func createCert(dnsNames []string, parent *x509.Certificate, parentKey crypto.PrivateKey, hoursValid int) (cert []byte, priv []byte) { |
| 31 | + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| 32 | + if err != nil { |
| 33 | + log.Panicf("Failed to generate private key: %v", err) |
| 34 | + } |
| 35 | + |
| 36 | + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) |
| 37 | + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) |
| 38 | + if err != nil { |
| 39 | + log.Panicf("Failed to generate serial number: %v", err) |
| 40 | + } |
| 41 | + |
| 42 | + template := x509.Certificate{ |
| 43 | + SerialNumber: serialNumber, |
| 44 | + Subject: pkix.Name{ |
| 45 | + Organization: []string{"Sample MITM proxy"}, |
| 46 | + }, |
| 47 | + DNSNames: dnsNames, |
| 48 | + NotBefore: time.Now(), |
| 49 | + NotAfter: time.Now().Add(time.Duration(hoursValid) * time.Hour), |
| 50 | + |
| 51 | + KeyUsage: x509.KeyUsageDigitalSignature, |
| 52 | + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, |
| 53 | + BasicConstraintsValid: true, |
| 54 | + } |
| 55 | + |
| 56 | + derBytes, err := x509.CreateCertificate(rand.Reader, &template, parent, &privateKey.PublicKey, parentKey) |
| 57 | + if err != nil { |
| 58 | + log.Panicf("Failed to create certificate: %v", err) |
| 59 | + } |
| 60 | + pemCert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) |
| 61 | + if pemCert == nil { |
| 62 | + log.Panic("failed to encode certificate to PEM") |
| 63 | + } |
| 64 | + |
| 65 | + privBytes, err := x509.MarshalPKCS8PrivateKey(privateKey) |
| 66 | + if err != nil { |
| 67 | + log.Panicf("Unable to marshal private key: %v", err) |
| 68 | + } |
| 69 | + pemKey := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}) |
| 70 | + if pemCert == nil { |
| 71 | + log.Panic("failed to encode key to PEM") |
| 72 | + } |
| 73 | + |
| 74 | + return pemCert, pemKey |
| 75 | +} |
| 76 | + |
| 77 | +// loadX509KeyPair loads a certificate/key pair from files, and unmarshals them |
| 78 | +// into data structures from the x509 package. Note that private key types in Go |
| 79 | +// don't have a shared named interface and use `any` (for backwards |
| 80 | +// compatibility reasons). |
| 81 | +func loadX509KeyPair(certFile, keyFile string) (cert *x509.Certificate, key any, err error) { |
| 82 | + cf, err := ioutil.ReadFile(certFile) |
| 83 | + if err != nil { |
| 84 | + return nil, nil, err |
| 85 | + } |
| 86 | + |
| 87 | + kf, err := ioutil.ReadFile(keyFile) |
| 88 | + if err != nil { |
| 89 | + return nil, nil, err |
| 90 | + } |
| 91 | + certBlock, _ := pem.Decode(cf) |
| 92 | + cert, err = x509.ParseCertificate(certBlock.Bytes) |
| 93 | + if err != nil { |
| 94 | + return nil, nil, err |
| 95 | + } |
| 96 | + |
| 97 | + keyBlock, _ := pem.Decode(kf) |
| 98 | + key, err = x509.ParsePKCS8PrivateKey(keyBlock.Bytes) |
| 99 | + if err != nil { |
| 100 | + return nil, nil, err |
| 101 | + } |
| 102 | + |
| 103 | + return cert, key, nil |
| 104 | +} |
| 105 | + |
| 106 | +// mitmProxy is a type implementing http.Handler that serves as a MITM proxy |
| 107 | +// for CONNECT tunnels. Create new instances of mitmProxy using createMitmProxy. |
| 108 | +type mitmProxy struct { |
| 109 | + caCert *x509.Certificate |
| 110 | + caKey any |
| 111 | +} |
| 112 | + |
| 113 | +// createMitmProxy creates a new MITM proxy. It should be passed the filenames |
| 114 | +// for the certificate and private key of a certificate authority trusted by the |
| 115 | +// client's machine. |
| 116 | +func createMitmProxy(caCertFile, caKeyFile string) *mitmProxy { |
| 117 | + caCert, caKey, err := loadX509KeyPair(caCertFile, caKeyFile) |
| 118 | + if err != nil { |
| 119 | + log.Panic("Error loading CA certificate/key:", err) |
| 120 | + } |
| 121 | + log.Printf("loaded CA certificate and key; IsCA=%v\n", caCert.IsCA) |
| 122 | + |
| 123 | + return &mitmProxy{ |
| 124 | + caCert: caCert, |
| 125 | + caKey: caKey, |
| 126 | + } |
| 127 | +} |
| 128 | + |
| 129 | +func (p *mitmProxy) ServeHTTP(w http.ResponseWriter, req *http.Request) { |
| 130 | + defer func() { |
| 131 | + if r := recover(); r != nil { |
| 132 | + log.Printf("panic serving %v: %v. stacktrace from panic: %s", req.URL, r, string(debug.Stack())) |
| 133 | + http.Error(w, "internal server error", http.StatusInternalServerError) |
| 134 | + } |
| 135 | + }() |
| 136 | + |
| 137 | + if req.Method == http.MethodConnect { |
| 138 | + p.proxyConnect(w, req) |
| 139 | + } else { |
| 140 | + p.proxyHTTP(w, req) |
| 141 | + } |
| 142 | +} |
| 143 | + |
| 144 | +func (p *mitmProxy) proxyHTTP(w http.ResponseWriter, req *http.Request) { |
| 145 | + // The "Host:" header is promoted to Request.Host and is removed from |
| 146 | + // request.Header by net/http, so we print it out explicitly. |
| 147 | + log.Println(req.RemoteAddr, "\t\t", req.Method, "\t\t", req.URL, "\t\t Host:", req.Host) |
| 148 | + log.Println("\t\t\t\t\t", req.Header) |
| 149 | + |
| 150 | + if req.URL.Scheme != "http" { |
| 151 | + msg := "unsupported protocol scheme " + req.URL.Scheme |
| 152 | + http.Error(w, msg, http.StatusBadRequest) |
| 153 | + log.Println(msg) |
| 154 | + return |
| 155 | + } |
| 156 | + |
| 157 | + client := &http.Client{} |
| 158 | + // When a http.Request is sent through an http.Client, RequestURI should not |
| 159 | + // be set (see documentation of this field). |
| 160 | + req.RequestURI = "" |
| 161 | + |
| 162 | + removeHopHeaders(req.Header) |
| 163 | + removeConnectionHeaders(req.Header) |
| 164 | + |
| 165 | + if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil { |
| 166 | + appendHostToXForwardHeader(req.Header, clientIP) |
| 167 | + } |
| 168 | + |
| 169 | + resp, err := client.Do(req) |
| 170 | + if err != nil { |
| 171 | + http.Error(w, err.Error(), http.StatusInternalServerError) |
| 172 | + log.Println(err) |
| 173 | + return |
| 174 | + } |
| 175 | + defer resp.Body.Close() |
| 176 | + |
| 177 | + log.Println(req.RemoteAddr, " ", resp.Status) |
| 178 | + |
| 179 | + removeHopHeaders(resp.Header) |
| 180 | + removeConnectionHeaders(resp.Header) |
| 181 | + |
| 182 | + copyHeader(w.Header(), resp.Header) |
| 183 | + w.WriteHeader(resp.StatusCode) |
| 184 | + io.Copy(w, resp.Body) |
| 185 | +} |
| 186 | + |
| 187 | +// Hop-by-hop headers. These are removed when sent to the backend. |
| 188 | +// http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html |
| 189 | +// Note: this may be out of date, see RFC 7230 Section 6.1 |
| 190 | +var hopHeaders = []string{ |
| 191 | + "Connection", |
| 192 | + "Proxy-Connection", |
| 193 | + "Keep-Alive", |
| 194 | + "Proxy-Authenticate", |
| 195 | + "Proxy-Authorization", |
| 196 | + "Te", // canonicalized version of "TE" |
| 197 | + "Trailer", // spelling per https://www.rfc-editor.org/errata_search.php?eid=4522 |
| 198 | + "Transfer-Encoding", |
| 199 | + "Upgrade", |
| 200 | +} |
| 201 | + |
| 202 | +func copyHeader(dst, src http.Header) { |
| 203 | + for k, vv := range src { |
| 204 | + for _, v := range vv { |
| 205 | + dst.Add(k, v) |
| 206 | + } |
| 207 | + } |
| 208 | +} |
| 209 | + |
| 210 | +func removeHopHeaders(header http.Header) { |
| 211 | + for _, h := range hopHeaders { |
| 212 | + header.Del(h) |
| 213 | + header.Del(strings.ToLower(h)) |
| 214 | + } |
| 215 | +} |
| 216 | + |
| 217 | +// removeConnectionHeaders removes hop-by-hop headers listed in the "Connection" |
| 218 | +// header of h. See RFC 7230, section 6.1 |
| 219 | +func removeConnectionHeaders(h http.Header) { |
| 220 | + for _, f := range h["Connection"] { |
| 221 | + for _, sf := range strings.Split(f, ",") { |
| 222 | + if sf = strings.TrimSpace(sf); sf != "" { |
| 223 | + h.Del(sf) |
| 224 | + } |
| 225 | + } |
| 226 | + } |
| 227 | +} |
| 228 | + |
| 229 | +var client = &http.Client{ |
| 230 | + // do not follow redirects, let the client handle them |
| 231 | + CheckRedirect: func(req *http.Request, via []*http.Request) error { |
| 232 | + return http.ErrUseLastResponse |
| 233 | + }, |
| 234 | +} |
| 235 | + |
| 236 | +func appendHostToXForwardHeader(header http.Header, host string) { |
| 237 | + // If we aren't the first proxy retain prior |
| 238 | + // X-Forwarded-For information as a comma+space |
| 239 | + // separated list and fold multiple headers into one. |
| 240 | + if prior, ok := header["X-Forwarded-For"]; ok { |
| 241 | + host = strings.Join(prior, ", ") + ", " + host |
| 242 | + } |
| 243 | + header.Set("X-Forwarded-For", host) |
| 244 | +} |
| 245 | + |
| 246 | +// proxyConnect implements the MITM proxy for CONNECT tunnels. |
| 247 | +func (p *mitmProxy) proxyConnect(w http.ResponseWriter, proxyReq *http.Request) { |
| 248 | + log.Printf("CONNECT requested to %v (from %v)", proxyReq.Host, proxyReq.RemoteAddr) |
| 249 | + |
| 250 | + // "Hijack" the client connection to get a TCP (or TLS) socket we can read |
| 251 | + // and write arbitrary data to/from. |
| 252 | + hj, ok := w.(http.Hijacker) |
| 253 | + if !ok { |
| 254 | + log.Panic("http server doesn't support hijacking connection") |
| 255 | + } |
| 256 | + |
| 257 | + clientConn, _, err := hj.Hijack() |
| 258 | + if err != nil { |
| 259 | + log.Panic("http hijacking failed") |
| 260 | + } |
| 261 | + |
| 262 | + // proxyReq.Host will hold the CONNECT target host, which will typically have |
| 263 | + // a port - e.g. example.org:443 |
| 264 | + // To generate a fake certificate for example.org, we have to first split off |
| 265 | + // the host from the port. |
| 266 | + host, _, err := net.SplitHostPort(proxyReq.Host) |
| 267 | + if err != nil { |
| 268 | + log.Panic("error splitting host/port:", err) |
| 269 | + } |
| 270 | + |
| 271 | + // Create a fake TLS certificate for the target host, signed by our CA. The |
| 272 | + // certificate will be valid for 10 days - this number can be changed. |
| 273 | + pemCert, pemKey := createCert([]string{host}, p.caCert, p.caKey, 240) |
| 274 | + tlsCert, err := tls.X509KeyPair(pemCert, pemKey) |
| 275 | + if err != nil { |
| 276 | + log.Panic(err) |
| 277 | + } |
| 278 | + |
| 279 | + // Send an HTTP OK response back to the client; this initiates the CONNECT |
| 280 | + // tunnel. From this point on the client will assume it's connected directly |
| 281 | + // to the target. |
| 282 | + if _, err := clientConn.Write([]byte("HTTP/1.1 200 OK\r\n\r\n")); err != nil { |
| 283 | + log.Panic("error writing status to client:", err) |
| 284 | + } |
| 285 | + |
| 286 | + // Configure a new TLS server, pointing it at the client connection, using |
| 287 | + // our certificate. This server will now pretend being the target. |
| 288 | + tlsConfig := &tls.Config{ |
| 289 | + PreferServerCipherSuites: true, |
| 290 | + CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256}, |
| 291 | + MinVersion: tls.VersionTLS12, |
| 292 | + Certificates: []tls.Certificate{tlsCert}, |
| 293 | + } |
| 294 | + |
| 295 | + tlsConn := tls.Server(clientConn, tlsConfig) |
| 296 | + // tls.Conn.Close() also closes the underlying net.Conn |
| 297 | + defer tlsConn.Close() |
| 298 | + |
| 299 | + connReader := bufio.NewReader(tlsConn) |
| 300 | + |
| 301 | + r, err := http.ReadRequest(connReader) |
| 302 | + if err != nil && err != io.EOF { |
| 303 | + log.Panic(err) |
| 304 | + } else if err == io.EOF { |
| 305 | + return |
| 306 | + } |
| 307 | + |
| 308 | + changeRequestToTarget(r, proxyReq.Host) |
| 309 | + |
| 310 | + resp, err := client.Do(r) |
| 311 | + if err != nil { |
| 312 | + log.Println("error sending request to target:", err) |
| 313 | + return |
| 314 | + } |
| 315 | + defer resp.Body.Close() |
| 316 | + |
| 317 | + if err := resp.Write(tlsConn); err != nil { |
| 318 | + log.Println("error writing response back:", err) |
| 319 | + } |
| 320 | +} |
| 321 | + |
| 322 | +// changeRequestToTarget modifies req to be re-routed to the given target; |
| 323 | +// the target should be taken from the Host of the original tunnel (CONNECT) |
| 324 | +// request. |
| 325 | +func changeRequestToTarget(req *http.Request, targetHost string) { |
| 326 | + targetUrl := addrToUrl(targetHost) |
| 327 | + targetUrl.Path = req.URL.Path |
| 328 | + targetUrl.RawQuery = req.URL.RawQuery |
| 329 | + // Unescape the path - the client will have escaped it, but the target |
| 330 | + // server will expect it unescaped since it's part of the HTTP request. |
| 331 | + path, err := url.PathUnescape(targetUrl.String()) |
| 332 | + if err != nil { |
| 333 | + log.Panic(err) |
| 334 | + } |
| 335 | + req.URL, err = url.Parse(path) |
| 336 | + if err != nil { |
| 337 | + log.Panicf("error parsing target URL %v: %v", path, err) |
| 338 | + } |
| 339 | + // Make sure this is unset for sending the request through a client |
| 340 | + req.RequestURI = "" |
| 341 | +} |
| 342 | + |
| 343 | +func addrToUrl(addr string) *url.URL { |
| 344 | + if !strings.HasPrefix(addr, "https") { |
| 345 | + addr = "https://" + addr |
| 346 | + } |
| 347 | + u, err := url.Parse(addr) |
| 348 | + if err != nil { |
| 349 | + log.Panic(err) |
| 350 | + } |
| 351 | + return u |
| 352 | +} |
| 353 | + |
| 354 | +func main() { |
| 355 | + var addr = flag.String("addr", "127.0.0.1:9999", "proxy address") |
| 356 | + caCertFile := flag.String("cacertfile", "", "certificate .pem file for trusted CA") |
| 357 | + caKeyFile := flag.String("cakeyfile", "", "key .pem file for trusted CA") |
| 358 | + flag.Parse() |
| 359 | + |
| 360 | + proxy := createMitmProxy(*caCertFile, *caKeyFile) |
| 361 | + |
| 362 | + log.Println("Starting proxy server on", *addr) |
| 363 | + if err := http.ListenAndServe(*addr, proxy); err != nil { |
| 364 | + log.Panic("ListenAndServe:", err) |
| 365 | + } |
| 366 | +} |
0 commit comments