From 404674a24a5657008647cd33e3fba2366a82d3ac Mon Sep 17 00:00:00 2001 From: Safi Date: Mon, 6 Apr 2026 21:59:38 +0100 Subject: [PATCH] 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. --- graphify/export.py | 7 ++++++- graphify/security.py | 36 ++++++++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/graphify/export.py b/graphify/export.py index 92991793..124eeb81 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -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, diff --git a/graphify/security.py b/graphify/security.py index d23ad957..1b8ff846 100644 --- a/graphify/security.py +++ b/graphify/security.py @@ -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