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更好(且更安全)
我们在库开发期间进行了广泛测试。然而,我们希望其他人也能审查我们的实现。
“只要有足够的眼睛,所有bug都是浅显的”。希望如此。
连接到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%的研究时间。请继续关注新内容。