bd347edc
feat: add MITM CONNECT handling with body scanning
a73x 2026-03-29 16:38
Commit message
ca/ca.go
| Old | New | ||
|---|---|---|---|
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | "encoding/pem" | 10 | "encoding/pem" |
| 11 | "errors" | 11 | "errors" |
| 12 | "math/big" | 12 | "math/big" |
| 13 | "net" | ||
| 13 | "os" | 14 | "os" |
| 14 | "path/filepath" | 15 | "path/filepath" |
| 15 | "time" | 16 | "time" |
| @@ -138,7 +139,6 @@ func GenerateLeaf(host string, caCert *x509.Certificate, caKey *ecdsa.PrivateKey | |||
| 138 | Subject: pkix.Name{ | 139 | Subject: pkix.Name{ |
| 139 | CommonName: host, | 140 | CommonName: host, |
| 140 | }, | 141 | }, |
| 141 | DNSNames: []string{host}, | ||
| 142 | NotBefore: time.Now().Add(-time.Minute), | 142 | NotBefore: time.Now().Add(-time.Minute), |
| 143 | NotAfter: time.Now().Add(24 * time.Hour), | 143 | NotAfter: time.Now().Add(24 * time.Hour), |
| 144 | KeyUsage: x509.KeyUsageDigitalSignature, | 144 | KeyUsage: x509.KeyUsageDigitalSignature, |
| @@ -147,6 +147,12 @@ func GenerateLeaf(host string, caCert *x509.Certificate, caKey *ecdsa.PrivateKey | |||
| 147 | }, | 147 | }, |
| 148 | } | 148 | } |
| 149 | 149 | ||
| 150 | if ip := net.ParseIP(host); ip != nil { | ||
| 151 | template.IPAddresses = []net.IP{ip} | ||
| 152 | } else { | ||
| 153 | template.DNSNames = []string{host} | ||
| 154 | } | ||
| 155 | |||
| 150 | certDER, err := x509.CreateCertificate(rand.Reader, template, caCert, &key.PublicKey, caKey) | 156 | certDER, err := x509.CreateCertificate(rand.Reader, template, caCert, &key.PublicKey, caKey) |
| 151 | if err != nil { | 157 | if err != nil { |
| 152 | return tls.Certificate{}, err | 158 | return tls.Certificate{}, err |
proxy/proxy.go
| Old | New | ||
|---|---|---|---|
| @@ -3,6 +3,9 @@ package proxy | |||
| 3 | import ( | 3 | import ( |
| 4 | "bufio" | 4 | "bufio" |
| 5 | "bytes" | 5 | "bytes" |
| 6 | "crypto/ecdsa" | ||
| 7 | "crypto/tls" | ||
| 8 | "crypto/x509" | ||
| 6 | "fmt" | 9 | "fmt" |
| 7 | "io" | 10 | "io" |
| 8 | "log" | 11 | "log" |
| @@ -10,7 +13,9 @@ import ( | |||
| 10 | "net/http" | 13 | "net/http" |
| 11 | "os" | 14 | "os" |
| 12 | "strings" | 15 | "strings" |
| 16 | "sync" | ||
| 13 | 17 | ||
| 18 | "github.com/xanderle/nono/ca" | ||
| 14 | "github.com/xanderle/nono/scanner" | 19 | "github.com/xanderle/nono/scanner" |
| 15 | ) | 20 | ) |
| 16 | 21 | ||
| @@ -29,10 +34,31 @@ func WithRules(rulesPath string) Option { | |||
| 29 | } | 34 | } |
| 30 | } | 35 | } |
| 31 | 36 | ||
| 37 | // WithCA returns an Option that enables MITM interception using the given CA cert and key. | ||
| 38 | func WithCA(cert *x509.Certificate, key *ecdsa.PrivateKey) Option { | ||
| 39 | return func(p *Proxy) { | ||
| 40 | p.caCert = cert | ||
| 41 | p.caKey = key | ||
| 42 | p.certCache = make(map[string]*tls.Certificate) | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | // WithUpstreamTLS returns an Option that sets the TLS config used when dialing upstream. | ||
| 47 | func WithUpstreamTLS(cfg *tls.Config) Option { | ||
| 48 | return func(p *Proxy) { | ||
| 49 | p.upstreamTLS = cfg | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 32 | // Proxy is an HTTP proxy that only allows connections to approved hosts. | 53 | // Proxy is an HTTP proxy that only allows connections to approved hosts. |
| 33 | type Proxy struct { | 54 | type Proxy struct { |
| 34 | hostsFile string | 55 | hostsFile string |
| 35 | scanner *scanner.Scanner | 56 | scanner *scanner.Scanner |
| 57 | caCert *x509.Certificate | ||
| 58 | caKey *ecdsa.PrivateKey | ||
| 59 | certCache map[string]*tls.Certificate | ||
| 60 | certMu sync.Mutex | ||
| 61 | upstreamTLS *tls.Config | ||
| 36 | } | 62 | } |
| 37 | 63 | ||
| 38 | // New creates a new Proxy that checks hosts against the given allowlist file. | 64 | // New creates a new Proxy that checks hosts against the given allowlist file. |
| @@ -80,6 +106,14 @@ func (p *Proxy) isApproved(host string) bool { | |||
| 80 | } | 106 | } |
| 81 | 107 | ||
| 82 | func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) { | 108 | func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) { |
| 109 | if p.caCert == nil { | ||
| 110 | p.handleConnectTunnel(w, r) | ||
| 111 | return | ||
| 112 | } | ||
| 113 | p.handleConnectMITM(w, r) | ||
| 114 | } | ||
| 115 | |||
| 116 | func (p *Proxy) handleConnectTunnel(w http.ResponseWriter, r *http.Request) { | ||
| 83 | targetConn, err := net.Dial("tcp", r.Host) | 117 | targetConn, err := net.Dial("tcp", r.Host) |
| 84 | if err != nil { | 118 | if err != nil { |
| 85 | http.Error(w, err.Error(), http.StatusBadGateway) | 119 | http.Error(w, err.Error(), http.StatusBadGateway) |
| @@ -107,6 +141,119 @@ func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) { | |||
| 107 | targetConn.Close() | 141 | targetConn.Close() |
| 108 | } | 142 | } |
| 109 | 143 | ||
| 144 | func (p *Proxy) handleConnectMITM(w http.ResponseWriter, r *http.Request) { | ||
| 145 | hj, ok := w.(http.Hijacker) | ||
| 146 | if !ok { | ||
| 147 | http.Error(w, "hijacking not supported", http.StatusInternalServerError) | ||
| 148 | return | ||
| 149 | } | ||
| 150 | |||
| 151 | clientConn, _, err := hj.Hijack() | ||
| 152 | if err != nil { | ||
| 153 | return | ||
| 154 | } | ||
| 155 | |||
| 156 | clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) | ||
| 157 | |||
| 158 | host := extractHost(r.Host) | ||
| 159 | |||
| 160 | leafCert, err := p.getOrCreateLeaf(host) | ||
| 161 | if err != nil { | ||
| 162 | log.Printf("ERROR: failed to get leaf cert for %s: %v", host, err) | ||
| 163 | clientConn.Close() | ||
| 164 | return | ||
| 165 | } | ||
| 166 | |||
| 167 | tlsConn := tls.Server(clientConn, &tls.Config{ | ||
| 168 | Certificates: []tls.Certificate{*leafCert}, | ||
| 169 | }) | ||
| 170 | if err := tlsConn.Handshake(); err != nil { | ||
| 171 | log.Printf("ERROR: TLS handshake with client failed for %s: %v", host, err) | ||
| 172 | tlsConn.Close() | ||
| 173 | return | ||
| 174 | } | ||
| 175 | defer tlsConn.Close() | ||
| 176 | |||
| 177 | req, err := http.ReadRequest(bufio.NewReader(tlsConn)) | ||
| 178 | if err != nil { | ||
| 179 | log.Printf("ERROR: failed to read request from TLS conn for %s: %v", host, err) | ||
| 180 | return | ||
| 181 | } | ||
| 182 | |||
| 183 | findings := p.scanRequest(req) | ||
| 184 | if len(findings) > 0 { | ||
| 185 | rules := make([]string, 0, len(findings)) | ||
| 186 | for _, f := range findings { | ||
| 187 | rules = append(rules, f.Rule) | ||
| 188 | } | ||
| 189 | log.Printf("BLOCKED %s %s [%s]", req.Method, r.Host, strings.Join(rules, ", ")) | ||
| 190 | resp := &http.Response{ | ||
| 191 | StatusCode: http.StatusForbidden, | ||
| 192 | ProtoMajor: 1, | ||
| 193 | ProtoMinor: 1, | ||
| 194 | Header: make(http.Header), | ||
| 195 | Body: io.NopCloser(strings.NewReader(fmt.Sprintf("request blocked: contains sensitive data (%s)\n", strings.Join(rules, ", ")))), | ||
| 196 | } | ||
| 197 | resp.Header.Set("Content-Type", "text/plain") | ||
| 198 | resp.Write(tlsConn) | ||
| 199 | return | ||
| 200 | } | ||
| 201 | |||
| 202 | upstreamCfg := p.upstreamTLS | ||
| 203 | if upstreamCfg == nil { | ||
| 204 | upstreamCfg = &tls.Config{} | ||
| 205 | } | ||
| 206 | cfg := upstreamCfg.Clone() | ||
| 207 | cfg.ServerName = host | ||
| 208 | |||
| 209 | upstreamConn, err := tls.Dial("tcp", r.Host, cfg) | ||
| 210 | if err != nil { | ||
| 211 | log.Printf("ERROR: failed to dial upstream %s: %v", r.Host, err) | ||
| 212 | resp := &http.Response{ | ||
| 213 | StatusCode: http.StatusBadGateway, | ||
| 214 | ProtoMajor: 1, | ||
| 215 | ProtoMinor: 1, | ||
| 216 | Header: make(http.Header), | ||
| 217 | Body: io.NopCloser(strings.NewReader(err.Error()+"\n")), | ||
| 218 | } | ||
| 219 | resp.Write(tlsConn) | ||
| 220 | return | ||
| 221 | } | ||
| 222 | defer upstreamConn.Close() | ||
| 223 | |||
| 224 | req.RequestURI = "" | ||
| 225 | if err := req.Write(upstreamConn); err != nil { | ||
| 226 | log.Printf("ERROR: failed to forward request to %s: %v", r.Host, err) | ||
| 227 | return | ||
| 228 | } | ||
| 229 | |||
| 230 | upstreamResp, err := http.ReadResponse(bufio.NewReader(upstreamConn), req) | ||
| 231 | if err != nil { | ||
| 232 | log.Printf("ERROR: failed to read response from %s: %v", r.Host, err) | ||
| 233 | return | ||
| 234 | } | ||
| 235 | defer upstreamResp.Body.Close() | ||
| 236 | |||
| 237 | upstreamResp.Write(tlsConn) | ||
| 238 | } | ||
| 239 | |||
| 240 | func (p *Proxy) getOrCreateLeaf(host string) (*tls.Certificate, error) { | ||
| 241 | p.certMu.Lock() | ||
| 242 | defer p.certMu.Unlock() | ||
| 243 | |||
| 244 | if cert, ok := p.certCache[host]; ok { | ||
| 245 | return cert, nil | ||
| 246 | } | ||
| 247 | |||
| 248 | leaf, err := ca.GenerateLeaf(host, p.caCert, p.caKey) | ||
| 249 | if err != nil { | ||
| 250 | return nil, err | ||
| 251 | } | ||
| 252 | |||
| 253 | p.certCache[host] = &leaf | ||
| 254 | return &leaf, nil | ||
| 255 | } | ||
| 256 | |||
| 110 | func (p *Proxy) scanRequest(r *http.Request) []scanner.Finding { | 257 | func (p *Proxy) scanRequest(r *http.Request) []scanner.Finding { |
| 111 | if p.scanner == nil { | 258 | if p.scanner == nil { |
| 112 | return nil | 259 | return nil |
proxy/proxy_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,8 @@ | |||
| 1 | package proxy_test | 1 | package proxy_test |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "crypto/tls" | ||
| 5 | "crypto/x509" | ||
| 4 | "net/http" | 6 | "net/http" |
| 5 | "net/http/httptest" | 7 | "net/http/httptest" |
| 6 | "net/url" | 8 | "net/url" |
| @@ -9,6 +11,7 @@ import ( | |||
| 9 | "strings" | 11 | "strings" |
| 10 | "testing" | 12 | "testing" |
| 11 | 13 | ||
| 14 | nca "github.com/xanderle/nono/ca" | ||
| 12 | "github.com/xanderle/nono/proxy" | 15 | "github.com/xanderle/nono/proxy" |
| 13 | ) | 16 | ) |
| 14 | 17 | ||
| @@ -181,3 +184,107 @@ func TestShouldAllowCleanRequest(t *testing.T) { | |||
| 181 | t.Errorf("expected 200, got %d", resp.StatusCode) | 184 | t.Errorf("expected 200, got %d", resp.StatusCode) |
| 182 | } | 185 | } |
| 183 | } | 186 | } |
| 187 | |||
| 188 | func TestShouldBlockHTTPSRequestWithAWSKey(t *testing.T) { | ||
| 189 | backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 190 | w.WriteHeader(http.StatusOK) | ||
| 191 | })) | ||
| 192 | defer backend.Close() | ||
| 193 | |||
| 194 | backendURL, _ := url.Parse(backend.URL) | ||
| 195 | host := backendURL.Hostname() | ||
| 196 | |||
| 197 | hostsFile := filepath.Join(t.TempDir(), "approved_hosts") | ||
| 198 | os.WriteFile(hostsFile, []byte(host+"\n"), 0644) | ||
| 199 | |||
| 200 | caDir := t.TempDir() | ||
| 201 | caCert, caKey, err := nca.LoadOrCreate(caDir) | ||
| 202 | if err != nil { | ||
| 203 | t.Fatalf("CA setup: %v", err) | ||
| 204 | } | ||
| 205 | |||
| 206 | rulesPath := writeTestRules(t) | ||
| 207 | p := proxy.New(hostsFile, | ||
| 208 | proxy.WithRules(rulesPath), | ||
| 209 | proxy.WithCA(caCert, caKey), | ||
| 210 | proxy.WithUpstreamTLS(&tls.Config{InsecureSkipVerify: true}), | ||
| 211 | ) | ||
| 212 | srv := httptest.NewServer(p) | ||
| 213 | defer srv.Close() | ||
| 214 | |||
| 215 | caPool := x509.NewCertPool() | ||
| 216 | caPool.AddCert(caCert) | ||
| 217 | |||
| 218 | proxyURL, _ := url.Parse(srv.URL) | ||
| 219 | client := &http.Client{ | ||
| 220 | Transport: &http.Transport{ | ||
| 221 | Proxy: http.ProxyURL(proxyURL), | ||
| 222 | TLSClientConfig: &tls.Config{ | ||
| 223 | RootCAs: caPool, | ||
| 224 | }, | ||
| 225 | }, | ||
| 226 | } | ||
| 227 | |||
| 228 | body := strings.NewReader("key=AKIAIOSFODNN7EXAMPLE") | ||
| 229 | resp, err := client.Post(backend.URL+"/upload", "text/plain", body) | ||
| 230 | if err != nil { | ||
| 231 | t.Fatalf("unexpected error: %v", err) | ||
| 232 | } | ||
| 233 | defer resp.Body.Close() | ||
| 234 | |||
| 235 | if resp.StatusCode != http.StatusForbidden { | ||
| 236 | t.Errorf("expected 403, got %d", resp.StatusCode) | ||
| 237 | } | ||
| 238 | } | ||
| 239 | |||
| 240 | func TestShouldAllowCleanHTTPSRequest(t *testing.T) { | ||
| 241 | backend := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 242 | w.WriteHeader(http.StatusOK) | ||
| 243 | w.Write([]byte("ok")) | ||
| 244 | })) | ||
| 245 | defer backend.Close() | ||
| 246 | |||
| 247 | backendURL, _ := url.Parse(backend.URL) | ||
| 248 | host := backendURL.Hostname() | ||
| 249 | |||
| 250 | hostsFile := filepath.Join(t.TempDir(), "approved_hosts") | ||
| 251 | os.WriteFile(hostsFile, []byte(host+"\n"), 0644) | ||
| 252 | |||
| 253 | caDir := t.TempDir() | ||
| 254 | caCert, caKey, err := nca.LoadOrCreate(caDir) | ||
| 255 | if err != nil { | ||
| 256 | t.Fatalf("CA setup: %v", err) | ||
| 257 | } | ||
| 258 | |||
| 259 | rulesPath := writeTestRules(t) | ||
| 260 | p := proxy.New(hostsFile, | ||
| 261 | proxy.WithRules(rulesPath), | ||
| 262 | proxy.WithCA(caCert, caKey), | ||
| 263 | proxy.WithUpstreamTLS(&tls.Config{InsecureSkipVerify: true}), | ||
| 264 | ) | ||
| 265 | srv := httptest.NewServer(p) | ||
| 266 | defer srv.Close() | ||
| 267 | |||
| 268 | caPool := x509.NewCertPool() | ||
| 269 | caPool.AddCert(caCert) | ||
| 270 | |||
| 271 | proxyURL, _ := url.Parse(srv.URL) | ||
| 272 | client := &http.Client{ | ||
| 273 | Transport: &http.Transport{ | ||
| 274 | Proxy: http.ProxyURL(proxyURL), | ||
| 275 | TLSClientConfig: &tls.Config{ | ||
| 276 | RootCAs: caPool, | ||
| 277 | }, | ||
| 278 | }, | ||
| 279 | } | ||
| 280 | |||
| 281 | resp, err := client.Post(backend.URL+"/data", "text/plain", strings.NewReader("clean")) | ||
| 282 | if err != nil { | ||
| 283 | t.Fatalf("unexpected error: %v", err) | ||
| 284 | } | ||
| 285 | defer resp.Body.Close() | ||
| 286 | |||
| 287 | if resp.StatusCode != http.StatusOK { | ||
| 288 | t.Errorf("expected 200, got %d", resp.StatusCode) | ||
| 289 | } | ||
| 290 | } | ||