diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 675973d..8fdd88e 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -39,3 +39,11 @@ **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-24 - [SSRF via DNS Resolution] +**Vulnerability:** The `validate_folder_url` function performed checks on the initial hostname but did not resolve domains to their IP addresses. This allowed attackers to bypass private IP blocklists by using a custom domain (e.g., `local.test`) that resolves to a private IP (e.g., `127.0.0.1`), leading to SSRF. +**Learning:** Validating hostnames without DNS resolution is insufficient for SSRF protection. Attackers can easily map public domains to internal IPs (DNS Rebinding/Pinning). +**Prevention:** +1. Resolve domains to their IP addresses using `socket.getaddrinfo`. +2. Validate all resolved IPs against a blocklist of private/loopback ranges. +3. Fail closed if DNS resolution fails. diff --git a/main.py b/main.py index e6aabc5..9ccdfdb 100644 --- a/main.py +++ b/main.py @@ -20,6 +20,7 @@ import sys import time import re +import socket import concurrent.futures import threading import ipaddress @@ -209,8 +210,19 @@ 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 to check for private IPs. + try: + # Resolve hostname to IPs + addr_infos = socket.getaddrinfo(hostname, None) + for family, kind, proto, canonname, sockaddr in addr_infos: + ip_str = sockaddr[0] + resolved_ip = ipaddress.ip_address(ip_str) + if resolved_ip.is_private or resolved_ip.is_loopback: + log.warning(f"Skipping unsafe URL (domain {sanitize_for_log(hostname)} resolves to private IP {resolved_ip}): {sanitize_for_log(url)}") + return False + except socket.gaierror: + log.warning(f"Could not resolve hostname {sanitize_for_log(hostname)}, treating as unsafe.") + return False except Exception as e: log.warning(f"Failed to validate URL {sanitize_for_log(url)}: {e}")