summaryrefslogtreecommitdiff
path: root/services/worker/internal/fetcher/ssrf_protection.go
blob: d61f7cd084d24142097aba87382c9d4ae0e59dab (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
package fetcher

import (
	"context"
	"fmt"
	"net"
	"net/url"
	"strings"
	"time"
)

var reservedNetworks = []net.IPNet{
	{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)},
	{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)},
	{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)},
	{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)},
	{IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)},
	{IP: net.IPv4(0, 0, 0, 0), Mask: net.CIDRMask(8, 32)},
	{IP: net.ParseIP("::1"), Mask: net.CIDRMask(128, 128)},
	{IP: net.ParseIP("fc00::"), Mask: net.CIDRMask(7, 128)},
	{IP: net.ParseIP("fe80::"), Mask: net.CIDRMask(10, 128)},
}

func isReservedAddress(ipAddress net.IP) bool {
	normalizedIP := ipAddress

	if ipv4 := ipAddress.To4(); ipv4 != nil {
		normalizedIP = ipv4
	}

	for _, network := range reservedNetworks {
		if network.Contains(normalizedIP) {
			return true
		}
	}

	return false
}

func ValidateFeedURL(feedURL string) error {
	parsedURL, parseError := url.Parse(feedURL)

	if parseError != nil {
		return fmt.Errorf("invalid URL: %w", parseError)
	}

	scheme := strings.ToLower(parsedURL.Scheme)

	if scheme != "http" && scheme != "https" {
		return fmt.Errorf("unsupported scheme %q: only http and https are allowed", parsedURL.Scheme)
	}

	hostname := parsedURL.Hostname()

	if hostname == "" {
		return fmt.Errorf("URL has no hostname")
	}

	if parsedIP := net.ParseIP(hostname); parsedIP != nil {
		if isReservedAddress(parsedIP) {
			return fmt.Errorf("feed URL resolves to a reserved IP address")
		}

		return nil
	}

	resolverContext, cancelResolver := context.WithTimeout(context.Background(), 5*time.Second)

	defer cancelResolver()

	resolvedAddresses, lookupError := net.DefaultResolver.LookupIPAddr(resolverContext, hostname)

	if lookupError != nil {
		return fmt.Errorf("failed to resolve hostname %q: %w", hostname, lookupError)
	}

	for _, resolvedAddress := range resolvedAddresses {
		if isReservedAddress(resolvedAddress.IP) {
			return fmt.Errorf("feed URL resolves to a reserved IP address")
		}
	}

	return nil
}

func ValidateRedirectTarget(redirectURL string) error {
	return ValidateFeedURL(redirectURL)
}