a73x

e6db3821

feat: integrate scanner into HTTP request handling

a73x   2026-03-29 16:26

Commit message
feat: integrate scanner into HTTP request handling

Add functional options pattern to Proxy, WithRules option to load a scanner,
scanRequest method that checks headers and body, and 403 blocking in handleHTTP.

proxy/proxy.go
Old New
@@ -2,6 +2,7 @@ package proxy
2 2
3 import ( 3 import (
4 "bufio" 4 "bufio"
5 "bytes"
5 "fmt" 6 "fmt"
6 "io" 7 "io"
7 "log" 8 "log"
@@ -9,16 +10,38 @@ import (
9 "net/http" 10 "net/http"
10 "os" 11 "os"
11 "strings" 12 "strings"
13
14 "github.com/xanderle/nono/scanner"
12 ) 15 )
13 16
17 // Option is a functional option for configuring a Proxy.
18 type Option func(*Proxy)
19
20 // WithRules returns an Option that loads a scanner from the given rules file path.
21 func WithRules(rulesPath string) Option {
22 return func(p *Proxy) {
23 s, err := scanner.New(rulesPath)
24 if err != nil {
25 log.Printf("WARNING: failed to load rules from %q: %v", rulesPath, err)
26 return
27 }
28 p.scanner = s
29 }
30 }
31
14 // Proxy is an HTTP proxy that only allows connections to approved hosts. 32 // Proxy is an HTTP proxy that only allows connections to approved hosts.
15 type Proxy struct { 33 type Proxy struct {
16 hostsFile string 34 hostsFile string
35 scanner *scanner.Scanner
17 } 36 }
18 37
19 // New creates a new Proxy that checks hosts against the given allowlist file. 38 // New creates a new Proxy that checks hosts against the given allowlist file.
20 func New(hostsFile string) *Proxy { 39 func New(hostsFile string, opts ...Option) *Proxy {
21 return &Proxy{hostsFile: hostsFile} 40 p := &Proxy{hostsFile: hostsFile}
41 for _, opt := range opts {
42 opt(p)
43 }
44 return p
22 } 45 }
23 46
24 func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { 47 func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -85,7 +108,45 @@ func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
85 targetConn.Close() 108 targetConn.Close()
86 } 109 }
87 110
111 func (p *Proxy) scanRequest(r *http.Request) []scanner.Finding {
112 if p.scanner == nil {
113 return nil
114 }
115
116 var buf bytes.Buffer
117
118 // Serialize headers as "Key: Value\n" lines
119 for k, vv := range r.Header {
120 for _, v := range vv {
121 fmt.Fprintf(&buf, "%s: %s\n", k, v)
122 }
123 }
124
125 // Read body if present
126 if r.Body != nil {
127 body, err := io.ReadAll(r.Body)
128 if err == nil {
129 buf.Write(body)
130 // Restore the body so it can be forwarded
131 r.Body = io.NopCloser(bytes.NewReader(body))
132 }
133 }
134
135 return p.scanner.Scan(buf.Bytes())
136 }
137
88 func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) { 138 func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
139 findings := p.scanRequest(r)
140 if len(findings) > 0 {
141 rules := make([]string, 0, len(findings))
142 for _, f := range findings {
143 rules = append(rules, f.Rule)
144 }
145 log.Printf("BLOCKED %s %s [%s]", r.Method, r.Host, strings.Join(rules, ", "))
146 http.Error(w, fmt.Sprintf("request blocked: contains sensitive data (%s)", strings.Join(rules, ", ")), http.StatusForbidden)
147 return
148 }
149
89 r.RequestURI = "" 150 r.RequestURI = ""
90 resp, err := http.DefaultTransport.RoundTrip(r) 151 resp, err := http.DefaultTransport.RoundTrip(r)
91 if err != nil { 152 if err != nil {
proxy/proxy_test.go
Old New
@@ -115,3 +115,69 @@ func TestAllowShouldCreateFileIfMissing(t *testing.T) {
115 t.Errorf("expected approved_hosts to contain example.com, got %q", string(data)) 115 t.Errorf("expected approved_hosts to contain example.com, got %q", string(data))
116 } 116 }
117 } 117 }
118
119 func writeTestRules(t *testing.T) string {
120 t.Helper()
121 dir := t.TempDir()
122 path := filepath.Join(dir, "rules.yaml")
123 os.WriteFile(path, []byte("rules:\n - name: ssh-private-key\n pattern: \"-----BEGIN RSA PRIVATE KEY-----\"\n - name: aws-access-key\n pattern: \"AKIA[0-9A-Z]{16}\"\n"), 0644)
124 return path
125 }
126
127 func TestShouldBlockRequestWithSSHKey(t *testing.T) {
128 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
129 w.WriteHeader(http.StatusOK)
130 }))
131 defer backend.Close()
132
133 backendURL, _ := url.Parse(backend.URL)
134 hostsFile := filepath.Join(t.TempDir(), "approved_hosts")
135 os.WriteFile(hostsFile, []byte(backendURL.Hostname()+"\n"), 0644)
136
137 rulesPath := writeTestRules(t)
138 p := proxy.New(hostsFile, proxy.WithRules(rulesPath))
139 srv := httptest.NewServer(p)
140 defer srv.Close()
141
142 client := newProxyClient(t, srv.URL)
143
144 body := strings.NewReader("data=-----BEGIN RSA PRIVATE KEY-----\nMIIE...")
145 resp, err := client.Post(backend.URL+"/upload", "text/plain", body)
146 if err != nil {
147 t.Fatalf("unexpected error: %v", err)
148 }
149 defer resp.Body.Close()
150
151 if resp.StatusCode != http.StatusForbidden {
152 t.Errorf("expected 403, got %d", resp.StatusCode)
153 }
154 }
155
156 func TestShouldAllowCleanRequest(t *testing.T) {
157 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
158 w.WriteHeader(http.StatusOK)
159 }))
160 defer backend.Close()
161
162 backendURL, _ := url.Parse(backend.URL)
163 hostsFile := filepath.Join(t.TempDir(), "approved_hosts")
164 os.WriteFile(hostsFile, []byte(backendURL.Hostname()+"\n"), 0644)
165
166 rulesPath := writeTestRules(t)
167 p := proxy.New(hostsFile, proxy.WithRules(rulesPath))
168 srv := httptest.NewServer(p)
169 defer srv.Close()
170
171 client := newProxyClient(t, srv.URL)
172
173 body := strings.NewReader("just normal data")
174 resp, err := client.Post(backend.URL+"/upload", "text/plain", body)
175 if err != nil {
176 t.Fatalf("unexpected error: %v", err)
177 }
178 defer resp.Body.Close()
179
180 if resp.StatusCode != http.StatusOK {
181 t.Errorf("expected 200, got %d", resp.StatusCode)
182 }
183 }