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.
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user