Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

## 2024-05-22 - SSRF Protection: Link-Local Addresses and DNS Resolution
**Vulnerability:** The application's SSRF protection checked private IPs but missed link-local addresses (e.g., `169.254.169.254`), exposing cloud metadata. Also, it only validated the hostname string, allowing bypass via DNS rebinding or domains pointing to private IPs.
**Learning:** SSRF protection must verify the *resolved* IP address, not just the hostname string. Cloud environments require blocking link-local addresses (`169.254.0.0/16`) in addition to standard private ranges.
**Prevention:**
1. Use `socket.getaddrinfo` to resolve domains during validation.
2. Check resolved IPs against `is_private`, `is_loopback`, AND `is_link_local`.
3. Apply checks to both IP literals and resolved domain IPs.
24 changes: 20 additions & 4 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -205,12 +206,27 @@

try:
ip = ipaddress.ip_address(hostname)
if ip.is_private or ip.is_loopback:
log.warning(f"Skipping unsafe URL (private IP): {sanitize_for_log(url)}")
if ip.is_private or ip.is_loopback or ip.is_link_local:
log.warning(f"Skipping unsafe URL (private/unsafe IP): {sanitize_for_log(url)}")

Check warning

Code 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 notice

Code scanning / Pylintpython3 (reported by Codacy)

Use lazy % formatting in logging functions Note

Use lazy % formatting in logging functions
return False
except ValueError:
# Not an IP literal, it's a domain.
pass
# Not an IP literal, it's a domain. Resolve it.
try:
# Resolve hostname to check if it points to a private IP

Check notice

Code scanning / Pylint (reported by Codacy)

Catching too general exception Exception Note

Catching too general exception Exception
# We use getaddrinfo to support both IPv4 and IPv6
for _, _, _, _, sockaddr in socket.getaddrinfo(hostname, None):
ip_str = sockaddr[0]
ip = ipaddress.ip_address(ip_str)

Check warning

Code scanning / Pylint (reported by Codacy)

Variable name "ip" doesn't conform to snake_case naming style Warning

Variable name "ip" doesn't conform to snake_case naming style

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Variable name "ip" doesn't conform to snake_case naming style Warning

Variable name "ip" doesn't conform to snake_case naming style
# Block private, loopback, AND link-local (e.g. 169.254.x.x for cloud metadata)
if ip.is_private or ip.is_loopback or ip.is_link_local:
log.warning(f"Skipping unsafe URL (domain {hostname} resolves to unsafe IP {ip_str}): {sanitize_for_log(url)}")

Check warning

Code 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 warning

Code scanning / Pylint (reported by Codacy)

Line too long (135/100) Warning

Line too long (135/100)

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Line too long (135/100) Warning

Line too long (135/100)

Check notice

Code 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"Skipping URL (DNS resolution failed): {sanitize_for_log(url)}")

Check warning

Code 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 notice

Code scanning / Pylintpython3 (reported by Codacy)

Use lazy % formatting in logging functions Note

Use lazy % formatting in logging functions
return False
except Exception as e:

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Variable name "e" doesn't conform to snake_case naming style Warning

Variable name "e" doesn't conform to snake_case naming style
log.warning(f"Error resolving domain {sanitize_for_log(hostname)}: {e}")

Check warning

Code 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 notice

Code scanning / Pylintpython3 (reported by Codacy)

Use lazy % formatting in logging functions Note

Use lazy % formatting in logging functions
return False

except Exception as e:
log.warning(f"Failed to validate URL {sanitize_for_log(url)}: {e}")
Expand Down Expand Up @@ -323,7 +339,7 @@
}
except (httpx.HTTPError, KeyError) as e:
log.error(f"Failed to list existing folders: {sanitize_for_log(e)}")
return {}

Check warning

Code scanning / Pylint (reported by Codacy)

Variable name "e" doesn't conform to snake_case naming style Warning

Variable name "e" doesn't conform to snake_case naming style

def get_all_existing_rules(client: httpx.Client, profile_id: str) -> Set[str]:
all_rules = set()
Expand Down Expand Up @@ -663,7 +679,7 @@
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_folder = {
executor.submit(
_process_single_folder,

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Catching too general exception Exception Note

Catching too general exception Exception
folder_data,
profile_id,
existing_rules,
Expand Down
86 changes: 86 additions & 0 deletions tests/test_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import unittest

Check warning

Code scanning / Pylint (reported by Codacy)

Missing module docstring Warning test

Missing module docstring

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Missing module docstring Warning test

Missing module docstring
from unittest.mock import patch
import socket
import ipaddress

Check warning

Code scanning / Prospector (reported by Codacy)

Unused import ipaddress (unused-import) Warning test

Unused import ipaddress (unused-import)

Check notice

Code scanning / Pylint (reported by Codacy)

Unused import ipaddress Note test

Unused import ipaddress

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Unused import ipaddress Note test

Unused import ipaddress
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.

Import of 'ipaddress' is not used.

Suggested change
import ipaddress

Copilot uses AI. Check for mistakes.
import logging

# We import the module to test its logic.
# We need to be careful about side effects from top-level code in main.py.
# However, standard unittest discovery or running this file directly is the standard way.
# We will mock environmental variables if needed, but the main.py logic handles defaults.

# Import validate_folder_url from main
# Note: This will execute the top-level code in main.py.
import main

class TestSSRFProtection(unittest.TestCase):

Check warning

Code scanning / Pylint (reported by Codacy)

Missing class docstring Warning test

Missing class docstring

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Missing class docstring Warning test

Missing class docstring

@patch('socket.getaddrinfo')
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 mock decorator is patching 'socket.getaddrinfo' globally, but it should patch 'main.socket.getaddrinfo' instead. Since main.py imports socket and calls socket.getaddrinfo, the patch needs to target where it's used (in the main module) rather than the socket module itself. This applies to all test methods that use @patch('socket.getaddrinfo').

Copilot uses AI. Check for mistakes.
def test_domain_resolving_to_localhost(self, mock_getaddrinfo):

Check warning

Code scanning / Pylint (reported by Codacy)

Missing method docstring Warning test

Missing method docstring

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Missing function or method docstring Warning test

Missing function or method docstring
# Simulate localtest.me resolving to 127.0.0.1
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('127.0.0.1', 443))
]

