Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/plane/app/views/webhook/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def patch(self, request, slug, pk):
serializer = WebhookSerializer(
webhook,
data=request.data,
context={request: request},
context={"request": request},
partial=True,
fields=(
"id",
Expand Down
82 changes: 48 additions & 34 deletions apps/api/plane/authentication/adapter/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
import uuid
from io import BytesIO

import requests
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from plane.utils.url_security import pinned_fetch_following_redirects

# Django imports
from django.utils import timezone
Expand Down Expand Up @@ -146,48 +146,62 @@ def download_and_upload_avatar(self, avatar_url, user):

try:
headers = self.get_avatar_download_headers()
# Download the avatar image
response = requests.get(avatar_url, timeout=10, headers=headers)
response.raise_for_status()

# Check content length before downloading
content_length = response.headers.get("Content-Length")
max_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE
if content_length and int(content_length) > max_size:
return None
# Download the avatar image over an SSRF-safe client: the avatar URL
# comes from the OAuth provider's (attacker-influenceable) profile
# data, so it must not be allowed to reach internal addresses. The
# connection is pinned to the validated IP (defeats DNS rebinding)
# and every redirect hop is re-validated, so a public URL cannot
# bounce the fetch to an internal target — GHSA-cv9p-325g-wmv5 /
# GHSA-hx79-5pj5-qh42 (avatar hop).
# stream=True so the body is read incrementally and the size cap
# below actually bounds memory (without it, requests buffers the
# whole body before any check runs).
response, _ = pinned_fetch_following_redirects(
"GET", avatar_url, headers=headers, timeout=10, max_redirects=5, stream=True
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try:
response.raise_for_status()

# Get content type and determine file extension
content_type = response.headers.get("Content-Type", "image/jpeg")
extension_map = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
extension = extension_map.get(content_type)

if not extension:
return None
# Check content length before downloading
content_length = response.headers.get("Content-Length")
max_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE
if content_length and int(content_length) > max_size:
return None

# Download with size limit
chunks = []
total_size = 0
for chunk in response.iter_content(chunk_size=8192):
total_size += len(chunk)
if total_size > max_size:
# Get content type and determine file extension
content_type = response.headers.get("Content-Type", "image/jpeg")
extension_map = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
extension = extension_map.get(content_type)

if not extension:
return None
chunks.append(chunk)
content = b"".join(chunks)
file_size = len(content)

# Download with size limit
chunks = []
total_size = 0
for chunk in response.iter_content(chunk_size=8192):
total_size += len(chunk)
if total_size > max_size:
return None
chunks.append(chunk)
content = b"".join(chunks)
file_size = len(content)
finally:
response.close()

# Generate unique filename
filename = f"{uuid.uuid4().hex}-user-avatar.{extension}"

storage = S3Storage(request=self.request)

# Create file-like object
file_obj = BytesIO(response.content)
# Create file-like object from the size-bounded buffer
file_obj = BytesIO(content)
file_obj.seek(0)

# Upload using boto3 directly
Expand Down
39 changes: 28 additions & 11 deletions apps/api/plane/bgtasks/webhook_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
from plane.license.utils.instance_value import get_email_configuration
from plane.utils.email import generate_plain_text_from_html
from plane.utils.exception_logger import log_exception
from plane.utils.ip_address import validate_url
from plane.utils.url_security import pinned_fetch


SERIALIZER_MAPPER = {
Expand Down Expand Up @@ -307,22 +307,20 @@ def webhook_send_task(
return

try:
# Re-validate the webhook URL at send time to prevent DNS-rebinding attacks
validate_url(
# Resolve + validate the webhook URL and pin the connection to the
# validated IP. Pinning closes the DNS-rebinding TOCTOU (validating the
# name then letting requests re-resolve it lets an attacker swap in an
# internal IP between the two lookups). Redirects are never followed, so
# a 3xx Location cannot bounce the request to an internal address
# (GHSA-mq87-52pf-hm3h / cluster C).
response = pinned_fetch(
"POST",
webhook.url,
allowed_ips=settings.WEBHOOK_ALLOWED_IPS,
allowed_hosts=settings.WEBHOOK_ALLOWED_HOSTS,
)

# Send the webhook event
# allow_redirects=False prevents SSRF via 3xx hops to internal addresses
# bypassing the validate_url() check above (GHSA-mq87-52pf-hm3h).
response = requests.post(
webhook.url,
headers=headers,
json=payload,
timeout=30,
allow_redirects=False,
)

# Log the webhook request
Expand Down Expand Up @@ -366,6 +364,25 @@ def webhook_send_task(
return
raise requests.RequestException()

except ValueError as e:
# SSRF validation failure (blocked/internal target or unresolvable host).
# Not retryable — record it so the failure is visible to the admin, but
# do not raise (no Celery retry) and do not auto-deactivate (the cause
# may be transient DNS).
save_webhook_log(
webhook=webhook,
request_method=action,
request_headers=headers,
request_body=payload,
response_status=400,
response_headers="",
response_body=f"Webhook URL rejected: {e}",
retry_count=self.request.retries,
event_type=event,
)
logger.warning(f"Webhook {webhook.id} URL rejected: {e}")
return

except Exception as e:
log_exception(e)
return
Expand Down
55 changes: 22 additions & 33 deletions apps/api/plane/bgtasks/work_item_link_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from typing import Optional
from plane.db.models import IssueLink
from plane.utils.exception_logger import log_exception
from plane.utils.ip_address import is_blocked_ip
from plane.utils.url_security import pinned_fetch, pinned_fetch_following_redirects

logger = logging.getLogger("plane.worker")

Expand Down Expand Up @@ -50,16 +52,19 @@ def validate_url_ip(url: str) -> None:

try:
addr_info = socket.getaddrinfo(hostname, None)
except socket.gaierror:
except (socket.gaierror, UnicodeError):
# UnicodeError covers IDNA failures raised before the address lookup.
raise ValueError("Hostname could not be resolved")

if not addr_info:
raise ValueError("No IP addresses found for the hostname")

# Check every resolved IP against blocked ranges to prevent SSRF
# Check every resolved IP against blocked ranges to prevent SSRF. The
# actual fetch is pinned to the validated IP (see safe_get), so this acts
# as an early, fail-closed pre-filter.
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:
ip = ipaddress.ip_address(addr[4][0].split("%")[0])
if is_blocked_ip(ip):
raise ValueError("Access to private/internal networks is not allowed")


Expand All @@ -72,8 +77,9 @@ def safe_get(
timeout: int = 1,
) -> Tuple[requests.Response, str]:
"""
Perform a GET request that validates every redirect hop against private IPs.
Prevents SSRF by ensuring no redirect lands on a private/internal address.
Perform a GET request that resolves, validates and pins every hop to its
validated IP. Prevents SSRF via private/internal targets, DNS rebinding
(TOCTOU) and redirects that bounce to internal addresses.

Args:
url: The URL to fetch
Expand All @@ -85,32 +91,16 @@ def safe_get(

Raises:
ValueError: If any URL in the redirect chain points to a private IP
requests.RequestException: On network errors
RuntimeError: If max redirects exceeded
requests.RequestException: On network errors (incl. TooManyRedirects)
"""
validate_url_ip(url)

current_url = url
response = requests.get(
current_url, headers=headers, timeout=timeout, allow_redirects=False
return pinned_fetch_following_redirects(
"GET",
url,
headers=headers,
timeout=timeout,
max_redirects=MAX_REDIRECTS,
)

redirect_count = 0
while response.is_redirect:
if redirect_count >= MAX_REDIRECTS:
raise RuntimeError(f"Too many redirects for URL: {url}")
redirect_url = response.headers.get("Location")
if not redirect_url:
break
current_url = urljoin(current_url, redirect_url)
validate_url_ip(current_url)
redirect_count += 1
response = requests.get(
current_url, headers=headers, timeout=timeout, allow_redirects=False
)

return response, current_url


def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
"""
Expand Down Expand Up @@ -199,14 +189,13 @@ def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[s
parsed_url = urlparse(base_url)
fallback_url = f"{parsed_url.scheme}://{parsed_url.netloc}/favicon.ico"

# Check if fallback exists
# Check if fallback exists (pinned to the validated IP).
try:
validate_url_ip(fallback_url)
response = requests.head(fallback_url, timeout=2, allow_redirects=False)
response = pinned_fetch("HEAD", fallback_url, timeout=2)

if response.status_code == 200:
return fallback_url
except requests.RequestException as e:
except (requests.RequestException, ValueError) as e:
log_exception(e, warning=True)
return None

Expand Down
Loading
Loading