package shopify import ( "errors" "fmt" "net" "net/url" "regexp" "strings" ) var ( ErrInvalidShopDomain = errors.New("invalid shop domain") ErrBlockedShopDomain = errors.New("shop domain host is not allowed") ) var shopNameRe = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$`) // NormalizeShopDomain validates a Shopify shop and returns host "name.myshopify.com". // Accepts "name", "name.myshopify.com", or https URLs. Admin API always uses myshopify.com // (custom storefront domains are rejected). Blocks IPs, localhost, and private ranges (SSRF). func NormalizeShopDomain(raw string) (string, error) { raw = strings.TrimSpace(strings.ToLower(raw)) if raw == "" { return "", ErrInvalidShopDomain } raw = strings.TrimPrefix(raw, "https://") raw = strings.TrimPrefix(raw, "http://") raw = strings.SplitN(raw, "/", 2)[0] raw = strings.SplitN(raw, "?", 2)[0] raw = strings.TrimSuffix(raw, ".") if strings.Contains(raw, ":") { host, _, err := net.SplitHostPort(raw) if err == nil { raw = host } } if ip := net.ParseIP(raw); ip != nil { return "", ErrBlockedShopDomain } if raw == "localhost" || raw == "metadata.google.internal" || raw == "metadata" { return "", ErrBlockedShopDomain } shop := raw if strings.HasSuffix(raw, ".myshopify.com") { shop = strings.TrimSuffix(raw, ".myshopify.com") } else if strings.Contains(raw, ".") { // Custom domains / arbitrary hosts are not valid Admin API bases. return "", fmt.Errorf("%w: use your *.myshopify.com shop name (not a custom domain)", ErrInvalidShopDomain) } shop = strings.TrimSpace(shop) if !shopNameRe.MatchString(shop) { return "", ErrInvalidShopDomain } host := shop + ".myshopify.com" ips, err := resolveHostIPs(host) if err != nil { // DNS may fail offline; still return canonical host for config save / dry-run. // Live TestConnection will fail closed on network errors. return host, nil } for _, ip := range ips { if !allowedPublicIP(ip) { return "", ErrBlockedShopDomain } } return host, nil } // AdminBaseURL builds https://{shop}.myshopify.com for Admin REST calls. func AdminBaseURL(shopDomain string) string { host := strings.TrimSpace(strings.ToLower(shopDomain)) host = strings.TrimPrefix(host, "https://") host = strings.TrimPrefix(host, "http://") host = strings.TrimRight(host, "/") return "https://" + host } func resolveHostIPs(host string) ([]net.IP, error) { if ip := net.ParseIP(host); ip != nil { return []net.IP{ip}, nil } addrs, err := net.LookupIP(host) if err != nil { return nil, ErrInvalidShopDomain } return addrs, nil } func allowedPublicIP(ip net.IP) bool { if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { return false } if ip4 := ip.To4(); ip4 != nil { if ip4[0] == 10 { return false } if ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31 { return false } if ip4[0] == 192 && ip4[1] == 168 { return false } if ip4[0] == 169 && ip4[1] == 254 { return false } if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 { return false } } else if ip.IsPrivate() { return false } return true } // GuardAdminURL ensures a request URL targets the expected shop host over https (SSRF). func GuardAdminURL(rawURL, expectedShopHost string) error { u, err := url.Parse(rawURL) if err != nil || u.Host == "" { return ErrInvalidShopDomain } if strings.ToLower(u.Scheme) != "https" { return ErrBlockedShopDomain } host := strings.ToLower(u.Hostname()) want := strings.ToLower(strings.TrimSpace(expectedShopHost)) want = strings.TrimPrefix(want, "https://") want = strings.TrimPrefix(want, "http://") if host != want { return ErrBlockedShopDomain } if ip := net.ParseIP(host); ip != nil { return ErrBlockedShopDomain } return nil }