forked from gophish/gophish
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement SSRF Mitigations (gophish#1940)
Initial commit of SSRF mitigations. This fixes gophish#1908 by creating a *net.Dialer which restricts outbound connections to only allowed IP ranges. This implementation is based on the blog post at https://www.agwa.name/blog/post/preventing_server_side_request_forgery_in_golang To keep things backwards compatible, by default we'll only block connections to 169.254.169.254, the link-local IP address commonly used in cloud environments to retrieve metadata about the running instance. For other internal addresses (e.g. localhost or RFC 1918 addresses), it's assumed that those are available to Gophish. To support more secure environments, we introduce the `allowed_internal_hosts` configuration option where an admin can set one or more IP ranges in CIDR format. If addresses are specified here, then all internal connections will be blocked except to these hosts. There are various bits about this approach I don't really like. For example, since various packages all need this functionality, I had to make the RestrictedDialer a global singleton rather than a dependency off of, say, the admin server. Additionally, since webhooks are implemented via a singleton, I had to introduce a new function, `SetTransport`. Finally, I had to make an update in the gomail package to support a custom net.Dialer.
- Loading branch information
1 parent
304aa3b
commit b265c8c
Showing
12 changed files
with
373 additions
and
15 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
package api | ||
|
||
import ( | ||
"bytes" | ||
"encoding/json" | ||
"fmt" | ||
"net/http" | ||
"net/http/httptest" | ||
"strings" | ||
"testing" | ||
|
||
"github.com/gophish/gophish/dialer" | ||
"github.com/gophish/gophish/models" | ||
) | ||
|
||
func makeImportRequest(ctx *testContext, allowedHosts []string, url string) *httptest.ResponseRecorder { | ||
orig := dialer.DefaultDialer.AllowedHosts() | ||
dialer.SetAllowedHosts(allowedHosts) | ||
req := httptest.NewRequest(http.MethodPost, "/api/import/site", | ||
bytes.NewBuffer([]byte(fmt.Sprintf(` | ||
{ | ||
"url" : "%s" | ||
} | ||
`, url)))) | ||
req.Header.Set("Content-Type", "application/json") | ||
response := httptest.NewRecorder() | ||
ctx.apiServer.ImportSite(response, req) | ||
dialer.SetAllowedHosts(orig) | ||
return response | ||
} | ||
|
||
func TestDefaultDeniedImport(t *testing.T) { | ||
ctx := setupTest(t) | ||
metadataURL := "http://169.254.169.254/latest/meta-data/" | ||
response := makeImportRequest(ctx, []string{}, metadataURL) | ||
expectedCode := http.StatusBadRequest | ||
if response.Code != expectedCode { | ||
t.Fatalf("incorrect status code received. expected %d got %d", expectedCode, response.Code) | ||
} | ||
got := &models.Response{} | ||
err := json.NewDecoder(response.Body).Decode(got) | ||
if err != nil { | ||
t.Fatalf("error decoding body: %v", err) | ||
} | ||
if !strings.Contains(got.Message, "upstream connection denied") { | ||
t.Fatalf("incorrect response error provided: %s", got.Message) | ||
} | ||
} | ||
|
||
func TestDefaultAllowedImport(t *testing.T) { | ||
ctx := setupTest(t) | ||
h := "<html><head></head><body><img src=\"/test.png\"/></body></html>" | ||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
fmt.Fprintln(w, h) | ||
})) | ||
defer ts.Close() | ||
response := makeImportRequest(ctx, []string{}, ts.URL) | ||
expectedCode := http.StatusOK | ||
if response.Code != expectedCode { | ||
t.Fatalf("incorrect status code received. expected %d got %d", expectedCode, response.Code) | ||
} | ||
} | ||
|
||
func TestCustomDeniedImport(t *testing.T) { | ||
ctx := setupTest(t) | ||
h := "<html><head></head><body><img src=\"/test.png\"/></body></html>" | ||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
fmt.Fprintln(w, h) | ||
})) | ||
defer ts.Close() | ||
response := makeImportRequest(ctx, []string{"192.168.1.1"}, ts.URL) | ||
expectedCode := http.StatusBadRequest | ||
if response.Code != expectedCode { | ||
t.Fatalf("incorrect status code received. expected %d got %d", expectedCode, response.Code) | ||
} | ||
got := &models.Response{} | ||
err := json.NewDecoder(response.Body).Decode(got) | ||
if err != nil { | ||
t.Fatalf("error decoding body: %v", err) | ||
} | ||
if !strings.Contains(got.Message, "upstream connection denied") { | ||
t.Fatalf("incorrect response error provided: %s", got.Message) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
package dialer | ||
|
||
import ( | ||
"fmt" | ||
"net" | ||
"syscall" | ||
"time" | ||
) | ||
|
||
// RestrictedDialer is used to create a net.Dialer which restricts outbound | ||
// connections to only allowlisted IP ranges. | ||
type RestrictedDialer struct { | ||
allowedHosts []*net.IPNet | ||
} | ||
|
||
// DefaultDialer is a global instance of a RestrictedDialer | ||
var DefaultDialer = &RestrictedDialer{} | ||
|
||
// SetAllowedHosts sets the list of allowed hosts or IP ranges for the default | ||
// dialer. | ||
func SetAllowedHosts(allowed []string) { | ||
DefaultDialer.SetAllowedHosts(allowed) | ||
} | ||
|
||
// AllowedHosts returns the configured hosts that are allowed for the dialer. | ||
func (d *RestrictedDialer) AllowedHosts() []string { | ||
ranges := []string{} | ||
for _, ipRange := range d.allowedHosts { | ||
ranges = append(ranges, ipRange.String()) | ||
} | ||
return ranges | ||
} | ||
|
||
// SetAllowedHosts sets the list of allowed hosts or IP ranges for the dialer. | ||
func (d *RestrictedDialer) SetAllowedHosts(allowed []string) error { | ||
for _, ipRange := range allowed { | ||
// For flexibility, try to parse as an IP first since this will | ||
// undoubtedly cause issues. If it works, then just append the | ||
// appropriate subnet mask, then parse as CIDR | ||
if singleIP := net.ParseIP(ipRange); singleIP != nil { | ||
if singleIP.To4() != nil { | ||
ipRange += "/32" | ||
} else { | ||
ipRange += "/128" | ||
} | ||
} | ||
_, parsed, err := net.ParseCIDR(ipRange) | ||
if err != nil { | ||
return fmt.Errorf("provided ip range is not valid CIDR notation: %v", err) | ||
} | ||
d.allowedHosts = append(d.allowedHosts, parsed) | ||
} | ||
return nil | ||
} | ||
|
||
// Dialer returns a net.Dialer that restricts outbound connections to only the | ||
// addresses allowed by the DefaultDialer. | ||
func Dialer() *net.Dialer { | ||
return DefaultDialer.Dialer() | ||
} | ||
|
||
// Dialer returns a net.Dialer that restricts outbound connections to only the | ||
// allowed addresses over TCP. | ||
// | ||
// By default, since Gophish anticipates connections originating to hosts on | ||
// the local network, we only deny access to the link-local addresses at | ||
// 169.254.0.0/16. | ||
// | ||
// If hosts are provided, then Gophish blocks access to all local addresses | ||
// except the ones provided. | ||
// | ||
// This implementation is based on the blog post by Andrew Ayer at | ||
// https://www.agwa.name/blog/post/preventing_server_side_request_forgery_in_golang | ||
func (d *RestrictedDialer) Dialer() *net.Dialer { | ||
return &net.Dialer{ | ||
Timeout: 30 * time.Second, | ||
KeepAlive: 30 * time.Second, | ||
Control: restrictedControl(d.allowedHosts), | ||
} | ||
} | ||
|
||
// defaultDeny represents the list of IP ranges that we want to block unless | ||
// explicitly overriden. | ||
var defaultDeny = []string{ | ||
"169.254.0.0/16", // Link-local (used for VPS instance metadata) | ||
} | ||
|
||
// allInternal represents all internal hosts such that the only connections | ||
// allowed are external ones. | ||
var allInternal = []string{ | ||
"0.0.0.0/8", | ||
"127.0.0.0/8", // IPv4 loopback | ||
"10.0.0.0/8", // RFC1918 | ||
"100.64.0.0/10", // CGNAT | ||
"172.16.0.0/12", // RFC1918 | ||
"169.254.0.0/16", // RFC3927 link-local | ||
"192.88.99.0/24", // IPv6 to IPv4 Relay | ||
"192.168.0.0/16", // RFC1918 | ||
"198.51.100.0/24", // TEST-NET-2 | ||
"203.0.113.0/24", // TEST-NET-3 | ||
"224.0.0.0/4", // Multicast | ||
"240.0.0.0/4", // Reserved | ||
"255.255.255.255/32", // Broadcast | ||
"::/0", // Default route | ||
"::/128", // Unspecified address | ||
"::1/128", // IPv6 loopback | ||
"::ffff:0:0/96", // IPv4 mapped addresses. | ||
"::ffff:0:0:0/96", // IPv4 translated addresses. | ||
"fe80::/10", // IPv6 link-local | ||
"fc00::/7", // IPv6 unique local addr | ||
} | ||
|
||
type dialControl = func(network, address string, c syscall.RawConn) error | ||
|
||
type restrictedDialer struct { | ||
*net.Dialer | ||
allowed []string | ||
} | ||
|
||
func restrictedControl(allowed []*net.IPNet) dialControl { | ||
return func(network string, address string, conn syscall.RawConn) error { | ||
if !(network == "tcp4" || network == "tcp6") { | ||
return fmt.Errorf("%s is not a safe network type", network) | ||
} | ||
|
||
host, _, err := net.SplitHostPort(address) | ||
if err != nil { | ||
return fmt.Errorf("%s is not a valid host/port pair: %s", address, err) | ||
} | ||
|
||
ip := net.ParseIP(host) | ||
if ip == nil { | ||
return fmt.Errorf("%s is not a valid IP address", host) | ||
} | ||
|
||
denyList := defaultDeny | ||
if len(allowed) > 0 { | ||
denyList = allInternal | ||
} | ||
|
||
for _, ipRange := range allowed { | ||
if ipRange.Contains(ip) { | ||
return nil | ||
} | ||
} | ||
|
||
for _, ipRange := range denyList { | ||
_, parsed, err := net.ParseCIDR(ipRange) | ||
if err != nil { | ||
return fmt.Errorf("error parsing denied range: %v", err) | ||
} | ||
if parsed.Contains(ip) { | ||
return fmt.Errorf("upstream connection denied to internal host") | ||
} | ||
} | ||
return nil | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
package dialer | ||
|
||
import ( | ||
"fmt" | ||
"net" | ||
"strings" | ||
"syscall" | ||
"testing" | ||
) | ||
|
||
func TestDefaultDeny(t *testing.T) { | ||
control := restrictedControl([]*net.IPNet{}) | ||
host := "169.254.169.254" | ||
expected := fmt.Errorf("upstream connection denied to internal host at %s", host) | ||
conn := new(syscall.RawConn) | ||
got := control("tcp4", fmt.Sprintf("%s:80", host), *conn) | ||
if !strings.Contains(got.Error(), "upstream connection denied") { | ||
t.Fatalf("unexpected error dialing denylisted host. expected %v got %v", expected, got) | ||
} | ||
} | ||
|
||
func TestDefaultAllow(t *testing.T) { | ||
control := restrictedControl([]*net.IPNet{}) | ||
host := "1.1.1.1" | ||
conn := new(syscall.RawConn) | ||
got := control("tcp4", fmt.Sprintf("%s:80", host), *conn) | ||
if got != nil { | ||
t.Fatalf("error dialing allowed host. got %v", got) | ||
} | ||
} | ||
|
||
func TestCustomAllow(t *testing.T) { | ||
host := "127.0.0.1" | ||
_, ipRange, _ := net.ParseCIDR(fmt.Sprintf("%s/32", host)) | ||
allowed := []*net.IPNet{ipRange} | ||
control := restrictedControl(allowed) | ||
conn := new(syscall.RawConn) | ||
got := control("tcp4", fmt.Sprintf("%s:80", host), *conn) | ||
if got != nil { | ||
t.Fatalf("error dialing allowed host. got %v", got) | ||
} | ||
} | ||
|
||
func TestCustomDeny(t *testing.T) { | ||
host := "127.0.0.1" | ||
_, ipRange, _ := net.ParseCIDR(fmt.Sprintf("%s/32", host)) | ||
allowed := []*net.IPNet{ipRange} | ||
control := restrictedControl(allowed) | ||
conn := new(syscall.RawConn) | ||
expected := fmt.Errorf("upstream connection denied to internal host at %s", host) | ||
got := control("tcp4", "192.168.1.2:80", *conn) | ||
if !strings.Contains(got.Error(), "upstream connection denied") { | ||
t.Fatalf("unexpected error dialing denylisted host. expected %v got %v", expected, got) | ||
} | ||
} | ||
|
||
func TestSingleIP(t *testing.T) { | ||
orig := DefaultDialer.AllowedHosts() | ||
host := "127.0.0.1" | ||
DefaultDialer.SetAllowedHosts([]string{host}) | ||
control := DefaultDialer.Dialer().Control | ||
conn := new(syscall.RawConn) | ||
expected := fmt.Errorf("upstream connection denied to internal host at %s", host) | ||
got := control("tcp4", "192.168.1.2:80", *conn) | ||
if !strings.Contains(got.Error(), "upstream connection denied") { | ||
t.Fatalf("unexpected error dialing denylisted host. expected %v got %v", expected, got) | ||
} | ||
|
||
host = "::1" | ||
DefaultDialer.SetAllowedHosts([]string{host}) | ||
control = DefaultDialer.Dialer().Control | ||
conn = new(syscall.RawConn) | ||
expected = fmt.Errorf("upstream connection denied to internal host at %s", host) | ||
got = control("tcp4", "192.168.1.2:80", *conn) | ||
if !strings.Contains(got.Error(), "upstream connection denied") { | ||
t.Fatalf("unexpected error dialing denylisted host. expected %v got %v", expected, got) | ||
} | ||
|
||
// Test an allowed connection | ||
got = control("tcp4", fmt.Sprintf("[%s]:80", host), *conn) | ||
if got != nil { | ||
t.Fatalf("error dialing allowed host. got %v", got) | ||
} | ||
DefaultDialer.SetAllowedHosts(orig) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.