Do you need a Go HTTP library to protect your applications from SSRF attacks? If so, try safeurl.
It’s a one-line drop-in replacement for Go’s net/http
client.
When building a web application, it is not uncommon to issue HTTP requests to internal microservices or even external third-party services. Whenever a URL is provided by the user, it is important to ensure that Server-Side Request Forgery (SSRF) vulnerabilities are properly mitigated. As eloquently described in PortSwigger’s Web Security Academy pages, SSRF is a web security vulnerability that allows an attacker to induce the server-side application to make requests to an unintended location.
While libraries mitigating SSRF in numerous programming languages exist, Go didn’t have an easy to use solution. Until now!
safeurl for Go
is a library with built-in SSRF and DNS rebinding protection that can easily replace Go’s default net/http
client. All the heavy work of parsing, validating and issuing requests is done by the library. The library works out-of-the-box with minimal configuration, while providing developers the customizations and filtering options they might need. Instead of fighting to solve application security problems, developers should be free to focus on delivering quality features to their customers.
This library was inspired by SafeCURL and SafeURL, respectively by Jack Whitton and Include Security. Since no SafeURL for Go existed, Doyensec made it available for the community.
safeurl
Offer?With minimal configuration, the library prevents unauthorized requests to internal, private or reserved IP addresses. All HTTP connections are validated against an allowlist and a blocklist. By default, the library blocks all traffic to private or reserved IP addresses, as defined by RFC1918. This behavior can be updated via the safeurl
’s client configuration. The library will give precedence to allowed items, be it a hostname, an IP address or a port. In general, allowlisting is the recommended way of building secure systems. In fact, it’s easier (and safer) to explicitly set allowed destinations, as opposed to having to deal with updating a blocklist in today’s ever-expanding threat landscape.
Include the safeurl
module in your Go program by simply adding github.com/doyensec/safeurl
to your project’s go.mod
file.
go get -u github.com/doyensec/safeurl
The safeurl.Client
, provided by the library, can be used as a drop-in replacement of Go’s native net/http.Client
.
The following code snippet shows a simple Go program that uses the safeurl
library:
import (
"fmt"
"github.com/doyensec/safeurl"
)
func main() {
config := safeurl.GetConfigBuilder().
Build()
client := safeurl.Client(config)
resp, err := client.Get("https://example.com")
if err != nil {
fmt.Errorf("request return error: %v", err)
}
// read response body
}
The minimal library configuration looks something like:
config := GetConfigBuilder().Build()
Using this configuration you get:
The safeurl.Config
is used to customize the safeurl.Client
. The configuration can be used to set the following:
AllowedPorts - list of ports the application can connect to
AllowedSchemes - list of schemas the application can use
AllowedHosts - list of hosts the application is allowed to communicate with
BlockedIPs - list of IP addresses the application is not allowed to connect to
AllowedIPs - list of IP addresses the application is allowed to connect to
AllowedCIDR - list of CIDR range the application is allowed to connect to
BlockedCIDR - list of CIDR range the application is not allowed to connect to
IsIPv6Enabled - specifies whether communication through IPv6 is enabled
AllowSendingCredentials - specifies whether HTTP credentials should be sent
IsDebugLoggingEnabled - enables debug logs
Being a wrapper around Go’s native net/http.Client
, the library allows you to configure others standard settings as well, such as HTTP redirects, cookie jar settings and request timeouts. Please refer to the official docs for more information on the suggested configuration for production environments.
To showcase how versatile safeurl.Client
is, let us show you a few configuration examples.
It is possible to allow only a single schema:
GetConfigBuilder().
SetAllowedSchemes("http").
Build()
Or configure one or more allowed ports:
// This enables only port 8080. All others are blocked (80, 443 are blocked too)
GetConfigBuilder().
SetAllowedPorts(8080).
Build()
// This enables only port 8080, 443, 80
GetConfigBuilder().
SetAllowedPorts(8080, 80, 443).
Build()
// **Incorrect.** This configuration will allow traffic to the last allowed port (443), and overwrite any that was set before
GetConfigBuilder().
SetAllowedPorts(8080).
SetAllowedPorts(80).
SetAllowedPorts(443).
Build()
This configuration allows traffic to only one host, example.com
in this case:
GetConfigBuilder().
SetAllowedHosts("example.com").
Build()
Additionally, you can block specific IPs (IPv4 or IPv6):
GetConfigBuilder().
SetBlockedIPs("1.2.3.4").
Build()
Note that with the previous configuration, the safeurl.Client
will block the IP 1.2.3.4 in addition to all IPs belonging to internal, private or reserved networks.
If you wish to allow traffic to an IP address, which the client blocks by default, you can use the following configuration:
GetConfigBuilder().
SetAllowedIPs("10.10.100.101").
Build()
It’s also possible to allow or block full CIDR ranges instead of single IPs:
GetConfigBuilder().
EnableIPv6(true).
SetBlockedIPsCIDR("34.210.62.0/25", "216.239.34.0/25", "2001:4860:4860::8888/32").
Build()
DNS rebinding attacks are possible due to a mismatch in the DNS responses between two (or more) consecutive HTTP requests. This vulnerability is a typical TOCTOU problem. At the time-of-check (TOC), the IP points to an allowed destination. However, at the time-of-use (TOU), it will point to a completely different IP address.
DNS rebinding protection in safeurl
is accomplished by performing the allow/block list validations on the actual IP address which will be used to make the HTTP request. This is achieved by utilizing Go’s net/dialer
package and the provided Control
hook. As stated in the official documentation:
// If Control is not nil, it is called after creating the network
// connection but before actually dialing.
Control func(network, address string, c syscall.RawConn) error
In our safeurl
implementation, the IPs validation happens inside the Control
hook. The following snippet shows some of the checks being performed. If all of them pass, the HTTP dial occurs. In case a check fails, the HTTP request is dropped.
func buildRunFunc(wc *WrappedClient) func(network, address string, c syscall.RawConn) error {
return func(network, address string, _ syscall.RawConn) error {
// [...]
if wc.config.AllowedIPs == nil && isIPBlocked(ip, wc.config.BlockedIPs) {
wc.log(fmt.Sprintf("ip: %v found in blocklist", ip))
return &AllowedIPError{ip: ip.String()}
}
if !isIPAllowed(ip, wc.config.AllowedIPs) && isIPBlocked(ip, wc.config.BlockedIPs) {
wc.log(fmt.Sprintf("ip: %v not found in allowlist", ip))
return &AllowedIPError{ip: ip.String()}
}
return nil
}
}
safeurl
Better (and Safer)We’ve performed extensive testing during the library development. However, we would love to have others pick at our implementation.
“Given enough eyes, all bugs are shallow”. Hopefully.
Connect to http://164.92.85.153/ and attempt to catch the flag hosted on this internal (and unauthorized) URL: http://164.92.85.153/flag
The challenge was shut down on 01/13/2023. You can always run the challenge locally, by using the code snippet below.
This is the source code of the challenge endpoint, with the specific safeurl
configuration:
func main() {
cfg := safeurl.GetConfigBuilder().
SetBlockedIPs("164.92.85.153").
SetAllowedPorts(80, 443).
Build()
client := safeurl.Client(cfg)
router := gin.Default()
router.GET("/webhook", func(context *gin.Context) {
urlFromUser := context.Query("url")
if urlFromUser == "" {
errorMessage := "Please provide an url. Example: /webhook?url=your-url.com\n"
context.String(http.StatusBadRequest, errorMessage)
} else {
stringResponseMessage := "The server is checking the url: " + urlFromUser + "\n"
resp, err := client.Get(urlFromUser)
if err != nil {
stringError := fmt.Errorf("request return error: %v", err)
fmt.Print(stringError)
context.String(http.StatusBadRequest, err.Error())
return
}
defer resp.Body.Close()
bodyString, err := io.ReadAll(resp.Body)
if err != nil {
context.String(http.StatusInternalServerError, err.Error())
return
}
fmt.Print("Response from the server: " + stringResponseMessage)
fmt.Print(resp)
context.String(http.StatusOK, string(bodyString))
}
})
router.GET("/flag", func(context *gin.Context) {
ip := context.RemoteIP()
nip := net.ParseIP(ip)
if nip != nil {
if nip.IsLoopback() {
context.String(http.StatusOK, "You found the flag")
} else {
context.String(http.StatusForbidden, "")
}
} else {
context.String(http.StatusInternalServerError, "")
}
})
router.GET("/", func(context *gin.Context) {
indexPage := "<!DOCTYPE html><html lang=\"en\"><head><title>SafeURL - challenge</title></head><body>...</body></html>"
context.Writer.Header().Set("Content-Type", "text/html; charset=UTF-8")
context.String(http.StatusOK, indexPage)
})
router.Run("127.0.0.1:8080")
}
If you are able to bypass the check enforced by the safeurl.Client
, the content of the flag will give you further instructions on how to collect your reward. Please note that unintended ways of getting the flag (e.g., not bypassing safeurl.Client
) are considered out of scope.
Feel free to contribute with pull requests, bug reports or enhancements ideas.
This tool was possible thanks to the 25% research time at Doyensec. Tune in again for new episodes.