mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
fix(security): implement DNS pinning for ontology and fix SPARQL regex
This commit is contained in:
@@ -978,7 +978,27 @@ def _normalize_format(fmt: Optional[str]) -> str:
|
|||||||
return _FORMAT_ALIASES.get(lower, lower)
|
return _FORMAT_ALIASES.get(lower, lower)
|
||||||
|
|
||||||
|
|
||||||
def _validate_fetch_url(url: str) -> None:
|
import threading
|
||||||
|
import urllib3.util.connection
|
||||||
|
|
||||||
|
if not hasattr(urllib3.util.connection, "_orig_create_connection"):
|
||||||
|
urllib3.util.connection._orig_create_connection = urllib3.util.connection.create_connection
|
||||||
|
|
||||||
|
_dns_pin_tls = threading.local()
|
||||||
|
|
||||||
|
def _patched_create_connection(address, *args, **kwargs):
|
||||||
|
host, port = address
|
||||||
|
pinned_host = getattr(_dns_pin_tls, 'pinned_host', None)
|
||||||
|
pinned_ip = getattr(_dns_pin_tls, 'pinned_ip', None)
|
||||||
|
|
||||||
|
if pinned_host and pinned_ip and host == pinned_host:
|
||||||
|
return urllib3.util.connection._orig_create_connection((pinned_ip, port), *args, **kwargs)
|
||||||
|
return urllib3.util.connection._orig_create_connection(address, *args, **kwargs)
|
||||||
|
|
||||||
|
urllib3.util.connection.create_connection = _patched_create_connection
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_fetch_url(url: str) -> str:
|
||||||
"""Reject non-HTTP(S) schemes and private/loopback/link-local targets."""
|
"""Reject non-HTTP(S) schemes and private/loopback/link-local targets."""
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
if parsed.scheme not in ("http", "https"):
|
if parsed.scheme not in ("http", "https"):
|
||||||
@@ -990,6 +1010,7 @@ def _validate_fetch_url(url: str) -> None:
|
|||||||
addrinfos = socket.getaddrinfo(hostname, None)
|
addrinfos = socket.getaddrinfo(hostname, None)
|
||||||
except socket.gaierror as exc:
|
except socket.gaierror as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"Cannot resolve hostname '{hostname}': {exc}") from exc
|
raise HTTPException(status_code=422, detail=f"Cannot resolve hostname '{hostname}': {exc}") from exc
|
||||||
|
safe_ips = []
|
||||||
for _family, _type, _proto, _canonname, sockaddr in addrinfos:
|
for _family, _type, _proto, _canonname, sockaddr in addrinfos:
|
||||||
try:
|
try:
|
||||||
ip = ipaddress.ip_address(sockaddr[0])
|
ip = ipaddress.ip_address(sockaddr[0])
|
||||||
@@ -1000,22 +1021,32 @@ def _validate_fetch_url(url: str) -> None:
|
|||||||
status_code=422,
|
status_code=422,
|
||||||
detail="Fetching from private, loopback, or reserved network addresses is not allowed.",
|
detail="Fetching from private, loopback, or reserved network addresses is not allowed.",
|
||||||
)
|
)
|
||||||
|
safe_ips.append(str(ip))
|
||||||
|
if not safe_ips:
|
||||||
|
raise HTTPException(status_code=422, detail="Could not resolve to a valid IP address.")
|
||||||
|
return safe_ips[0]
|
||||||
|
|
||||||
|
|
||||||
def _fetch_url_sync(url: str) -> bytes:
|
def _fetch_url_sync(url: str) -> bytes:
|
||||||
_validate_fetch_url(url)
|
|
||||||
import requests as _req
|
import requests as _req
|
||||||
_MAX_REDIRECTS = 5
|
_MAX_REDIRECTS = 5
|
||||||
current_url = url
|
current_url = url
|
||||||
try:
|
try:
|
||||||
for _ in range(_MAX_REDIRECTS + 1):
|
for _ in range(_MAX_REDIRECTS + 1):
|
||||||
resp = _req.get(
|
safe_ip = _validate_fetch_url(current_url)
|
||||||
current_url,
|
_dns_pin_tls.pinned_host = urlparse(current_url).hostname
|
||||||
headers={"Accept": "text/turtle, application/rdf+xml, application/ld+json, */*;q=0.1"},
|
_dns_pin_tls.pinned_ip = safe_ip
|
||||||
timeout=30,
|
try:
|
||||||
stream=True,
|
resp = _req.get(
|
||||||
allow_redirects=False, # SECURITY: follow redirects manually
|
current_url,
|
||||||
)
|
headers={"Accept": "text/turtle, application/rdf+xml, application/ld+json, */*;q=0.1"},
|
||||||
|
timeout=30,
|
||||||
|
stream=True,
|
||||||
|
allow_redirects=False, # SECURITY: follow redirects manually
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_dns_pin_tls.pinned_host = None
|
||||||
|
_dns_pin_tls.pinned_ip = None
|
||||||
if resp.is_redirect or resp.is_permanent_redirect:
|
if resp.is_redirect or resp.is_permanent_redirect:
|
||||||
redirect_url = resp.headers.get("Location")
|
redirect_url = resp.headers.get("Location")
|
||||||
resp.close() # Release the streamed connection before following the redirect
|
resp.close() # Release the streamed connection before following the redirect
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ _FORBIDDEN_KEYWORDS = re.compile(
|
|||||||
|
|
||||||
# Matches SPARQL single-line comments (# ...) and PREFIX declarations
|
# Matches SPARQL single-line comments (# ...) and PREFIX declarations
|
||||||
_COMMENT_LINE = re.compile(r"#[^\n]*", re.MULTILINE)
|
_COMMENT_LINE = re.compile(r"#[^\n]*", re.MULTILINE)
|
||||||
_PREFIX_DECL = re.compile(r"^\s*(?:PREFIX|BASE)\s+\S+\s*<[^>]*>\s*", re.IGNORECASE | re.MULTILINE)
|
_PREFIX_DECL = re.compile(r"^\s*(?:PREFIX|BASE)\s+(?:\S+\s+)?<[^>]*>\s*", re.IGNORECASE | re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
def _is_read_only_query(query: str) -> bool:
|
def _is_read_only_query(query: str) -> bool:
|
||||||
@@ -51,10 +51,10 @@ def _is_read_only_query(query: str) -> bool:
|
|||||||
keywords anywhere in the body, preventing injection via embedded strings
|
keywords anywhere in the body, preventing injection via embedded strings
|
||||||
or multi-statement tricks.
|
or multi-statement tricks.
|
||||||
"""
|
"""
|
||||||
# 1. Remove single-line comments that could hide the real query type
|
# 1. Remove PREFIX/BASE declarations (do this first so # in URIs aren't mangled by comment stripping)
|
||||||
cleaned = _COMMENT_LINE.sub("", query)
|
cleaned = _PREFIX_DECL.sub("", query)
|
||||||
# 2. Remove PREFIX/BASE declarations
|
# 2. Remove single-line comments that could hide the real query type
|
||||||
cleaned = _PREFIX_DECL.sub("", cleaned)
|
cleaned = _COMMENT_LINE.sub("", cleaned)
|
||||||
# 3. Strip remaining whitespace
|
# 3. Strip remaining whitespace
|
||||||
cleaned = cleaned.strip()
|
cleaned = cleaned.strip()
|
||||||
|
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ _FORBIDDEN_KEYWORDS = re.compile(
|
|||||||
)
|
)
|
||||||
_COMMENT_LINE = re.compile(r"#[^\n]*", re.MULTILINE)
|
_COMMENT_LINE = re.compile(r"#[^\n]*", re.MULTILINE)
|
||||||
_PREFIX_DECL = re.compile(
|
_PREFIX_DECL = re.compile(
|
||||||
r"^\s*(?:PREFIX|BASE)\s+\S+\s*<[^>]*>\s*",
|
r"^\s*(?:PREFIX|BASE)\s+(?:\S+\s+)?<[^>]*>\s*",
|
||||||
re.IGNORECASE | re.MULTILINE,
|
re.IGNORECASE | re.MULTILINE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_read_only_query(query: str) -> bool:
|
def _is_read_only_query(query: str) -> bool:
|
||||||
cleaned = _COMMENT_LINE.sub("", query)
|
cleaned = _PREFIX_DECL.sub("", query)
|
||||||
cleaned = _PREFIX_DECL.sub("", cleaned)
|
cleaned = _COMMENT_LINE.sub("", cleaned)
|
||||||
cleaned = cleaned.strip()
|
cleaned = cleaned.strip()
|
||||||
if not _ALLOWED_QUERY_TYPES.match(cleaned):
|
if not _ALLOWED_QUERY_TYPES.match(cleaned):
|
||||||
return False
|
return False
|
||||||
|
|||||||
Reference in New Issue
Block a user