url = "https://localtest.me/config.json"
is_valid = main.validate_folder_url(url)

self.assertFalse(is_valid, "Should reject domain resolving to localhost")

@patch('socket.getaddrinfo')
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 mock decorator is patching 'socket.getaddrinfo' globally, but it should patch 'main.socket.getaddrinfo' instead. Since main.py imports socket and calls socket.getaddrinfo, the patch needs to target where it's used (in the main module) rather than the socket module itself.

Copilot uses AI. Check for mistakes.
def test_domain_resolving_to_private_ip(self, mock_getaddrinfo):

Check warning

Code scanning / Pylint (reported by Codacy)

Missing method docstring Warning test

Missing method docstring

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Missing function or method docstring Warning test

Missing function or method docstring
# Simulate internal.corp resolving to 192.168.1.5
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('192.168.1.5', 443))
]

url = "https://internal.corp/secret.json"
is_valid = main.validate_folder_url(url)

self.assertFalse(is_valid, "Should reject domain resolving to private IP")

def test_ip_literal_link_local(self):

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Missing function or method docstring Warning test

Missing function or method docstring

Check warning

Code scanning / Pylint (reported by Codacy)

Missing method docstring Warning test

Missing method docstring
# Test explicit link-local IP (169.254.x.x)
# This hits the first check (IP literal), not the DNS resolution path.
url = "https://169.254.169.254/latest/meta-data/"
is_valid = main.validate_folder_url(url)
self.assertFalse(is_valid, "Should reject link-local IP literal")

@patch('socket.getaddrinfo')
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 mock decorator is patching 'socket.getaddrinfo' globally, but it should patch 'main.socket.getaddrinfo' instead. Since main.py imports socket and calls socket.getaddrinfo, the patch needs to target where it's used (in the main module) rather than the socket module itself.

Copilot uses AI. Check for mistakes.
def test_domain_resolving_to_link_local(self, mock_getaddrinfo):

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Missing function or method docstring Warning test

Missing function or method docstring

Check warning

Code scanning / Pylint (reported by Codacy)

Missing method docstring Warning test

Missing method docstring
# Simulate aws-metadata resolving to 169.254.169.254
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('169.254.169.254', 80))
]

url = "https://169.254.169.254.nip.io/latest/meta-data/"
is_valid = main.validate_folder_url(url)

self.assertFalse(is_valid, "Should reject domain resolving to link-local IP")

@patch('socket.getaddrinfo')
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 mock decorator is patching 'socket.getaddrinfo' globally, but it should patch 'main.socket.getaddrinfo' instead. Since main.py imports socket and calls socket.getaddrinfo, the patch needs to target where it's used (in the main module) rather than the socket module itself.

Copilot uses AI. Check for mistakes.
def test_domain_resolving_to_public_ip(self, mock_getaddrinfo):

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Missing function or method docstring Warning test

Missing function or method docstring

Check warning

Code scanning / Pylint (reported by Codacy)

Missing method docstring Warning test

Missing method docstring
# Simulate google.com resolving to 8.8.8.8 (public)
mock_getaddrinfo.return_value = [
(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('8.8.8.8', 443))
]

url = "https://google.com/config.json"
is_valid = main.validate_folder_url(url)

self.assertTrue(is_valid, "Should accept domain resolving to public IP")

@patch('socket.getaddrinfo')
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 mock decorator is patching 'socket.getaddrinfo' globally, but it should patch 'main.socket.getaddrinfo' instead. Since main.py imports socket and calls socket.getaddrinfo, the patch needs to target where it's used (in the main module) rather than the socket module itself.

Copilot uses AI. Check for mistakes.
def test_dns_resolution_failure(self, mock_getaddrinfo):

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Missing function or method docstring Warning test

Missing function or method docstring

Check warning

Code scanning / Pylint (reported by Codacy)

Missing method docstring Warning test

Missing method docstring
# Simulate DNS failure
mock_getaddrinfo.side_effect = socket.gaierror("Name or service not known")

url = "https://nonexistent.domain/config.json"
is_valid = main.validate_folder_url(url)

self.assertFalse(is_valid, "Should reject URL if DNS resolution fails")

if __name__ == '__main__':

Check warning

Code scanning / Prospector (reported by Codacy)

expected 2 blank lines after class or function definition, found 1 (E305) Warning test

expected 2 blank lines after class or function definition, found 1 (E305)
# Suppress logging during tests
logging.getLogger('control-d-sync').setLevel(logging.CRITICAL)
unittest.main()
Loading