a73x

bd347edc

feat: add MITM CONNECT handling with body scanning

a73x   2026-03-29 16:38

Commit message
feat: add MITM CONNECT handling with body scanning

Add WithCA and WithUpstreamTLS options; replace blind tunnel with TLS
interception when a CA is configured, scanning decrypted request bodies.
Also extend ca.GenerateLeaf to support IP SAN certificates.

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 }