HIGH: SSRF protection has TOCTOU race condition — DNS resolved separately from HTTP request #37

Closed
opened 2026-05-09 05:19:06 +00:00 by Mark · 2 comments
Owner

Severity: High

Description

The SSRF protection in backend/app/security_utils.py has a time-of-check-time-of-use (TOCTOU) vulnerability. The validate_url() function (line 74) resolves the hostname to an IP using socket.gethostbyname(), but the actual HTTP request is made later by aiohttp or Playwright, which performs its own DNS resolution independently.

This means:

  1. validate_url() resolves example.com to 203.0.113.1 (a safe public IP) → validation passes
  2. Between validation and the actual request, the DNS record for example.com is changed to 10.0.0.1 (internal)
  3. The actual HTTP request goes to the internal IP — SSRF bypass
# security_utils.py:74 - TOCTOU window
try:
    ip = socket.gethostbyname(hostname)  # DNS lookup #1
except socket.gaierror:
    return False, f"Could not resolve hostname: {hostname}"

if is_private_ip(ip):
    if not allow_internal:
        return False, f"Internal IP not allowed: {ip}"
# ... later, aiohttp/playwright does its own DNS lookup #2

Affected modules

  • browser_scraper.py — uses validate_url then routes target to Playwright
  • employee_scraper.py — uses validate_url then makes aiohttp requests
  • web_scraper.py — same pattern
  • All modules using safe_url()

Impact

Bypass of SSRF protection, allowing access to internal services (Redis on 6379, PostgreSQL on 5432, and the internal LLM server on port 11434).

Recommendation

  1. Use aiohttp's connector with a custom DNS resolver that blocks private IPs
  2. Or validate the resolved IP after the connection is established, before sending data
  3. Add integration tests that specifically test SSRF scenarios with DNS rebinding
## Severity: High ## Description The SSRF protection in `backend/app/security_utils.py` has a time-of-check-time-of-use (TOCTOU) vulnerability. The `validate_url()` function (line 74) resolves the hostname to an IP using `socket.gethostbyname()`, but the actual HTTP request is made later by aiohttp or Playwright, which performs its own DNS resolution independently. This means: 1. `validate_url()` resolves `example.com` to `203.0.113.1` (a safe public IP) → validation passes 2. Between validation and the actual request, the DNS record for `example.com` is changed to `10.0.0.1` (internal) 3. The actual HTTP request goes to the internal IP — SSRF bypass ```python # security_utils.py:74 - TOCTOU window try: ip = socket.gethostbyname(hostname) # DNS lookup #1 except socket.gaierror: return False, f"Could not resolve hostname: {hostname}" if is_private_ip(ip): if not allow_internal: return False, f"Internal IP not allowed: {ip}" # ... later, aiohttp/playwright does its own DNS lookup #2 ``` ## Affected modules - `browser_scraper.py` — uses `validate_url` then routes target to Playwright - `employee_scraper.py` — uses `validate_url` then makes aiohttp requests - `web_scraper.py` — same pattern - All modules using `safe_url()` ## Impact Bypass of SSRF protection, allowing access to internal services (Redis on 6379, PostgreSQL on 5432, and the internal LLM server on port 11434). ## Recommendation 1. Use aiohttp's `connector` with a custom DNS resolver that blocks private IPs 2. Or validate the resolved IP **after** the connection is established, before sending data 3. Add integration tests that specifically test SSRF scenarios with DNS rebinding
Author
Owner

GSD IMPLEMENT phase made no changes.
Worker output:

GSD IMPLEMENT phase made no changes. Worker output:
Author
Owner

Fixed in commit. Closing.

Fixed in commit. Closing.
Mark closed this issue 2026-05-14 01:42:19 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Mark/osint-hub#37
No description provided.