diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 675973d3..8c6467a3 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -39,3 +39,10 @@ **Prevention:** 1. Parse URLs and check hostnames against `localhost` and private IP ranges using `ipaddress` module. 2. Enforce strict length limits on user inputs (e.g., profile IDs) to prevent resource exhaustion or buffer abuse. + +## 2025-01-27 - [SSRF Protection via DNS Resolution] +**Vulnerability:** The application's SSRF protection checked `hostname` strings for private IP literals but did not resolve domains. This allowed an attacker to bypass the check using a public domain that resolves to a private IP (e.g., `localtest.me` -> `127.0.0.1`). +**Learning:** Checking hostnames only as strings is insufficient for SSRF protection because of DNS rebinding and public domains pointing to private IPs. +**Prevention:** +1. Resolve domains using `socket.getaddrinfo` and check all returned IPs against private ranges. +2. Implement this check before making the connection. diff --git a/main.py b/main.py index e6aabc57..24f3bcb4 100644 --- a/main.py +++ b/main.py @@ -23,6 +23,7 @@ import concurrent.futures import threading import ipaddress +import socket from urllib.parse import urlparse from typing import Dict, List, Optional, Any, Set, Sequence @@ -209,8 +210,24 @@ def validate_folder_url(url: str) -> bool: log.warning(f"Skipping unsafe URL (private IP): {sanitize_for_log(url)}") return False except ValueError: - # Not an IP literal, it's a domain. - pass + # Not an IP literal, it's a domain. Resolve it to check for private IPs. + try: + # Resolve hostname to check if it points to private IPs + # socket.getaddrinfo returns a list of tuples. sockaddr is the 5th element. + # For AF_INET/AF_INET6, sockaddr[0] is the IP address string. + addr_infos = socket.getaddrinfo(hostname, None) + for family, type_, proto, canonname, sockaddr in addr_infos: + ip_str = sockaddr[0] + ip = ipaddress.ip_address(ip_str) + if ip.is_private or ip.is_loopback: + log.warning(f"Skipping unsafe URL (domain resolves to private IP {ip_str}): {sanitize_for_log(url)}") + return False + except (socket.gaierror, ValueError): + # If DNS resolution fails, or IP parsing fails, we pass. + # If it's a real DNS error, httpx will fail later. + pass + except Exception as e: + log.warning(f"DNS resolution check failed for {sanitize_for_log(url)}: {e}") except Exception as e: log.warning(f"Failed to validate URL {sanitize_for_log(url)}: {e}")