Files
sub2api/backend/internal/util/urlvalidator/validator.go
yangjianbo d7011163b8 fix: 修复代码审核发现的安全和质量问题
安全修复(P0):
- 移除硬编码的 OAuth client_secret(Antigravity、Gemini CLI),
  改为通过环境变量注入(ANTIGRAVITY_OAUTH_CLIENT_SECRET、
  GEMINI_CLI_OAUTH_CLIENT_SECRET)
- 新增 logredact.RedactText() 对非结构化文本做敏感信息脱敏,
  覆盖 GOCSPX-*/AIza* 令牌和常见 key=value 模式
- 日志中不再打印 org_uuid、account_uuid、email_address 等敏感值

安全修复(P1):
- URL 验证增强:新增 ValidateHTTPURL 统一入口,支持 allowlist 和
  私网地址阻断(localhost/内网 IP)
- 代理回退安全:代理初始化失败时默认阻止直连回退,防止 IP 泄露,
  可通过 security.proxy_fallback.allow_direct_on_error 显式开启
- Gemini OAuth 配置校验:client_id 与 client_secret 必须同时
  设置或同时留空

其他改进:
- 新增 tools/secret_scan.py 密钥扫描工具和 Makefile secret-scan 目标
- 更新所有 docker-compose 和部署配置,传递 OAuth secret 环境变量
- google_one OAuth 类型使用固定 redirectURI,与 code_assist 对齐

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:58:13 +08:00

176 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package urlvalidator
import (
"context"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
)
type ValidationOptions struct {
AllowedHosts []string
RequireAllowlist bool
AllowPrivate bool
}
// ValidateHTTPURL validates an outbound HTTP/HTTPS URL.
//
// It provides a single validation entry point that supports:
// - scheme 校验https 或可选允许 http
// - 可选 allowlist支持 *.example.com 通配)
// - allow_private_hosts 策略(阻断 localhost/私网字面量 IP
//
// 注意DNS Rebinding 防护(解析后 IP 校验)应在实际发起请求时执行,避免 TOCTOU。
func ValidateHTTPURL(raw string, allowInsecureHTTP bool, opts ValidationOptions) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", errors.New("url is required")
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid url: %s", trimmed)
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" && (!allowInsecureHTTP || scheme != "http") {
return "", fmt.Errorf("invalid url scheme: %s", parsed.Scheme)
}
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
if host == "" {
return "", errors.New("invalid host")
}
if !opts.AllowPrivate && isBlockedHost(host) {
return "", fmt.Errorf("host is not allowed: %s", host)
}
if port := parsed.Port(); port != "" {
num, err := strconv.Atoi(port)
if err != nil || num <= 0 || num > 65535 {
return "", fmt.Errorf("invalid port: %s", port)
}
}
allowlist := normalizeAllowlist(opts.AllowedHosts)
if opts.RequireAllowlist && len(allowlist) == 0 {
return "", errors.New("allowlist is not configured")
}
if len(allowlist) > 0 && !isAllowedHost(host, allowlist) {
return "", fmt.Errorf("host is not allowed: %s", host)
}
parsed.Path = strings.TrimRight(parsed.Path, "/")
parsed.RawPath = ""
return strings.TrimRight(parsed.String(), "/"), nil
}
func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) {
// 最小格式校验:仅保证 URL 可解析且 scheme 合规,不做白名单/私网/SSRF 校验
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", errors.New("url is required")
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid url: %s", trimmed)
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" && (!allowInsecureHTTP || scheme != "http") {
return "", fmt.Errorf("invalid url scheme: %s", parsed.Scheme)
}
host := strings.TrimSpace(parsed.Hostname())
if host == "" {
return "", errors.New("invalid host")
}
if port := parsed.Port(); port != "" {
num, err := strconv.Atoi(port)
if err != nil || num <= 0 || num > 65535 {
return "", fmt.Errorf("invalid port: %s", port)
}
}
return strings.TrimRight(trimmed, "/"), nil
}
func ValidateHTTPSURL(raw string, opts ValidationOptions) (string, error) {
return ValidateHTTPURL(raw, false, opts)
}
// ValidateResolvedIP 验证 DNS 解析后的 IP 地址是否安全
// 用于防止 DNS Rebinding 攻击:在实际 HTTP 请求时调用此函数验证解析后的 IP
func ValidateResolvedIP(host string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return fmt.Errorf("dns resolution failed: %w", err)
}
for _, ip := range ips {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return fmt.Errorf("resolved ip %s is not allowed", ip.String())
}
}
return nil
}
func normalizeAllowlist(values []string) []string {
if len(values) == 0 {
return nil
}
normalized := make([]string, 0, len(values))
for _, v := range values {
entry := strings.ToLower(strings.TrimSpace(v))
if entry == "" {
continue
}
if host, _, err := net.SplitHostPort(entry); err == nil {
entry = host
}
normalized = append(normalized, entry)
}
return normalized
}
func isAllowedHost(host string, allowlist []string) bool {
for _, entry := range allowlist {
if entry == "" {
continue
}
if strings.HasPrefix(entry, "*.") {
suffix := strings.TrimPrefix(entry, "*.")
if host == suffix || strings.HasSuffix(host, "."+suffix) {
return true
}
continue
}
if host == entry {
return true
}
}
return false
}
func isBlockedHost(host string) bool {
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return true
}
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return true
}
}
return false
}