From c3f5478593d5260681cc88334120ac078a3a7767 Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 13 Sep 2025 17:34:22 +0800 Subject: [PATCH 1/8] feat: implement SSRF protection settings and update related references --- common/ip.go | 22 + common/ssrf_protection.go | 384 ++++++++++++++++++ service/{cf_worker.go => download.go} | 26 +- service/user_notify.go | 12 +- service/webhook.go | 13 +- setting/system_setting/fetch_setting.go | 28 ++ types/error.go | 12 +- web/src/components/settings/SystemSetting.jsx | 200 +++++++++ web/src/i18n/locales/en.json | 24 +- web/src/i18n/locales/zh.json | 24 +- 10 files changed, 727 insertions(+), 18 deletions(-) create mode 100644 common/ip.go create mode 100644 common/ssrf_protection.go rename service/{cf_worker.go => download.go} (52%) create mode 100644 setting/system_setting/fetch_setting.go diff --git a/common/ip.go b/common/ip.go new file mode 100644 index 00000000..bfb64ee7 --- /dev/null +++ b/common/ip.go @@ -0,0 +1,22 @@ +package common + +import "net" + +func IsPrivateIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + + private := []net.IPNet{ + {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, + {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, + {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, + } + + for _, privateNet := range private { + if privateNet.Contains(ip) { + return true + } + } + return false +} diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go new file mode 100644 index 00000000..b0988d90 --- /dev/null +++ b/common/ssrf_protection.go @@ -0,0 +1,384 @@ +package common + +import ( + "fmt" + "net" + "net/url" + "strconv" + "strings" +) + +// SSRFProtection SSRF防护配置 +type SSRFProtection struct { + AllowPrivateIp bool + WhitelistDomains []string // domain format, e.g. example.com, *.example.com + WhitelistIps []string // CIDR format + AllowedPorts []int // 允许的端口范围 +} + +// DefaultSSRFProtection 默认SSRF防护配置 +var DefaultSSRFProtection = &SSRFProtection{ + AllowPrivateIp: false, + WhitelistDomains: []string{}, + WhitelistIps: []string{}, + AllowedPorts: []int{}, +} + +// isPrivateIP 检查IP是否为私有地址 +func isPrivateIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + + // 检查私有网段 + private := []net.IPNet{ + {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8 + {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12 + {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16 + {IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8 + {IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地) + {IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播) + {IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留) + } + + for _, privateNet := range private { + if privateNet.Contains(ip) { + return true + } + } + + // 检查IPv6私有地址 + if ip.To4() == nil { + // IPv6 loopback + if ip.Equal(net.IPv6loopback) { + return true + } + // IPv6 link-local + if strings.HasPrefix(ip.String(), "fe80:") { + return true + } + // IPv6 unique local + if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") { + return true + } + } + + return false +} + +// parsePortRanges 解析端口范围配置 +// 支持格式: "80", "443", "8000-9000" +func parsePortRanges(portConfigs []string) ([]int, error) { + var ports []int + + for _, config := range portConfigs { + config = strings.TrimSpace(config) + if config == "" { + continue + } + + if strings.Contains(config, "-") { + // 处理端口范围 "8000-9000" + parts := strings.Split(config, "-") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid port range format: %s", config) + } + + startPort, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil { + return nil, fmt.Errorf("invalid start port in range %s: %v", config, err) + } + + endPort, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil { + return nil, fmt.Errorf("invalid end port in range %s: %v", config, err) + } + + if startPort > endPort { + return nil, fmt.Errorf("invalid port range %s: start port cannot be greater than end port", config) + } + + if startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535 { + return nil, fmt.Errorf("port range %s contains invalid port numbers (must be 1-65535)", config) + } + + // 添加范围内的所有端口 + for port := startPort; port <= endPort; port++ { + ports = append(ports, port) + } + } else { + // 处理单个端口 "80" + port, err := strconv.Atoi(config) + if err != nil { + return nil, fmt.Errorf("invalid port number: %s", config) + } + + if port < 1 || port > 65535 { + return nil, fmt.Errorf("invalid port number %d (must be 1-65535)", port) + } + + ports = append(ports, port) + } + } + + return ports, nil +} + +// isAllowedPort 检查端口是否被允许 +func (p *SSRFProtection) isAllowedPort(port int) bool { + if len(p.AllowedPorts) == 0 { + return true // 如果没有配置端口限制,则允许所有端口 + } + + for _, allowedPort := range p.AllowedPorts { + if port == allowedPort { + return true + } + } + return false +} + +// isAllowedPortFromRanges 从端口范围字符串检查端口是否被允许 +func isAllowedPortFromRanges(port int, portRanges []string) bool { + if len(portRanges) == 0 { + return true // 如果没有配置端口限制,则允许所有端口 + } + + allowedPorts, err := parsePortRanges(portRanges) + if err != nil { + // 如果解析失败,为安全起见拒绝访问 + return false + } + + for _, allowedPort := range allowedPorts { + if port == allowedPort { + return true + } + } + return false +} + +// isDomainWhitelisted 检查域名是否在白名单中 +func (p *SSRFProtection) isDomainWhitelisted(domain string) bool { + if len(p.WhitelistDomains) == 0 { + return false + } + + domain = strings.ToLower(domain) + for _, whitelistDomain := range p.WhitelistDomains { + whitelistDomain = strings.ToLower(whitelistDomain) + + // 精确匹配 + if domain == whitelistDomain { + return true + } + + // 通配符匹配 (*.example.com) + if strings.HasPrefix(whitelistDomain, "*.") { + suffix := strings.TrimPrefix(whitelistDomain, "*.") + if strings.HasSuffix(domain, "."+suffix) || domain == suffix { + return true + } + } + } + return false +} + +// isIPWhitelisted 检查IP是否在白名单中 +func (p *SSRFProtection) isIPWhitelisted(ip net.IP) bool { + if len(p.WhitelistIps) == 0 { + return false + } + + for _, whitelistCIDR := range p.WhitelistIps { + _, network, err := net.ParseCIDR(whitelistCIDR) + if err != nil { + // 尝试作为单个IP处理 + if whitelistIP := net.ParseIP(whitelistCIDR); whitelistIP != nil { + if ip.Equal(whitelistIP) { + return true + } + } + continue + } + + if network.Contains(ip) { + return true + } + } + return false +} + +// IsIPAccessAllowed 检查IP是否允许访问 +func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool { + // 如果IP在白名单中,直接允许访问(绕过私有IP检查) + if p.isIPWhitelisted(ip) { + return true + } + + // 如果IP白名单为空,允许所有IP(但仍需通过私有IP检查) + if len(p.WhitelistIps) == 0 { + // 检查私有IP限制 + if isPrivateIP(ip) && !p.AllowPrivateIp { + return false + } + return true + } + + // 如果IP白名单不为空且IP不在白名单中,拒绝访问 + return false +} + +// ValidateURL 验证URL是否安全 +func (p *SSRFProtection) ValidateURL(urlStr string) error { + // 解析URL + u, err := url.Parse(urlStr) + if err != nil { + return fmt.Errorf("invalid URL format: %v", err) + } + + // 只允许HTTP/HTTPS协议 + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme) + } + + // 解析主机和端口 + host, portStr, err := net.SplitHostPort(u.Host) + if err != nil { + // 没有端口,使用默认端口 + host = u.Host + if u.Scheme == "https" { + portStr = "443" + } else { + portStr = "80" + } + } + + // 验证端口 + port, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("invalid port: %s", portStr) + } + + if !p.isAllowedPort(port) { + return fmt.Errorf("port %d is not allowed", port) + } + + // 检查域名白名单 + if p.isDomainWhitelisted(host) { + return nil // 白名单域名直接通过 + } + + // DNS解析获取IP地址 + ips, err := net.LookupIP(host) + if err != nil { + return fmt.Errorf("DNS resolution failed for %s: %v", host, err) + } + + // 检查所有解析的IP地址 + for _, ip := range ips { + if !p.IsIPAccessAllowed(ip) { + if isPrivateIP(ip) { + return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String()) + } else { + return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String()) + } + } + } + + return nil +} + +// ValidateURLWithDefaults 使用默认配置验证URL +func ValidateURLWithDefaults(urlStr string) error { + return DefaultSSRFProtection.ValidateURL(urlStr) +} + +// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL +func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error { + // 如果SSRF防护被禁用,直接返回成功 + if !enableSSRFProtection { + return nil + } + + // 解析端口范围配置 + allowedPortInts, err := parsePortRanges(allowedPorts) + if err != nil { + return fmt.Errorf("request reject - invalid port configuration: %v", err) + } + + protection := &SSRFProtection{ + AllowPrivateIp: allowPrivateIp, + WhitelistDomains: whitelistDomains, + WhitelistIps: whitelistIps, + AllowedPorts: allowedPortInts, + } + return protection.ValidateURL(urlStr) +} + +// ValidateURLWithPortRanges 直接使用端口范围字符串验证URL(更高效的版本) +func ValidateURLWithPortRanges(urlStr string, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error { + // 解析URL + u, err := url.Parse(urlStr) + if err != nil { + return fmt.Errorf("invalid URL format: %v", err) + } + + // 只允许HTTP/HTTPS协议 + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme) + } + + // 解析主机和端口 + host, portStr, err := net.SplitHostPort(u.Host) + if err != nil { + // 没有端口,使用默认端口 + host = u.Host + if u.Scheme == "https" { + portStr = "443" + } else { + portStr = "80" + } + } + + // 验证端口 + port, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("invalid port: %s", portStr) + } + + if !isAllowedPortFromRanges(port, allowedPorts) { + return fmt.Errorf("port %d is not allowed", port) + } + + // 创建临时的SSRFProtection来复用域名和IP检查逻辑 + protection := &SSRFProtection{ + AllowPrivateIp: allowPrivateIp, + WhitelistDomains: whitelistDomains, + WhitelistIps: whitelistIps, + } + + // 检查域名白名单 + if protection.isDomainWhitelisted(host) { + return nil // 白名单域名直接通过 + } + + // DNS解析获取IP地址 + ips, err := net.LookupIP(host) + if err != nil { + return fmt.Errorf("DNS resolution failed for %s: %v", host, err) + } + + // 检查所有解析的IP地址 + for _, ip := range ips { + if !protection.IsIPAccessAllowed(ip) { + if isPrivateIP(ip) { + return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String()) + } else { + return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String()) + } + } + } + + return nil +} diff --git a/service/cf_worker.go b/service/download.go similarity index 52% rename from service/cf_worker.go rename to service/download.go index 4a7b4376..2f30870d 100644 --- a/service/cf_worker.go +++ b/service/download.go @@ -6,7 +6,7 @@ import ( "fmt" "net/http" "one-api/common" - "one-api/setting" + "one-api/setting/system_setting" "strings" ) @@ -21,14 +21,20 @@ type WorkerRequest struct { // DoWorkerRequest 通过Worker发送请求 func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { - if !setting.EnableWorker() { + if !system_setting.EnableWorker() { return nil, fmt.Errorf("worker not enabled") } - if !setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") { + if !system_setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") { return nil, fmt.Errorf("only support https url") } - workerUrl := setting.WorkerUrl + // SSRF防护:验证请求URL + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + return nil, fmt.Errorf("request reject: %v", err) + } + + workerUrl := system_setting.WorkerUrl if !strings.HasSuffix(workerUrl, "/") { workerUrl += "/" } @@ -43,15 +49,21 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { } func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, err error) { - if setting.EnableWorker() { + if system_setting.EnableWorker() { common.SysLog(fmt.Sprintf("downloading file from worker: %s, reason: %s", originUrl, strings.Join(reason, ", "))) req := &WorkerRequest{ URL: originUrl, - Key: setting.WorkerValidKey, + Key: system_setting.WorkerValidKey, } return DoWorkerRequest(req) } else { - common.SysLog(fmt.Sprintf("downloading from origin with worker: %s, reason: %s", originUrl, strings.Join(reason, ", "))) + // SSRF防护:验证请求URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + return nil, fmt.Errorf("request reject: %v", err) + } + + common.SysLog(fmt.Sprintf("downloading from origin: %s, reason: %s", common.MaskSensitiveInfo(originUrl), strings.Join(reason, ", "))) return http.Get(originUrl) } } diff --git a/service/user_notify.go b/service/user_notify.go index c4a3ea91..f9d7b669 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -7,7 +7,7 @@ import ( "one-api/common" "one-api/dto" "one-api/model" - "one-api/setting" + "one-api/setting/system_setting" "strings" ) @@ -91,11 +91,11 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { var resp *http.Response var err error - if setting.EnableWorker() { + if system_setting.EnableWorker() { // 使用worker发送请求 workerReq := &WorkerRequest{ URL: finalURL, - Key: setting.WorkerValidKey, + Key: system_setting.WorkerValidKey, Method: http.MethodGet, Headers: map[string]string{ "User-Agent": "OneAPI-Bark-Notify/1.0", @@ -113,6 +113,12 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode) } } else { + // SSRF防护:验证Bark URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + return fmt.Errorf("request reject: %v", err) + } + // 直接发送请求 req, err = http.NewRequest(http.MethodGet, finalURL, nil) if err != nil { diff --git a/service/webhook.go b/service/webhook.go index 8faccda3..1f159eb4 100644 --- a/service/webhook.go +++ b/service/webhook.go @@ -8,8 +8,9 @@ import ( "encoding/json" "fmt" "net/http" + "one-api/common" "one-api/dto" - "one-api/setting" + "one-api/setting/system_setting" "time" ) @@ -56,11 +57,11 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error var req *http.Request var resp *http.Response - if setting.EnableWorker() { + if system_setting.EnableWorker() { // 构建worker请求数据 workerReq := &WorkerRequest{ URL: webhookURL, - Key: setting.WorkerValidKey, + Key: system_setting.WorkerValidKey, Method: http.MethodPost, Headers: map[string]string{ "Content-Type": "application/json", @@ -86,6 +87,12 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error return fmt.Errorf("webhook request failed with status code: %d", resp.StatusCode) } } else { + // SSRF防护:验证Webhook URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + return fmt.Errorf("request reject: %v", err) + } + req, err = http.NewRequest(http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes)) if err != nil { return fmt.Errorf("failed to create webhook request: %v", err) diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go new file mode 100644 index 00000000..6e47c3f0 --- /dev/null +++ b/setting/system_setting/fetch_setting.go @@ -0,0 +1,28 @@ +package system_setting + +import "one-api/setting/config" + +type FetchSetting struct { + EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护 + AllowPrivateIp bool `json:"allow_private_ip"` + WhitelistDomains []string `json:"whitelist_domains"` // domain format, e.g. example.com, *.example.com + WhitelistIps []string `json:"whitelist_ips"` // CIDR format + AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000 +} + +var defaultFetchSetting = FetchSetting{ + EnableSSRFProtection: true, // 默认开启SSRF防护 + AllowPrivateIp: false, + WhitelistDomains: []string{}, + WhitelistIps: []string{}, + AllowedPorts: []string{"80", "443", "8080", "8443"}, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("fetch_setting", &defaultFetchSetting) +} + +func GetFetchSetting() *FetchSetting { + return &defaultFetchSetting +} diff --git a/types/error.go b/types/error.go index 883ee064..a42e8438 100644 --- a/types/error.go +++ b/types/error.go @@ -122,6 +122,9 @@ func (e *NewAPIError) MaskSensitiveError() string { return string(e.errorCode) } errStr := e.Err.Error() + if e.errorCode == ErrorCodeCountTokenFailed { + return errStr + } return common.MaskSensitiveInfo(errStr) } @@ -153,8 +156,9 @@ func (e *NewAPIError) ToOpenAIError() OpenAIError { Code: e.errorCode, } } - - result.Message = common.MaskSensitiveInfo(result.Message) + if e.errorCode != ErrorCodeCountTokenFailed { + result.Message = common.MaskSensitiveInfo(result.Message) + } return result } @@ -178,7 +182,9 @@ func (e *NewAPIError) ToClaudeError() ClaudeError { Type: string(e.errorType), } } - result.Message = common.MaskSensitiveInfo(result.Message) + if e.errorCode != ErrorCodeCountTokenFailed { + result.Message = common.MaskSensitiveInfo(result.Message) + } return result } diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 9c7eeaad..71dfaac8 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -44,6 +44,7 @@ import { useTranslation } from 'react-i18next'; const SystemSetting = () => { const { t } = useTranslation(); let [inputs, setInputs] = useState({ + PasswordLoginEnabled: '', PasswordRegisterEnabled: '', EmailVerificationEnabled: '', @@ -87,6 +88,12 @@ const SystemSetting = () => { LinuxDOClientSecret: '', LinuxDOMinimumTrustLevel: '', ServerAddress: '', + // SSRF防护配置 + 'fetch_setting.enable_ssrf_protection': true, + 'fetch_setting.allow_private_ip': '', + 'fetch_setting.whitelist_domains': [], + 'fetch_setting.whitelist_ips': [], + 'fetch_setting.allowed_ports': [], }); const [originInputs, setOriginInputs] = useState({}); @@ -98,6 +105,9 @@ const SystemSetting = () => { useState(false); const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false); const [emailToAdd, setEmailToAdd] = useState(''); + const [whitelistDomains, setWhitelistDomains] = useState([]); + const [whitelistIps, setWhitelistIps] = useState([]); + const [allowedPorts, setAllowedPorts] = useState([]); const getOptions = async () => { setLoading(true); @@ -113,6 +123,34 @@ const SystemSetting = () => { case 'EmailDomainWhitelist': setEmailDomainWhitelist(item.value ? item.value.split(',') : []); break; + case 'fetch_setting.allow_private_ip': + case 'fetch_setting.enable_ssrf_protection': + item.value = toBoolean(item.value); + break; + case 'fetch_setting.whitelist_domains': + try { + const domains = item.value ? JSON.parse(item.value) : []; + setWhitelistDomains(Array.isArray(domains) ? domains : []); + } catch (e) { + setWhitelistDomains([]); + } + break; + case 'fetch_setting.whitelist_ips': + try { + const ips = item.value ? JSON.parse(item.value) : []; + setWhitelistIps(Array.isArray(ips) ? ips : []); + } catch (e) { + setWhitelistIps([]); + } + break; + case 'fetch_setting.allowed_ports': + try { + const ports = item.value ? JSON.parse(item.value) : []; + setAllowedPorts(Array.isArray(ports) ? ports : []); + } catch (e) { + setAllowedPorts(['80', '443', '8080', '8443']); + } + break; case 'PasswordLoginEnabled': case 'PasswordRegisterEnabled': case 'EmailVerificationEnabled': @@ -276,6 +314,38 @@ const SystemSetting = () => { } }; + const submitSSRF = async () => { + const options = []; + + // 处理域名白名单 + if (Array.isArray(whitelistDomains)) { + options.push({ + key: 'fetch_setting.whitelist_domains', + value: JSON.stringify(whitelistDomains), + }); + } + + // 处理IP白名单 + if (Array.isArray(whitelistIps)) { + options.push({ + key: 'fetch_setting.whitelist_ips', + value: JSON.stringify(whitelistIps), + }); + } + + // 处理端口配置 + if (Array.isArray(allowedPorts)) { + options.push({ + key: 'fetch_setting.allowed_ports', + value: JSON.stringify(allowedPorts), + }); + } + + if (options.length > 0) { + await updateOptions(options); + } + }; + const handleAddEmail = () => { if (emailToAdd && emailToAdd.trim() !== '') { const domain = emailToAdd.trim(); @@ -587,6 +657,136 @@ const SystemSetting = () => { + + + + {t('配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全')} + + + + + handleCheckboxChange('fetch_setting.enable_ssrf_protection', e) + } + > + {t('启用SSRF防护(推荐开启以保护服务器安全)')} + + + + + + + + handleCheckboxChange('fetch_setting.allow_private_ip', e) + } + > + {t('允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)')} + + + + + + + {t('域名白名单')} + + {t('支持通配符格式,如:example.com, *.api.example.com')} + + { + setWhitelistDomains(value); + // 触发Form的onChange事件 + setInputs(prev => ({ + ...prev, + 'fetch_setting.whitelist_domains': value + })); + }} + placeholder={t('输入域名后回车,如:example.com')} + style={{ width: '100%' }} + /> + + {t('域名白名单详细说明')} + + + + + + + {t('IP白名单')} + + {t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')} + + { + setWhitelistIps(value); + // 触发Form的onChange事件 + setInputs(prev => ({ + ...prev, + 'fetch_setting.whitelist_ips': value + })); + }} + placeholder={t('输入IP地址后回车,如:8.8.8.8')} + style={{ width: '100%' }} + /> + + {t('IP白名单详细说明')} + + + + + + + {t('允许的端口')} + + {t('支持单个端口和端口范围,如:80, 443, 8000-8999')} + + { + setAllowedPorts(value); + // 触发Form的onChange事件 + setInputs(prev => ({ + ...prev, + 'fetch_setting.allowed_ports': value + })); + }} + placeholder={t('输入端口后回车,如:80 或 8000-8999')} + style={{ width: '100%' }} + /> + + {t('端口配置详细说明')} + + + + + + + + Date: Tue, 16 Sep 2025 22:40:40 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9F=9F?= =?UTF-8?q?=E5=90=8D=E5=92=8Cip=E8=BF=87=E6=BB=A4=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/download.go | 4 +- service/user_notify.go | 2 +- service/webhook.go | 2 +- setting/system_setting/fetch_setting.go | 14 ++- web/src/components/settings/SystemSetting.jsx | 114 +++++++++++++----- 5 files changed, 99 insertions(+), 37 deletions(-) diff --git a/service/download.go b/service/download.go index 2f30870d..43b6fe7d 100644 --- a/service/download.go +++ b/service/download.go @@ -30,7 +30,7 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { // SSRF防护:验证请求URL fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return nil, fmt.Errorf("request reject: %v", err) } @@ -59,7 +59,7 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, } else { // SSRF防护:验证请求URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return nil, fmt.Errorf("request reject: %v", err) } diff --git a/service/user_notify.go b/service/user_notify.go index f9d7b669..1e9e8947 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -115,7 +115,7 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { } else { // SSRF防护:验证Bark URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return fmt.Errorf("request reject: %v", err) } diff --git a/service/webhook.go b/service/webhook.go index 1f159eb4..5d9ce400 100644 --- a/service/webhook.go +++ b/service/webhook.go @@ -89,7 +89,7 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error } else { // SSRF防护:验证Webhook URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.WhitelistDomains, fetchSetting.WhitelistIps, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return fmt.Errorf("request reject: %v", err) } diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go index 6e47c3f0..5277e103 100644 --- a/setting/system_setting/fetch_setting.go +++ b/setting/system_setting/fetch_setting.go @@ -5,16 +5,20 @@ import "one-api/setting/config" type FetchSetting struct { EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护 AllowPrivateIp bool `json:"allow_private_ip"` - WhitelistDomains []string `json:"whitelist_domains"` // domain format, e.g. example.com, *.example.com - WhitelistIps []string `json:"whitelist_ips"` // CIDR format - AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000 + DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式 + IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式 + DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com + IpList []string `json:"ip_list"` // CIDR format + AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000 } var defaultFetchSetting = FetchSetting{ EnableSSRFProtection: true, // 默认开启SSRF防护 AllowPrivateIp: false, - WhitelistDomains: []string{}, - WhitelistIps: []string{}, + DomainFilterMode: true, + IpFilterMode: true, + DomainList: []string{}, + IpList: []string{}, AllowedPorts: []string{"80", "443", "8080", "8443"}, } diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 71dfaac8..ebe4084b 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -29,6 +29,7 @@ import { TagInput, Spin, Card, + Radio, } from '@douyinfe/semi-ui'; const { Text } = Typography; import { @@ -91,8 +92,10 @@ const SystemSetting = () => { // SSRF防护配置 'fetch_setting.enable_ssrf_protection': true, 'fetch_setting.allow_private_ip': '', - 'fetch_setting.whitelist_domains': [], - 'fetch_setting.whitelist_ips': [], + 'fetch_setting.domain_filter_mode': true, // true 白名单,false 黑名单 + 'fetch_setting.ip_filter_mode': true, // true 白名单,false 黑名单 + 'fetch_setting.domain_list': [], + 'fetch_setting.ip_list': [], 'fetch_setting.allowed_ports': [], }); @@ -105,8 +108,10 @@ const SystemSetting = () => { useState(false); const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false); const [emailToAdd, setEmailToAdd] = useState(''); - const [whitelistDomains, setWhitelistDomains] = useState([]); - const [whitelistIps, setWhitelistIps] = useState([]); + const [domainFilterMode, setDomainFilterMode] = useState(true); + const [ipFilterMode, setIpFilterMode] = useState(true); + const [domainList, setDomainList] = useState([]); + const [ipList, setIpList] = useState([]); const [allowedPorts, setAllowedPorts] = useState([]); const getOptions = async () => { @@ -125,22 +130,24 @@ const SystemSetting = () => { break; case 'fetch_setting.allow_private_ip': case 'fetch_setting.enable_ssrf_protection': + case 'fetch_setting.domain_filter_mode': + case 'fetch_setting.ip_filter_mode': item.value = toBoolean(item.value); break; - case 'fetch_setting.whitelist_domains': + case 'fetch_setting.domain_list': try { const domains = item.value ? JSON.parse(item.value) : []; - setWhitelistDomains(Array.isArray(domains) ? domains : []); + setDomainList(Array.isArray(domains) ? domains : []); } catch (e) { - setWhitelistDomains([]); + setDomainList([]); } break; - case 'fetch_setting.whitelist_ips': + case 'fetch_setting.ip_list': try { const ips = item.value ? JSON.parse(item.value) : []; - setWhitelistIps(Array.isArray(ips) ? ips : []); + setIpList(Array.isArray(ips) ? ips : []); } catch (e) { - setWhitelistIps([]); + setIpList([]); } break; case 'fetch_setting.allowed_ports': @@ -178,6 +185,13 @@ const SystemSetting = () => { }); setInputs(newInputs); setOriginInputs(newInputs); + // 同步模式布尔到本地状态 + if (typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined') { + setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']); + } + if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') { + setIpFilterMode(!!newInputs['fetch_setting.ip_filter_mode']); + } if (formApiRef.current) { formApiRef.current.setValues(newInputs); } @@ -317,19 +331,27 @@ const SystemSetting = () => { const submitSSRF = async () => { const options = []; - // 处理域名白名单 - if (Array.isArray(whitelistDomains)) { + // 处理域名过滤模式与列表 + options.push({ + key: 'fetch_setting.domain_filter_mode', + value: domainFilterMode, + }); + if (Array.isArray(domainList)) { options.push({ - key: 'fetch_setting.whitelist_domains', - value: JSON.stringify(whitelistDomains), + key: 'fetch_setting.domain_list', + value: JSON.stringify(domainList), }); } - // 处理IP白名单 - if (Array.isArray(whitelistIps)) { + // 处理IP过滤模式与列表 + options.push({ + key: 'fetch_setting.ip_filter_mode', + value: ipFilterMode, + }); + if (Array.isArray(ipList)) { options.push({ - key: 'fetch_setting.whitelist_ips', - value: JSON.stringify(whitelistIps), + key: 'fetch_setting.ip_list', + value: JSON.stringify(ipList), }); } @@ -702,25 +724,43 @@ const SystemSetting = () => { style={{ marginTop: 16 }} > - {t('域名白名单')} + + {t(domainFilterMode ? '域名白名单' : '域名黑名单')} + {t('支持通配符格式,如:example.com, *.api.example.com')} + { + const isWhitelist = val === 'whitelist'; + setDomainFilterMode(isWhitelist); + setInputs(prev => ({ + ...prev, + 'fetch_setting.domain_filter_mode': isWhitelist, + })); + }} + style={{ marginBottom: 8 }} + > + {t('白名单')} + {t('黑名单')} + { - setWhitelistDomains(value); + setDomainList(value); // 触发Form的onChange事件 setInputs(prev => ({ ...prev, - 'fetch_setting.whitelist_domains': value + 'fetch_setting.domain_list': value })); }} placeholder={t('输入域名后回车,如:example.com')} style={{ width: '100%' }} /> - {t('域名白名单详细说明')} + {t('域名过滤详细说明')} @@ -730,25 +770,43 @@ const SystemSetting = () => { style={{ marginTop: 16 }} > - {t('IP白名单')} + + {t(ipFilterMode ? 'IP白名单' : 'IP黑名单')} + {t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')} + { + const isWhitelist = val === 'whitelist'; + setIpFilterMode(isWhitelist); + setInputs(prev => ({ + ...prev, + 'fetch_setting.ip_filter_mode': isWhitelist, + })); + }} + style={{ marginBottom: 8 }} + > + {t('白名单')} + {t('黑名单')} + { - setWhitelistIps(value); + setIpList(value); // 触发Form的onChange事件 setInputs(prev => ({ ...prev, - 'fetch_setting.whitelist_ips': value + 'fetch_setting.ip_list': value })); }} placeholder={t('输入IP地址后回车,如:8.8.8.8')} style={{ width: '100%' }} /> - {t('IP白名单详细说明')} + {t('IP过滤详细说明')} From b46bffde03cc4a686de19a13d8036af112be5965 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Wed, 17 Sep 2025 15:41:21 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20ssrf=E6=94=AF=E6=8C=81=E5=9F=9F?= =?UTF-8?q?=E5=90=8D=E5=92=8Cip=E9=BB=91=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/ssrf_protection.go | 199 ++++++++++++++------------------------ service/download.go | 4 +- service/user_notify.go | 2 +- service/webhook.go | 2 +- 4 files changed, 74 insertions(+), 133 deletions(-) diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go index b0988d90..52b83952 100644 --- a/common/ssrf_protection.go +++ b/common/ssrf_protection.go @@ -11,16 +11,20 @@ import ( // SSRFProtection SSRF防护配置 type SSRFProtection struct { AllowPrivateIp bool - WhitelistDomains []string // domain format, e.g. example.com, *.example.com - WhitelistIps []string // CIDR format + DomainFilterMode bool // true: 白名单, false: 黑名单 + DomainList []string // domain format, e.g. example.com, *.example.com + IpFilterMode bool // true: 白名单, false: 黑名单 + IpList []string // CIDR or single IP AllowedPorts []int // 允许的端口范围 } // DefaultSSRFProtection 默认SSRF防护配置 var DefaultSSRFProtection = &SSRFProtection{ AllowPrivateIp: false, - WhitelistDomains: []string{}, - WhitelistIps: []string{}, + DomainFilterMode: true, + DomainList: []string{}, + IpFilterMode: true, + IpList: []string{}, AllowedPorts: []int{}, } @@ -138,44 +142,25 @@ func (p *SSRFProtection) isAllowedPort(port int) bool { return false } -// isAllowedPortFromRanges 从端口范围字符串检查端口是否被允许 -func isAllowedPortFromRanges(port int, portRanges []string) bool { - if len(portRanges) == 0 { - return true // 如果没有配置端口限制,则允许所有端口 - } - - allowedPorts, err := parsePortRanges(portRanges) - if err != nil { - // 如果解析失败,为安全起见拒绝访问 - return false - } - - for _, allowedPort := range allowedPorts { - if port == allowedPort { - return true - } - } - return false -} - // isDomainWhitelisted 检查域名是否在白名单中 -func (p *SSRFProtection) isDomainWhitelisted(domain string) bool { - if len(p.WhitelistDomains) == 0 { +func isDomainListed(domain string, list []string) bool { + if len(list) == 0 { return false } domain = strings.ToLower(domain) - for _, whitelistDomain := range p.WhitelistDomains { - whitelistDomain = strings.ToLower(whitelistDomain) - + for _, item := range list { + item = strings.ToLower(strings.TrimSpace(item)) + if item == "" { + continue + } // 精确匹配 - if domain == whitelistDomain { + if domain == item { return true } - // 通配符匹配 (*.example.com) - if strings.HasPrefix(whitelistDomain, "*.") { - suffix := strings.TrimPrefix(whitelistDomain, "*.") + if strings.HasPrefix(item, "*.") { + suffix := strings.TrimPrefix(item, "*.") if strings.HasSuffix(domain, "."+suffix) || domain == suffix { return true } @@ -184,13 +169,23 @@ func (p *SSRFProtection) isDomainWhitelisted(domain string) bool { return false } +func (p *SSRFProtection) isDomainAllowed(domain string) bool { + listed := isDomainListed(domain, p.DomainList) + if p.DomainFilterMode { // 白名单 + return listed + } + // 黑名单 + return !listed +} + // isIPWhitelisted 检查IP是否在白名单中 -func (p *SSRFProtection) isIPWhitelisted(ip net.IP) bool { - if len(p.WhitelistIps) == 0 { + +func isIPListed(ip net.IP, list []string) bool { + if len(list) == 0 { return false } - for _, whitelistCIDR := range p.WhitelistIps { + for _, whitelistCIDR := range list { _, network, err := net.ParseCIDR(whitelistCIDR) if err != nil { // 尝试作为单个IP处理 @@ -211,22 +206,17 @@ func (p *SSRFProtection) isIPWhitelisted(ip net.IP) bool { // IsIPAccessAllowed 检查IP是否允许访问 func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool { - // 如果IP在白名单中,直接允许访问(绕过私有IP检查) - if p.isIPWhitelisted(ip) { - return true + // 私有IP限制 + if isPrivateIP(ip) && !p.AllowPrivateIp { + return false } - // 如果IP白名单为空,允许所有IP(但仍需通过私有IP检查) - if len(p.WhitelistIps) == 0 { - // 检查私有IP限制 - if isPrivateIP(ip) && !p.AllowPrivateIp { - return false - } - return true + listed := isIPListed(ip, p.IpList) + if p.IpFilterMode { // 白名单 + return listed } - - // 如果IP白名单不为空且IP不在白名单中,拒绝访问 - return false + // 黑名单 + return !listed } // ValidateURL 验证URL是否安全 @@ -264,28 +254,44 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error { return fmt.Errorf("port %d is not allowed", port) } - // 检查域名白名单 - if p.isDomainWhitelisted(host) { - return nil // 白名单域名直接通过 + // 如果 host 是 IP,则跳过域名检查 + if ip := net.ParseIP(host); ip != nil { + if !p.IsIPAccessAllowed(ip) { + if isPrivateIP(ip) { + return fmt.Errorf("private IP address not allowed: %s", ip.String()) + } + if p.IpFilterMode { + return fmt.Errorf("ip not in whitelist: %s", ip.String()) + } + return fmt.Errorf("ip in blacklist: %s", ip.String()) + } + return nil } - // DNS解析获取IP地址 + // 先进行域名过滤 + if !p.isDomainAllowed(host) { + if p.DomainFilterMode { + return fmt.Errorf("domain not in whitelist: %s", host) + } + return fmt.Errorf("domain in blacklist: %s", host) + } + + // 解析域名对应IP并检查 ips, err := net.LookupIP(host) if err != nil { return fmt.Errorf("DNS resolution failed for %s: %v", host, err) } - - // 检查所有解析的IP地址 for _, ip := range ips { if !p.IsIPAccessAllowed(ip) { - if isPrivateIP(ip) { + if isPrivateIP(ip) && !p.AllowPrivateIp { return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String()) - } else { - return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String()) } + if p.IpFilterMode { + return fmt.Errorf("ip not in whitelist: %s resolves to %s", host, ip.String()) + } + return fmt.Errorf("ip in blacklist: %s resolves to %s", host, ip.String()) } } - return nil } @@ -295,7 +301,7 @@ func ValidateURLWithDefaults(urlStr string) error { } // ValidateURLWithFetchSetting 使用FetchSetting配置验证URL -func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error { +func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string) error { // 如果SSRF防护被禁用,直接返回成功 if !enableSSRFProtection { return nil @@ -309,76 +315,11 @@ func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPriva protection := &SSRFProtection{ AllowPrivateIp: allowPrivateIp, - WhitelistDomains: whitelistDomains, - WhitelistIps: whitelistIps, + DomainFilterMode: domainFilterMode, + DomainList: domainList, + IpFilterMode: ipFilterMode, + IpList: ipList, AllowedPorts: allowedPortInts, } return protection.ValidateURL(urlStr) } - -// ValidateURLWithPortRanges 直接使用端口范围字符串验证URL(更高效的版本) -func ValidateURLWithPortRanges(urlStr string, allowPrivateIp bool, whitelistDomains, whitelistIps, allowedPorts []string) error { - // 解析URL - u, err := url.Parse(urlStr) - if err != nil { - return fmt.Errorf("invalid URL format: %v", err) - } - - // 只允许HTTP/HTTPS协议 - if u.Scheme != "http" && u.Scheme != "https" { - return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme) - } - - // 解析主机和端口 - host, portStr, err := net.SplitHostPort(u.Host) - if err != nil { - // 没有端口,使用默认端口 - host = u.Host - if u.Scheme == "https" { - portStr = "443" - } else { - portStr = "80" - } - } - - // 验证端口 - port, err := strconv.Atoi(portStr) - if err != nil { - return fmt.Errorf("invalid port: %s", portStr) - } - - if !isAllowedPortFromRanges(port, allowedPorts) { - return fmt.Errorf("port %d is not allowed", port) - } - - // 创建临时的SSRFProtection来复用域名和IP检查逻辑 - protection := &SSRFProtection{ - AllowPrivateIp: allowPrivateIp, - WhitelistDomains: whitelistDomains, - WhitelistIps: whitelistIps, - } - - // 检查域名白名单 - if protection.isDomainWhitelisted(host) { - return nil // 白名单域名直接通过 - } - - // DNS解析获取IP地址 - ips, err := net.LookupIP(host) - if err != nil { - return fmt.Errorf("DNS resolution failed for %s: %v", host, err) - } - - // 检查所有解析的IP地址 - for _, ip := range ips { - if !protection.IsIPAccessAllowed(ip) { - if isPrivateIP(ip) { - return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String()) - } else { - return fmt.Errorf("IP address not in whitelist: %s resolves to %s", host, ip.String()) - } - } - } - - return nil -} diff --git a/service/download.go b/service/download.go index 43b6fe7d..c07c9e1c 100644 --- a/service/download.go +++ b/service/download.go @@ -30,7 +30,7 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { // SSRF防护:验证请求URL fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return nil, fmt.Errorf("request reject: %v", err) } @@ -59,7 +59,7 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, } else { // SSRF防护:验证请求URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return nil, fmt.Errorf("request reject: %v", err) } diff --git a/service/user_notify.go b/service/user_notify.go index 1e9e8947..76d15903 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -115,7 +115,7 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { } else { // SSRF防护:验证Bark URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return fmt.Errorf("request reject: %v", err) } diff --git a/service/webhook.go b/service/webhook.go index 5d9ce400..b7fd13df 100644 --- a/service/webhook.go +++ b/service/webhook.go @@ -89,7 +89,7 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error } else { // SSRF防护:验证Webhook URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { return fmt.Errorf("request reject: %v", err) } From f9a6e7f04f7c7b25f9004f98784ba9ae84c37299 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Wed, 17 Sep 2025 23:29:18 +0800 Subject: [PATCH 4/8] feat: remove ValidateURLWithDefaults --- common/ssrf_protection.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go index 52b83952..e48ca0e0 100644 --- a/common/ssrf_protection.go +++ b/common/ssrf_protection.go @@ -295,11 +295,6 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error { return nil } -// ValidateURLWithDefaults 使用默认配置验证URL -func ValidateURLWithDefaults(urlStr string) error { - return DefaultSSRFProtection.ValidateURL(urlStr) -} - // ValidateURLWithFetchSetting 使用FetchSetting配置验证URL func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string) error { // 如果SSRF防护被禁用,直接返回成功 From 82163b4be77781f99a4350f1c82b4f6ed7cd7839 Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Wed, 17 Sep 2025 23:46:04 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9F=9F?= =?UTF-8?q?=E5=90=8D=E5=90=AF=E7=94=A8ip=E8=BF=87=E6=BB=A4=E5=BC=80?= =?UTF-8?q?=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/ssrf_protection.go | 33 +++++++++++-------- service/download.go | 4 +-- service/user_notify.go | 2 +- service/webhook.go | 2 +- setting/system_setting/fetch_setting.go | 30 +++++++++-------- web/src/components/settings/SystemSetting.jsx | 19 +++++++++-- 6 files changed, 57 insertions(+), 33 deletions(-) diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go index e48ca0e0..40d3b10b 100644 --- a/common/ssrf_protection.go +++ b/common/ssrf_protection.go @@ -10,12 +10,13 @@ import ( // SSRFProtection SSRF防护配置 type SSRFProtection struct { - AllowPrivateIp bool - DomainFilterMode bool // true: 白名单, false: 黑名单 - DomainList []string // domain format, e.g. example.com, *.example.com - IpFilterMode bool // true: 白名单, false: 黑名单 - IpList []string // CIDR or single IP - AllowedPorts []int // 允许的端口范围 + AllowPrivateIp bool + DomainFilterMode bool // true: 白名单, false: 黑名单 + DomainList []string // domain format, e.g. example.com, *.example.com + IpFilterMode bool // true: 白名单, false: 黑名单 + IpList []string // CIDR or single IP + AllowedPorts []int // 允许的端口范围 + ApplyIPFilterForDomain bool // 对域名启用IP过滤 } // DefaultSSRFProtection 默认SSRF防护配置 @@ -276,6 +277,11 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error { return fmt.Errorf("domain in blacklist: %s", host) } + // 若未启用对域名应用IP过滤,则到此通过 + if !p.ApplyIPFilterForDomain { + return nil + } + // 解析域名对应IP并检查 ips, err := net.LookupIP(host) if err != nil { @@ -296,7 +302,7 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error { } // ValidateURLWithFetchSetting 使用FetchSetting配置验证URL -func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string) error { +func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) error { // 如果SSRF防护被禁用,直接返回成功 if !enableSSRFProtection { return nil @@ -309,12 +315,13 @@ func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPriva } protection := &SSRFProtection{ - AllowPrivateIp: allowPrivateIp, - DomainFilterMode: domainFilterMode, - DomainList: domainList, - IpFilterMode: ipFilterMode, - IpList: ipList, - AllowedPorts: allowedPortInts, + AllowPrivateIp: allowPrivateIp, + DomainFilterMode: domainFilterMode, + DomainList: domainList, + IpFilterMode: ipFilterMode, + IpList: ipList, + AllowedPorts: allowedPortInts, + ApplyIPFilterForDomain: applyIPFilterForDomain, } return protection.ValidateURL(urlStr) } diff --git a/service/download.go b/service/download.go index c07c9e1c..036c43af 100644 --- a/service/download.go +++ b/service/download.go @@ -30,7 +30,7 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { // SSRF防护:验证请求URL fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { return nil, fmt.Errorf("request reject: %v", err) } @@ -59,7 +59,7 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, } else { // SSRF防护:验证请求URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { return nil, fmt.Errorf("request reject: %v", err) } diff --git a/service/user_notify.go b/service/user_notify.go index 76d15903..fba12d9d 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -115,7 +115,7 @@ func sendBarkNotify(barkURL string, data dto.Notify) error { } else { // SSRF防护:验证Bark URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { return fmt.Errorf("request reject: %v", err) } diff --git a/service/webhook.go b/service/webhook.go index b7fd13df..c678b863 100644 --- a/service/webhook.go +++ b/service/webhook.go @@ -89,7 +89,7 @@ func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error } else { // SSRF防护:验证Webhook URL(非Worker模式) fetchSetting := system_setting.GetFetchSetting() - if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts); err != nil { + if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { return fmt.Errorf("request reject: %v", err) } diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go index 5277e103..3c7f1e05 100644 --- a/setting/system_setting/fetch_setting.go +++ b/setting/system_setting/fetch_setting.go @@ -3,23 +3,25 @@ package system_setting import "one-api/setting/config" type FetchSetting struct { - EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护 - AllowPrivateIp bool `json:"allow_private_ip"` - DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式 - IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式 - DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com - IpList []string `json:"ip_list"` // CIDR format - AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000 + EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护 + AllowPrivateIp bool `json:"allow_private_ip"` + DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式 + IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式 + DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com + IpList []string `json:"ip_list"` // CIDR format + AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000 + ApplyIPFilterForDomain bool `json:"apply_ip_filter_for_domain"` // 对域名启用IP过滤(实验性) } var defaultFetchSetting = FetchSetting{ - EnableSSRFProtection: true, // 默认开启SSRF防护 - AllowPrivateIp: false, - DomainFilterMode: true, - IpFilterMode: true, - DomainList: []string{}, - IpList: []string{}, - AllowedPorts: []string{"80", "443", "8080", "8443"}, + EnableSSRFProtection: true, // 默认开启SSRF防护 + AllowPrivateIp: false, + DomainFilterMode: true, + IpFilterMode: true, + DomainList: []string{}, + IpList: []string{}, + AllowedPorts: []string{"80", "443", "8080", "8443"}, + ApplyIPFilterForDomain: false, } func init() { diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index ebe4084b..a1d26a4a 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -97,6 +97,7 @@ const SystemSetting = () => { 'fetch_setting.domain_list': [], 'fetch_setting.ip_list': [], 'fetch_setting.allowed_ports': [], + 'fetch_setting.apply_ip_filter_for_domain': false, }); const [originInputs, setOriginInputs] = useState({}); @@ -132,6 +133,7 @@ const SystemSetting = () => { case 'fetch_setting.enable_ssrf_protection': case 'fetch_setting.domain_filter_mode': case 'fetch_setting.ip_filter_mode': + case 'fetch_setting.apply_ip_filter_for_domain': item.value = toBoolean(item.value); break; case 'fetch_setting.domain_list': @@ -724,6 +726,17 @@ const SystemSetting = () => { style={{ marginTop: 16 }} > + + + handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e) + } + style={{ marginBottom: 8 }} + > + {t('对域名启用 IP 过滤(实验性)')} + {t(domainFilterMode ? '域名白名单' : '域名黑名单')} @@ -734,7 +747,8 @@ const SystemSetting = () => { type='button' value={domainFilterMode ? 'whitelist' : 'blacklist'} onChange={(val) => { - const isWhitelist = val === 'whitelist'; + const selected = val && val.target ? val.target.value : val; + const isWhitelist = selected === 'whitelist'; setDomainFilterMode(isWhitelist); setInputs(prev => ({ ...prev, @@ -780,7 +794,8 @@ const SystemSetting = () => { type='button' value={ipFilterMode ? 'whitelist' : 'blacklist'} onChange={(val) => { - const isWhitelist = val === 'whitelist'; + const selected = val && val.target ? val.target.value : val; + const isWhitelist = selected === 'whitelist'; setIpFilterMode(isWhitelist); setInputs(prev => ({ ...prev, From 7af1dc42d4bd1ecca6eb705b67cba3548317b33b Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Wed, 17 Sep 2025 23:47:59 +0800 Subject: [PATCH 6/8] fix: use u.Hostname() instead of u.Host to avoid ipv6 host parse failed --- common/ssrf_protection.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/ssrf_protection.go b/common/ssrf_protection.go index 40d3b10b..6f7d289f 100644 --- a/common/ssrf_protection.go +++ b/common/ssrf_protection.go @@ -237,7 +237,7 @@ func (p *SSRFProtection) ValidateURL(urlStr string) error { host, portStr, err := net.SplitHostPort(u.Host) if err != nil { // 没有端口,使用默认端口 - host = u.Host + host = u.Hostname() if u.Scheme == "https" { portStr = "443" } else { From 98d5b3dbcbd3dbcc2224525a40bbe2983b9127da Mon Sep 17 00:00:00 2001 From: creamlike1024 Date: Wed, 17 Sep 2025 23:54:34 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4=E5=A4=9A?= =?UTF-8?q?=E4=BD=99=E7=9A=84=E8=AF=B4=E6=98=8E=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/settings/SystemSetting.jsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index a1d26a4a..3218cdf0 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -773,9 +773,6 @@ const SystemSetting = () => { placeholder={t('输入域名后回车,如:example.com')} style={{ width: '100%' }} /> - - {t('域名过滤详细说明')} - @@ -820,9 +817,6 @@ const SystemSetting = () => { placeholder={t('输入IP地址后回车,如:8.8.8.8')} style={{ width: '100%' }} /> - - {t('IP过滤详细说明')} - From 0008d2e3a0e58b37e2b5d63a889520e36aa86a57 Mon Sep 17 00:00:00 2001 From: CaIon Date: Thu, 18 Sep 2025 13:40:52 +0800 Subject: [PATCH 8/8] feat: add experimental IP filtering for domains and update related settings --- setting/system_setting/fetch_setting.go | 4 ++-- web/src/components/settings/SystemSetting.jsx | 6 +++--- web/src/i18n/locales/en.json | 8 ++++++-- web/src/i18n/locales/zh.json | 3 ++- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go index 3c7f1e05..c41b930a 100644 --- a/setting/system_setting/fetch_setting.go +++ b/setting/system_setting/fetch_setting.go @@ -16,8 +16,8 @@ type FetchSetting struct { var defaultFetchSetting = FetchSetting{ EnableSSRFProtection: true, // 默认开启SSRF防护 AllowPrivateIp: false, - DomainFilterMode: true, - IpFilterMode: true, + DomainFilterMode: false, + IpFilterMode: false, DomainList: []string{}, IpList: []string{}, AllowedPorts: []string{"80", "443", "8080", "8443"}, diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 3218cdf0..f9a2c019 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -92,8 +92,8 @@ const SystemSetting = () => { // SSRF防护配置 'fetch_setting.enable_ssrf_protection': true, 'fetch_setting.allow_private_ip': '', - 'fetch_setting.domain_filter_mode': true, // true 白名单,false 黑名单 - 'fetch_setting.ip_filter_mode': true, // true 白名单,false 黑名单 + 'fetch_setting.domain_filter_mode': false, // true 白名单,false 黑名单 + 'fetch_setting.ip_filter_mode': false, // true 白名单,false 黑名单 'fetch_setting.domain_list': [], 'fetch_setting.ip_list': [], 'fetch_setting.allowed_ports': [], @@ -726,10 +726,10 @@ const SystemSetting = () => { style={{ marginTop: 16 }} > - handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e) } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 6759f53e..0af06477 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -2098,7 +2098,6 @@ "支持通配符格式,如:example.com, *.api.example.com": "Supports wildcard format, e.g.: example.com, *.api.example.com", "域名白名单详细说明": "Whitelisted domains bypass all SSRF checks and are allowed direct access. Supports exact domains (example.com) or wildcards (*.api.example.com) for subdomains. When whitelist is empty, all domains go through SSRF validation.", "输入域名后回车,如:example.com": "Enter domain and press Enter, e.g.: example.com", - "IP白名单": "IP Whitelist", "支持CIDR格式,如:8.8.8.8, 192.168.1.0/24": "Supports CIDR format, e.g.: 8.8.8.8, 192.168.1.0/24", "IP白名单详细说明": "Controls which IP addresses are allowed access. Use single IPs (8.8.8.8) or CIDR notation (192.168.1.0/24). Empty whitelist allows all IPs (subject to private IP settings), non-empty whitelist only allows listed IPs.", "输入IP地址后回车,如:8.8.8.8": "Enter IP address and press Enter, e.g.: 8.8.8.8", @@ -2106,5 +2105,10 @@ "支持单个端口和端口范围,如:80, 443, 8000-8999": "Supports single ports and port ranges, e.g.: 80, 443, 8000-8999", "端口配置详细说明": "Restrict external requests to specific ports. Use single ports (80, 443) or ranges (8000-8999). Empty list allows all ports. Default includes common web ports.", "输入端口后回车,如:80 或 8000-8999": "Enter port and press Enter, e.g.: 80 or 8000-8999", - "更新SSRF防护设置": "Update SSRF Protection Settings" + "更新SSRF防护设置": "Update SSRF Protection Settings", + "对域名启用 IP 过滤(实验性)": "Enable IP filtering for domains (experimental)", + "域名IP过滤详细说明": "⚠️ This is an experimental option. A domain may resolve to multiple IPv4/IPv6 addresses. If enabled, ensure the IP filter list covers these addresses, otherwise access may fail.", + "域名黑名单": "Domain Blacklist", + "白名单": "Whitelist", + "黑名单": "Blacklist" } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 71777044..95fa0641 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -31,5 +31,6 @@ "支持单个端口和端口范围,如:80, 443, 8000-8999": "支持单个端口和端口范围,如:80, 443, 8000-8999", "端口配置详细说明": "限制外部请求只能访问指定端口。支持单个端口(80, 443)或端口范围(8000-8999)。空列表允许所有端口。默认包含常用Web端口。", "输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999", - "更新SSRF防护设置": "更新SSRF防护设置" + "更新SSRF防护设置": "更新SSRF防护设置", + "域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。" }