mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 15:43:13 +08:00
账户开关 images_url_to_b64_json 下载的图片 url 来自上游响应,是不可信输入。此前沿用 base_url 的 security.url_allowlist 策略,默认配置下只做格式校验,上游返回 localhost:8080 之类地址时网关会真的去请求内网。 - 下载 URL 主机为 localhost、回环、私网、链路本地、未指定地址的字面量时直接拒绝, 不受 security.url_allowlist 配置影响(urlvalidator 导出 IsBlockedHost) - 新增 ctx 标记 WithHTTPUpstreamPublicHostsOnly:共享上游客户端对带标记的请求无视配置 校验解析后 IP,并为其克隆客户端挂 redirectChecker,重定向保持跟随但每一跳都校验 - 下载内容改为只信 http.DetectContentType 嗅探结果且限 png/jpeg/webp/gif, 响应头不再作为依据 - 下载失败仍只记日志并保留原样;超时与端口策略不变
182 lines
5.0 KiB
Go
182 lines
5.0 KiB
Go
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
|
||
}
|
||
|
||
// IsBlockedHost 报告 host 是否为 localhost、*.localhost,或回环、私网、链路本地、未指定地址的字面量 IP。
|
||
// 只判断字面量,域名的解析结果由 ValidateResolvedIP 校验。
|
||
func IsBlockedHost(host string) bool {
|
||
return isBlockedHost(strings.ToLower(strings.TrimSpace(host)))
|
||
}
|
||
|
||
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
|
||
}
|