Files

222 lines
7.9 KiB
Go

package kitutil
import (
"maps"
"net"
"net/url"
"regexp"
"slices"
"strings"
)
var (
// maskURLPattern stops at characters a URL cannot contain unescaped, such
// as double quotes and angle or curly brackets, so a URL quoted in an error
// keeps its delimiters. Apostrophes and parentheses are valid inside a URL;
// the caller trims them, like other punctuation, only when they trail it.
maskURLPattern = regexp.MustCompile(`(?:https?|wss?)://[^\s"<>{}]+`)
maskIPPattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
// maskBracketedIPv6Pattern matches the bracketed form Go network errors use
// for IPv6 addresses, as in "dial tcp [2001:db8::1]:443", with an optional
// zone; the caller confirms the content parses as an IP.
maskBracketedIPv6Pattern = regexp.MustCompile(`\[([0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*)(%[^\]\s]*)?\]`)
// maskApiKeyPattern matches patterns like 'api_key:xxx' or "api_key:xxx" to mask the API key value
maskApiKeyPattern = regexp.MustCompile(`(['"]?)api_key:([^\s'"]+)(['"]?)`)
)
// maskHostTail returns the tail parts of a domain/host that should be preserved.
// It keeps 2 parts for likely country-code TLDs (e.g., co.uk, com.cn), otherwise keeps only the TLD.
func maskHostTail(parts []string) []string {
if len(parts) < 2 {
return parts
}
lastPart := parts[len(parts)-1]
secondLastPart := parts[len(parts)-2]
if len(lastPart) == 2 && len(secondLastPart) <= 3 {
// Likely country code TLD like co.uk, com.cn
return []string{secondLastPart, lastPart}
}
return []string{lastPart}
}
// maskHostForURL collapses subdomains and keeps only masked prefix + preserved tail.
// Example: api.openai.com -> ***.com, sub.domain.co.uk -> ***.co.uk
func maskHostForURL(host string) string {
parts := strings.Split(host, ".")
if len(parts) < 2 {
return "***"
}
tail := maskHostTail(parts)
return "***." + strings.Join(tail, ".")
}
// maskHostForPlainDomain masks a plain domain and reflects subdomain depth with multiple ***.
// Example: openai.com -> ***.com, api.openai.com -> ***.***.com, sub.domain.co.uk -> ***.***.co.uk
func maskHostForPlainDomain(domain string) string {
parts := strings.Split(domain, ".")
if len(parts) < 2 {
return domain
}
tail := maskHostTail(parts)
numStars := max(len(parts)-len(tail), 1)
stars := strings.TrimSuffix(strings.Repeat("***.", numStars), ".")
return stars + "." + strings.Join(tail, ".")
}
// maskURLQuery keeps the parameter names of a raw query and masks the values.
// Example: key=secret&alt=sse -> ?alt=***&key=***
func maskURLQuery(rawQuery string) string {
if rawQuery == "" {
return ""
}
values, err := url.ParseQuery(rawQuery)
if err != nil {
// If can't parse query, just mask the whole query string
return "?***"
}
if len(values) == 0 {
return ""
}
maskedParams := make([]string, 0, len(values))
for _, key := range slices.Sorted(maps.Keys(values)) {
maskedParams = append(maskedParams, key+"=***")
}
return "?" + strings.Join(maskedParams, "&")
}
// MaskSensitiveInfo masks the upstream addresses in str and leaves every other
// URL and domain readable. upstreamURLs are the channel base URL and the URL
// the gateway actually requested, which differs for routes such as absolute
// Advanced Custom paths; empty entries are skipped. Credentials and IPv4
// addresses are masked regardless of the upstream: API key values, the query
// values and userinfo of every URL, which carry secrets such as client_secret
// or key on provider endpoints, and IPs, which in network errors include the
// gateway's own address.
// Example with upstream https://api.upstream.example.com:
// https://api.upstream.example.com/v1/chat?key=secret -> https://***.com/***/***?key=***
// lookup api.upstream.example.com -> lookup ***.***.***.com
// dial tcp 192.168.1.1:443 -> dial tcp ***.***.***.***:443
// https://docs.example.org/errors -> unchanged
// https://auth.example.org/token?client_secret=s -> https://auth.example.org/token?client_secret=***
func MaskSensitiveInfo(str string, upstreamURLs ...string) string {
// Mask API keys (e.g., "api_key:test-api-key-for-redaction" -> "api_key:***")
str = maskApiKeyPattern.ReplaceAllString(str, "${1}api_key:***${3}")
upstreamHosts := make([]string, 0, len(upstreamURLs))
for _, upstreamURL := range upstreamURLs {
upstreamURL = strings.TrimSpace(upstreamURL)
if upstreamURL == "" {
continue
}
if !strings.Contains(upstreamURL, "://") {
// A base URL saved without a scheme would otherwise parse as a path.
upstreamURL = "//" + upstreamURL
}
parsed, err := url.Parse(upstreamURL)
if err != nil {
continue
}
// A fully qualified host keeps its trailing root dot; errors may omit it.
if host := strings.TrimSuffix(parsed.Hostname(), "."); host != "" {
upstreamHosts = append(upstreamHosts, host)
}
}
// Mask URLs on an upstream host, and the query values of every other URL
str = maskURLPattern.ReplaceAllStringFunc(str, func(match string) string {
urlStr := match
for urlStr != "" {
last := urlStr[len(urlStr)-1]
if strings.IndexByte(".,;:!?'", last) < 0 && (last != ')' || strings.Count(urlStr, "(") >= strings.Count(urlStr, ")")) {
break
}
urlStr = urlStr[:len(urlStr)-1]
}
suffix := match[len(urlStr):]
u, err := url.Parse(urlStr)
if err != nil {
return match
}
host := strings.TrimSuffix(u.Hostname(), ".")
if !slices.ContainsFunc(upstreamHosts, func(upstreamHost string) bool { return strings.EqualFold(upstreamHost, host) }) {
if u.RawQuery == "" && u.User == nil {
return match
}
// Userinfo is a credential wherever the URL points.
if u.User != nil {
scheme, afterScheme, _ := strings.Cut(urlStr, "://")
authorityEnd := strings.IndexAny(afterScheme, "/?#")
if authorityEnd < 0 {
authorityEnd = len(afterScheme)
}
urlStr = scheme + "://***@" + afterScheme[strings.LastIndex(afterScheme[:authorityEnd], "@")+1:]
}
if u.RawQuery == "" {
return urlStr + suffix
}
beforeQuery, _, _ := strings.Cut(urlStr, "?")
fragment := ""
if u.Fragment != "" {
fragment = "#" + u.EscapedFragment()
}
return beforeQuery + maskURLQuery(u.RawQuery) + fragment + suffix
}
maskedHost := maskHostForURL(host)
if ip := net.ParseIP(host); ip != nil && ip.To4() != nil {
maskedHost = "***.***.***.***"
}
if port := u.Port(); port != "" {
maskedHost += ":" + port
}
result := u.Scheme + "://" + maskedHost
// Mask path
if u.Path != "" && u.Path != "/" {
pathParts := strings.Split(strings.Trim(u.Path, "/"), "/")
maskedPathParts := make([]string, len(pathParts))
for i := range pathParts {
if pathParts[i] != "" {
maskedPathParts[i] = "***"
}
}
if len(maskedPathParts) > 0 {
result += "/" + strings.Join(maskedPathParts, "/")
}
} else if u.Path == "/" {
result += "/"
}
return result + maskURLQuery(u.RawQuery) + suffix
})
// Mask IP addresses: in network errors they are the resolved upstream
// address or the gateway's own, so they are masked even without an
// upstream address, and an IP upstream host is covered too. IPv6 goes
// first so an embedded IPv4 such as [::ffff:10.0.0.1] still parses.
str = maskBracketedIPv6Pattern.ReplaceAllStringFunc(str, func(match string) string {
if net.ParseIP(maskBracketedIPv6Pattern.FindStringSubmatch(match)[1]) == nil {
return match
}
return "[***]"
})
str = maskIPPattern.ReplaceAllString(str, "***.***.***.***")
if len(upstreamHosts) == 0 {
return str
}
// Mask an upstream host where it appears without a scheme, as in
// "dial tcp: lookup api.example.com: no such host". Longer hosts go first
// so a subdomain upstream is masked whole before its parent base host.
slices.SortFunc(upstreamHosts, func(a, b string) int { return len(b) - len(a) })
for _, host := range upstreamHosts {
if net.ParseIP(host) != nil {
continue
}
hostPattern := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(host) + `\b`)
str = hostPattern.ReplaceAllStringFunc(str, maskHostForPlainDomain)
}
return str
}