security: SSRF private IP blocking and Neo4j Cypher injection fix

Two targeted security hardening changes:

1. SSRF: validate_url() now resolves hostnames and blocks private/reserved
   IP ranges (127.x, 10.x, 169.254.x, etc.) and cloud metadata endpoints.
   Prevents SSRF attacks in cloud environments where a URL like
   http://169.254.169.254/latest/meta-data/ could leak instance credentials.

2. Neo4j Cypher injection: Node labels (file_type) are now sanitized to
   alphanumeric + underscore before interpolation into Cypher queries.
   Previously, attacker-controlled file_type metadata could inject
   arbitrary Cypher in team/shared graph scenarios.

Both changes are zero-impact on normal UX - no new config, no new flags,
no behavioral changes for legitimate use.
This commit is contained in:
Safi
2026-04-06 21:59:38 +01:00
parent 934957cbfd
commit 404674a24a
2 changed files with 40 additions and 3 deletions
+6 -1
View File
@@ -832,6 +832,11 @@ def push_to_neo4j(
def _safe_rel(relation: str) -> str:
return re.sub(r"[^A-Z0-9_]", "_", relation.upper().replace(" ", "_").replace("-", "_")) or "RELATED_TO"
def _safe_label(label: str) -> str:
"""Sanitize a Neo4j node label to prevent Cypher injection."""
sanitized = re.sub(r"[^A-Za-z0-9_]", "", label)
return sanitized if sanitized else "Entity"
driver = GraphDatabase.driver(uri, auth=(user, password))
nodes_pushed = 0
edges_pushed = 0
@@ -843,7 +848,7 @@ def push_to_neo4j(
cid = node_community.get(node_id)
if cid is not None:
props["community"] = cid
ftype = data.get("file_type", "Entity").capitalize()
ftype = _safe_label(data.get("file_type", "Entity").capitalize())
session.run(
f"MERGE (n:{ftype} {{id: $id}}) SET n += $props",
id=node_id,
+34 -2
View File
@@ -8,20 +8,28 @@ import urllib.parse
import urllib.request
from pathlib import Path
import ipaddress
import socket
_ALLOWED_SCHEMES = {"http", "https"}
_MAX_FETCH_BYTES = 52_428_800 # 50 MB hard cap for binary downloads
_MAX_TEXT_BYTES = 10_485_760 # 10 MB hard cap for HTML / text
# AWS metadata, link-local, and common cloud metadata endpoints
_BLOCKED_HOSTS = {"metadata.google.internal", "metadata.google.com"}
# ---------------------------------------------------------------------------
# URL validation
# ---------------------------------------------------------------------------
def validate_url(url: str) -> str:
"""Raise ValueError if *url* is not http or https.
"""Raise ValueError if *url* is not http or https, or targets a private/internal IP.
Blocks file://, ftp://, data:, and any other scheme that could be used
for SSRF or local file access.
for SSRF or local file access. Also blocks requests to private/reserved
IP ranges (127.x, 10.x, 169.254.x, etc.) and cloud metadata endpoints
to prevent SSRF in cloud environments.
"""
parsed = urllib.parse.urlparse(url)
if parsed.scheme.lower() not in _ALLOWED_SCHEMES:
@@ -29,6 +37,30 @@ def validate_url(url: str) -> str:
f"Blocked URL scheme '{parsed.scheme}' - only http and https are allowed. "
f"Got: {url!r}"
)
hostname = parsed.hostname
if hostname:
# Block known cloud metadata hostnames
if hostname.lower() in _BLOCKED_HOSTS:
raise ValueError(
f"Blocked cloud metadata endpoint '{hostname}'. "
f"Got: {url!r}"
)
# Resolve hostname and block private/reserved IP ranges
try:
infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
for info in infos:
addr = info[4][0]
ip = ipaddress.ip_address(addr)
if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local:
raise ValueError(
f"Blocked private/internal IP {addr} (resolved from '{hostname}'). "
f"Got: {url!r}"
)
except socket.gaierror:
pass # DNS failure will surface later during fetch
return url