e6db3821
feat: integrate scanner into HTTP request handling
a73x 2026-03-29 16:26
Commit message
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 | } | ||