Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SSRF 防护 helper:
|
||||
// - validateEndpoint 在 admin 提交时阻止 http/loopback/私网/云元数据 URL
|
||||
// - safeDialContext 在 socket 层再次校验真实 IP,防止 DNS rebinding
|
||||
//
|
||||
// 已知 cloud metadata hostname 拒绝列表(小写比较)。
|
||||
var monitorBlockedHostnames = map[string]struct{}{
|
||||
"localhost": {},
|
||||
"localhost.localdomain": {},
|
||||
"metadata": {},
|
||||
"metadata.google.internal": {},
|
||||
"metadata.goog": {},
|
||||
"instance-data": {},
|
||||
"instance-data.ec2.internal": {},
|
||||
}
|
||||
|
||||
// CIDR 列表:包含所有需要拒绝的 IPv4/IPv6 段。
|
||||
// 解析时只 panic 一次(启动时确认),生产路径只做 Contains。
|
||||
var monitorBlockedCIDRs = mustParseCIDRs([]string{
|
||||
"127.0.0.0/8", // IPv4 loopback
|
||||
"10.0.0.0/8", // RFC1918
|
||||
"172.16.0.0/12", // RFC1918
|
||||
"192.168.0.0/16", // RFC1918
|
||||
"169.254.0.0/16", // link-local(含云元数据 169.254.169.254)
|
||||
"100.64.0.0/10", // CGNAT
|
||||
"0.0.0.0/8", // "this network"
|
||||
"::1/128", // IPv6 loopback
|
||||
"fc00::/7", // IPv6 ULA
|
||||
"fe80::/10", // IPv6 link-local
|
||||
"::/128", // IPv6 unspecified
|
||||
})
|
||||
|
||||
// monitorDialer 共享 Dialer,与 net/http 默认值对齐。
|
||||
var monitorDialer = &net.Dialer{
|
||||
Timeout: monitorDialTimeout,
|
||||
KeepAlive: monitorDialKeepAlive,
|
||||
}
|
||||
|
||||
// mustParseCIDRs 在包初始化时解析 CIDR 字符串,失败 panic。
|
||||
func mustParseCIDRs(cidrs []string) []*net.IPNet {
|
||||
out := make([]*net.IPNet, 0, len(cidrs))
|
||||
for _, c := range cidrs {
|
||||
_, n, err := net.ParseCIDR(c)
|
||||
if err != nil {
|
||||
panic("channel_monitor_ssrf: invalid CIDR " + c + ": " + err.Error())
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isBlockedHostname 判断 hostname 是否命中黑名单。
|
||||
func isBlockedHostname(hostname string) bool {
|
||||
if hostname == "" {
|
||||
return true
|
||||
}
|
||||
_, blocked := monitorBlockedHostnames[strings.ToLower(hostname)]
|
||||
return blocked
|
||||
}
|
||||
|
||||
// isPrivateIP 判断 IP 是否落在禁止段(loopback/RFC1918/link-local/ULA 等)。
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
if ip.IsUnspecified() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
for _, n := range monitorBlockedCIDRs {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isPrivateOrLoopbackHost 解析 hostname 的所有 A/AAAA 记录,
|
||||
// 任一 IP 落在私网/loopback 段即认为不安全。
|
||||
//
|
||||
// hostname 是 IP 字面量时也走同一路径。
|
||||
func isPrivateOrLoopbackHost(ctx context.Context, hostname string) (bool, error) {
|
||||
if isBlockedHostname(hostname) {
|
||||
return true, nil
|
||||
}
|
||||
// IP 字面量直接判断。
|
||||
if ip := net.ParseIP(hostname); ip != nil {
|
||||
return isPrivateIP(ip), nil
|
||||
}
|
||||
resolver := net.DefaultResolver
|
||||
addrs, err := resolver.LookupIPAddr(ctx, hostname)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if isPrivateIP(a.IP) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// safeDialContext 在真实 dial 前再次校验目标 IP,防止 DNS rebinding。
|
||||
// 解析 hostname 后逐个 IP 尝试连接,命中私网即拒绝(即便 validateEndpoint 时返回的是公网 IP)。
|
||||
func safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 字面量 IP 走快速路径。
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if isPrivateIP(ip) {
|
||||
return nil, &net.AddrError{Err: "blocked by SSRF policy", Addr: address}
|
||||
}
|
||||
return monitorDialer.DialContext(ctx, network, address)
|
||||
}
|
||||
if isBlockedHostname(host) {
|
||||
return nil, &net.AddrError{Err: "blocked by SSRF policy", Addr: address}
|
||||
}
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return nil, &net.AddrError{Err: "no addresses for host", Addr: host}
|
||||
}
|
||||
var lastErr error
|
||||
for _, a := range addrs {
|
||||
if isPrivateIP(a.IP) {
|
||||
lastErr = &net.AddrError{Err: "blocked by SSRF policy", Addr: a.IP.String()}
|
||||
continue
|
||||
}
|
||||
conn, err := monitorDialer.DialContext(ctx, network, net.JoinHostPort(a.IP.String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = &net.AddrError{Err: "no usable addresses", Addr: host}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
Reference in New Issue
Block a user