[SECUR-117] fix: update Ip address validation as common util function #6208
This commit is contained in:
@@ -10,8 +10,6 @@
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
# Python imports
|
||||
import socket
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Django imports
|
||||
@@ -24,87 +22,38 @@ from rest_framework import serializers
|
||||
from .base import DynamicBaseSerializer
|
||||
from plane.db.models import Webhook, WebhookLog
|
||||
from plane.db.models.webhook import validate_domain, validate_schema
|
||||
from plane.utils.ip_address import validate_url
|
||||
|
||||
|
||||
class WebhookSerializer(DynamicBaseSerializer):
|
||||
url = serializers.URLField(validators=[validate_schema, validate_domain])
|
||||
|
||||
def create(self, validated_data):
|
||||
url = validated_data.get("url", None)
|
||||
|
||||
# Extract the hostname from the URL
|
||||
hostname = urlparse(url).hostname
|
||||
if not hostname:
|
||||
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
|
||||
|
||||
# Resolve the hostname to IP addresses
|
||||
def _validate_webhook_url(self, url):
|
||||
"""Validate a webhook URL against SSRF and disallowed domain rules."""
|
||||
try:
|
||||
ip_addresses = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
|
||||
validate_url(url, block_private=not settings.IS_SELF_MANAGED)
|
||||
except ValueError as e:
|
||||
raise serializers.ValidationError({"url": str(e)})
|
||||
|
||||
if not ip_addresses:
|
||||
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
|
||||
|
||||
for addr in ip_addresses:
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_loopback:
|
||||
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
|
||||
|
||||
# if in cloud environment, private IP addresses are also not allowed
|
||||
if not settings.IS_SELF_MANAGED and ip.is_private:
|
||||
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
|
||||
|
||||
# Additional validation for multiple request domains and their subdomains
|
||||
hostname = urlparse(url).hostname
|
||||
request = self.context.get("request")
|
||||
disallowed_domains = ["plane.so"] # Add your disallowed domains here
|
||||
disallowed_domains = ["plane.so"]
|
||||
if request:
|
||||
request_host = request.get_host().split(":")[0] # Remove port if present
|
||||
request_host = request.get_host().split(":")[0]
|
||||
disallowed_domains.append(request_host)
|
||||
|
||||
# Check if hostname is a subdomain or exact match of any disallowed domain
|
||||
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
|
||||
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
|
||||
|
||||
def create(self, validated_data):
|
||||
url = validated_data.get("url", None)
|
||||
self._validate_webhook_url(url)
|
||||
return Webhook.objects.create(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
url = validated_data.get("url", None)
|
||||
if url:
|
||||
# Extract the hostname from the URL
|
||||
hostname = urlparse(url).hostname
|
||||
if not hostname:
|
||||
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
|
||||
|
||||
# Resolve the hostname to IP addresses
|
||||
try:
|
||||
ip_addresses = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
|
||||
|
||||
if not ip_addresses:
|
||||
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
|
||||
|
||||
for addr in ip_addresses:
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_loopback:
|
||||
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
|
||||
|
||||
# if in cloud environment, private IP addresses are also not allowed
|
||||
if not settings.IS_SELF_MANAGED and ip.is_private:
|
||||
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
|
||||
|
||||
# Additional validation for multiple request domains and their subdomains
|
||||
request = self.context.get("request")
|
||||
disallowed_domains = ["plane.so"] # Add your disallowed domains here
|
||||
if request:
|
||||
request_host = request.get_host().split(":")[0] # Remove port if present
|
||||
disallowed_domains.append(request_host)
|
||||
|
||||
# Check if hostname is a subdomain or exact match of any disallowed domain
|
||||
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
|
||||
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
|
||||
|
||||
self._validate_webhook_url(url)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -11,9 +11,7 @@
|
||||
|
||||
# Python imports
|
||||
import base64
|
||||
import ipaddress
|
||||
import logging
|
||||
import socket
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
@@ -24,6 +22,7 @@ from celery import shared_task
|
||||
|
||||
# Module imports
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.ip_address import validate_url
|
||||
from plane.utils.link_crawler import LinkCrawlerEntity, LinkCrawlerInput
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
@@ -33,40 +32,6 @@ DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoP
|
||||
MAX_REDIRECTS = 5
|
||||
|
||||
|
||||
def validate_url_ip(url: str) -> None:
|
||||
"""
|
||||
Validate that a URL doesn't point to a private/internal IP address.
|
||||
Resolves hostnames to IPs before checking.
|
||||
|
||||
Args:
|
||||
url: The URL to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If the URL points to a private/internal IP
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
|
||||
if not hostname:
|
||||
raise ValueError("Invalid URL: No hostname found")
|
||||
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")
|
||||
|
||||
try:
|
||||
addr_info = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
raise ValueError("Hostname could not be resolved")
|
||||
|
||||
if not addr_info:
|
||||
raise ValueError("No IP addresses found for the hostname")
|
||||
|
||||
for addr in addr_info:
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
||||
raise ValueError("Access to private/internal networks is not allowed")
|
||||
|
||||
|
||||
def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[str]:
|
||||
"""
|
||||
Find the favicon URL from HTML soup.
|
||||
@@ -91,7 +56,7 @@ def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[s
|
||||
if favicon_tag and favicon_tag.get("href"):
|
||||
favicon_href = urljoin(base_url, favicon_tag["href"])
|
||||
try:
|
||||
validate_url_ip(favicon_href)
|
||||
validate_url(favicon_href)
|
||||
except ValueError:
|
||||
continue
|
||||
return favicon_href
|
||||
@@ -100,7 +65,7 @@ def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[s
|
||||
fallback_url = f"{parsed_url.scheme}://{parsed_url.netloc}/favicon.ico"
|
||||
|
||||
try:
|
||||
validate_url_ip(fallback_url)
|
||||
validate_url(fallback_url)
|
||||
response = requests.head(fallback_url, timeout=2, allow_redirects=False)
|
||||
if response.status_code == 200:
|
||||
return fallback_url
|
||||
@@ -133,7 +98,7 @@ def fetch_and_encode_favicon(
|
||||
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
||||
}
|
||||
|
||||
validate_url_ip(favicon_url)
|
||||
validate_url(favicon_url)
|
||||
response = requests.get(favicon_url, headers=headers, timeout=1)
|
||||
|
||||
content_type = response.headers.get("content-type", "image/x-icon")
|
||||
@@ -172,7 +137,7 @@ def crawl_link_metadata(url: str) -> Dict[str, Any]:
|
||||
final_url = url
|
||||
|
||||
try:
|
||||
validate_url_ip(final_url)
|
||||
validate_url(final_url)
|
||||
except ValueError as e:
|
||||
logger.warning(f"URL validation failed for {url}: {e}")
|
||||
return {
|
||||
@@ -191,7 +156,7 @@ def crawl_link_metadata(url: str) -> Dict[str, Any]:
|
||||
if not redirect_url:
|
||||
break
|
||||
final_url = urljoin(final_url, redirect_url)
|
||||
validate_url_ip(final_url)
|
||||
validate_url(final_url)
|
||||
redirect_count += 1
|
||||
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
|
||||
|
||||
|
||||
@@ -10,11 +10,10 @@
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
# Python imports
|
||||
import socket
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third party imports
|
||||
@@ -25,6 +24,7 @@ from rest_framework.response import Response
|
||||
from plane.ee.permissions import WorkSpaceAdminPermission
|
||||
from plane.db.models import Workspace, Webhook
|
||||
from plane.ee.views.base import BaseAPIView
|
||||
from plane.utils.ip_address import validate_url
|
||||
|
||||
|
||||
class InternalWebhookEndpoint(BaseAPIView):
|
||||
@@ -41,56 +41,27 @@ class InternalWebhookEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Extract the hostname from the URL
|
||||
hostname = urlparse(url).hostname
|
||||
if not hostname:
|
||||
return Response(
|
||||
{"url": "Invalid URL: No hostname found."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Resolve the hostname to IP addresses
|
||||
# Validate URL against SSRF
|
||||
try:
|
||||
ip_addresses = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
validate_url(url, block_private=not settings.IS_SELF_MANAGED)
|
||||
except ValueError as e:
|
||||
return Response(
|
||||
{"url": "Hostname could not be resolved."},
|
||||
{"url": str(e)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not ip_addresses:
|
||||
return Response(
|
||||
{"url": "No IP addresses found for the hostname."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
for addr in ip_addresses:
|
||||
_ip = ipaddress.ip_address(addr[4][0])
|
||||
# if ip.is_loopback:
|
||||
# return Response(
|
||||
# {"url": "URL resolves to a blocked IP address."},
|
||||
# status=status.HTTP_400_BAD_REQUEST,
|
||||
# )
|
||||
|
||||
# if in cloud environment, private IP addresses are also not allowed
|
||||
# if not settings.IS_SELF_MANAGED and ip.is_private:
|
||||
# return Response(
|
||||
# {"url": "URL resolves to a blocked IP address."},
|
||||
# status=status.HTTP_400_BAD_REQUEST,
|
||||
# )
|
||||
|
||||
# Additional validation for multiple request domains and their subdomains
|
||||
disallowed_domains = ["plane.so"] # Add your disallowed domains here
|
||||
# Additional validation for disallowed domains
|
||||
hostname = urlparse(url).hostname
|
||||
disallowed_domains = ["plane.so"]
|
||||
if request:
|
||||
request_host = request.get_host().split(":")[0] # Remove port if present
|
||||
request_host = request.get_host().split(":")[0]
|
||||
disallowed_domains.append(request_host)
|
||||
|
||||
# Check if hostname is a subdomain or exact match of any disallowed domain
|
||||
# if any(
|
||||
# hostname == domain or hostname.endswith("." + domain)
|
||||
# for domain in disallowed_domains
|
||||
# ):
|
||||
# return Response({"url": "URL domain or its subdomain is not allowed."})
|
||||
if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains):
|
||||
return Response(
|
||||
{"url": "URL domain or its subdomain is not allowed."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
existing_webhooks = Webhook.objects.filter(workspace_id=workspace.id, url=url).first()
|
||||
|
||||
@@ -10,6 +10,49 @@
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
|
||||
# Python imports
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def validate_url(url, block_private=True):
|
||||
"""
|
||||
Validate that a URL doesn't resolve to a private/internal IP address (SSRF protection).
|
||||
|
||||
Args:
|
||||
url: The URL to validate.
|
||||
block_private: If True, also block private IPs. Set to False for self-managed
|
||||
deployments where private IPs may be legitimate webhook targets.
|
||||
|
||||
Raises:
|
||||
ValueError: If the URL is invalid or resolves to a blocked IP.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
|
||||
if not hostname:
|
||||
raise ValueError("Invalid URL: No hostname found")
|
||||
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")
|
||||
|
||||
try:
|
||||
addr_info = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror:
|
||||
raise ValueError("Hostname could not be resolved")
|
||||
|
||||
if not addr_info:
|
||||
raise ValueError("No IP addresses found for the hostname")
|
||||
|
||||
for addr in addr_info:
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
||||
raise ValueError("Access to private/internal networks is not allowed")
|
||||
if block_private and ip.is_private:
|
||||
raise ValueError("Access to private/internal networks is not allowed")
|
||||
|
||||
|
||||
def get_client_ip(request):
|
||||
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||
if x_forwarded_for:
|
||||
|
||||
Reference in New Issue
Block a user