Files
descrybe/apps/api/internal/security/ssrf.go
T

245 lines
6.4 KiB
Go
Raw Normal View History

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
}