url_safety.go 2.0 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "net"
  6. "net/url"
  7. "strings"
  8. "time"
  9. )
  10. // SanitizeHTTPURL validates and normalizes an http(s) URL without resolving
  11. // DNS. Use SanitizePublicHTTPURL at the point of an outbound request.
  12. func SanitizeHTTPURL(raw string) (string, error) {
  13. raw = strings.TrimSpace(raw)
  14. if raw == "" {
  15. return "", nil
  16. }
  17. u, err := url.Parse(raw)
  18. if err != nil {
  19. return "", err
  20. }
  21. if u.Scheme != "http" && u.Scheme != "https" {
  22. return "", fmt.Errorf("unsupported URL scheme %q", u.Scheme)
  23. }
  24. if u.Host == "" || u.Hostname() == "" {
  25. return "", fmt.Errorf("URL host is required")
  26. }
  27. clean := &url.URL{
  28. Scheme: u.Scheme,
  29. Host: u.Host,
  30. Path: u.Path,
  31. RawPath: u.RawPath,
  32. RawQuery: u.RawQuery,
  33. Fragment: u.Fragment,
  34. }
  35. return clean.String(), nil
  36. }
  37. // SanitizePublicHTTPURL validates and normalizes an http(s) URL, then blocks
  38. // private/internal targets unless the caller explicitly allows them.
  39. func SanitizePublicHTTPURL(raw string, allowPrivate bool) (string, error) {
  40. clean, err := SanitizeHTTPURL(raw)
  41. if err != nil || clean == "" {
  42. return clean, err
  43. }
  44. if allowPrivate {
  45. return clean, nil
  46. }
  47. u, err := url.Parse(clean)
  48. if err != nil {
  49. return "", err
  50. }
  51. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  52. defer cancel()
  53. if err := rejectPrivateHost(ctx, u.Hostname()); err != nil {
  54. return "", err
  55. }
  56. return clean, nil
  57. }
  58. func rejectPrivateHost(ctx context.Context, hostname string) error {
  59. if ip := net.ParseIP(hostname); ip != nil {
  60. if isBlockedIP(ip) {
  61. return fmt.Errorf("blocked private/internal address %s", ip.String())
  62. }
  63. return nil
  64. }
  65. ips, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
  66. if err != nil {
  67. return fmt.Errorf("cannot resolve host %s: %w", hostname, err)
  68. }
  69. if len(ips) == 0 {
  70. return fmt.Errorf("host %s has no IP addresses", hostname)
  71. }
  72. for _, ipAddr := range ips {
  73. if isBlockedIP(ipAddr.IP) {
  74. return fmt.Errorf("host %s resolves to blocked private/internal address %s", hostname, ipAddr.IP.String())
  75. }
  76. }
  77. return nil
  78. }