diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 675973d3..fa7a8d0f 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -33,9 +33,9 @@ 1. Maintain a list of sensitive values (tokens, keys). 2. Ensure logging utilities check against this list and mask values before outputting. -## 2025-01-21 - [SSRF Protection and Input Limits] -**Vulnerability:** The `folder_url` validation checked for HTTPS but allowed internal IP addresses (e.g., `127.0.0.1`, `10.0.0.0/8`). This could theoretically allow Server-Side Request Forgery (SSRF) if the script is run in an environment with access to sensitive internal services. Additionally, `profile_id` had no length limit. -**Learning:** HTTPS validation alone is insufficient to prevent SSRF against internal services that might support HTTPS or use self-signed certs (if verification was disabled or bypassed). Explicitly blocking private IP ranges provides necessary defense-in-depth. +## 2025-01-21 - [SSRF Protection via DNS Resolution] +**Vulnerability:** The `folder_url` validation checked for private IP literals but missed domain names that resolve to private IPs (e.g., `localtest.me` -> `127.0.0.1`). This allowed SSRF attacks against internal services using public DNS names. +**Learning:** String-based hostname validation is insufficient for SSRF protection because DNS resolution can bypass it. **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. +1. Resolve hostnames to IP addresses using `socket.getaddrinfo`. +2. Check resolved IPs against private/loopback ranges using `ipaddress` before allowing the request. diff --git a/main.py b/main.py index e6aabc57..51ccd3b1 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 @@ -210,7 +211,26 @@ def validate_folder_url(url: str) -> bool: return False except ValueError: # Not an IP literal, it's a domain. - pass + # Resolve to check if it points to a private IP (prevent SSRF) + try: + # Use getaddrinfo to support both IPv4 and IPv6 + addr_infos = socket.getaddrinfo(hostname, None) + for family, type, proto, canonname, sockaddr in addr_infos: + ip_str = sockaddr[0] + # Remove IPv6 scope ID if present + if '%' in ip_str: + ip_str = ip_str.split('%')[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: + log.warning(f"Could not resolve hostname for validation: {sanitize_for_log(hostname)}") + return False + except Exception as e: + log.warning(f"Error during DNS resolution check: {e}") + return False except Exception as e: log.warning(f"Failed to validate URL {sanitize_for_log(url)}: {e}")