A URL field that rejects URLs resolving to internal network addresses, preventing Server-Side Request Forgery (SSRF) attacks. Blocks loopback (127.0.0.0/8, ::1), RFC 1918 private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), link-local (169.254.0.0/16, fe80::/10), and oth
| 6 | |
| 7 | |
| 8 | class NoSSRFURLField(serializers.URLField): |
| 9 | """ |
| 10 | A URL field that rejects URLs resolving to internal network addresses, |
| 11 | preventing Server-Side Request Forgery (SSRF) attacks. |
| 12 | |
| 13 | Blocks loopback (127.0.0.0/8, ::1), RFC 1918 private ranges |
| 14 | (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), link-local |
| 15 | (169.254.0.0/16, fe80::/10), and other reserved/multicast ranges. |
| 16 | Hostnames are resolved to their IP address before checking. |
| 17 | """ |
| 18 | |
| 19 | default_error_messages = { |
| 20 | **serializers.URLField.default_error_messages, |
| 21 | "internal_address": ( |
| 22 | "Webhook URLs must not target internal or private network addresses." |
| 23 | ), |
| 24 | } |
| 25 | |
| 26 | def run_validators(self, value: str) -> None: |
| 27 | super().run_validators(value) |
| 28 | |
| 29 | hostname = urlparse(value).hostname or "" |
| 30 | |
| 31 | try: |
| 32 | ips = [ipaddress.ip_address(hostname)] |
| 33 | except ValueError: |
| 34 | # hostname is a name rather than a literal IP — resolve it. |
| 35 | try: |
| 36 | results = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC) |
| 37 | ips = [ |
| 38 | ipaddress.ip_address(str(r[4][0]).split("%")[0]) for r in results |
| 39 | ] |
| 40 | except socket.gaierror: |
| 41 | # Unresolvable hostname; leave it to the URL validator. |
| 42 | return |
| 43 | |
| 44 | for ip in ips: |
| 45 | if ( |
| 46 | ip.is_loopback |
| 47 | or ip.is_private |
| 48 | or ip.is_link_local |
| 49 | or ip.is_reserved |
| 50 | or ip.is_multicast |
| 51 | ): |
| 52 | self.fail("internal_address") |
no outgoing calls
searching dependent graphs…