From eeff8bfbbafa79071fb716b3cf51ce7454db208c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:44:52 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[HIGH]=20Fi?= =?UTF-8?q?x=20SSRF=20bypass=20via=20DNS=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚨 Severity: HIGH 💡 Vulnerability: The `validate_folder_url` function checked for private IP literals but did not resolve domains. This allowed an attacker to bypass the check using a domain that resolves to a private IP (e.g., `localtest.me` -> `127.0.0.1`), enabling Server-Side Request Forgery (SSRF) against internal services. 🎯 Impact: An attacker could force the application to make HTTP requests to internal services running on localhost or private networks, potentially exposing sensitive data or interacting with internal APIs. 🔧 Fix: Updated `validate_folder_url` to resolve hostnames using `socket.getaddrinfo` and verify that none of the resolved IPs are private or loopback addresses. ✅ Verification: Verified using a reproduction script that `localtest.me` is now blocked, while legitimate URLs and IP checks still function correctly. --- .jules/sentinel.md | 7 +++++++ main.py | 21 +++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) 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}")