| 1 | package installsource |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "net" |
| 7 | "net/http" |
| 8 | "net/url" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | "time" |
| 12 | ) |
| 13 | |
| 14 | // recordingProxy is a dummy HTTP proxy: it accepts absolute-form requests, |
| 15 | // records the destination the client asked it to reach, and answers 200. It |
| 16 | // never contacts the destination, so tests can assert what would have been |
| 17 | // forwarded without touching a real internal service. |
| 18 | type recordingProxy struct { |
| 19 | mu sync.Mutex |
| 20 | dests []string |
| 21 | |
| 22 | listener net.Listener |
| 23 | } |
| 24 | |
| 25 | func newRecordingProxy(t *testing.T) *recordingProxy { |
| 26 | t.Helper() |
| 27 | l, err := net.Listen("tcp", "127.0.0.1:0") |
| 28 | if err != nil { |
| 29 | t.Fatalf("listen: %v", err) |
| 30 | } |
| 31 | p := &recordingProxy{listener: l} |
| 32 | go func() { |
| 33 | for { |
| 34 | conn, err := l.Accept() |
| 35 | if err != nil { |
| 36 | return |
| 37 | } |
| 38 | go p.serve(conn) |
| 39 | } |
| 40 | }() |
| 41 | t.Cleanup(func() { _ = l.Close() }) |
| 42 | return p |
| 43 | } |
| 44 | |
| 45 | func (p *recordingProxy) serve(conn net.Conn) { |
| 46 | defer conn.Close() |
| 47 | _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) |
| 48 | req, err := http.ReadRequest(bufio.NewReader(conn)) |
| 49 | if err != nil { |
| 50 | return |
| 51 | } |
| 52 | if req.URL != nil && req.URL.IsAbs() { |
| 53 | p.mu.Lock() |
| 54 | p.dests = append(p.dests, req.URL.String()) |
| 55 | p.mu.Unlock() |
| 56 | } |
| 57 | _, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")) |
| 58 | } |
| 59 | |
| 60 | func (p *recordingProxy) destinations() []string { |
| 61 | p.mu.Lock() |
| 62 | defer p.mu.Unlock() |
| 63 | return append([]string(nil), p.dests...) |
| 64 | } |
| 65 | |
| 66 | func (p *recordingProxy) URL() string { |
| 67 | return "http://" + p.listener.Addr().String() |
| 68 | } |
| 69 | |
| 70 | func TestSSRFGuardRejectsBlockedIPLiteralThroughProxy(t *testing.T) { |
| 71 | proxy := newRecordingProxy(t) |
| 72 | transport := &http.Transport{Proxy: func(*http.Request) (*url.URL, error) { |
| 73 | return url.Parse(proxy.URL()) |
| 74 | }} |
| 75 | client := ssrfGuardClient(&http.Client{Transport: transport, Timeout: 5 * time.Second}) |
| 76 | |
| 77 | for _, target := range []string{ |
| 78 | "http://169.254.169.254/latest/meta-data/", |
| 79 | "http://10.0.0.1/mcp", |
| 80 | "http://192.168.0.1/mcp", |
| 81 | "http://100.64.0.1/mcp", |
| 82 | } { |
| 83 | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, target, nil) |
| 84 | if err != nil { |
| 85 | t.Fatalf("new request: %v", err) |
| 86 | } |
| 87 | resp, err := client.Do(req) |
| 88 | if err == nil { |
| 89 | _ = resp.Body.Close() |
| 90 | t.Fatalf("request to %s through proxy succeeded; want SSRF refusal", target) |
| 91 | } |
| 92 | } |
| 93 | if got := proxy.destinations(); len(got) != 0 { |
| 94 | t.Fatalf("proxy was asked to reach %v; want the guard to refuse before forwarding", got) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestSSRFGuardRejectsBlockedIPLiteralDirect(t *testing.T) { |
| 99 | client := ssrfGuardClient(&http.Client{Timeout: 2 * time.Second}) |
| 100 | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://169.254.169.254/latest/meta-data/", nil) |
| 101 | if err != nil { |
| 102 | t.Fatalf("new request: %v", err) |
| 103 | } |
| 104 | if _, err := client.Do(req); err == nil { |
| 105 | t.Fatal("direct request to blocked IP succeeded; want SSRF refusal") |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | func TestSSRFGuardAllowsLoopbackThroughProxy(t *testing.T) { |
| 110 | proxy := newRecordingProxy(t) |
| 111 | transport := &http.Transport{Proxy: func(*http.Request) (*url.URL, error) { |
| 112 | return url.Parse(proxy.URL()) |
| 113 | }} |
| 114 | client := ssrfGuardClient(&http.Client{Transport: transport, Timeout: 5 * time.Second}) |
| 115 | |
| 116 | // Loopback is deliberately allowed by this guard; the request must reach |
| 117 | // the proxy carrying its destination intact. |
| 118 | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://127.0.0.1:8799/mcp", nil) |
| 119 | if err != nil { |
| 120 | t.Fatalf("new request: %v", err) |
| 121 | } |
| 122 | resp, err := client.Do(req) |
| 123 | if err != nil { |
| 124 | t.Fatalf("loopback request through proxy: %v", err) |
| 125 | } |
| 126 | _ = resp.Body.Close() |
| 127 | if got := proxy.destinations(); len(got) != 1 || got[0] != "http://127.0.0.1:8799/mcp" { |
| 128 | t.Fatalf("proxy destinations = %v; want exactly the loopback target", got) |
| 129 | } |
| 130 | } |
| 131 |