-
Notifications
You must be signed in to change notification settings - Fork 1
Sentinel: [HIGH] Fix SSRF via DNS resolution #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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] | ||||||
Check noticeCode scanning / Remark-lint (reported by Codacy) Warn when shortcut reference links are used. Note
[no-shortcut-reference-link] Use the trailing [] on reference links
|
||||||
| **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). | ||||||
|
||||||
| **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). |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -20,6 +20,7 @@ | |||||||||||||||||||||||||||||||||||||||
| import sys | ||||||||||||||||||||||||||||||||||||||||
| import time | ||||||||||||||||||||||||||||||||||||||||
| import re | ||||||||||||||||||||||||||||||||||||||||
| import socket | ||||||||||||||||||||||||||||||||||||||||
| import concurrent.futures | ||||||||||||||||||||||||||||||||||||||||
| import threading | ||||||||||||||||||||||||||||||||||||||||
| import ipaddress | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -209,8 +210,19 @@ | |||||||||||||||||||||||||||||||||||||||
| 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: | ||||||||||||||||||||||||||||||||||||||||
Check warningCode scanning / Prospector (reported by Codacy) Unused variable 'family' (unused-variable) Warning
Unused variable 'family' (unused-variable)
Check noticeCode scanning / Pylint (reported by Codacy) Unused variable 'proto' Note
Unused variable 'proto'
Check noticeCode scanning / Pylint (reported by Codacy) Unused variable 'kind' Note
Unused variable 'kind'
Check noticeCode scanning / Pylint (reported by Codacy) Unused variable 'family' Note
Unused variable 'family'
Check noticeCode scanning / Pylintpython3 (reported by Codacy) Unused variable 'kind' Note
Unused variable 'kind'
Check noticeCode scanning / Pylintpython3 (reported by Codacy) Unused variable 'family' Note
Unused variable 'family'
Check noticeCode scanning / Pylint (reported by Codacy) Unused variable 'canonname' Note
Unused variable 'canonname'
Check noticeCode scanning / Pylintpython3 (reported by Codacy) Unused variable 'proto' Note
Unused variable 'proto'
Check noticeCode scanning / Pylintpython3 (reported by Codacy) Unused variable 'canonname' Note
Unused variable 'canonname'
|
||||||||||||||||||||||||||||||||||||||||
| 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 warningCode scanning / Prospector (reported by Codacy) Use lazy % formatting in logging functions (logging-fstring-interpolation) Warning
Use lazy % formatting in logging functions (logging-fstring-interpolation)
Check warningCode scanning / Pylint (reported by Codacy) Line too long (159/100) Warning
Line too long (159/100)
Check warningCode scanning / Pylintpython3 (reported by Codacy) Line too long (159/100) Warning
Line too long (159/100)
Check noticeCode scanning / Pylintpython3 (reported by Codacy) Use lazy % formatting in logging functions Note
Use lazy % formatting in logging functions
|
||||||||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||||||||
| except socket.gaierror: | ||||||||||||||||||||||||||||||||||||||||
| log.warning(f"Could not resolve hostname {sanitize_for_log(hostname)}, treating as unsafe.") | ||||||||||||||||||||||||||||||||||||||||
Check warningCode scanning / Prospector (reported by Codacy) Use lazy % formatting in logging functions (logging-fstring-interpolation) Warning
Use lazy % formatting in logging functions (logging-fstring-interpolation)
Check warningCode scanning / Pylint (reported by Codacy) Line too long (108/100) Warning
Line too long (108/100)
Check warningCode scanning / Pylintpython3 (reported by Codacy) Line too long (108/100) Warning
Line too long (108/100)
Check noticeCode scanning / Pylintpython3 (reported by Codacy) Use lazy % formatting in logging functions Note
Use lazy % formatting in logging functions
Comment on lines
+223
to
+224
|
||||||||||||||||||||||||||||||||||||||||
| 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.") |
Copilot
AI
Jan 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 |
Check notice
Code scanning / Remark-lint (reported by Codacy)
Warn when references to undefined definitions are found. Note