142 lines
3.8 KiB
Go
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
|
||
|
|
}
|