Files
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

142 lines
3.8 KiB
Go

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
}