Sentinel: [HIGH] Fix SSRF via DNS resolution#75
Sentinel: [HIGH] Fix SSRF via DNS resolution#75google-labs-jules[bot] wants to merge 2 commits intomainfrom
Conversation
- Updated `validate_folder_url` in `main.py` to resolve domains using `socket.getaddrinfo`. - Added checks to reject URLs where the domain resolves to a private or loopback IP address. - Ensures defense-in-depth against SSRF attacks using domains pointing to internal services. - Updated Sentinel journal with the finding.
|
👋 Jules, reporting for duty! I'm here to lend a hand with this pull request. When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down. I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job! For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with New to Jules? Learn more at jules.google/docs. For security, I will only act on instructions from the user who triggered this task. |
| try: | ||
| # Resolve hostname to IPs | ||
| addr_infos = socket.getaddrinfo(hostname, None) | ||
| for family, kind, proto, canonname, sockaddr in addr_infos: |
Check warning
Code scanning / Prospector (reported by Codacy)
Unused variable 'family' (unused-variable) Warning
| 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)}") |
Check warning
Code scanning / Prospector (reported by Codacy)
Use lazy % formatting in logging functions (logging-fstring-interpolation) Warning
| 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.") |
Check warning
Code scanning / Prospector (reported by Codacy)
Use lazy % formatting in logging functions (logging-fstring-interpolation) Warning
| 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] |
Check notice
Code scanning / Remark-lint (reported by Codacy)
Warn when references to undefined definitions are found. Note
| 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] |
Check notice
Code scanning / Remark-lint (reported by Codacy)
Warn when shortcut reference links are used. Note
| 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)}") |
Check warning
Code scanning / Pylint (reported by Codacy)
Line too long (159/100) Warning
| 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.") |
Check warning
Code scanning / Pylint (reported by Codacy)
Line too long (108/100) Warning
| try: | ||
| # Resolve hostname to IPs | ||
| addr_infos = socket.getaddrinfo(hostname, None) | ||
| for family, kind, proto, canonname, sockaddr in addr_infos: |
Check notice
Code scanning / Pylint (reported by Codacy)
Unused variable 'proto' Note
| try: | ||
| # Resolve hostname to IPs | ||
| addr_infos = socket.getaddrinfo(hostname, None) | ||
| for family, kind, proto, canonname, sockaddr in addr_infos: |
Check notice
Code scanning / Pylint (reported by Codacy)
Unused variable 'kind' Note
| try: | ||
| # Resolve hostname to IPs | ||
| addr_infos = socket.getaddrinfo(hostname, None) | ||
| for family, kind, proto, canonname, sockaddr in addr_infos: |
Check notice
Code scanning / Pylint (reported by Codacy)
Unused variable 'family' Note
| try: | ||
| # Resolve hostname to IPs | ||
| addr_infos = socket.getaddrinfo(hostname, None) | ||
| for family, kind, proto, canonname, sockaddr in addr_infos: |
Check notice
Code scanning / Pylint (reported by Codacy)
Unused variable 'canonname' Note
| 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)}") |
Check warning
Code scanning / Pylintpython3 (reported by Codacy)
Line too long (159/100) Warning
| 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.") |
Check warning
Code scanning / Pylintpython3 (reported by Codacy)
Line too long (108/100) Warning
| try: | ||
| # Resolve hostname to IPs | ||
| addr_infos = socket.getaddrinfo(hostname, None) | ||
| for family, kind, proto, canonname, sockaddr in addr_infos: |
Check notice
Code scanning / Pylintpython3 (reported by Codacy)
Unused variable 'kind' Note
| try: | ||
| # Resolve hostname to IPs | ||
| addr_infos = socket.getaddrinfo(hostname, None) | ||
| for family, kind, proto, canonname, sockaddr in addr_infos: |
Check notice
Code scanning / Pylintpython3 (reported by Codacy)
Unused variable 'family' Note
| try: | ||
| # Resolve hostname to IPs | ||
| addr_infos = socket.getaddrinfo(hostname, None) | ||
| for family, kind, proto, canonname, sockaddr in addr_infos: |
Check notice
Code scanning / Pylintpython3 (reported by Codacy)
Unused variable 'proto' Note
| try: | ||
| # Resolve hostname to IPs | ||
| addr_infos = socket.getaddrinfo(hostname, None) | ||
| for family, kind, proto, canonname, sockaddr in addr_infos: |
Check notice
Code scanning / Pylintpython3 (reported by Codacy)
Unused variable 'canonname' Note
| 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)}") |
Check notice
Code scanning / Pylintpython3 (reported by Codacy)
Use lazy % formatting in logging functions Note
| 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.") |
Check notice
Code scanning / Pylintpython3 (reported by Codacy)
Use lazy % formatting in logging functions Note
There was a problem hiding this comment.
Pull request overview
This PR addresses a Server-Side Request Forgery (SSRF) vulnerability by adding DNS resolution to the URL validation logic. Previously, attackers could bypass private IP checks by using domain names that resolve to internal addresses.
Key Changes:
- Added DNS resolution using
socket.getaddrinfoto validate that domain names don't resolve to private or loopback IP addresses - Enhanced fail-closed behavior to reject URLs when DNS resolution fails
- Documented the vulnerability and fix in the security sentinel log
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| main.py | Added socket import and DNS resolution logic in validate_folder_url to check resolved IPs against private/loopback ranges |
| .jules/sentinel.md | Added security documentation entry describing the DNS resolution bypass vulnerability and prevention measures |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # 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) |
There was a problem hiding this comment.
The DNS resolution using socket.getaddrinfo is synchronous and blocking, which can cause performance issues. Each URL validation will block the calling thread while performing DNS lookups, which could significantly slow down the application, especially when processing multiple URLs. Consider adding a timeout parameter to prevent hanging on slow DNS servers, or document this performance characteristic if it's acceptable for the use case.
| 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: |
There was a problem hiding this comment.
The current implementation only checks is_private and is_loopback, but doesn't check for other potentially dangerous IP address categories. Consider also checking is_link_local (e.g., 169.254.0.0/16), is_multicast, and is_reserved to provide more comprehensive SSRF protection against access to special-use IP addresses.
| # 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 |
There was a problem hiding this comment.
This DNS resolution approach is vulnerable to Time-of-Check-Time-of-Use (TOCTOU) race conditions. An attacker could use DNS rebinding where the domain initially resolves to a safe IP during validation, but later resolves to a private IP when the actual request is made. While this fix improves security, be aware that it doesn't fully protect against sophisticated DNS rebinding attacks. Consider implementing additional protections such as DNS pinning (caching resolved IPs and using them for the request) or validating IPs at request time if httpx supports custom DNS resolution.
| # 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 | |
| # Not an IP literal; avoid DNS-based validation here to prevent | |
| # time-of-check vs time-of-use (TOCTOU) issues with DNS rebinding. | |
| # Additional enforcement should be done at request time (e.g. via | |
| # httpx transport / DNS configuration) if private IP access must be | |
| # restricted for resolved domains. | |
| pass |
|
|
||
| ## 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). |
There was a problem hiding this comment.
The term "DNS Rebinding/Pinning" is potentially confusing. DNS Rebinding is an attack technique, while DNS Pinning is a defense mechanism. Consider clarifying this to "DNS Rebinding attacks" for accuracy, as the context discusses how attackers can bypass validation by controlling DNS resolution.
| **Learning:** Validating hostnames without DNS resolution is insufficient for SSRF protection. Attackers can easily map public domains to internal IPs (DNS Rebinding/Pinning). | |
| **Learning:** Validating hostnames without DNS resolution is insufficient for SSRF protection. Attackers can easily map public domains to internal IPs (DNS rebinding attacks). |
| except socket.gaierror: | ||
| log.warning(f"Could not resolve hostname {sanitize_for_log(hostname)}, treating as unsafe.") |
There was a problem hiding this comment.
The socket.gaierror exception only catches DNS resolution failures, but socket.getaddrinfo can also raise other exceptions like socket.timeout or OSError. While the outer try-except block at line 227 will catch these, it's less specific and the error message won't accurately reflect a DNS resolution failure. Consider catching a broader set of socket-related exceptions (OSError, socket.timeout) alongside socket.gaierror for more specific error handling.
| except socket.gaierror: | |
| log.warning(f"Could not resolve hostname {sanitize_for_log(hostname)}, treating as unsafe.") | |
| except (socket.gaierror, socket.timeout, OSError) as exc: | |
| log.warning(f"Could not resolve hostname {sanitize_for_log(hostname)} (socket error: {exc}), treating as unsafe.") |
|
Superseded: Security improvements already in main. |
This PR addresses a Server-Side Request Forgery (SSRF) vulnerability where the application could be tricked into accessing internal services by providing a domain name that resolves to a private IP address (e.g.,
local.test->127.0.0.1).Previously, the validation only checked for explicit IP literals. The fix adds DNS resolution using
socket.getaddrinfoand verifies that none of the resolved IPs fall into private or loopback ranges. It fails closed if DNS resolution fails.This change enhances the security of the folder sync tool by preventing it from interacting with local network resources.
PR created automatically by Jules for task 7206426744808280296 started by @abhimehro