Go语言安全HTTP客户端:safeurl库全面解析与SSRF防护实战

本文详细介绍Doyensec开发的safeurl库,这是一个专为Go语言设计的防SSRF攻击HTTP客户端,提供内置DNS重绑定保护、IP白名单/黑名单机制,以及灵活的配置选项,帮助开发者轻松构建安全的网络请求功能。

safeurl for Go

2022年12月13日 - 作者:Alessandro Cotto 和 Viktor Chuchurski

你是否需要一个Go HTTP库来保护应用程序免受SSRF攻击?如果是,请尝试safeurl。它是Go net/http客户端的一行代码替代方案。

Go Web应用中不再有SSRF

构建Web应用时,向内部微服务甚至外部第三方服务发起HTTP请求并不罕见。每当用户提供URL时,确保正确缓解服务器端请求伪造(SSRF)漏洞至关重要。正如PortSwigger的Web安全学院页面所描述的,SSRF是一种Web安全漏洞,允许攻击者诱导服务器端应用程序向非预期位置发出请求。

虽然存在多种编程语言的SSRF缓解库,但Go一直缺乏易于使用的解决方案。直到现在!

safeurl for Go是一个具有内置SSRF和DNS重绑定保护的库,可以轻松替代Go的默认net/http客户端。解析、验证和发出请求的所有繁重工作都由库完成。该库开箱即用,只需最小配置,同时提供开发人员可能需要的自定义和过滤选项。开发人员应该自由地专注于向客户提供高质量功能,而不是费力解决应用安全问题。

该库的灵感来自Jack Whitton的SafeCURL和Include Security的SafeURL。由于没有Go版本的SafeURL,Doyensec为社区提供了这个库。

safeurl提供什么功能?

通过最小配置,该库可防止向内部、私有或保留IP地址发出未经授权的请求。所有HTTP连接都会根据允许列表和阻止列表进行验证。默认情况下,该库阻止所有到RFC1918定义的私有或保留IP地址的流量。此行为可以通过safeurl的客户端配置进行更新。库将优先处理允许的项目,无论是主机名、IP地址还是端口。通常,允许列表是构建安全系统的推荐方式。实际上,明确设置允许的目标比在当今不断扩大的威胁环境中处理更新阻止列表更容易(且更安全)。

安装

只需将github.com/doyensec/safeurl添加到项目的go.mod文件中,即可在Go程序中包含safeurl模块。

1
go get -u github.com/doyensec/safeurl

使用方法

库提供的safeurl.Client可以用作Go原生net/http.Client的替代品。

以下代码片段展示了一个使用safeurl库的简单Go程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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)
    }

    // 读取响应体
}

最小库配置如下:

1
config := GetConfigBuilder().Build()

使用此配置,您将获得:

  • 仅允许端口80和443的流量
  • 允许使用HTTP或HTTPS协议的流量
  • 阻止到私有IP地址的流量
  • 阻止到任何地址的IPv6流量
  • DNS重绑定攻击缓解

配置

safeurl.Config用于自定义safeurl.Client。配置可用于设置以下内容:

  • AllowedPorts - 应用程序可以连接的端口列表
  • AllowedSchemes - 应用程序可以使用的协议列表
  • AllowedHosts - 应用程序允许通信的主机列表
  • BlockedIPs - 应用程序不允许连接的IP地址列表
  • AllowedIPs - 应用程序允许连接的IP地址列表
  • AllowedCIDR - 应用程序允许连接的CIDR范围列表
  • BlockedCIDR - 应用程序不允许连接的CIDR范围列表
  • IsIPv6Enabled - 指定是否启用IPv6通信
  • AllowSendingCredentials - 指定是否发送HTTP凭据
  • IsDebugLoggingEnabled - 启用调试日志

作为Go原生net/http.Client的包装器,该库还允许您配置其他标准设置,例如HTTP重定向、cookie jar设置和请求超时。请参阅官方文档以获取有关生产环境建议配置的更多信息。

配置示例

为了展示safeurl.Client的多功能性,让我们向您展示一些配置示例。

可以只允许单个协议:

1
2
3
GetConfigBuilder().
    SetAllowedSchemes("http").
    Build()

或配置一个或多个允许的端口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 这仅启用端口8080。所有其他端口被阻止(80、443也被阻止)
GetConfigBuilder().
    SetAllowedPorts(8080).
    Build()

// 这仅启用端口8080、443、80
GetConfigBuilder().
    SetAllowedPorts(8080, 80, 443). 
    Build()

// **不正确。** 此配置将允许流量到最后一个允许的端口(443),并覆盖之前设置的任何端口
GetConfigBuilder().
    SetAllowedPorts(8080).
    SetAllowedPorts(80).
    SetAllowedPorts(443).
    Build()

此配置允许流量仅到一个主机,本例中为example.com:

1
2
3
GetConfigBuilder().
    SetAllowedHosts("example.com").
    Build()

此外,您可以阻止特定IP(IPv4或IPv6):

1
2
3
GetConfigBuilder().
    SetBlockedIPs("1.2.3.4").
    Build()

请注意,使用先前的配置,safeurl.Client将阻止IP 1.2.3.4以及属于内部、私有或保留网络的所有IP。

如果您希望允许流量到客户端默认阻止的IP地址,可以使用以下配置:

1
2
3
GetConfigBuilder().
    SetAllowedIPs("10.10.100.101").
    Build()

也可以允许或阻止完整的CIDR范围而不是单个IP:

1
2
3
4
GetConfigBuilder().
    EnableIPv6(true).
    SetBlockedIPsCIDR("34.210.62.0/25", "216.239.34.0/25", "2001:4860:4860::8888/32").
    Build()

DNS重绑定缓解

DNS重绑定攻击之所以可能,是因为两个(或更多)连续HTTP请求之间的DNS响应不匹配。此漏洞是典型的TOCTOU问题。在检查时(TOC),IP指向允许的目标。然而,在使用时(TOU),它将指向完全不同的IP地址。

safeurl中的DNS重绑定保护是通过在实际将用于发出HTTP请求的IP地址上执行允许/阻止列表验证来实现的。这是通过利用Go的net/dialer包和提供的Control钩子实现的。正如官方文档所述:

1
2
// 如果Control不为nil,它在创建网络连接后但在实际拨号之前调用。
Control func(network, address string, c syscall.RawConn) error

在我们的safeurl实现中,IP验证发生在Control钩子内部。以下片段显示了一些正在执行的检查。如果所有检查都通过,则发生HTTP拨号。如果检查失败,则丢弃HTTP请求。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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更好(更安全)

我们在库开发期间进行了广泛测试。但是,我们希望其他人能够检查我们的实现。

“只要有足够的眼睛,所有错误都是浅显的”。希望如此。

连接到http://164.92.85.153/并尝试捕获托管在此内部(且未经授权)URL上的标志:http://164.92.85.153/flag

该挑战已于2023年1月13日关闭。您始终可以使用下面的代码片段在本地运行挑战。

这是挑战端点的源代码,具有特定的safeurl配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
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")
}

如果您能够绕过safeurl.Client实施的检查,标志的内容将为您提供有关如何领取奖励的进一步说明。请注意,获取标志的意外方式(例如,不绕过safeurl.Client)被视为超出范围。

欢迎通过拉取请求、错误报告或增强想法做出贡献。

这个工具得益于Doyensec的25%研究时间。请继续关注新内容。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计