CVE-2025-59146
Description
New API is a large language mode (LLM) gateway and artificial intelligence (AI) asset management system. An authenticated Server-Side Request Forgery (SSRF) vulnerability exists in versions prior to 0.9.0.5. A feature within the application allows authenticated users to submit a URL for the server to process its content. The application fails to properly validate this user-supplied URL before making a server-side request. This vulnerability is not limited to image URLs and can be triggered with any link provided to the vulnerable endpoint. Since user registration is often enabled by default, any registered user can exploit this. By crafting a malicious URL, an attacker can coerce the server to send requests to arbitrary internal or external services. The vulnerability has been patched in version 0.9.0.5. The patch introduces a comprehensive, user-configurable SSRF protection module, which is enabled by default to protect server security. This new feature provides administrators with granular control over outbound requests made by the server. For users who cannot upgrade immediately, some temporary mitigation options are available. Enable new-api image processing worker (new-api-worker) and/or configure egress firewall rules.
Affected products
1Patches
21f8ae6e65a71Merge commit from fork
10 files changed · +736 −6
common/ip.go+22 −0 added@@ -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 +}
common/ssrf_protection.go+327 −0 added@@ -0,0 +1,327 @@ +package common + +import ( + "fmt" + "net" + "net/url" + "strconv" + "strings" +) + +// 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 // 允许的端口范围 + ApplyIPFilterForDomain bool // 对域名启用IP过滤 +} + +// DefaultSSRFProtection 默认SSRF防护配置 +var DefaultSSRFProtection = &SSRFProtection{ + AllowPrivateIp: false, + DomainFilterMode: true, + DomainList: []string{}, + IpFilterMode: true, + IpList: []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 +} + +// isDomainWhitelisted 检查域名是否在白名单中 +func isDomainListed(domain string, list []string) bool { + if len(list) == 0 { + return false + } + + domain = strings.ToLower(domain) + for _, item := range list { + item = strings.ToLower(strings.TrimSpace(item)) + if item == "" { + continue + } + // 精确匹配 + if domain == item { + return true + } + // 通配符匹配 (*.example.com) + if strings.HasPrefix(item, "*.") { + suffix := strings.TrimPrefix(item, "*.") + if strings.HasSuffix(domain, "."+suffix) || domain == suffix { + return true + } + } + } + return false +} + +func (p *SSRFProtection) isDomainAllowed(domain string) bool { + listed := isDomainListed(domain, p.DomainList) + if p.DomainFilterMode { // 白名单 + return listed + } + // 黑名单 + return !listed +} + +// isIPWhitelisted 检查IP是否在白名单中 + +func isIPListed(ip net.IP, list []string) bool { + if len(list) == 0 { + return false + } + + for _, whitelistCIDR := range list { + _, 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限制 + if isPrivateIP(ip) && !p.AllowPrivateIp { + return false + } + + listed := isIPListed(ip, p.IpList) + if p.IpFilterMode { // 白名单 + return listed + } + // 黑名单 + return !listed +} + +// 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.Hostname() + 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) + } + + // 如果 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 + } + + // 先进行域名过滤 + 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过滤,则到此通过 + if !p.ApplyIPFilterForDomain { + return nil + } + + // 解析域名对应IP并检查 + ips, err := net.LookupIP(host) + if err != nil { + return fmt.Errorf("DNS resolution failed for %s: %v", host, err) + } + for _, ip := range ips { + if !p.IsIPAccessAllowed(ip) { + if isPrivateIP(ip) && !p.AllowPrivateIp { + return fmt.Errorf("private IP address not allowed: %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 +} + +// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL +func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) 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, + DomainFilterMode: domainFilterMode, + DomainList: domainList, + IpFilterMode: ipFilterMode, + IpList: ipList, + AllowedPorts: allowedPortInts, + ApplyIPFilterForDomain: applyIPFilterForDomain, + } + return protection.ValidateURL(urlStr) +}
service/download.go+13 −1 renamed@@ -28,6 +28,12 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { return nil, fmt.Errorf("only support https url") } + // 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, fetchSetting.ApplyIPFilterForDomain); err != nil { + return nil, fmt.Errorf("request reject: %v", err) + } + workerUrl := system_setting.WorkerUrl if !strings.HasSuffix(workerUrl, "/") { workerUrl += "/" @@ -51,7 +57,13 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, } 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.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); 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) } }
service/user_notify.go+6 −0 modified@@ -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.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return fmt.Errorf("request reject: %v", err) + } + // 直接发送请求 req, err = http.NewRequest(http.MethodGet, finalURL, nil) if err != nil {
service/webhook.go+7 −0 modified@@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "net/http" + "one-api/common" "one-api/dto" "one-api/setting/system_setting" "time" @@ -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.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); 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)
setting/system_setting/fetch_setting.go+34 −0 added@@ -0,0 +1,34 @@ +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 + ApplyIPFilterForDomain bool `json:"apply_ip_filter_for_domain"` // 对域名启用IP过滤(实验性) +} + +var defaultFetchSetting = FetchSetting{ + EnableSSRFProtection: true, // 默认开启SSRF防护 + AllowPrivateIp: false, + DomainFilterMode: false, + IpFilterMode: false, + DomainList: []string{}, + IpList: []string{}, + AllowedPorts: []string{"80", "443", "8080", "8443"}, + ApplyIPFilterForDomain: false, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("fetch_setting", &defaultFetchSetting) +} + +func GetFetchSetting() *FetchSetting { + return &defaultFetchSetting +}
types/error.go+9 −3 modified@@ -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 }
web/src/components/settings/SystemSetting.jsx+267 −0 modified@@ -29,6 +29,7 @@ import { TagInput, Spin, Card, + Radio, } from '@douyinfe/semi-ui'; const { Text } = Typography; import { @@ -44,6 +45,7 @@ import { useTranslation } from 'react-i18next'; const SystemSetting = () => { const { t } = useTranslation(); let [inputs, setInputs] = useState({ + PasswordLoginEnabled: '', PasswordRegisterEnabled: '', EmailVerificationEnabled: '', @@ -87,6 +89,15 @@ const SystemSetting = () => { LinuxDOClientSecret: '', LinuxDOMinimumTrustLevel: '', ServerAddress: '', + // SSRF防护配置 + 'fetch_setting.enable_ssrf_protection': true, + 'fetch_setting.allow_private_ip': '', + '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': [], + 'fetch_setting.apply_ip_filter_for_domain': false, }); const [originInputs, setOriginInputs] = useState({}); @@ -98,6 +109,11 @@ const SystemSetting = () => { useState(false); const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false); const [emailToAdd, setEmailToAdd] = 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 () => { setLoading(true); @@ -113,6 +129,37 @@ const SystemSetting = () => { case 'EmailDomainWhitelist': setEmailDomainWhitelist(item.value ? item.value.split(',') : []); 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': + case 'fetch_setting.apply_ip_filter_for_domain': + item.value = toBoolean(item.value); + break; + case 'fetch_setting.domain_list': + try { + const domains = item.value ? JSON.parse(item.value) : []; + setDomainList(Array.isArray(domains) ? domains : []); + } catch (e) { + setDomainList([]); + } + break; + case 'fetch_setting.ip_list': + try { + const ips = item.value ? JSON.parse(item.value) : []; + setIpList(Array.isArray(ips) ? ips : []); + } catch (e) { + setIpList([]); + } + 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': @@ -140,6 +187,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); } @@ -276,6 +330,46 @@ const SystemSetting = () => { } }; + const submitSSRF = async () => { + const options = []; + + // 处理域名过滤模式与列表 + options.push({ + key: 'fetch_setting.domain_filter_mode', + value: domainFilterMode, + }); + if (Array.isArray(domainList)) { + options.push({ + key: 'fetch_setting.domain_list', + value: JSON.stringify(domainList), + }); + } + + // 处理IP过滤模式与列表 + options.push({ + key: 'fetch_setting.ip_filter_mode', + value: ipFilterMode, + }); + if (Array.isArray(ipList)) { + options.push({ + key: 'fetch_setting.ip_list', + value: JSON.stringify(ipList), + }); + } + + // 处理端口配置 + 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 +681,179 @@ const SystemSetting = () => { </Form.Section> </Card> + <Card> + <Form.Section text={t('SSRF防护设置')}> + <Text extraText={t('SSRF防护详细说明')}> + {t('配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全')} + </Text> + <Row + gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} + > + <Col xs={24} sm={24} md={24} lg={24} xl={24}> + <Form.Checkbox + field='fetch_setting.enable_ssrf_protection' + noLabel + extraText={t('SSRF防护开关详细说明')} + onChange={(e) => + handleCheckboxChange('fetch_setting.enable_ssrf_protection', e) + } + > + {t('启用SSRF防护(推荐开启以保护服务器安全)')} + </Form.Checkbox> + </Col> + </Row> + + <Row + gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} + style={{ marginTop: 16 }} + > + <Col xs={24} sm={24} md={24} lg={24} xl={24}> + <Form.Checkbox + field='fetch_setting.allow_private_ip' + noLabel + extraText={t('私有IP访问详细说明')} + onChange={(e) => + handleCheckboxChange('fetch_setting.allow_private_ip', e) + } + > + {t('允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)')} + </Form.Checkbox> + </Col> + </Row> + + <Row + gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} + style={{ marginTop: 16 }} + > + <Col xs={24} sm={24} md={24} lg={24} xl={24}> + <Form.Checkbox + field='fetch_setting.apply_ip_filter_for_domain' + noLabel + extraText={t('域名IP过滤详细说明')} + onChange={(e) => + handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e) + } + style={{ marginBottom: 8 }} + > + {t('对域名启用 IP 过滤(实验性)')} + </Form.Checkbox> + <Text strong> + {t(domainFilterMode ? '域名白名单' : '域名黑名单')} + </Text> + <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> + {t('支持通配符格式,如:example.com, *.api.example.com')} + </Text> + <Radio.Group + type='button' + value={domainFilterMode ? 'whitelist' : 'blacklist'} + onChange={(val) => { + const selected = val && val.target ? val.target.value : val; + const isWhitelist = selected === 'whitelist'; + setDomainFilterMode(isWhitelist); + setInputs(prev => ({ + ...prev, + 'fetch_setting.domain_filter_mode': isWhitelist, + })); + }} + style={{ marginBottom: 8 }} + > + <Radio value='whitelist'>{t('白名单')}</Radio> + <Radio value='blacklist'>{t('黑名单')}</Radio> + </Radio.Group> + <TagInput + value={domainList} + onChange={(value) => { + setDomainList(value); + // 触发Form的onChange事件 + setInputs(prev => ({ + ...prev, + 'fetch_setting.domain_list': value + })); + }} + placeholder={t('输入域名后回车,如:example.com')} + style={{ width: '100%' }} + /> + </Col> + </Row> + + <Row + gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} + style={{ marginTop: 16 }} + > + <Col xs={24} sm={24} md={24} lg={24} xl={24}> + <Text strong> + {t(ipFilterMode ? 'IP白名单' : 'IP黑名单')} + </Text> + <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> + {t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')} + </Text> + <Radio.Group + type='button' + value={ipFilterMode ? 'whitelist' : 'blacklist'} + onChange={(val) => { + const selected = val && val.target ? val.target.value : val; + const isWhitelist = selected === 'whitelist'; + setIpFilterMode(isWhitelist); + setInputs(prev => ({ + ...prev, + 'fetch_setting.ip_filter_mode': isWhitelist, + })); + }} + style={{ marginBottom: 8 }} + > + <Radio value='whitelist'>{t('白名单')}</Radio> + <Radio value='blacklist'>{t('黑名单')}</Radio> + </Radio.Group> + <TagInput + value={ipList} + onChange={(value) => { + setIpList(value); + // 触发Form的onChange事件 + setInputs(prev => ({ + ...prev, + 'fetch_setting.ip_list': value + })); + }} + placeholder={t('输入IP地址后回车,如:8.8.8.8')} + style={{ width: '100%' }} + /> + </Col> + </Row> + + <Row + gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} + style={{ marginTop: 16 }} + > + <Col xs={24} sm={24} md={24} lg={24} xl={24}> + <Text strong>{t('允许的端口')}</Text> + <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> + {t('支持单个端口和端口范围,如:80, 443, 8000-8999')} + </Text> + <TagInput + value={allowedPorts} + onChange={(value) => { + setAllowedPorts(value); + // 触发Form的onChange事件 + setInputs(prev => ({ + ...prev, + 'fetch_setting.allowed_ports': value + })); + }} + placeholder={t('输入端口后回车,如:80 或 8000-8999')} + style={{ width: '100%' }} + /> + <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> + {t('端口配置详细说明')} + </Text> + </Col> + </Row> + + <Button onClick={submitSSRF} style={{ marginTop: 16 }}> + {t('更新SSRF防护设置')} + </Button> + </Form.Section> + </Card> + <Card> <Form.Section text={t('配置登录注册')}> <Row
web/src/i18n/locales/en.json+27 −1 modified@@ -2084,5 +2084,31 @@ "原价": "Original price", "优惠": "Discount", "折": "% off", - "节省": "Save" + "节省": "Save", + "代理设置": "Proxy Settings", + "更新Worker设置": "Update Worker Settings", + "SSRF防护设置": "SSRF Protection Settings", + "配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全": "Configure Server-Side Request Forgery (SSRF) protection to secure internal network resources", + "SSRF防护详细说明": "SSRF protection prevents malicious users from using your server to access internal network resources. Configure whitelists for trusted domains/IPs and restrict allowed ports. Applies to file downloads, webhooks, and notifications.", + "启用SSRF防护(推荐开启以保护服务器安全)": "Enable SSRF Protection (Recommended for server security)", + "SSRF防护开关详细说明": "Master switch controls whether SSRF protection is enabled. When disabled, all SSRF checks are bypassed, allowing access to any URL. ⚠️ Only disable this feature in completely trusted environments.", + "允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)": "Allow access to private IP addresses (127.0.0.1, 192.168.x.x and other internal addresses)", + "私有IP访问详细说明": "⚠️ Security Warning: Enabling this allows access to internal network resources (localhost, private networks). Only enable if you need to access internal services and understand the security implications.", + "域名白名单": "Domain Whitelist", + "支持通配符格式,如: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", + "支持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", + "允许的端口": "Allowed Ports", + "支持单个端口和端口范围,如: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", + "对域名启用 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" }
web/src/i18n/locales/zh.json+24 −1 modified@@ -9,5 +9,28 @@ "语言": "语言", "展开侧边栏": "展开侧边栏", "关闭侧边栏": "关闭侧边栏", - "注销成功!": "注销成功!" + "注销成功!": "注销成功!", + "代理设置": "代理设置", + "更新Worker设置": "更新Worker设置", + "SSRF防护设置": "SSRF防护设置", + "配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全": "配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全", + "SSRF防护详细说明": "SSRF防护可防止恶意用户利用您的服务器访问内网资源。您可以配置受信任域名/IP的白名单,并限制允许的端口。适用于文件下载、Webhook回调和通知功能。", + "启用SSRF防护(推荐开启以保护服务器安全)": "启用SSRF防护(推荐开启以保护服务器安全)", + "SSRF防护开关详细说明": "总开关控制是否启用SSRF防护功能。关闭后将跳过所有SSRF检查,允许访问任意URL。⚠️ 仅在完全信任环境中关闭此功能。", + "允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)": "允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)", + "私有IP访问详细说明": "⚠️ 安全警告:启用此选项将允许访问内网资源(本地主机、私有网络)。仅在需要访问内部服务且了解安全风险的情况下启用。", + "域名白名单": "域名白名单", + "支持通配符格式,如:example.com, *.api.example.com": "支持通配符格式,如:example.com, *.api.example.com", + "域名白名单详细说明": "白名单中的域名将绕过所有SSRF检查,直接允许访问。支持精确域名(example.com)或通配符(*.api.example.com)匹配子域名。白名单为空时,所有域名都需要通过SSRF检查。", + "输入域名后回车,如:example.com": "输入域名后回车,如:example.com", + "IP白名单": "IP白名单", + "支持CIDR格式,如:8.8.8.8, 192.168.1.0/24": "支持CIDR格式,如:8.8.8.8, 192.168.1.0/24", + "IP白名单详细说明": "控制允许访问的IP地址。支持单个IP(8.8.8.8)或CIDR网段(192.168.1.0/24)。空白名单允许所有IP(但仍受私有IP设置限制),非空白名单仅允许列表中的IP访问。", + "输入IP地址后回车,如:8.8.8.8": "输入IP地址后回车,如:8.8.8.8", + "允许的端口": "允许的端口", + "支持单个端口和端口范围,如:80, 443, 8000-8999": "支持单个端口和端口范围,如:80, 443, 8000-8999", + "端口配置详细说明": "限制外部请求只能访问指定端口。支持单个端口(80, 443)或端口范围(8000-8999)。空列表允许所有端口。默认包含常用Web端口。", + "输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999", + "更新SSRF防护设置": "更新SSRF防护设置", + "域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。" }
ef634160986cMerge commit from fork
10 files changed · +736 −6
common/ip.go+22 −0 added@@ -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 +}
common/ssrf_protection.go+327 −0 added@@ -0,0 +1,327 @@ +package common + +import ( + "fmt" + "net" + "net/url" + "strconv" + "strings" +) + +// 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 // 允许的端口范围 + ApplyIPFilterForDomain bool // 对域名启用IP过滤 +} + +// DefaultSSRFProtection 默认SSRF防护配置 +var DefaultSSRFProtection = &SSRFProtection{ + AllowPrivateIp: false, + DomainFilterMode: true, + DomainList: []string{}, + IpFilterMode: true, + IpList: []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 +} + +// isDomainWhitelisted 检查域名是否在白名单中 +func isDomainListed(domain string, list []string) bool { + if len(list) == 0 { + return false + } + + domain = strings.ToLower(domain) + for _, item := range list { + item = strings.ToLower(strings.TrimSpace(item)) + if item == "" { + continue + } + // 精确匹配 + if domain == item { + return true + } + // 通配符匹配 (*.example.com) + if strings.HasPrefix(item, "*.") { + suffix := strings.TrimPrefix(item, "*.") + if strings.HasSuffix(domain, "."+suffix) || domain == suffix { + return true + } + } + } + return false +} + +func (p *SSRFProtection) isDomainAllowed(domain string) bool { + listed := isDomainListed(domain, p.DomainList) + if p.DomainFilterMode { // 白名单 + return listed + } + // 黑名单 + return !listed +} + +// isIPWhitelisted 检查IP是否在白名单中 + +func isIPListed(ip net.IP, list []string) bool { + if len(list) == 0 { + return false + } + + for _, whitelistCIDR := range list { + _, 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限制 + if isPrivateIP(ip) && !p.AllowPrivateIp { + return false + } + + listed := isIPListed(ip, p.IpList) + if p.IpFilterMode { // 白名单 + return listed + } + // 黑名单 + return !listed +} + +// 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.Hostname() + 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) + } + + // 如果 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 + } + + // 先进行域名过滤 + 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过滤,则到此通过 + if !p.ApplyIPFilterForDomain { + return nil + } + + // 解析域名对应IP并检查 + ips, err := net.LookupIP(host) + if err != nil { + return fmt.Errorf("DNS resolution failed for %s: %v", host, err) + } + for _, ip := range ips { + if !p.IsIPAccessAllowed(ip) { + if isPrivateIP(ip) && !p.AllowPrivateIp { + return fmt.Errorf("private IP address not allowed: %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 +} + +// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL +func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) 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, + DomainFilterMode: domainFilterMode, + DomainList: domainList, + IpFilterMode: ipFilterMode, + IpList: ipList, + AllowedPorts: allowedPortInts, + ApplyIPFilterForDomain: applyIPFilterForDomain, + } + return protection.ValidateURL(urlStr) +}
service/download.go+13 −1 renamed@@ -28,6 +28,12 @@ func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { return nil, fmt.Errorf("only support https url") } + // 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, fetchSetting.ApplyIPFilterForDomain); err != nil { + return nil, fmt.Errorf("request reject: %v", err) + } + workerUrl := system_setting.WorkerUrl if !strings.HasSuffix(workerUrl, "/") { workerUrl += "/" @@ -51,7 +57,13 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, } 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.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); 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) } }
service/user_notify.go+6 −0 modified@@ -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.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return fmt.Errorf("request reject: %v", err) + } + // 直接发送请求 req, err = http.NewRequest(http.MethodGet, finalURL, nil) if err != nil {
service/webhook.go+7 −0 modified@@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "net/http" + "one-api/common" "one-api/dto" "one-api/setting/system_setting" "time" @@ -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.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); 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)
setting/system_setting/fetch_setting.go+34 −0 added@@ -0,0 +1,34 @@ +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 + ApplyIPFilterForDomain bool `json:"apply_ip_filter_for_domain"` // 对域名启用IP过滤(实验性) +} + +var defaultFetchSetting = FetchSetting{ + EnableSSRFProtection: true, // 默认开启SSRF防护 + AllowPrivateIp: false, + DomainFilterMode: false, + IpFilterMode: false, + DomainList: []string{}, + IpList: []string{}, + AllowedPorts: []string{"80", "443", "8080", "8443"}, + ApplyIPFilterForDomain: false, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("fetch_setting", &defaultFetchSetting) +} + +func GetFetchSetting() *FetchSetting { + return &defaultFetchSetting +}
types/error.go+9 −3 modified@@ -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 }
web/src/components/settings/SystemSetting.jsx+267 −0 modified@@ -29,6 +29,7 @@ import { TagInput, Spin, Card, + Radio, } from '@douyinfe/semi-ui'; const { Text } = Typography; import { @@ -44,6 +45,7 @@ import { useTranslation } from 'react-i18next'; const SystemSetting = () => { const { t } = useTranslation(); let [inputs, setInputs] = useState({ + PasswordLoginEnabled: '', PasswordRegisterEnabled: '', EmailVerificationEnabled: '', @@ -87,6 +89,15 @@ const SystemSetting = () => { LinuxDOClientSecret: '', LinuxDOMinimumTrustLevel: '', ServerAddress: '', + // SSRF防护配置 + 'fetch_setting.enable_ssrf_protection': true, + 'fetch_setting.allow_private_ip': '', + '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': [], + 'fetch_setting.apply_ip_filter_for_domain': false, }); const [originInputs, setOriginInputs] = useState({}); @@ -98,6 +109,11 @@ const SystemSetting = () => { useState(false); const [linuxDOOAuthEnabled, setLinuxDOOAuthEnabled] = useState(false); const [emailToAdd, setEmailToAdd] = 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 () => { setLoading(true); @@ -113,6 +129,37 @@ const SystemSetting = () => { case 'EmailDomainWhitelist': setEmailDomainWhitelist(item.value ? item.value.split(',') : []); 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': + case 'fetch_setting.apply_ip_filter_for_domain': + item.value = toBoolean(item.value); + break; + case 'fetch_setting.domain_list': + try { + const domains = item.value ? JSON.parse(item.value) : []; + setDomainList(Array.isArray(domains) ? domains : []); + } catch (e) { + setDomainList([]); + } + break; + case 'fetch_setting.ip_list': + try { + const ips = item.value ? JSON.parse(item.value) : []; + setIpList(Array.isArray(ips) ? ips : []); + } catch (e) { + setIpList([]); + } + 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': @@ -140,6 +187,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); } @@ -276,6 +330,46 @@ const SystemSetting = () => { } }; + const submitSSRF = async () => { + const options = []; + + // 处理域名过滤模式与列表 + options.push({ + key: 'fetch_setting.domain_filter_mode', + value: domainFilterMode, + }); + if (Array.isArray(domainList)) { + options.push({ + key: 'fetch_setting.domain_list', + value: JSON.stringify(domainList), + }); + } + + // 处理IP过滤模式与列表 + options.push({ + key: 'fetch_setting.ip_filter_mode', + value: ipFilterMode, + }); + if (Array.isArray(ipList)) { + options.push({ + key: 'fetch_setting.ip_list', + value: JSON.stringify(ipList), + }); + } + + // 处理端口配置 + 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 +681,179 @@ const SystemSetting = () => { </Form.Section> </Card> + <Card> + <Form.Section text={t('SSRF防护设置')}> + <Text extraText={t('SSRF防护详细说明')}> + {t('配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全')} + </Text> + <Row + gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} + > + <Col xs={24} sm={24} md={24} lg={24} xl={24}> + <Form.Checkbox + field='fetch_setting.enable_ssrf_protection' + noLabel + extraText={t('SSRF防护开关详细说明')} + onChange={(e) => + handleCheckboxChange('fetch_setting.enable_ssrf_protection', e) + } + > + {t('启用SSRF防护(推荐开启以保护服务器安全)')} + </Form.Checkbox> + </Col> + </Row> + + <Row + gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} + style={{ marginTop: 16 }} + > + <Col xs={24} sm={24} md={24} lg={24} xl={24}> + <Form.Checkbox + field='fetch_setting.allow_private_ip' + noLabel + extraText={t('私有IP访问详细说明')} + onChange={(e) => + handleCheckboxChange('fetch_setting.allow_private_ip', e) + } + > + {t('允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)')} + </Form.Checkbox> + </Col> + </Row> + + <Row + gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} + style={{ marginTop: 16 }} + > + <Col xs={24} sm={24} md={24} lg={24} xl={24}> + <Form.Checkbox + field='fetch_setting.apply_ip_filter_for_domain' + noLabel + extraText={t('域名IP过滤详细说明')} + onChange={(e) => + handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e) + } + style={{ marginBottom: 8 }} + > + {t('对域名启用 IP 过滤(实验性)')} + </Form.Checkbox> + <Text strong> + {t(domainFilterMode ? '域名白名单' : '域名黑名单')} + </Text> + <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> + {t('支持通配符格式,如:example.com, *.api.example.com')} + </Text> + <Radio.Group + type='button' + value={domainFilterMode ? 'whitelist' : 'blacklist'} + onChange={(val) => { + const selected = val && val.target ? val.target.value : val; + const isWhitelist = selected === 'whitelist'; + setDomainFilterMode(isWhitelist); + setInputs(prev => ({ + ...prev, + 'fetch_setting.domain_filter_mode': isWhitelist, + })); + }} + style={{ marginBottom: 8 }} + > + <Radio value='whitelist'>{t('白名单')}</Radio> + <Radio value='blacklist'>{t('黑名单')}</Radio> + </Radio.Group> + <TagInput + value={domainList} + onChange={(value) => { + setDomainList(value); + // 触发Form的onChange事件 + setInputs(prev => ({ + ...prev, + 'fetch_setting.domain_list': value + })); + }} + placeholder={t('输入域名后回车,如:example.com')} + style={{ width: '100%' }} + /> + </Col> + </Row> + + <Row + gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} + style={{ marginTop: 16 }} + > + <Col xs={24} sm={24} md={24} lg={24} xl={24}> + <Text strong> + {t(ipFilterMode ? 'IP白名单' : 'IP黑名单')} + </Text> + <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> + {t('支持CIDR格式,如:8.8.8.8, 192.168.1.0/24')} + </Text> + <Radio.Group + type='button' + value={ipFilterMode ? 'whitelist' : 'blacklist'} + onChange={(val) => { + const selected = val && val.target ? val.target.value : val; + const isWhitelist = selected === 'whitelist'; + setIpFilterMode(isWhitelist); + setInputs(prev => ({ + ...prev, + 'fetch_setting.ip_filter_mode': isWhitelist, + })); + }} + style={{ marginBottom: 8 }} + > + <Radio value='whitelist'>{t('白名单')}</Radio> + <Radio value='blacklist'>{t('黑名单')}</Radio> + </Radio.Group> + <TagInput + value={ipList} + onChange={(value) => { + setIpList(value); + // 触发Form的onChange事件 + setInputs(prev => ({ + ...prev, + 'fetch_setting.ip_list': value + })); + }} + placeholder={t('输入IP地址后回车,如:8.8.8.8')} + style={{ width: '100%' }} + /> + </Col> + </Row> + + <Row + gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }} + style={{ marginTop: 16 }} + > + <Col xs={24} sm={24} md={24} lg={24} xl={24}> + <Text strong>{t('允许的端口')}</Text> + <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> + {t('支持单个端口和端口范围,如:80, 443, 8000-8999')} + </Text> + <TagInput + value={allowedPorts} + onChange={(value) => { + setAllowedPorts(value); + // 触发Form的onChange事件 + setInputs(prev => ({ + ...prev, + 'fetch_setting.allowed_ports': value + })); + }} + placeholder={t('输入端口后回车,如:80 或 8000-8999')} + style={{ width: '100%' }} + /> + <Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> + {t('端口配置详细说明')} + </Text> + </Col> + </Row> + + <Button onClick={submitSSRF} style={{ marginTop: 16 }}> + {t('更新SSRF防护设置')} + </Button> + </Form.Section> + </Card> + <Card> <Form.Section text={t('配置登录注册')}> <Row
web/src/i18n/locales/en.json+27 −1 modified@@ -2084,5 +2084,31 @@ "原价": "Original price", "优惠": "Discount", "折": "% off", - "节省": "Save" + "节省": "Save", + "代理设置": "Proxy Settings", + "更新Worker设置": "Update Worker Settings", + "SSRF防护设置": "SSRF Protection Settings", + "配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全": "Configure Server-Side Request Forgery (SSRF) protection to secure internal network resources", + "SSRF防护详细说明": "SSRF protection prevents malicious users from using your server to access internal network resources. Configure whitelists for trusted domains/IPs and restrict allowed ports. Applies to file downloads, webhooks, and notifications.", + "启用SSRF防护(推荐开启以保护服务器安全)": "Enable SSRF Protection (Recommended for server security)", + "SSRF防护开关详细说明": "Master switch controls whether SSRF protection is enabled. When disabled, all SSRF checks are bypassed, allowing access to any URL. ⚠️ Only disable this feature in completely trusted environments.", + "允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)": "Allow access to private IP addresses (127.0.0.1, 192.168.x.x and other internal addresses)", + "私有IP访问详细说明": "⚠️ Security Warning: Enabling this allows access to internal network resources (localhost, private networks). Only enable if you need to access internal services and understand the security implications.", + "域名白名单": "Domain Whitelist", + "支持通配符格式,如: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", + "支持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", + "允许的端口": "Allowed Ports", + "支持单个端口和端口范围,如: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", + "对域名启用 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" }
web/src/i18n/locales/zh.json+24 −1 modified@@ -9,5 +9,28 @@ "语言": "语言", "展开侧边栏": "展开侧边栏", "关闭侧边栏": "关闭侧边栏", - "注销成功!": "注销成功!" + "注销成功!": "注销成功!", + "代理设置": "代理设置", + "更新Worker设置": "更新Worker设置", + "SSRF防护设置": "SSRF防护设置", + "配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全": "配置服务器端请求伪造(SSRF)防护,用于保护内网资源安全", + "SSRF防护详细说明": "SSRF防护可防止恶意用户利用您的服务器访问内网资源。您可以配置受信任域名/IP的白名单,并限制允许的端口。适用于文件下载、Webhook回调和通知功能。", + "启用SSRF防护(推荐开启以保护服务器安全)": "启用SSRF防护(推荐开启以保护服务器安全)", + "SSRF防护开关详细说明": "总开关控制是否启用SSRF防护功能。关闭后将跳过所有SSRF检查,允许访问任意URL。⚠️ 仅在完全信任环境中关闭此功能。", + "允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)": "允许访问私有IP地址(127.0.0.1、192.168.x.x等内网地址)", + "私有IP访问详细说明": "⚠️ 安全警告:启用此选项将允许访问内网资源(本地主机、私有网络)。仅在需要访问内部服务且了解安全风险的情况下启用。", + "域名白名单": "域名白名单", + "支持通配符格式,如:example.com, *.api.example.com": "支持通配符格式,如:example.com, *.api.example.com", + "域名白名单详细说明": "白名单中的域名将绕过所有SSRF检查,直接允许访问。支持精确域名(example.com)或通配符(*.api.example.com)匹配子域名。白名单为空时,所有域名都需要通过SSRF检查。", + "输入域名后回车,如:example.com": "输入域名后回车,如:example.com", + "IP白名单": "IP白名单", + "支持CIDR格式,如:8.8.8.8, 192.168.1.0/24": "支持CIDR格式,如:8.8.8.8, 192.168.1.0/24", + "IP白名单详细说明": "控制允许访问的IP地址。支持单个IP(8.8.8.8)或CIDR网段(192.168.1.0/24)。空白名单允许所有IP(但仍受私有IP设置限制),非空白名单仅允许列表中的IP访问。", + "输入IP地址后回车,如:8.8.8.8": "输入IP地址后回车,如:8.8.8.8", + "允许的端口": "允许的端口", + "支持单个端口和端口范围,如:80, 443, 8000-8999": "支持单个端口和端口范围,如:80, 443, 8000-8999", + "端口配置详细说明": "限制外部请求只能访问指定端口。支持单个端口(80, 443)或端口范围(8000-8999)。空列表允许所有端口。默认包含常用Web端口。", + "输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999", + "更新SSRF防护设置": "更新SSRF防护设置", + "域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。" }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.