Skip to content

🛡️ Sentinel: Fix SSRF via DNS resolution in URL validation#72

Closed
google-labs-jules[bot] wants to merge 11 commits intomainfrom
sentinel-ssrf-dns-resolution-5407761294063338854
Closed

🛡️ Sentinel: Fix SSRF via DNS resolution in URL validation#72
google-labs-jules[bot] wants to merge 11 commits intomainfrom
sentinel-ssrf-dns-resolution-5407761294063338854

Conversation

@google-labs-jules
Copy link

Implemented DNS resolution check in validate_folder_url to prevent SSRF via domain names resolving to private IPs.


PR created automatically by Jules for task 5407761294063338854 started by @abhimehro

Modified `validate_folder_url` in `main.py` to resolve domain names using `socket.getaddrinfo`. This prevents SSRF attacks where a malicious domain resolves to a private IP address (e.g., localhost). The check now verifies that all resolved IPs are not private or loopback addresses before allowing the URL. Fail-closed behavior is implemented for DNS resolution failures.

Security Journal updated with this finding.
@google-labs-jules
Copy link
Author

👋 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 @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@abhimehro abhimehro marked this pull request as ready for review January 8, 2026 23:59
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an SSRF vulnerability in URL validation by adding DNS resolution checks to prevent attackers from using domain names that resolve to private IP addresses to bypass security controls.

Key Changes:

  • Added DNS resolution using socket.getaddrinfo() to validate that domain names don't resolve to private/loopback IPs
  • Updated exception handling to treat DNS resolution failures as unsafe (fail-closed approach)
  • Documented the vulnerability and prevention approach in the Sentinel security log

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
main.py Added socket import and DNS resolution logic in validate_folder_url to check if domains resolve to private/loopback IPs
.jules/sentinel.md Documented the SSRF via DNS resolution vulnerability, learning, and prevention measures

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

main.py Outdated
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:
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exception handling only catches socket.gaierror, but socket.getaddrinfo() can also raise other exceptions such as OSError (for network issues) or socket.timeout. While these would be caught by the outer exception handler at line 227, they would result in a generic error message rather than being treated as DNS resolution failures. Consider catching (socket.gaierror, OSError) for more comprehensive error handling.

Suggested change
except socket.gaierror:
except (socket.gaierror, OSError):

Copilot uses AI. Check for mistakes.
main.py Outdated
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:
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The security check is incomplete. In addition to is_private and is_loopback, you should also check for other dangerous IP ranges:

  • is_link_local: Covers IPv6 link-local addresses (fe80::/10) and IPv4 link-local (169.254.0.0/16)
  • is_reserved: Includes reserved IP ranges that shouldn't be accessed
  • is_multicast: Multicast addresses could potentially be abused

Consider updating the condition to: if resolved_ip.is_private or resolved_ip.is_loopback or resolved_ip.is_link_local or resolved_ip.is_reserved or resolved_ip.is_multicast:

Copilot uses AI. Check for mistakes.
Comment on lines +214 to +222
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
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation has a Time-Of-Check-Time-Of-Use (TOCTOU) vulnerability. DNS resolution happens during validation, but the actual HTTP request occurs later. An attacker could exploit DNS rebinding by having their DNS server return a safe IP during validation and then a private IP when the actual request is made moments later. While this is a known limitation of DNS-based SSRF protection, consider documenting this limitation and potentially adding a note about DNS caching or re-validation closer to the actual request time if feasible.

Copilot uses AI. Check for mistakes.
main.py Outdated
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)
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For IPv6 addresses with zone IDs (e.g., fe80::1%eth0), the sockaddr[0] extraction might include the zone identifier, which could cause ipaddress.ip_address() to raise a ValueError. While this would be caught by the outer exception handler, it would result in an unclear error message. Consider stripping the zone identifier if present by using ip_str.split('%')[0] before passing to ipaddress.ip_address().

Suggested change
resolved_ip = ipaddress.ip_address(ip_str)
# Strip IPv6 zone identifier (e.g., "%eth0") if present
ip_no_zone = ip_str.split('%', 1)[0]
resolved_ip = ipaddress.ip_address(ip_no_zone)

Copilot uses AI. Check for mistakes.
@github-actions github-actions bot added the python label Jan 9, 2026
@abhimehro
Copy link
Owner

Hi @copilot, please open a new pull request to apply changes based on the comments in this thread. Thank you!

Copy link

Copilot AI commented Jan 9, 2026

@abhimehro I've opened a new pull request, #85, to work on those changes. Once the pull request is ready, I'll request review from you.

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)

Unused variable 'family' (unused-variable)
main.py Outdated
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)

Use lazy % formatting in logging functions (logging-fstring-interpolation)
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)

Use lazy % formatting in logging functions (logging-fstring-interpolation)
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.

[no-undefined-references] Found reference to undefined definition
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.

[no-shortcut-reference-link] Use the trailing `[]` on reference links
main.py Outdated
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)

Line too long (159/100)
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)

