Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
245 lines
6.4 KiB
Go
245 lines
6.4 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidURL = errors.New("invalid url")
|
|
ErrBlockedURL = errors.New("url host is not allowed")
|
|
ErrBlockedHost = errors.New("host is not allowed")
|
|
)
|
|
|
|
// ValidatePublicHTTPSURL checks logo / webhook / CTA URLs for SSRF.
|
|
// Allows http only for loopback hosts (local dev). Does not fetch the URL.
|
|
// In production (APP_ENV=production|prod), loopback hosts are rejected.
|
|
func ValidatePublicHTTPSURL(raw string) (string, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", nil
|
|
}
|
|
if len(raw) > 2048 {
|
|
return "", ErrInvalidURL
|
|
}
|
|
if !strings.Contains(raw, "://") {
|
|
raw = "https://" + raw
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Host == "" {
|
|
return "", ErrInvalidURL
|
|
}
|
|
scheme := strings.ToLower(u.Scheme)
|
|
if scheme != "https" && scheme != "http" {
|
|
return "", ErrInvalidURL
|
|
}
|
|
// Reject credentialed URLs (userinfo tricks / accidental secret leakage).
|
|
if u.User != nil {
|
|
return "", ErrInvalidURL
|
|
}
|
|
host := strings.ToLower(u.Hostname())
|
|
if host == "" {
|
|
return "", ErrInvalidURL
|
|
}
|
|
if host == "metadata.google.internal" || host == "metadata" ||
|
|
strings.HasSuffix(host, ".internal") || strings.HasSuffix(host, ".intranet") {
|
|
return "", ErrBlockedURL
|
|
}
|
|
allowLoopback := !isProductionAppEnv()
|
|
if err := AssertPublicHost(context.Background(), host, allowLoopback); err != nil {
|
|
return "", err
|
|
}
|
|
if scheme == "http" && !isLoopbackHost(host) {
|
|
return "", fmt.Errorf("%w: https required (http only allowed for localhost)", ErrInvalidURL)
|
|
}
|
|
u.Fragment = ""
|
|
return u.String(), nil
|
|
}
|
|
|
|
func isProductionAppEnv() bool {
|
|
e := strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV")))
|
|
return e == "production" || e == "prod"
|
|
}
|
|
|
|
// DialPolicy controls which non-public destinations SafeHTTP* may dial.
|
|
// Production callers should leave both flags false (fail-closed SSRF).
|
|
type DialPolicy struct {
|
|
AllowLoopback bool // localhost / 127.0.0.0/8 / ::1
|
|
AllowPrivate bool // RFC1918 / ULA only; never link-local, CGNAT, or metadata
|
|
}
|
|
|
|
// AssertPublicHost rejects private / link-local / metadata targets.
|
|
// allowLoopback permits localhost (SMTP Mailhog, local webhooks).
|
|
func AssertPublicHost(ctx context.Context, host string, allowLoopback bool) error {
|
|
return AssertHost(ctx, host, DialPolicy{AllowLoopback: allowLoopback})
|
|
}
|
|
|
|
// AssertHost rejects private / link-local / metadata targets per DialPolicy.
|
|
func AssertHost(ctx context.Context, host string, policy DialPolicy) error {
|
|
host = strings.ToLower(strings.TrimSpace(host))
|
|
if host == "" {
|
|
return ErrBlockedHost
|
|
}
|
|
if host == "metadata.google.internal" || host == "metadata" {
|
|
return ErrBlockedHost
|
|
}
|
|
if isLoopbackHost(host) {
|
|
if policy.AllowLoopback {
|
|
return nil
|
|
}
|
|
return ErrBlockedHost
|
|
}
|
|
ips, err := resolveHostIPs(ctx, host)
|
|
if err != nil {
|
|
if net.ParseIP(host) != nil {
|
|
return err
|
|
}
|
|
// Unresolvable non-literal host: reject fail-closed for dial targets.
|
|
return ErrBlockedHost
|
|
}
|
|
for _, ip := range ips {
|
|
if ip.IsLoopback() {
|
|
if !policy.AllowLoopback {
|
|
return ErrBlockedHost
|
|
}
|
|
continue
|
|
}
|
|
if isBlockedIP(ip) {
|
|
if policy.AllowPrivate && isPrivateLANIP(ip) {
|
|
continue
|
|
}
|
|
return ErrBlockedHost
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AssertDialableSMTPHost validates an SMTP hostname before dial / send.
|
|
func AssertDialableSMTPHost(ctx context.Context, host string) error {
|
|
return AssertPublicHost(ctx, host, true)
|
|
}
|
|
|
|
// ValidateShopifyShopDomain normalizes a Shopify Admin API shop hostname.
|
|
// Prefer shopify.NormalizeShopDomain in the Shopify package; this helper is the
|
|
// shared security entry point for callers outside that package.
|
|
func ValidateShopifyShopDomain(raw string) (string, error) {
|
|
raw = strings.TrimSpace(strings.ToLower(raw))
|
|
if raw == "" {
|
|
return "", ErrInvalidURL
|
|
}
|
|
if len(raw) > 255 {
|
|
return "", ErrInvalidURL
|
|
}
|
|
if strings.Contains(raw, "://") {
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Hostname() == "" {
|
|
return "", ErrInvalidURL
|
|
}
|
|
raw = u.Hostname()
|
|
}
|
|
raw = strings.TrimSuffix(raw, "/")
|
|
if i := strings.IndexAny(raw, "/?#"); i >= 0 {
|
|
raw = raw[:i]
|
|
}
|
|
if strings.Contains(raw, ":") {
|
|
host, _, err := net.SplitHostPort(raw)
|
|
if err != nil {
|
|
return "", ErrInvalidURL
|
|
}
|
|
raw = host
|
|
}
|
|
if net.ParseIP(raw) != nil {
|
|
return "", ErrBlockedURL
|
|
}
|
|
if !strings.HasSuffix(raw, ".myshopify.com") {
|
|
if strings.Contains(raw, ".") {
|
|
return "", fmt.Errorf("%w: shop must be *.myshopify.com", ErrBlockedURL)
|
|
}
|
|
raw = raw + ".myshopify.com"
|
|
}
|
|
shop := strings.TrimSuffix(raw, ".myshopify.com")
|
|
if shop == "" || strings.Contains(shop, ".") {
|
|
return "", ErrInvalidURL
|
|
}
|
|
for _, r := range shop {
|
|
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-'
|
|
if !ok {
|
|
return "", ErrInvalidURL
|
|
}
|
|
}
|
|
// Format-only; dial with SafeHTTPClient(allowLoopback=false) for runtime SSRF.
|
|
return raw, nil
|
|
}
|
|
|
|
func resolveHostIPs(ctx context.Context, host string) ([]net.IP, error) {
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
return []net.IP{ip}, nil
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
|
if err != nil {
|
|
return nil, ErrInvalidURL
|
|
}
|
|
out := make([]net.IP, 0, len(addrs))
|
|
for _, a := range addrs {
|
|
out = append(out, a.IP)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func isLoopbackHost(host string) bool {
|
|
if host == "localhost" {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(host)
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|
|
|
|
// isPrivateLANIP reports RFC1918 / ULA addresses that AllowPrivate may dial.
|
|
// Link-local (incl. cloud metadata 169.254.169.254) stays excluded.
|
|
func isPrivateLANIP(ip net.IP) bool {
|
|
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
|
return false
|
|
}
|
|
return ip.IsPrivate()
|
|
}
|
|
|
|
func isBlockedIP(ip net.IP) bool {
|
|
if ip.IsLoopback() {
|
|
return false // loopback gated by allowLoopback at host level
|
|
}
|
|
if ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
|
return true
|
|
}
|
|
if ip4 := ip.To4(); ip4 != nil {
|
|
if ip4[0] == 10 {
|
|
return true
|
|
}
|
|
if ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31 {
|
|
return true
|
|
}
|
|
if ip4[0] == 192 && ip4[1] == 168 {
|
|
return true
|
|
}
|
|
if ip4[0] == 169 && ip4[1] == 254 {
|
|
return true
|
|
}
|
|
if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
|
|
return true
|
|
}
|
|
} else if ip.IsPrivate() {
|
|
return true
|
|
}
|
|
return false
|
|
}
|