Line too long (108/100)
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'

Unused variable 'canonname'
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'

Unused variable 'proto'
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'

Unused variable 'kind'
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'

Unused variable 'family'
main.py Outdated
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)

Line too long (159/100)
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)

Line too long (108/100)
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'

Unused variable 'canonname'
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'

Unused variable 'kind'
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'

Unused variable 'proto'
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'

Unused variable 'family'
main.py Outdated
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

Use lazy % formatting in logging functions
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

Use lazy % formatting in logging functions
@google-labs-jules
Copy link
Author

Hi @copilot, please open a new pull request to apply changes based on the comments in this thread. Thank you!

I have implemented the requested changes from the review thread:

  1. Added comprehensive IP checks (link-local, reserved, multicast).
  2. Added OSError to the exception handling for DNS resolution.
  3. Implemented IPv6 zone identifier stripping.
  4. Added a comment acknowledging the TOCTOU limitation.

google-labs-jules bot and others added 3 commits January 9, 2026 00:14
Modified `validate_folder_url` in `main.py` to resolve domain names using `socket.getaddrinfo`. This prevents SSRF attacks where a malicious domain resolves to a private or restricted IP address.

Key changes:
- Added `socket` import and DNS resolution logic.
- Validates resolved IPs against private, loopback, link-local, reserved, and multicast ranges.
- Implemented secure fail-closed behavior for DNS resolution errors (`socket.gaierror`, `OSError`).
- Added handling for IPv6 zone identifiers.
- Added code comment acknowledging TOCTOU limitations (DNS rebinding).
- Updated security journal.
- Enhanced exception handling to catch OSError in addition to socket.gaierror
- Added comprehensive IP range checks (link_local, reserved, multicast)
- Strip IPv6 zone identifiers before validation
- Document TOCTOU vulnerability limitation in code comments and sentinel.md

Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com>
Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com>
@github-actions github-actions bot added documentation Improvements or additions to documentation configuration labels Jan 9, 2026
@abhimehro abhimehro requested review from Copilot and removed request for abhimehro January 9, 2026 00:18
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +218 to +234
addr_infos = socket.getaddrinfo(hostname, None)
for family, kind, proto, canonname, sockaddr in addr_infos:
ip_str = sockaddr[0]

# Strip IPv6 zone identifier if present (e.g., fe80::1%eth0)
if '%' in ip_str:
ip_str = ip_str.split('%', 1)[0]

resolved_ip = ipaddress.ip_address(ip_str)

if (resolved_ip.is_private or
resolved_ip.is_loopback or
resolved_ip.is_link_local or
resolved_ip.is_reserved or
resolved_ip.is_multicast):
log.warning(f"Skipping unsafe URL (domain {sanitize_for_log(hostname)} resolves to restricted IP {resolved_ip}): {sanitize_for_log(url)}")
return False
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If socket.getaddrinfo returns an empty list (which is theoretically possible in edge cases), the loop will not execute and the function will proceed to return True at line 243, allowing the potentially unsafe URL. While this scenario is unlikely in practice, consider adding an explicit check after the getaddrinfo call to ensure at least one address was resolved, or track whether any iteration occurred to ensure the validation actually ran.

Copilot uses AI. Check for mistakes.
main.py Outdated
if '%' in ip_str:
ip_str = ip_str.split('%', 1)[0]

resolved_ip = ipaddress.ip_address(ip_str)
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exception handling for ipaddress.ip_address(ip_str) on line 226 is not explicitly caught. If the resolved IP address string is malformed or invalid, this could raise a ValueError that would be caught by the outer exception handler on line 239, but it would not be logged with a specific message. Consider adding explicit exception handling around the ipaddress.ip_address() call to log parsing errors more clearly, or ensure that any IP string format issues from socket.getaddrinfo are properly handled.

Suggested change
resolved_ip = ipaddress.ip_address(ip_str)
try:
resolved_ip = ipaddress.ip_address(ip_str)
except ValueError as ve:
log.warning(
f"Failed to parse resolved IP {sanitize_for_log(ip_str)} for hostname "
f"{sanitize_for_log(hostname)}: {ve}. Treating URL as unsafe."
)
return False

Copilot uses AI. Check for mistakes.
Enhance SSRF DNS validation with comprehensive IP checks and IPv6 support
@abhimehro abhimehro enabled auto-merge January 9, 2026 00:36
@abhimehro abhimehro disabled auto-merge January 9, 2026 00:36
@abhimehro abhimehro enabled auto-merge (squash) January 9, 2026 00:36
@abhimehro abhimehro disabled auto-merge January 9, 2026 00:37
abhimehro and others added 2 commits January 8, 2026 18:37
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@abhimehro
Copy link
Owner

Superseded: Security improvements already in main.

@abhimehro abhimehro closed this Jan 18, 2026
@google-labs-jules
Copy link
Author

Superseded: Security improvements already in main.

Understood. Acknowledging that this work is now superseded and stopping work on this task.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

configuration documentation Improvements or additions to documentation python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants