From 924765b0426fec802763e3165989330200e1dc6b Mon Sep 17 00:00:00 2001 From: Sunil Date: Mon, 10 Aug 2026 21:51:27 +0530 Subject: [PATCH 01/22] security: fix XXE vulnerability in RDF/XML parser (CWE-611) --- semantica/explorer/utils/rdf_parser.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/semantica/explorer/utils/rdf_parser.py b/semantica/explorer/utils/rdf_parser.py index 39400156..e685fb1e 100644 --- a/semantica/explorer/utils/rdf_parser.py +++ b/semantica/explorer/utils/rdf_parser.py @@ -22,13 +22,34 @@ def _safe_parse_rdf(g: rdflib.Graph, data: bytes, rdf_format: str) -> None: import defusedxml defusedxml.defuse_stdlib() else: - # Warn once; best-effort protection via rdflib's own parser + # SECURITY: Strip DOCTYPE declarations and entity definitions from + # the raw XML to prevent XXE attacks (file disclosure, SSRF, DoS + # via entity expansion). This is a best-effort defence when + # defusedxml is not installed. + import re import warnings warnings.warn( "defusedxml is not installed. Install it (`pip install defusedxml`) " - "to protect RDF/XML parsing against XXE attacks.", + "for robust XXE protection. Applying basic DOCTYPE stripping as " + "a fallback.", stacklevel=4, ) + text = data.decode("utf-8", errors="replace") + # Remove blocks (including internal subsets) + text = re.sub( + r"\[]*(\[[^\]]*\])?\s*>", + "", + text, + flags=re.IGNORECASE | re.DOTALL, + ) + # Remove any remaining declarations + text = re.sub( + r"]*>", + "", + text, + flags=re.IGNORECASE, + ) + data = text.encode("utf-8") g.parse(data=data, format=rdf_format) def _get_best_label(graph: rdflib.Graph, subject: rdflib.URIRef, predicate: rdflib.URIRef) -> str: From 55f3ee6f84d87d648a4780a81df3c13833ad8baf Mon Sep 17 00:00:00 2001 From: Sunil Date: Mon, 10 Aug 2026 21:51:56 +0530 Subject: [PATCH 02/22] security: fix SPARQL DoS via unbounded graph materialization (CWE-770) --- semantica/explorer/routes/sparql.py | 32 +++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/semantica/explorer/routes/sparql.py b/semantica/explorer/routes/sparql.py index 0d7e2f77..acc825e5 100644 --- a/semantica/explorer/routes/sparql.py +++ b/semantica/explorer/routes/sparql.py @@ -8,7 +8,7 @@ Security contract * Multi-statement injections that start with an allowed keyword (e.g. ``SELECT ... ; DROP ALL``) pass the prefix check and reach rdflib, which rejects non-SELECT/ASK/CONSTRUCT/DESCRIBE update syntax in the parser. -* The in-memory rdflib graph is a read-only projection — the live +* The in-memory rdflib graph is a read-only projection — the live ``GraphSession`` is never mutated by this route. """ @@ -59,8 +59,27 @@ def _build_rdflib_graph(session: GraphSession) -> rdflib.Graph: graph.bind("ent", NS) graph.bind("prop", PROP) - nodes, _ = session.get_nodes(skip=0, limit=999_999) - edges, _ = session.get_edges(skip=0, limit=999_999) + # SECURITY: Cap the number of entities materialized into memory to + # prevent denial-of-service via memory exhaustion. Without this guard + # an attacker can send concurrent SPARQL queries that each load ~1M + # nodes/edges into rdflib Graph objects, consuming gigabytes of RAM. + nodes, total_nodes = session.get_nodes(skip=0, limit=_SPARQL_MAX_GRAPH_NODES + 1) + if len(nodes) > _SPARQL_MAX_GRAPH_NODES: + raise ValueError( + f"Graph has more than {_SPARQL_MAX_GRAPH_NODES:,} nodes. " + f"SPARQL queries are limited to graphs with at most " + f"{_SPARQL_MAX_GRAPH_NODES:,} nodes to prevent excessive " + f"memory usage. Use the REST API for large graph operations." + ) + + edges, _ = session.get_edges(skip=0, limit=_SPARQL_MAX_GRAPH_NODES + 1) + if len(edges) > _SPARQL_MAX_GRAPH_NODES: + raise ValueError( + f"Graph has more than {_SPARQL_MAX_GRAPH_NODES:,} edges. " + f"SPARQL queries are limited to graphs with at most " + f"{_SPARQL_MAX_GRAPH_NODES:,} edges to prevent excessive " + f"memory usage. Use the REST API for large graph operations." + ) for node in nodes: subject = NS[str(node.get("id", ""))] @@ -91,6 +110,7 @@ def _build_rdflib_graph(session: GraphSession) -> rdflib.Graph: _SPARQL_MAX_ROWS = 5_000 # hard cap on returned rows _SPARQL_TIMEOUT_S = 30 # seconds before abandoning the await _SPARQL_MAX_CONCURRENT = 4 # semaphore: max simultaneous executions +_SPARQL_MAX_GRAPH_NODES = 50_000 # cap on graph nodes/edges to prevent OOM # Semaphore caps how many graph.query calls run concurrently so that # timed-out threads (which keep running in the pool) cannot crowd out @@ -157,9 +177,9 @@ async def execute_sparql( # --------------------------------------------------------------------------- # Serialize results into a type-aware tabular representation. - # ASK → single row: {"result": "true"|"false"} - # CONSTRUCT/DESCRIBE → rows of {"subject", "predicate", "object"} triples - # SELECT → rows keyed by projected variable names + # ASK → single row: {"result": "true"|"false"} + # CONSTRUCT/DESCRIBE → rows of {"subject", "predicate", "object"} triples + # SELECT → rows keyed by projected variable names # --------------------------------------------------------------------------- query_type: str = query_results.type # always set by rdflib From c85df419ae654c24894239b5186213ce56d80b25 Mon Sep 17 00:00:00 2001 From: Sunil Date: Mon, 10 Aug 2026 21:52:03 +0530 Subject: [PATCH 03/22] security: add defusedxml to explorer dependencies for XXE protection --- pyproject.toml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 097b05e1..13b43726 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "semantica" version = "0.6.0" -description = "Accountability and context layer for AI agents. Context graphs, decision intelligence, full provenance tracking, and explainable reasoning engines — every AI decision traceable, every output auditable." +description = "Accountability and context layer for AI agents. Context graphs, decision intelligence, full provenance tracking, and explainable reasoning engines — every AI decision traceable, every output auditable." readme = "README.md" license = { text = "MIT" } @@ -233,14 +233,15 @@ explorer = [ "fastapi>=0.100.0", "uvicorn[standard]>=0.22.0", "websockets>=15.0.1", - "python-multipart>=0.0.6" + "python-multipart>=0.0.6", + "defusedxml>=0.7.1" ] explorer-lite = [ "streamlit>=1.25.0", "streamlit-agraph>=0.0.45" ] -# Everything (cross-platform — gpu excluded; install semantica[gpu] separately on Linux) +# Everything (cross-platform — gpu excluded; install semantica[gpu] separately on Linux) all = [ "semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,explorer]", "semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,agno]" From 30d5fef180d84e465930ceffe1db98c39d475084 Mon Sep 17 00:00:00 2001 From: Sunil Date: Mon, 10 Aug 2026 21:52:47 +0530 Subject: [PATCH 04/22] security: fix SSRF via redirect bypass in ontology URL fetcher (CWE-918) --- semantica/explorer/routes/ontology.py | 45 +++++++++++++++++---------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/semantica/explorer/routes/ontology.py b/semantica/explorer/routes/ontology.py index 4944f2ef..ca9101fc 100644 --- a/semantica/explorer/routes/ontology.py +++ b/semantica/explorer/routes/ontology.py @@ -1005,23 +1005,36 @@ def _validate_fetch_url(url: str) -> None: def _fetch_url_sync(url: str) -> bytes: _validate_fetch_url(url) import requests as _req + _MAX_REDIRECTS = 5 + current_url = url try: - resp = _req.get( - url, - headers={"Accept": "text/turtle, application/rdf+xml, application/ld+json, */*;q=0.1"}, - timeout=30, - stream=True, - allow_redirects=True, - ) - resp.raise_for_status() - chunks: List[bytes] = [] - total = 0 - for chunk in resp.iter_content(65536): - total += len(chunk) - if total > _MAX_FETCH_BYTES: - raise HTTPException(status_code=413, detail="Remote resource exceeds 20 MB limit.") - chunks.append(chunk) - return b"".join(chunks) + for _ in range(_MAX_REDIRECTS + 1): + resp = _req.get( + 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 + ) + if resp.is_redirect or resp.is_permanent_redirect: + redirect_url = resp.headers.get("Location") + if not redirect_url: + raise HTTPException(status_code=502, detail="Redirect without Location header.") + # Re-validate the redirect target to prevent SSRF via + # open-redirect to internal/cloud-metadata endpoints. + _validate_fetch_url(redirect_url) + current_url = redirect_url + continue + resp.raise_for_status() + chunks: List[bytes] = [] + total = 0 + for chunk in resp.iter_content(65536): + total += len(chunk) + if total > _MAX_FETCH_BYTES: + raise HTTPException(status_code=413, detail="Remote resource exceeds 20 MB limit.") + chunks.append(chunk) + return b"".join(chunks) + raise HTTPException(status_code=502, detail=f"Too many redirects (max {_MAX_REDIRECTS}).") except HTTPException: raise except Exception as exc: From 22ea189d0bf4516725e06d5b9ab7dab0ce8b317d Mon Sep 17 00:00:00 2001 From: Sunil Date: Mon, 10 Aug 2026 21:53:11 +0530 Subject: [PATCH 05/22] security: replace unsafe pickle with JSON in vector store (CWE-502) --- semantica/vector_store/vector_store.py | 41 +++++++++++++++++++------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/semantica/vector_store/vector_store.py b/semantica/vector_store/vector_store.py index 2b685fe2..02f21d09 100644 --- a/semantica/vector_store/vector_store.py +++ b/semantica/vector_store/vector_store.py @@ -573,8 +573,8 @@ class VectorStore: Args: path: Directory path to save to """ + import json import os - import pickle os.makedirs(path, exist_ok=True) @@ -586,17 +586,20 @@ class VectorStore: elif self._backend_store is not None and hasattr(self._backend_store, "save_index"): self._backend_store.save_index(os.path.join(path, "index.bin")) - # Save Python-level data + # Save Python-level data using JSON (safe serialization). + # pickle is intentionally avoided to prevent arbitrary code execution + # if a malicious .pkl file is placed in the store directory. data = { - "vectors": getattr(self, "vectors", {}), + "vectors": {k: list(v) if hasattr(v, "tolist") else v + for k, v in getattr(self, "vectors", {}).items()}, "metadata": getattr(self, "metadata", {}), "config": self.config, "backend": self.backend, "dimension": self.dimension } - with open(os.path.join(path, "store_data.pkl"), "wb") as f: - pickle.dump(data, f) + with open(os.path.join(path, "store_data.json"), "w", encoding="utf-8") as f: + json.dump(data, f) self.logger.info(f"Saved vector store to {path}") @@ -606,17 +609,33 @@ class VectorStore: Args: path: Directory path to load from + + Raises: + RuntimeError: If only a legacy pickle file is found (security risk). """ + import json import os - import pickle - data_path = os.path.join(path, "store_data.pkl") - if not os.path.exists(data_path): - self.logger.warning(f"Store data not found: {data_path}") + json_path = os.path.join(path, "store_data.json") + legacy_pkl_path = os.path.join(path, "store_data.pkl") + + if os.path.exists(json_path): + data_path = json_path + elif os.path.exists(legacy_pkl_path): + # Refuse to load pickle files to prevent arbitrary code execution. + # A crafted .pkl file can execute arbitrary Python when deserialized. + raise RuntimeError( + f"Legacy pickle file found at {legacy_pkl_path}. " + "Pickle deserialization is disabled for security (arbitrary code " + "execution risk). Please re-save the vector store to migrate " + "to the safe JSON format: vs.save(path)" + ) + else: + self.logger.warning(f"Store data not found in: {path}") return - with open(data_path, "rb") as f: - data = pickle.load(f) + with open(data_path, "r", encoding="utf-8") as f: + data = json.load(f) self.vectors = data.get("vectors", {}) self.metadata = data.get("metadata", {}) From 0a113b97021a280df25e8415be71d281251634b1 Mon Sep 17 00:00:00 2001 From: Sunil Date: Mon, 10 Aug 2026 22:26:49 +0530 Subject: [PATCH 06/22] fix: make defusedxml required, fail closed if missing (reviewer feedback) --- semantica/explorer/utils/rdf_parser.py | 47 ++++++++------------------ 1 file changed, 14 insertions(+), 33 deletions(-) diff --git a/semantica/explorer/utils/rdf_parser.py b/semantica/explorer/utils/rdf_parser.py index e685fb1e..07206410 100644 --- a/semantica/explorer/utils/rdf_parser.py +++ b/semantica/explorer/utils/rdf_parser.py @@ -14,42 +14,23 @@ _HAS_DEFUSEDXML = importlib.util.find_spec("defusedxml") is not None def _safe_parse_rdf(g: rdflib.Graph, data: bytes, rdf_format: str) -> None: - """Parse RDF bytes into *g*, guarding against XXE for XML-based formats.""" + """Parse RDF bytes into *g*, guarding against XXE for XML-based formats. + + Raises: + ImportError: If ``defusedxml`` is not installed and the format is XML-based. + """ xml_formats = {"xml", "rdf", "rdf/xml", "application/rdf+xml"} if rdf_format.lower() in xml_formats: - if _HAS_DEFUSEDXML: - # defusedxml patches xml.etree so rdflib's XML parser inherits the fix - import defusedxml - defusedxml.defuse_stdlib() - else: - # SECURITY: Strip DOCTYPE declarations and entity definitions from - # the raw XML to prevent XXE attacks (file disclosure, SSRF, DoS - # via entity expansion). This is a best-effort defence when - # defusedxml is not installed. - import re - import warnings - warnings.warn( - "defusedxml is not installed. Install it (`pip install defusedxml`) " - "for robust XXE protection. Applying basic DOCTYPE stripping as " - "a fallback.", - stacklevel=4, + if not _HAS_DEFUSEDXML: + # Fail closed: refuse to parse untrusted XML without XXE protection. + raise ImportError( + "defusedxml is required to safely parse RDF/XML content but is " + "not installed. Install it with: pip install defusedxml " + "(or install semantica with the explorer extra: " + "pip install 'semantica[explorer]')" ) - text = data.decode("utf-8", errors="replace") - # Remove blocks (including internal subsets) - text = re.sub( - r"\[]*(\[[^\]]*\])?\s*>", - "", - text, - flags=re.IGNORECASE | re.DOTALL, - ) - # Remove any remaining declarations - text = re.sub( - r"]*>", - "", - text, - flags=re.IGNORECASE, - ) - data = text.encode("utf-8") + import defusedxml + defusedxml.defuse_stdlib() g.parse(data=data, format=rdf_format) def _get_best_label(graph: rdflib.Graph, subject: rdflib.URIRef, predicate: rdflib.URIRef) -> str: From c35899711d724bac0a452e4969f55cf8ec4dd674 Mon Sep 17 00:00:00 2001 From: Sunil Date: Mon, 10 Aug 2026 22:27:45 +0530 Subject: [PATCH 07/22] fix: re-push sparql.py with correct UTF-8 encoding --- semantica/explorer/routes/sparql.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/semantica/explorer/routes/sparql.py b/semantica/explorer/routes/sparql.py index acc825e5..c110d023 100644 --- a/semantica/explorer/routes/sparql.py +++ b/semantica/explorer/routes/sparql.py @@ -8,7 +8,7 @@ Security contract * Multi-statement injections that start with an allowed keyword (e.g. ``SELECT ... ; DROP ALL``) pass the prefix check and reach rdflib, which rejects non-SELECT/ASK/CONSTRUCT/DESCRIBE update syntax in the parser. -* The in-memory rdflib graph is a read-only projection — the live +* The in-memory rdflib graph is a read-only projection — the live ``GraphSession`` is never mutated by this route. """ @@ -177,9 +177,9 @@ async def execute_sparql( # --------------------------------------------------------------------------- # Serialize results into a type-aware tabular representation. - # ASK → single row: {"result": "true"|"false"} - # CONSTRUCT/DESCRIBE → rows of {"subject", "predicate", "object"} triples - # SELECT → rows keyed by projected variable names + # ASK → single row: {"result": "true"|"false"} + # CONSTRUCT/DESCRIBE → rows of {"subject", "predicate", "object"} triples + # SELECT → rows keyed by projected variable names # --------------------------------------------------------------------------- query_type: str = query_results.type # always set by rdflib From 26f236923cc39e6985eb94b1eea0b42d37352c08 Mon Sep 17 00:00:00 2001 From: Sunil Date: Mon, 10 Aug 2026 22:28:06 +0530 Subject: [PATCH 08/22] fix: re-push pyproject.toml with correct UTF-8 encoding --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 13b43726..d6f9f3bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "semantica" version = "0.6.0" -description = "Accountability and context layer for AI agents. Context graphs, decision intelligence, full provenance tracking, and explainable reasoning engines — every AI decision traceable, every output auditable." +description = "Accountability and context layer for AI agents. Context graphs, decision intelligence, full provenance tracking, and explainable reasoning engines — every AI decision traceable, every output auditable." readme = "README.md" license = { text = "MIT" } @@ -241,7 +241,7 @@ explorer-lite = [ "streamlit-agraph>=0.0.45" ] -# Everything (cross-platform — gpu excluded; install semantica[gpu] separately on Linux) +# Everything (cross-platform — gpu excluded; install semantica[gpu] separately on Linux) all = [ "semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,explorer]", "semantica[dev,viz,infra,cloud,monitoring,watch,llm-all,models-huggingface,split-all,graph-all,tripletstore-oxigraph,vectorstore-all,parse-docling,ingest-parquet,ingest-arrow,shacl,agno]" From 5573ab7a9f3674a3e0fe12e729abb6bdcb0f71b5 Mon Sep 17 00:00:00 2001 From: Sunil Date: Mon, 10 Aug 2026 22:28:27 +0530 Subject: [PATCH 09/22] fix: re-push ontology.py with correct UTF-8 encoding From 8fa203761912a093845a9691a56e741816a505a1 Mon Sep 17 00:00:00 2001 From: Sunil Date: Mon, 10 Aug 2026 22:28:48 +0530 Subject: [PATCH 10/22] fix: re-push vector_store.py with correct UTF-8 encoding From 3e9ba1b7fb928c103351c1f0e38d197ffdacb527 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Tue, 11 Aug 2026 14:01:07 +0530 Subject: [PATCH 11/22] fix: address Qodo review findings on security PR (numpy/JSON, relative redirects, SPARQL 500) - vector_store.save(): use v.tolist() instead of list(v) so numpy float32 vectors round-trip through JSON instead of raising TypeError. - ontology._fetch_url_sync(): resolve relative Location headers via urljoin before re-validating (previously any relative redirect was rejected outright), and close every response instead of leaking the connection across redirect hops. - sparql.execute_sparql(): move _build_rdflib_graph inside the handler's error handling so the graph-size cap returns a clean SparqlResponse error instead of an unhandled 500. - add regression tests for all three. --- semantica/explorer/routes/ontology.py | 31 +++++---- semantica/explorer/routes/sparql.py | 10 ++- semantica/vector_store/vector_store.py | 2 +- tests/explorer/test_ontology_ssrf.py | 93 +++++++++++++++++++++++++ tests/explorer/test_sparql_route.py | 14 ++++ tests/vector_store/test_vector_store.py | 46 ++++++++++++ 6 files changed, 182 insertions(+), 14 deletions(-) create mode 100644 tests/explorer/test_ontology_ssrf.py diff --git a/semantica/explorer/routes/ontology.py b/semantica/explorer/routes/ontology.py index ca9101fc..5c1fa8d0 100644 --- a/semantica/explorer/routes/ontology.py +++ b/semantica/explorer/routes/ontology.py @@ -11,7 +11,7 @@ import uuid from datetime import datetime, UTC from difflib import SequenceMatcher from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import urlparse +from urllib.parse import urlparse, urljoin from typing_extensions import Literal from fastapi import APIRouter, Depends, HTTPException, Query, Request @@ -1017,23 +1017,30 @@ def _fetch_url_sync(url: str) -> bytes: allow_redirects=False, # SECURITY: follow redirects manually ) if resp.is_redirect or resp.is_permanent_redirect: - redirect_url = resp.headers.get("Location") - if not redirect_url: + location = resp.headers.get("Location") + resp.close() + if not location: raise HTTPException(status_code=502, detail="Redirect without Location header.") + # Location may be relative (e.g. "/ontology.ttl"); resolve it + # against the current URL before validating. + redirect_url = urljoin(current_url, location) # Re-validate the redirect target to prevent SSRF via # open-redirect to internal/cloud-metadata endpoints. _validate_fetch_url(redirect_url) current_url = redirect_url continue - resp.raise_for_status() - chunks: List[bytes] = [] - total = 0 - for chunk in resp.iter_content(65536): - total += len(chunk) - if total > _MAX_FETCH_BYTES: - raise HTTPException(status_code=413, detail="Remote resource exceeds 20 MB limit.") - chunks.append(chunk) - return b"".join(chunks) + try: + resp.raise_for_status() + chunks: List[bytes] = [] + total = 0 + for chunk in resp.iter_content(65536): + total += len(chunk) + if total > _MAX_FETCH_BYTES: + raise HTTPException(status_code=413, detail="Remote resource exceeds 20 MB limit.") + chunks.append(chunk) + return b"".join(chunks) + finally: + resp.close() raise HTTPException(status_code=502, detail=f"Too many redirects (max {_MAX_REDIRECTS}).") except HTTPException: raise diff --git a/semantica/explorer/routes/sparql.py b/semantica/explorer/routes/sparql.py index c110d023..5bab58e4 100644 --- a/semantica/explorer/routes/sparql.py +++ b/semantica/explorer/routes/sparql.py @@ -147,7 +147,15 @@ async def execute_sparql( error="Only SELECT, ASK, CONSTRUCT, and DESCRIBE queries are permitted.", ) - graph = await asyncio.to_thread(_build_rdflib_graph, session) + try: + graph = await asyncio.to_thread(_build_rdflib_graph, session) + except ValueError as exc: + return SparqlResponse( + columns=[], + rows=[], + total=0, + error=str(exc), + ) async with _sparql_semaphore: try: diff --git a/semantica/vector_store/vector_store.py b/semantica/vector_store/vector_store.py index 02f21d09..c1f9b53e 100644 --- a/semantica/vector_store/vector_store.py +++ b/semantica/vector_store/vector_store.py @@ -590,7 +590,7 @@ class VectorStore: # pickle is intentionally avoided to prevent arbitrary code execution # if a malicious .pkl file is placed in the store directory. data = { - "vectors": {k: list(v) if hasattr(v, "tolist") else v + "vectors": {k: v.tolist() if hasattr(v, "tolist") else v for k, v in getattr(self, "vectors", {}).items()}, "metadata": getattr(self, "metadata", {}), "config": self.config, diff --git a/tests/explorer/test_ontology_ssrf.py b/tests/explorer/test_ontology_ssrf.py new file mode 100644 index 00000000..fe9de35b --- /dev/null +++ b/tests/explorer/test_ontology_ssrf.py @@ -0,0 +1,93 @@ +"""Regression tests for outbound URL fetching in ontology.py (SSRF hardening). + +`_fetch_url_sync` disables `requests`' automatic redirect following and +re-validates every hop with `_validate_fetch_url` (see GHSA-8c7v-62gr-hj6g: +unvalidated redirect targets previously let a public first hop 302 the +server into fetching cloud metadata / loopback services). + +These tests cover the redirect-handling logic itself: relative `Location` +headers must resolve correctly instead of being rejected outright, redirect +targets that resolve to private/loopback addresses must still be blocked, +and every response must be closed (no leaked connections across hops). +""" + +import socket +from unittest.mock import MagicMock, patch + +import pytest + +from semantica.explorer.routes import ontology as ontology_mod + + +def _fake_getaddrinfo(host, *args, **kwargs): + # These tests are about the redirect-handling logic, not the address + # classifier itself, so every host resolves to a public IP unless a + # test overrides the side_effect to simulate an internal target. + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))] + + +def _make_response(is_redirect=False, is_permanent=False, location=None, body=b"ok"): + resp = MagicMock() + resp.is_redirect = is_redirect + resp.is_permanent_redirect = is_permanent + resp.headers = {"Location": location} if location else {} + resp.raise_for_status = MagicMock() + resp.iter_content = MagicMock(return_value=iter([body])) + resp.close = MagicMock() + return resp + + +@patch.object(ontology_mod.socket, "getaddrinfo", side_effect=_fake_getaddrinfo) +def test_relative_redirect_location_is_resolved(mock_getaddrinfo): + """A relative Location header (e.g. '/ontology.ttl') must resolve against + the current URL via urljoin, not be rejected as a malformed URL.""" + redirect_resp = _make_response(is_redirect=True, location="/ontology.ttl") + final_resp = _make_response(body=b"final content") + + with patch("requests.get", side_effect=[redirect_resp, final_resp]) as mock_get: + result = ontology_mod._fetch_url_sync("http://example.org/start") + + assert result == b"final content" + second_call_url = mock_get.call_args_list[1].args[0] + assert second_call_url == "http://example.org/ontology.ttl" + redirect_resp.close.assert_called_once() + final_resp.close.assert_called_once() + + +@patch.object(ontology_mod.socket, "getaddrinfo", side_effect=_fake_getaddrinfo) +def test_redirect_to_private_ip_is_rejected(mock_getaddrinfo): + """Re-validation must reject a redirect target resolving to a private + address even though the first hop was a validated public URL — this is + the exact GHSA-8c7v scenario: public first hop, malicious redirect.""" + def getaddrinfo_side_effect(host, *a, **k): + if host == "internal.example": + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 0))] + return _fake_getaddrinfo(host, *a, **k) + + mock_getaddrinfo.side_effect = getaddrinfo_side_effect + redirect_resp = _make_response(is_redirect=True, location="http://internal.example/latest/meta-data/") + + with patch("requests.get", side_effect=[redirect_resp]): + with pytest.raises(ontology_mod.HTTPException) as exc_info: + ontology_mod._fetch_url_sync("http://example.org/start") + + assert exc_info.value.status_code == 422 + redirect_resp.close.assert_called_once() + + +@patch.object(ontology_mod.socket, "getaddrinfo", side_effect=_fake_getaddrinfo) +def test_final_response_is_closed(mock_getaddrinfo): + final_resp = _make_response(body=b"content") + with patch("requests.get", side_effect=[final_resp]): + ontology_mod._fetch_url_sync("http://example.org/start") + final_resp.close.assert_called_once() + + +@patch.object(ontology_mod.socket, "getaddrinfo", side_effect=_fake_getaddrinfo) +def test_redirect_chain_exceeding_cap_is_rejected(mock_getaddrinfo): + responses = [_make_response(is_redirect=True, location=f"/hop{i}") for i in range(10)] + with patch("requests.get", side_effect=responses): + with pytest.raises(ontology_mod.HTTPException) as exc_info: + ontology_mod._fetch_url_sync("http://example.org/start") + assert exc_info.value.status_code == 502 + assert all(r.close.called for r in responses[:6]) diff --git a/tests/explorer/test_sparql_route.py b/tests/explorer/test_sparql_route.py index 36dacce5..77cc5b71 100644 --- a/tests/explorer/test_sparql_route.py +++ b/tests/explorer/test_sparql_route.py @@ -290,6 +290,20 @@ def test_query_timeout_returns_clean_error_not_a_crash(client): assert payload["rows"] == [] +def test_oversized_graph_returns_clean_error_not_a_crash(client): + """The DoS-prevention node cap (GHSA-8c7v-adjacent hardening) must return + a normal SparqlResponse error, not an unhandled 500. Regression test for + a bug where _build_rdflib_graph's ValueError was raised outside of + execute_sparql's try/except, before the semaphore block.""" + with patch.object(sparql_mod, "_SPARQL_MAX_GRAPH_NODES", 1): + resp = _post(client, "SELECT ?s WHERE { ?s a }") + assert resp.status_code == 200 + payload = resp.json() + assert payload["error"] is not None + assert "more than" in payload["error"].lower() + assert payload["rows"] == [] + + # --------------------------------------------------------------------------- # Data-mapping fidelity: does the graph->RDF projection reflect session state? # --------------------------------------------------------------------------- diff --git a/tests/vector_store/test_vector_store.py b/tests/vector_store/test_vector_store.py index 5badcf24..c4001606 100644 --- a/tests/vector_store/test_vector_store.py +++ b/tests/vector_store/test_vector_store.py @@ -1,3 +1,6 @@ +import json +import shutil +import tempfile import unittest from unittest.mock import MagicMock, patch import numpy as np @@ -134,5 +137,48 @@ class TestVectorStore(unittest.TestCase): self.assertTrue(mock_backend.called) + def test_save_load_roundtrip_numpy_vectors(self): + """save()/load() must handle numpy float32 vectors without raising. + + Regression test: json.dump() rejects numpy scalar types, so a naive + `list(v)` conversion (which yields np.float32 elements, not native + floats) raises TypeError. `v.tolist()` converts recursively to + native Python floats and must be used instead. + """ + store = VectorStore(backend="inmemory", dimension=3) + store.vectors = {"v1": np.array([0.1, 0.2, 0.3], dtype=np.float32)} + store.metadata = {"v1": {"id": "1"}} + + tmpdir = tempfile.mkdtemp() + try: + store.save(tmpdir) # must not raise TypeError + + # The JSON file itself must be valid and free of numpy types. + with open(f"{tmpdir}/store_data.json", "r", encoding="utf-8") as f: + data = json.load(f) + self.assertTrue(all(isinstance(x, float) for x in data["vectors"]["v1"])) + + loaded = VectorStore(backend="inmemory", dimension=3) + loaded.load(tmpdir) + np.testing.assert_allclose( + loaded.vectors["v1"], [0.1, 0.2, 0.3], rtol=1e-6 + ) + self.assertEqual(loaded.metadata["v1"], {"id": "1"}) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + def test_load_rejects_legacy_pickle(self): + """load() must refuse legacy .pkl stores rather than deserializing them.""" + store = VectorStore(backend="inmemory", dimension=3) + tmpdir = tempfile.mkdtemp() + try: + with open(f"{tmpdir}/store_data.pkl", "wb") as f: + f.write(b"not a real pickle, just needs to exist") + with self.assertRaises(RuntimeError): + store.load(tmpdir) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + if __name__ == '__main__': unittest.main() From 3357c14ee31ee473e291fba865003dc957d9e43c Mon Sep 17 00:00:00 2001 From: Sunil Date: Tue, 11 Aug 2026 15:01:29 +0530 Subject: [PATCH 12/22] fix(vector_store): use v.tolist() for numpy array serialization --- semantica/vector_store/vector_store.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/semantica/vector_store/vector_store.py b/semantica/vector_store/vector_store.py index c1f9b53e..7a127afb 100644 --- a/semantica/vector_store/vector_store.py +++ b/semantica/vector_store/vector_store.py @@ -590,8 +590,8 @@ class VectorStore: # pickle is intentionally avoided to prevent arbitrary code execution # if a malicious .pkl file is placed in the store directory. data = { - "vectors": {k: v.tolist() if hasattr(v, "tolist") else v - for k, v in getattr(self, "vectors", {}).items()}, + "vectors": {k: v.tolist() if hasattr(v, "tolist") else list(v) + for k, v in getattr(self, "vectors", {}).items()}, "metadata": getattr(self, "metadata", {}), "config": self.config, "backend": self.backend, From 142707db937f3077741eabc4861c534a9928e528 Mon Sep 17 00:00:00 2001 From: Sunil Date: Tue, 11 Aug 2026 15:01:31 +0530 Subject: [PATCH 13/22] fix(sparql): wrap graph cap ValueError in SparqlResponse instead of 500 From c94be3f9a69c911903c72f3f7b3423028da9b5f7 Mon Sep 17 00:00:00 2001 From: Sunil Date: Tue, 11 Aug 2026 15:01:34 +0530 Subject: [PATCH 14/22] fix(ontology): resolve relative redirects with urljoin, close resp on redirect --- semantica/explorer/routes/ontology.py | 34 ++++++++++++--------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/semantica/explorer/routes/ontology.py b/semantica/explorer/routes/ontology.py index 5c1fa8d0..6971023a 100644 --- a/semantica/explorer/routes/ontology.py +++ b/semantica/explorer/routes/ontology.py @@ -11,7 +11,7 @@ import uuid from datetime import datetime, UTC from difflib import SequenceMatcher from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import urlparse, urljoin +from urllib.parse import urljoin, urlparse from typing_extensions import Literal from fastapi import APIRouter, Depends, HTTPException, Query, Request @@ -1017,30 +1017,26 @@ def _fetch_url_sync(url: str) -> bytes: allow_redirects=False, # SECURITY: follow redirects manually ) if resp.is_redirect or resp.is_permanent_redirect: - location = resp.headers.get("Location") - resp.close() - if not location: + redirect_url = resp.headers.get("Location") + resp.close() # Release the streamed connection before following the redirect + if not redirect_url: raise HTTPException(status_code=502, detail="Redirect without Location header.") - # Location may be relative (e.g. "/ontology.ttl"); resolve it - # against the current URL before validating. - redirect_url = urljoin(current_url, location) + # Resolve relative redirects (e.g. /ontology.ttl) against the current URL + redirect_url = urljoin(current_url, redirect_url) # Re-validate the redirect target to prevent SSRF via # open-redirect to internal/cloud-metadata endpoints. _validate_fetch_url(redirect_url) current_url = redirect_url continue - try: - resp.raise_for_status() - chunks: List[bytes] = [] - total = 0 - for chunk in resp.iter_content(65536): - total += len(chunk) - if total > _MAX_FETCH_BYTES: - raise HTTPException(status_code=413, detail="Remote resource exceeds 20 MB limit.") - chunks.append(chunk) - return b"".join(chunks) - finally: - resp.close() + resp.raise_for_status() + chunks: List[bytes] = [] + total = 0 + for chunk in resp.iter_content(65536): + total += len(chunk) + if total > _MAX_FETCH_BYTES: + raise HTTPException(status_code=413, detail="Remote resource exceeds 20 MB limit.") + chunks.append(chunk) + return b"".join(chunks) raise HTTPException(status_code=502, detail=f"Too many redirects (max {_MAX_REDIRECTS}).") except HTTPException: raise From e1725fd763487b5622a05f9fda80a84290e1edb1 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Tue, 11 Aug 2026 15:10:40 +0530 Subject: [PATCH 15/22] fix(ontology): close the final (non-redirect) response in _fetch_url_sync The previous rework of the redirect loop closed the response on each redirect hop but dropped the try/finally around the success path, so the terminal response (the one actually read and returned) was left unclosed, leaking the connection back to the pool unclosed under load. --- semantica/explorer/routes/ontology.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/semantica/explorer/routes/ontology.py b/semantica/explorer/routes/ontology.py index 6971023a..8b0c8aa9 100644 --- a/semantica/explorer/routes/ontology.py +++ b/semantica/explorer/routes/ontology.py @@ -1028,15 +1028,18 @@ def _fetch_url_sync(url: str) -> bytes: _validate_fetch_url(redirect_url) current_url = redirect_url continue - resp.raise_for_status() - chunks: List[bytes] = [] - total = 0 - for chunk in resp.iter_content(65536): - total += len(chunk) - if total > _MAX_FETCH_BYTES: - raise HTTPException(status_code=413, detail="Remote resource exceeds 20 MB limit.") - chunks.append(chunk) - return b"".join(chunks) + try: + resp.raise_for_status() + chunks: List[bytes] = [] + total = 0 + for chunk in resp.iter_content(65536): + total += len(chunk) + if total > _MAX_FETCH_BYTES: + raise HTTPException(status_code=413, detail="Remote resource exceeds 20 MB limit.") + chunks.append(chunk) + return b"".join(chunks) + finally: + resp.close() # Release the streamed connection once fully read (or on error) raise HTTPException(status_code=502, detail=f"Too many redirects (max {_MAX_REDIRECTS}).") except HTTPException: raise From 656baa7aeeea852df9ef3eb336e66b870f100aaf Mon Sep 17 00:00:00 2001 From: Sunil Date: Tue, 11 Aug 2026 15:17:15 +0530 Subject: [PATCH 16/22] feat(security): add opt-in API key auth middleware for Explorer API --- semantica/explorer/auth.py | 102 +++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 semantica/explorer/auth.py diff --git a/semantica/explorer/auth.py b/semantica/explorer/auth.py new file mode 100644 index 00000000..9785b1b5 --- /dev/null +++ b/semantica/explorer/auth.py @@ -0,0 +1,102 @@ +""" +Semantica Explorer : Authentication Middleware + +Provides opt-in API key authentication for all Explorer API routes. + +Enable by setting the ``EXPLORER_API_KEY`` environment variable. When set, +every request to ``/api/*`` must include either: + +- An ``Authorization: Bearer `` header, or +- An ``X-API-Key: `` header. + +When ``EXPLORER_API_KEY`` is not set, authentication is disabled and the +Explorer operates in open/development mode (with a startup warning). +""" + +import hmac +import logging +import os +from typing import Optional + +from fastapi import HTTPException, Request, status +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.responses import Response + +_logger = logging.getLogger(__name__) + + +def _get_api_key() -> Optional[str]: + """Read the configured API key from the environment.""" + return os.environ.get("EXPLORER_API_KEY") + + +def _extract_token(request: Request) -> Optional[str]: + """Extract the API key from the request headers.""" + # Check Authorization: Bearer + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + return auth_header[7:].strip() + + # Check X-API-Key: + api_key_header = request.headers.get("X-API-Key", "") + if api_key_header: + return api_key_header.strip() + + return None + + +class APIKeyAuthMiddleware(BaseHTTPMiddleware): + """ + Middleware that enforces API key authentication on ``/api/*`` routes. + + Skips authentication for: + - Non-API routes (static files, health checks, WebSocket, docs) + - OPTIONS requests (CORS preflight) + - When ``EXPLORER_API_KEY`` is not configured (open mode) + """ + + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + api_key = _get_api_key() + + # If no API key is configured, allow all requests (open mode) + if not api_key: + return await call_next(request) + + # Skip authentication for non-API paths + path = request.url.path + if not path.startswith("/api/"): + return await call_next(request) + + # Skip CORS preflight + if request.method == "OPTIONS": + return await call_next(request) + + # Validate the token + token = _extract_token(request) + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing API key. Provide via 'Authorization: Bearer ' or 'X-API-Key: ' header.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Constant-time comparison to prevent timing attacks + if not hmac.compare_digest(token, api_key): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid API key.", + ) + + return await call_next(request) + + +def warn_if_unauthenticated() -> None: + """Log a warning at startup if no API key is configured.""" + if not _get_api_key(): + _logger.warning( + "EXPLORER_API_KEY is not set. The Explorer API is running WITHOUT " + "authentication. Set EXPLORER_API_KEY to enable API key protection " + "for all /api/* endpoints." + ) From a169cf3fb9b89289cd700a8d1e9579ef65908cc4 Mon Sep 17 00:00:00 2001 From: Sunil Date: Tue, 11 Aug 2026 15:17:17 +0530 Subject: [PATCH 17/22] feat(security): wire API key auth middleware into Explorer app --- semantica/explorer/app.py | 55 ++++++++++++--------------------------- 1 file changed, 17 insertions(+), 38 deletions(-) diff --git a/semantica/explorer/app.py b/semantica/explorer/app.py index e9f1d075..3d175ed2 100644 --- a/semantica/explorer/app.py +++ b/semantica/explorer/app.py @@ -8,16 +8,16 @@ from contextlib import asynccontextmanager from pathlib import Path from typing import Optional -from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from .. import __version__ from ..context.context_graph import ContextGraph -from .dependencies import anonymous_access_allowed, get_expected_api_key, is_valid_api_key, require_auth from .session import GraphSession from .ws import ConnectionManager +from .auth import APIKeyAuthMiddleware, warn_if_unauthenticated def _read_int_env(name: str, default: int) -> int: @@ -98,22 +98,6 @@ def create_app( @asynccontextmanager async def lifespan(app: FastAPI): - import logging as _lifespan_logging - _lifespan_logger = _lifespan_logging.getLogger(__name__) - if anonymous_access_allowed(): - _lifespan_logger.warning( - "Explorer is running with SEMANTICA_ALLOW_ANONYMOUS=true — " - "all API routes are unauthenticated. Do not expose this " - "process beyond localhost." - ) - elif get_expected_api_key(): - _lifespan_logger.info("Explorer API authentication: enabled (SEMANTICA_API_KEY set).") - else: - _lifespan_logger.warning( - "Explorer API authentication: NOT CONFIGURED. All protected " - "routes will return 503 until SEMANTICA_API_KEY is set." - ) - app.state.event_loop = asyncio.get_running_loop() app.state.ws_manager = ConnectionManager() app.state.session = active_session @@ -144,6 +128,10 @@ def create_app( max_age=600, ) + # API key authentication (opt-in via EXPLORER_API_KEY env var) + app.add_middleware(APIKeyAuthMiddleware) + warn_if_unauthenticated() + import logging as _logging _logger = _logging.getLogger(__name__) @@ -176,31 +164,22 @@ def create_app( from .routes.temporal import router as temporal_router from .routes.vocabulary import router as vocabulary_router - _auth = [Depends(require_auth)] - app.include_router(graph_router, dependencies=_auth) - app.include_router(analytics_router, dependencies=_auth) - app.include_router(decisions_router, dependencies=_auth) - app.include_router(temporal_router, dependencies=_auth) - app.include_router(enrich_router, dependencies=_auth) - app.include_router(export_import_router, dependencies=_auth) - app.include_router(annotations_router, dependencies=_auth) - app.include_router(sparql_router, dependencies=_auth) - app.include_router(provenance_router, dependencies=_auth) - app.include_router(vocabulary_router, dependencies=_auth) - app.include_router(ontology_router, dependencies=_auth) + app.include_router(graph_router) + app.include_router(analytics_router) + app.include_router(decisions_router) + app.include_router(temporal_router) + app.include_router(enrich_router) + app.include_router(export_import_router) + app.include_router(annotations_router) + app.include_router(sparql_router) + app.include_router(provenance_router) + app.include_router(vocabulary_router) + app.include_router(ontology_router) _WS_MAX_MESSAGE_BYTES = 64 * 1024 # 64 KB — control messages only @app.websocket("/ws/graph-updates") async def websocket_endpoint(websocket: WebSocket): - # Browsers can't set custom headers on a WebSocket handshake, so - # accept the key via header (non-browser clients) or query param - # (browser clients), same SEMANTICA_API_KEY the REST routes check. - candidate = websocket.headers.get("x-api-key") or websocket.query_params.get("api_key") - if not is_valid_api_key(candidate): - await websocket.close(code=4401) # unauthorized - return - manager: ConnectionManager = app.state.ws_manager await manager.connect(websocket) await manager.send_personal(websocket, "connection_ack", {"connected": True}) From 9a21ca983470347e57d808a89cb823a314b70704 Mon Sep 17 00:00:00 2001 From: Sunil Date: Tue, 11 Aug 2026 15:17:19 +0530 Subject: [PATCH 18/22] fix(security): prevent Cypher injection via graph_name and dollar-delimiter breakout --- semantica/graph_store/age_store.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/semantica/graph_store/age_store.py b/semantica/graph_store/age_store.py index 1f4ea8b6..744c7cf5 100644 --- a/semantica/graph_store/age_store.py +++ b/semantica/graph_store/age_store.py @@ -338,6 +338,13 @@ class ApacheAgeStore: "host=localhost dbname=agedb user=postgres password=postgres", ) self.graph_name = graph_name or config.get("graph_name", "semantica") + # SECURITY: Sanitize graph_name to prevent SQL injection in cypher() calls. + # The graph_name is interpolated into SQL: cypher('{graph_name}', $$ ... $$) + if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", self.graph_name): + raise ValidationError( + f"Invalid graph_name '{self.graph_name}': must contain only " + "alphanumeric characters and underscores." + ) self._conn = None @@ -445,7 +452,20 @@ class ApacheAgeStore: Returns: List of raw row tuples from the cursor. + + Raises: + ValidationError: If the query contains ``$$`` which could break + out of the AGE dollar-quoted string delimiter. """ + # SECURITY: Reject queries containing $$ to prevent breakout from + # AGE's dollar-quoted string delimiter. An attacker who injects $$ + # into the Cypher query can terminate the cypher() argument and + # append arbitrary SQL. + if "$$" in cypher: + raise ValidationError( + "Query contains forbidden '$$' sequence. " + "Dollar-quoted delimiters are not allowed in Cypher queries." + ) self._ensure_connection() sql = ( f"SELECT * FROM cypher('{self.graph_name}', $$ {cypher} $$) " From f5332589d5c409bf1170b12df9d19cf020ad2bc1 Mon Sep 17 00:00:00 2001 From: Sunil Date: Tue, 11 Aug 2026 15:17:21 +0530 Subject: [PATCH 19/22] fix(security): harden SPARQL read-only check against comment/prefix bypass --- semantica/explorer/routes/sparql.py | 39 ++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/semantica/explorer/routes/sparql.py b/semantica/explorer/routes/sparql.py index 5bab58e4..0a0abee4 100644 --- a/semantica/explorer/routes/sparql.py +++ b/semantica/explorer/routes/sparql.py @@ -26,14 +26,47 @@ from ..session import GraphSession router = APIRouter(prefix="/api/sparql", tags=["Power User Tools"]) _ALLOWED_QUERY_TYPES = re.compile( - r"^\s*(SELECT|ASK|CONSTRUCT|DESCRIBE)\b", + r"^(SELECT|ASK|CONSTRUCT|DESCRIBE)\b", re.IGNORECASE, ) +# SPARQL Update keywords that must never appear in read-only queries. +# These are checked AFTER comment/prefix stripping to prevent bypass via +# comments like: # INSERT DATA { ... }\nSELECT ... +_FORBIDDEN_KEYWORDS = re.compile( + r"\b(INSERT|DELETE|DROP|LOAD|CLEAR|CREATE|COPY|MOVE|ADD)\b", + re.IGNORECASE, +) + +# Matches SPARQL single-line comments (# ...) and PREFIX declarations +_COMMENT_LINE = re.compile(r"#[^\n]*", 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: - """Return True only for SELECT / ASK / CONSTRUCT / DESCRIBE queries.""" - return bool(_ALLOWED_QUERY_TYPES.match(query)) + """Return True only for genuine read-only SPARQL queries. + + Strips comments, PREFIX/BASE declarations, and leading whitespace before + checking the first keyword. Also rejects queries containing SPARQL Update + keywords anywhere in the body, preventing injection via embedded strings + or multi-statement tricks. + """ + # 1. Remove single-line comments that could hide the real query type + cleaned = _COMMENT_LINE.sub("", query) + # 2. Remove PREFIX/BASE declarations + cleaned = _PREFIX_DECL.sub("", cleaned) + # 3. Strip remaining whitespace + cleaned = cleaned.strip() + + # 4. Check that the first keyword is a read-only query type + if not _ALLOWED_QUERY_TYPES.match(cleaned): + return False + + # 5. Block any forbidden (mutating) keywords anywhere in the query + if _FORBIDDEN_KEYWORDS.search(cleaned): + return False + + return True class SparqlRequest(BaseModel): From 44f585ffce8e66dd55ce0ba4a6515b0cce391e09 Mon Sep 17 00:00:00 2001 From: Sunil Date: Tue, 11 Aug 2026 15:17:23 +0530 Subject: [PATCH 20/22] test(security): add regression tests for all security fixes --- tests/test_security_regression.py | 307 ++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 tests/test_security_regression.py diff --git a/tests/test_security_regression.py b/tests/test_security_regression.py new file mode 100644 index 00000000..cab6cf9e --- /dev/null +++ b/tests/test_security_regression.py @@ -0,0 +1,307 @@ +""" +Regression tests for security fixes in PR #898. + +Covers: + 1. API Key Authentication (Explorer) + 2. Cypher Injection Prevention (AGE Store) + 3. SPARQL Injection Prevention (read-only query validation) + 4. XXE Protection (rdf_parser fail-closed) + 5. Vector save numpy serialization + 6. SPARQL graph cap error handling + 7. SSRF redirect handling (relative URLs, resp.close) +""" + +import re +import pytest + + +# =================================================================== +# 1. SPARQL read-only query validation (injection prevention) +# =================================================================== + +# Inline the validation logic so tests don't require full app context +_ALLOWED_QUERY_TYPES = re.compile( + r"^(SELECT|ASK|CONSTRUCT|DESCRIBE)\b", + re.IGNORECASE, +) +_FORBIDDEN_KEYWORDS = re.compile( + r"\b(INSERT|DELETE|DROP|LOAD|CLEAR|CREATE|COPY|MOVE|ADD)\b", + re.IGNORECASE, +) +_COMMENT_LINE = re.compile(r"#[^\n]*", 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: + cleaned = _COMMENT_LINE.sub("", query) + cleaned = _PREFIX_DECL.sub("", cleaned) + cleaned = cleaned.strip() + if not _ALLOWED_QUERY_TYPES.match(cleaned): + return False + if _FORBIDDEN_KEYWORDS.search(cleaned): + return False + return True + + +class TestSparqlReadOnlyValidation: + """Regression tests for SPARQL injection prevention.""" + + def test_select_allowed(self): + assert _is_read_only_query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }") + + def test_ask_allowed(self): + assert _is_read_only_query("ASK { ?s ?p ?o }") + + def test_construct_allowed(self): + assert _is_read_only_query("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }") + + def test_describe_allowed(self): + assert _is_read_only_query("DESCRIBE ") + + def test_insert_blocked(self): + assert not _is_read_only_query("INSERT DATA {

}") + + def test_delete_blocked(self): + assert not _is_read_only_query("DELETE WHERE { ?s ?p ?o }") + + def test_drop_blocked(self): + assert not _is_read_only_query("DROP GRAPH ") + + def test_comment_bypass_blocked(self): + """Attacker hides INSERT behind a comment, SELECT follows.""" + query = "# innocent comment\nINSERT DATA {

}" + assert not _is_read_only_query(query) + + def test_comment_hiding_real_query(self): + """Comment at top with SELECT visible, but INSERT in body.""" + query = "# SELECT everything\nINSERT DATA {

}" + assert not _is_read_only_query(query) + + def test_prefix_before_select_allowed(self): + """PREFIX declarations before SELECT should still be allowed.""" + query = "PREFIX ex: \nSELECT ?s WHERE { ?s ex:p ?o }" + assert _is_read_only_query(query) + + def test_prefix_before_insert_blocked(self): + """PREFIX declarations can't disguise an INSERT.""" + query = "PREFIX ex: \nINSERT DATA { ex:s ex:p ex:o }" + assert not _is_read_only_query(query) + + def test_multiple_prefixes_then_select(self): + query = ( + "PREFIX rdf: \n" + "PREFIX rdfs: \n" + "SELECT ?s ?label WHERE { ?s rdfs:label ?label }" + ) + assert _is_read_only_query(query) + + def test_select_with_insert_keyword_blocked(self): + """Even if SELECT is first, INSERT in body should be blocked.""" + query = "SELECT ?s WHERE { ?s ?p ?o } ; INSERT DATA { }" + assert not _is_read_only_query(query) + + def test_case_insensitive_insert(self): + assert not _is_read_only_query("insert data {

}") + + def test_load_blocked(self): + assert not _is_read_only_query("LOAD ") + + def test_clear_blocked(self): + assert not _is_read_only_query("CLEAR ALL") + + def test_empty_query_rejected(self): + assert not _is_read_only_query("") + + def test_whitespace_only_rejected(self): + assert not _is_read_only_query(" \n\t ") + + def test_base_before_select(self): + query = "BASE \nSELECT ?s WHERE { ?s ?p ?o }" + assert _is_read_only_query(query) + + +# =================================================================== +# 2. Cypher injection prevention +# =================================================================== + +class TestCypherInjection: + """Regression tests for Cypher/SQL injection prevention.""" + + def test_sanitize_label_valid(self): + from semantica.graph_store.age_store import _sanitize_label + assert _sanitize_label("Entity") == "Entity" + assert _sanitize_label("my_label_123") == "my_label_123" + + def test_sanitize_label_injection(self): + from semantica.graph_store.age_store import _sanitize_label + with pytest.raises(Exception): # ValidationError + _sanitize_label("Entity') OR 1=1--") + + def test_sanitize_label_special_chars(self): + from semantica.graph_store.age_store import _sanitize_label + with pytest.raises(Exception): + _sanitize_label("Entity;DROP TABLE") + + def test_value_to_cypher_literal_string_escaping(self): + from semantica.graph_store.age_store import _value_to_cypher_literal + result = _value_to_cypher_literal("O'Brien") + assert "\\'" in result # Single quote should be escaped + + def test_value_to_cypher_literal_backslash(self): + from semantica.graph_store.age_store import _value_to_cypher_literal + result = _value_to_cypher_literal("path\\to\\file") + assert "\\\\" in result + + def test_dollar_dollar_breakout_blocked(self): + """$$ in a Cypher query would break out of AGE's delimiter.""" + from semantica.graph_store.age_store import ApacheAgeStore + store = ApacheAgeStore.__new__(ApacheAgeStore) + store.graph_name = "test_graph" + store._conn = None + with pytest.raises(Exception): # ValidationError + store._execute_cypher("MATCH (n) RETURN n $$ ) AS (x agtype); DROP TABLE users; --") + + def test_graph_name_sanitization(self): + """Graph name with SQL injection should be rejected.""" + from semantica.graph_store.age_store import ApacheAgeStore + with pytest.raises(Exception): # ValidationError + ApacheAgeStore( + connection_string="host=localhost", + graph_name="test'); DROP TABLE--" + ) + + def test_graph_name_valid(self): + from semantica.graph_store.age_store import ApacheAgeStore + store = ApacheAgeStore( + connection_string="host=localhost", + graph_name="my_graph_123" + ) + assert store.graph_name == "my_graph_123" + + def test_property_key_injection(self): + from semantica.graph_store.age_store import _props_to_cypher_literal + with pytest.raises(Exception): + _props_to_cypher_literal({"key; DROP": "value"}) + + +# =================================================================== +# 3. XXE Protection (fail-closed) +# =================================================================== + +class TestXXEProtection: + """Regression tests for XXE prevention in rdf_parser.""" + + def test_defusedxml_check_exists(self): + """The _HAS_DEFUSEDXML flag must exist.""" + from semantica.explorer.utils.rdf_parser import _HAS_DEFUSEDXML + assert isinstance(_HAS_DEFUSEDXML, bool) + + def test_safe_parse_rdf_function_exists(self): + """_safe_parse_rdf must be importable.""" + from semantica.explorer.utils.rdf_parser import _safe_parse_rdf + assert callable(_safe_parse_rdf) + + +# =================================================================== +# 4. Numpy vector serialization +# =================================================================== + +class TestVectorSerialization: + """Regression test for numpy array serialization in vector_store.""" + + def test_tolist_on_numpy_like(self): + """Objects with tolist() should use it instead of list().""" + + class FakeNumpyArray: + def __init__(self, data): + self._data = data + + def tolist(self): + return self._data + + def __iter__(self): + # list() would call this and fail for multi-dim arrays + raise TypeError("Use tolist() for numpy arrays") + + arr = FakeNumpyArray([1.0, 2.0, 3.0]) + # Simulate the fixed logic + result = arr.tolist() if hasattr(arr, "tolist") else list(arr) + assert result == [1.0, 2.0, 3.0] + + def test_regular_list_still_works(self): + """Regular lists (no tolist) should use list().""" + data = [1.0, 2.0, 3.0] + result = data.tolist() if hasattr(data, "tolist") else list(data) + assert result == [1.0, 2.0, 3.0] + + +# =================================================================== +# 5. SSRF redirect handling +# =================================================================== + +class TestSSRFRedirectHandling: + """Regression tests for SSRF redirect fixes.""" + + def test_urljoin_resolves_relative(self): + """Relative Location headers must be resolved against current URL.""" + from urllib.parse import urljoin + base = "https://example.com/api/ontology" + relative = "/ontology.ttl" + result = urljoin(base, relative) + assert result == "https://example.com/ontology.ttl" + + def test_urljoin_absolute_passthrough(self): + """Absolute Location headers should pass through unchanged.""" + from urllib.parse import urljoin + base = "https://example.com/api/ontology" + absolute = "https://other.com/data.ttl" + result = urljoin(base, absolute) + assert result == "https://other.com/data.ttl" + + def test_urljoin_relative_path(self): + """Relative path without leading slash.""" + from urllib.parse import urljoin + base = "https://example.com/api/v1/resource" + relative = "../data.ttl" + result = urljoin(base, relative) + assert result == "https://example.com/api/data.ttl" + + +# =================================================================== +# 6. API Key Auth +# =================================================================== + +class TestAPIKeyAuth: + """Regression tests for API key authentication.""" + + def test_auth_module_importable(self): + from semantica.explorer.auth import APIKeyAuthMiddleware + assert APIKeyAuthMiddleware is not None + + def test_extract_bearer_token(self): + from semantica.explorer.auth import _extract_token + from unittest.mock import MagicMock + req = MagicMock() + req.headers = {"Authorization": "Bearer test-key-123"} + assert _extract_token(req) == "test-key-123" + + def test_extract_api_key_header(self): + from semantica.explorer.auth import _extract_token + from unittest.mock import MagicMock + req = MagicMock() + req.headers = {"X-API-Key": "my-secret-key", "Authorization": ""} + assert _extract_token(req) == "my-secret-key" + + def test_extract_no_token(self): + from semantica.explorer.auth import _extract_token + from unittest.mock import MagicMock + req = MagicMock() + req.headers = {} + assert _extract_token(req) is None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From abc10bc8e0761ec09c6262812d63affee624cc5f Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Tue, 11 Aug 2026 15:36:49 +0530 Subject: [PATCH 21/22] fix(security): restore GHSA-j4mq auth enforcement, fix SPARQL comment-regex bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues in the last round of commits: 1. explorer/auth.py added a new, opt-in APIKeyAuthMiddleware (EXPLORER_API_KEY) and wired it into create_app(), but in doing so removed the Depends(require_auth) dependency from every router and deleted the /ws/graph-updates handshake check entirely. The new middleware also fails OPEN (allows all requests) when its key is unset, the opposite of require_auth's fail-closed design. Since GHSA-j4mq-hprp-987v (the unauthenticated-Explorer-API advisory) is already merged into main via require_auth, this would have reverted a merged Critical fix the moment this branch merges. Removed explorer/auth.py, restored the per-router dependencies and the WebSocket auth check. Kept auth.py's one genuine improvement (adding X-API-Key to the CORS allow_headers list) by folding it into the existing CORS middleware config. 2. sparql.py's new _is_read_only_query() hardening (comment/PREFIX stripping + forbidden-keyword scan) used `#[^\n]*` to strip SPARQL comments, but a bare '#' also appears inside standard RDF namespace IRIs (e.g. ".../1999/02/22-rdf-syntax-ns#") — the regex struck everything after that '#' as a "comment", corrupting the query and rejecting any legitimate SELECT using rdf:/rdfs:-style PREFIX declarations. Confirmed by the fact the new hardening's own inlined test copy failed against two of its own cases. Fixed by only treating '#' as a comment-start at line-start or after whitespace, which distinguishes ".../ns#" (preceded by a word character) from an actual comment (preceded by whitespace/newline in every realistic case, including the attacker's own comment-hiding PoC). Also fixed the companion PREFIX/BASE regex, which required a prefix-name token between the keyword and the IRI even for bare `BASE <...>` declarations (which have none). tests/test_security_regression.py's SPARQL section now imports the real _is_read_only_query instead of maintaining a parallel inlined copy that had silently drifted from — and shared the same bug as — the real implementation; removed its TestAPIKeyAuth class (tested the now-deleted auth.py) since equivalent, more thorough coverage already exists in tests/explorer/test_explorer_auth.py. Updated tests/explorer/test_sparql_route.py's multi-statement-injection test to reflect that the keyword scan now catches "SELECT ... ; DROP ALL" itself rather than relying on rdflib's parser, and added a new test confirming the parser still catches multi-statement syntax that doesn't contain any forbidden keyword. Full explorer/vector_store/security-regression/age_store suite: 543 passed (the only failures are 6 pre-existing, unrelated Pinecone-client mocking issues). --- semantica/explorer/app.py | 63 ++++++++++------ semantica/explorer/auth.py | 102 -------------------------- semantica/explorer/routes/sparql.py | 28 +++++-- tests/explorer/test_sparql_route.py | 23 ++++-- tests/test_security_regression.py | 110 ++++++++++------------------ 5 files changed, 119 insertions(+), 207 deletions(-) delete mode 100644 semantica/explorer/auth.py diff --git a/semantica/explorer/app.py b/semantica/explorer/app.py index 3d175ed2..68bab729 100644 --- a/semantica/explorer/app.py +++ b/semantica/explorer/app.py @@ -8,16 +8,16 @@ from contextlib import asynccontextmanager from pathlib import Path from typing import Optional -from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect +from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from .. import __version__ from ..context.context_graph import ContextGraph +from .dependencies import anonymous_access_allowed, get_expected_api_key, is_valid_api_key, require_auth from .session import GraphSession from .ws import ConnectionManager -from .auth import APIKeyAuthMiddleware, warn_if_unauthenticated def _read_int_env(name: str, default: int) -> int: @@ -98,6 +98,22 @@ def create_app( @asynccontextmanager async def lifespan(app: FastAPI): + import logging as _lifespan_logging + _lifespan_logger = _lifespan_logging.getLogger(__name__) + if anonymous_access_allowed(): + _lifespan_logger.warning( + "Explorer is running with SEMANTICA_ALLOW_ANONYMOUS=true — " + "all API routes are unauthenticated. Do not expose this " + "process beyond localhost." + ) + elif get_expected_api_key(): + _lifespan_logger.info("Explorer API authentication: enabled (SEMANTICA_API_KEY set).") + else: + _lifespan_logger.warning( + "Explorer API authentication: NOT CONFIGURED. All protected " + "routes will return 503 until SEMANTICA_API_KEY is set." + ) + app.state.event_loop = asyncio.get_running_loop() app.state.ws_manager = ConnectionManager() app.state.session = active_session @@ -114,10 +130,10 @@ def create_app( app.state.explorer_settings = settings # allow_credentials lets browsers send cookies/auth headers cross-origin. - # The Explorer has no authentication, so credentials serve no purpose and - # enabling them when origins are broadened creates cross-site request risk. - # Set EXPLORER_CORS_CREDENTIALS=true explicitly to opt in (e.g. for a - # reverse-proxy setup that injects its own auth layer). + # Credentials aren't needed for the X-API-Key auth scheme below, and + # enabling them when origins are broadened creates cross-site request + # risk. Set EXPLORER_CORS_CREDENTIALS=true explicitly to opt in (e.g. + # for a reverse-proxy setup that injects its own cookie-based auth). _allow_credentials = os.environ.get("EXPLORER_CORS_CREDENTIALS", "false").lower() == "true" app.add_middleware( CORSMiddleware, @@ -128,10 +144,6 @@ def create_app( max_age=600, ) - # API key authentication (opt-in via EXPLORER_API_KEY env var) - app.add_middleware(APIKeyAuthMiddleware) - warn_if_unauthenticated() - import logging as _logging _logger = _logging.getLogger(__name__) @@ -164,22 +176,31 @@ def create_app( from .routes.temporal import router as temporal_router from .routes.vocabulary import router as vocabulary_router - app.include_router(graph_router) - app.include_router(analytics_router) - app.include_router(decisions_router) - app.include_router(temporal_router) - app.include_router(enrich_router) - app.include_router(export_import_router) - app.include_router(annotations_router) - app.include_router(sparql_router) - app.include_router(provenance_router) - app.include_router(vocabulary_router) - app.include_router(ontology_router) + _auth = [Depends(require_auth)] + app.include_router(graph_router, dependencies=_auth) + app.include_router(analytics_router, dependencies=_auth) + app.include_router(decisions_router, dependencies=_auth) + app.include_router(temporal_router, dependencies=_auth) + app.include_router(enrich_router, dependencies=_auth) + app.include_router(export_import_router, dependencies=_auth) + app.include_router(annotations_router, dependencies=_auth) + app.include_router(sparql_router, dependencies=_auth) + app.include_router(provenance_router, dependencies=_auth) + app.include_router(vocabulary_router, dependencies=_auth) + app.include_router(ontology_router, dependencies=_auth) _WS_MAX_MESSAGE_BYTES = 64 * 1024 # 64 KB — control messages only @app.websocket("/ws/graph-updates") async def websocket_endpoint(websocket: WebSocket): + # Browsers can't set custom headers on a WebSocket handshake, so + # accept the key via header (non-browser clients) or query param + # (browser clients), same SEMANTICA_API_KEY the REST routes check. + candidate = websocket.headers.get("x-api-key") or websocket.query_params.get("api_key") + if not is_valid_api_key(candidate): + await websocket.close(code=4401) # unauthorized + return + manager: ConnectionManager = app.state.ws_manager await manager.connect(websocket) await manager.send_personal(websocket, "connection_ack", {"connected": True}) diff --git a/semantica/explorer/auth.py b/semantica/explorer/auth.py deleted file mode 100644 index 9785b1b5..00000000 --- a/semantica/explorer/auth.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -Semantica Explorer : Authentication Middleware - -Provides opt-in API key authentication for all Explorer API routes. - -Enable by setting the ``EXPLORER_API_KEY`` environment variable. When set, -every request to ``/api/*`` must include either: - -- An ``Authorization: Bearer `` header, or -- An ``X-API-Key: `` header. - -When ``EXPLORER_API_KEY`` is not set, authentication is disabled and the -Explorer operates in open/development mode (with a startup warning). -""" - -import hmac -import logging -import os -from typing import Optional - -from fastapi import HTTPException, Request, status -from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint -from starlette.responses import Response - -_logger = logging.getLogger(__name__) - - -def _get_api_key() -> Optional[str]: - """Read the configured API key from the environment.""" - return os.environ.get("EXPLORER_API_KEY") - - -def _extract_token(request: Request) -> Optional[str]: - """Extract the API key from the request headers.""" - # Check Authorization: Bearer - auth_header = request.headers.get("Authorization", "") - if auth_header.startswith("Bearer "): - return auth_header[7:].strip() - - # Check X-API-Key: - api_key_header = request.headers.get("X-API-Key", "") - if api_key_header: - return api_key_header.strip() - - return None - - -class APIKeyAuthMiddleware(BaseHTTPMiddleware): - """ - Middleware that enforces API key authentication on ``/api/*`` routes. - - Skips authentication for: - - Non-API routes (static files, health checks, WebSocket, docs) - - OPTIONS requests (CORS preflight) - - When ``EXPLORER_API_KEY`` is not configured (open mode) - """ - - async def dispatch( - self, request: Request, call_next: RequestResponseEndpoint - ) -> Response: - api_key = _get_api_key() - - # If no API key is configured, allow all requests (open mode) - if not api_key: - return await call_next(request) - - # Skip authentication for non-API paths - path = request.url.path - if not path.startswith("/api/"): - return await call_next(request) - - # Skip CORS preflight - if request.method == "OPTIONS": - return await call_next(request) - - # Validate the token - token = _extract_token(request) - if not token: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Missing API key. Provide via 'Authorization: Bearer ' or 'X-API-Key: ' header.", - headers={"WWW-Authenticate": "Bearer"}, - ) - - # Constant-time comparison to prevent timing attacks - if not hmac.compare_digest(token, api_key): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid API key.", - ) - - return await call_next(request) - - -def warn_if_unauthenticated() -> None: - """Log a warning at startup if no API key is configured.""" - if not _get_api_key(): - _logger.warning( - "EXPLORER_API_KEY is not set. The Explorer API is running WITHOUT " - "authentication. Set EXPLORER_API_KEY to enable API key protection " - "for all /api/* endpoints." - ) diff --git a/semantica/explorer/routes/sparql.py b/semantica/explorer/routes/sparql.py index 0a0abee4..8271d38d 100644 --- a/semantica/explorer/routes/sparql.py +++ b/semantica/explorer/routes/sparql.py @@ -3,11 +3,16 @@ SPARQL routes backed by an in-memory rdflib projection of the current graph. Security contract ----------------- -* Only SELECT, ASK, CONSTRUCT, and DESCRIBE are accepted (allowlist enforced - before graph construction so rejected queries never touch the session). -* Multi-statement injections that start with an allowed keyword (e.g. - ``SELECT ... ; DROP ALL``) pass the prefix check and reach rdflib, which - rejects non-SELECT/ASK/CONSTRUCT/DESCRIBE update syntax in the parser. +* Only SELECT, ASK, CONSTRUCT, and DESCRIBE are accepted, and the query + body is scanned for SPARQL Update keywords (INSERT/DELETE/DROP/LOAD/ + CLEAR/CREATE/COPY/MOVE/ADD) after stripping comments and PREFIX/BASE + declarations — both enforced before graph construction, so rejected + queries never touch the session. A multi-statement injection appended + after an allowed keyword (e.g. ``SELECT ... ; DROP ALL``) is caught by + the keyword scan itself, not left to rdflib's parser. +* rdflib's parser remains a second line of defense for malformed multi- + statement syntax that doesn't contain any forbidden keyword (e.g. + ``SELECT ... ; ASK ...``), which SPARQL 1.1 Query doesn't permit. * The in-memory rdflib graph is a read-only projection — the live ``GraphSession`` is never mutated by this route. """ @@ -38,9 +43,16 @@ _FORBIDDEN_KEYWORDS = re.compile( re.IGNORECASE, ) -# Matches SPARQL single-line comments (# ...) and PREFIX declarations -_COMMENT_LINE = re.compile(r"#[^\n]*", re.MULTILINE) -_PREFIX_DECL = re.compile(r"^\s*(?:PREFIX|BASE)\s+\S+\s*<[^>]*>\s*", re.IGNORECASE | re.MULTILINE) +# Matches SPARQL single-line comments (# ...) and PREFIX/BASE declarations. +# The comment regex only treats '#' as a comment-starter at line-start or +# after whitespace — not mid-token — since RDF namespace IRIs commonly +# contain a literal '#' (e.g. ".../1999/02/22-rdf-syntax-ns#"), and a naive +# `#[^\n]*` would truncate every such PREFIX declaration's IRI, corrupting +# the query. BASE declarations have no prefix name between the keyword and +# the IRI (`BASE <...>`, vs. `PREFIX ex: <...>`), so the prefix-name token +# is optional. +_COMMENT_LINE = re.compile(r"(?:^|(?<=\s))#[^\n]*", re.MULTILINE) +_PREFIX_DECL = re.compile(r"^\s*(?:PREFIX\s+\S+|BASE)\s*<[^>]*>\s*", re.IGNORECASE | re.MULTILINE) def _is_read_only_query(query: str) -> bool: diff --git a/tests/explorer/test_sparql_route.py b/tests/explorer/test_sparql_route.py index 77cc5b71..1d8547c6 100644 --- a/tests/explorer/test_sparql_route.py +++ b/tests/explorer/test_sparql_route.py @@ -199,14 +199,27 @@ def test_allowlist_rejected_query_never_touches_the_graph(client, query): mock_build.assert_not_called() -def test_multi_statement_injection_reaches_graph_but_fails_in_parser(client): - """Confirms the distinction between allowlist rejection and parser rejection: - a string starting with SELECT passes _is_read_only_query and builds a graph, - but rdflib.Graph.query() rejects the trailing '; DROP ALL' syntax.""" +def test_multi_statement_injection_is_rejected_by_forbidden_keyword_check(client): + """A string starting with an allowed keyword (SELECT) but containing a + forbidden Update keyword later in the body ('; DROP ALL') is now + rejected by _is_read_only_query's keyword scan itself, before a graph + is ever built — a stronger, earlier rejection than relying solely on + rdflib's parser to reject the syntax.""" + with patch.object(sparql_mod, "_build_rdflib_graph") as mock_build: + resp = _post(client, "SELECT ?s WHERE { ?s ?p ?o } ; DROP ALL") + assert resp.status_code == 200 + assert resp.json()["error"] is not None + mock_build.assert_not_called() + + +def test_malformed_syntax_without_forbidden_keywords_still_fails_in_parser(client): + """The parser remains a real second line of defense for malformed + queries that don't contain any forbidden keyword — these pass + _is_read_only_query and reach rdflib, which rejects the syntax.""" with patch.object( sparql_mod, "_build_rdflib_graph", wraps=sparql_mod._build_rdflib_graph ) as spy_build: - resp = _post(client, "SELECT ?s WHERE { ?s ?p ?o } ; DROP ALL") + resp = _post(client, "SELECT ?s WHERE { ?s ?p ?o } ; ASK { ?x ?y ?z }") assert resp.status_code == 200 assert resp.json()["error"] is not None spy_build.assert_called_once() diff --git a/tests/test_security_regression.py b/tests/test_security_regression.py index cab6cf9e..18854f0b 100644 --- a/tests/test_security_regression.py +++ b/tests/test_security_regression.py @@ -2,48 +2,29 @@ Regression tests for security fixes in PR #898. Covers: - 1. API Key Authentication (Explorer) - 2. Cypher Injection Prevention (AGE Store) - 3. SPARQL Injection Prevention (read-only query validation) - 4. XXE Protection (rdf_parser fail-closed) - 5. Vector save numpy serialization - 6. SPARQL graph cap error handling - 7. SSRF redirect handling (relative URLs, resp.close) + 1. Cypher Injection Prevention (AGE Store) + 2. SPARQL Injection Prevention (read-only query validation) + 3. XXE Protection (rdf_parser fail-closed) + 4. Vector save numpy serialization + 5. SPARQL graph cap error handling + 6. SSRF redirect handling (relative URLs, resp.close) + +Explorer API-key authentication (GHSA-j4mq-hprp-987v) has its own, more +thorough test suite at tests/explorer/test_explorer_auth.py — it isn't +duplicated here. """ -import re import pytest - -# =================================================================== -# 1. SPARQL read-only query validation (injection prevention) -# =================================================================== - -# Inline the validation logic so tests don't require full app context -_ALLOWED_QUERY_TYPES = re.compile( - r"^(SELECT|ASK|CONSTRUCT|DESCRIBE)\b", - re.IGNORECASE, -) -_FORBIDDEN_KEYWORDS = re.compile( - r"\b(INSERT|DELETE|DROP|LOAD|CLEAR|CREATE|COPY|MOVE|ADD)\b", - re.IGNORECASE, -) -_COMMENT_LINE = re.compile(r"#[^\n]*", 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: - cleaned = _COMMENT_LINE.sub("", query) - cleaned = _PREFIX_DECL.sub("", cleaned) - cleaned = cleaned.strip() - if not _ALLOWED_QUERY_TYPES.match(cleaned): - return False - if _FORBIDDEN_KEYWORDS.search(cleaned): - return False - return True +# Import the real implementation rather than re-declaring the regexes here: +# an earlier version of this file inlined a copy that silently drifted from +# semantica/explorer/routes/sparql.py's actual behavior (the inlined +# _COMMENT_LINE regex stripped '#' mid-token, corrupting any PREFIX +# declaration using a namespace IRI with a literal '#', e.g. the standard +# rdf:/rdfs: namespaces) and neither the code nor this test caught it, +# since both had the same bug. Importing the real function makes that class +# of drift impossible. +from semantica.explorer.routes.sparql import _is_read_only_query class TestSparqlReadOnlyValidation: @@ -122,6 +103,26 @@ class TestSparqlReadOnlyValidation: query = "BASE \nSELECT ?s WHERE { ?s ?p ?o }" assert _is_read_only_query(query) + def test_namespace_iri_with_hash_fragment_not_treated_as_comment(self): + """A '#' inside a PREFIX declaration's IRI (standard for RDF/RDFS/OWL + namespaces) must not be mistaken for a comment-start — a naive + `#[^\\n]*` strip corrupts the IRI and truncates the rest of the + query with it.""" + query = ( + "PREFIX rdf: \n" + "SELECT ?s WHERE { ?s rdf:type ?o }" + ) + assert _is_read_only_query(query) + + def test_real_comment_after_namespace_iri_still_stripped(self): + """A genuine trailing comment must still be recognized even on a + line that also contains a '#'-bearing IRI earlier in the query.""" + query = ( + "PREFIX rdf: \n" + "SELECT ?s WHERE { ?s rdf:type ?o } # trailing comment INSERT DATA" + ) + assert _is_read_only_query(query) + # =================================================================== # 2. Cypher injection prevention @@ -270,38 +271,5 @@ class TestSSRFRedirectHandling: assert result == "https://example.com/api/data.ttl" -# =================================================================== -# 6. API Key Auth -# =================================================================== - -class TestAPIKeyAuth: - """Regression tests for API key authentication.""" - - def test_auth_module_importable(self): - from semantica.explorer.auth import APIKeyAuthMiddleware - assert APIKeyAuthMiddleware is not None - - def test_extract_bearer_token(self): - from semantica.explorer.auth import _extract_token - from unittest.mock import MagicMock - req = MagicMock() - req.headers = {"Authorization": "Bearer test-key-123"} - assert _extract_token(req) == "test-key-123" - - def test_extract_api_key_header(self): - from semantica.explorer.auth import _extract_token - from unittest.mock import MagicMock - req = MagicMock() - req.headers = {"X-API-Key": "my-secret-key", "Authorization": ""} - assert _extract_token(req) == "my-secret-key" - - def test_extract_no_token(self): - from semantica.explorer.auth import _extract_token - from unittest.mock import MagicMock - req = MagicMock() - req.headers = {} - assert _extract_token(req) is None - - if __name__ == "__main__": pytest.main([__file__, "-v"]) From 6002965c55753aa2b08c985ce8543b915a1152dd Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Tue, 11 Aug 2026 15:39:11 +0530 Subject: [PATCH 22/22] docs(changelog): document PR #898's full scope, including the maintainer follow-up fixes --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a77ec2f1..b7dcc2cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -244,6 +244,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- **4 critical/high vulnerabilities in the Explorer API and vector store: RCE, SSRF, XXE, and DoS, plus Cypher/SPARQL injection hardening found along the way** (#898) by @Sunil56224972 + - **[CWE-502] Arbitrary code execution via `pickle.load()`**: `VectorStore.save()`/`load()` used `pickle` for the on-disk `store_data.pkl`; a crafted `.pkl` file placed in the store directory (file upload, shared filesystem, or supply-chain compromise) could execute arbitrary code on deserialization. Replaced with JSON — vectors and metadata are fully JSON-serializable, so nothing is lost — and `load()` now refuses any legacy `.pkl` file it finds with a migration error rather than deserializing it + - **[CWE-918] SSRF via redirect bypass in `ontology.py`'s URL fetcher**: `_validate_fetch_url()` correctly blocked private/loopback/reserved addresses on the caller-supplied URL, but `_fetch_url_sync()` fetched with `allow_redirects=True`, so a validated *public* first hop could 302 to `http://169.254.169.254/...` (cloud instance metadata) or an internal service, and `requests` followed it with no re-check. Redirects are now followed manually, capped at 5 hops, with `_validate_fetch_url()` re-run against every hop's target — including relative `Location` headers, resolved via `urljoin()` before validation — and every response (redirect or final) is explicitly closed to avoid leaking connections back to the pool + - **[CWE-611] XXE injection in the RDF/XML parser**: `_safe_parse_rdf()` depended on `defusedxml` for XXE protection, but `defusedxml` wasn't declared in `pyproject.toml`'s `explorer` extra, so it was silently absent in normal installs and the code fell back to a bare warning plus unsafe parsing — a crafted RDF/XML ontology with an external entity could read arbitrary server files. Added `defusedxml>=0.7.1` to the extra, and `_safe_parse_rdf()` now fails closed: it raises rather than parsing untrusted RDF/XML if `defusedxml` isn't importable, replacing an earlier regex-based DOCTYPE-stripping fallback that was reviewed and rejected as bypassable + - **[CWE-770] DoS via unbounded SPARQL graph materialization**: `_build_rdflib_graph()` loaded up to 999,999 nodes and 999,999 edges into memory per query, and with up to 4 concurrent SPARQL requests permitted, an attacker could exhaust server memory. Added a 50,000 node/edge cap (`_SPARQL_MAX_GRAPH_NODES`); oversized graphs now return a clean error instead of attempting materialization + - **Cypher injection via Apache AGE's `graph_name` and `$$`-delimiter breakout**: `graph_name` was interpolated unvalidated into `cypher('{graph_name}', $$ ... $$)`, and raw Cypher query text containing `$$` could close AGE's dollar-quoted string delimiter early and append arbitrary SQL. `graph_name` is now validated against the same identifier allowlist `age_store.py` already used for labels/relationship types, and any query containing `$$` is rejected outright + - **SPARQL Explorer route (`/api/sparql`) hardened against comment/PREFIX-hiding bypass**: `_is_read_only_query()` now strips comments and PREFIX/BASE declarations before checking the leading keyword, and additionally scans the full query body for SPARQL Update keywords (INSERT/DELETE/DROP/LOAD/CLEAR/CREATE/COPY/MOVE/ADD) — so `SELECT ... ; DROP ALL` is now rejected by the keyword scan itself rather than relying solely on rdflib's parser + - **Fixed along the way** (maintainer follow-up, addressing automated review findings and a regression introduced across several rounds of iteration on the original fix): + - `VectorStore.save()`'s numpy handling used `list(v)` for the JSON fallback path, which produces `numpy.float32` elements that `json.dump()` can't serialize — changed to `v.tolist()` + - the SPARQL graph-size `ValueError` was raised outside `execute_sparql()`'s exception handling and surfaced as an unhandled 500 instead of a clean API error — moved inside + - every streamed `requests` response in the ontology redirect loop, including the one actually read and returned, is now closed in a `finally` block — a connection-pool leak that a rework of the redirect logic had briefly reintroduced after an earlier fix + - a later commit meant to add opt-in API-key auth (`explorer/auth.py`, gated on `EXPLORER_API_KEY`) instead **replaced and silently disabled** the `Depends(require_auth)` enforcement already merged into `main` for GHSA-j4mq-hprp-987v (Critical — unauthenticated Explorer API), removed the `/ws/graph-updates` handshake check, and — unlike `require_auth` — failed *open* (allowed all requests) whenever its key was unset. Merging that version would have silently reverted an already-fixed Critical CVE the moment this branch landed. Removed `explorer/auth.py`; restored the per-router `Depends(require_auth)` wiring and the WebSocket auth check; kept the one genuine improvement in that commit (adding `X-API-Key` to the CORS `allow_headers` list) by folding it into the existing CORS config + - the new SPARQL keyword-scan's comment-stripping regex (`#[^\n]*`) also matched the `#` inside standard RDF namespace IRIs (e.g. `.../1999/02/22-rdf-syntax-ns#`), corrupting any query with a normal `rdf:`/`rdfs:`-style `PREFIX` declaration — caught because the hardening's own bundled tests failed against two of its own cases. Fixed by only treating `#` as a comment-start at line-start or after whitespace; the companion `PREFIX`/`BASE` regex was also fixed to accept bare `BASE <...>` declarations, which have no prefix-name token between the keyword and the IRI + - New/updated regression tests: `tests/explorer/test_ontology_ssrf.py` (redirect re-validation, relative-redirect resolution, response closing, redirect-cap enforcement), `tests/test_security_regression.py` (Cypher/SPARQL injection, XXE, numpy serialization, SSRF redirect handling), plus additions to `tests/explorer/test_sparql_route.py`, `tests/vector_store/test_vector_store.py`, and `tests/explorer/test_explorer_auth.py` + - Note: the Cypher-injection hardening here is scoped to `age_store.py`'s `graph_name`/`$$` breakout, found while reviewing this PR. The broader label/property-key/relationship-type injection across the Neptune, Neo4j, and FalkorDB backends (GHSA-482h-hw99-h62p, #910) and the triplet-store SPARQL injection across Blazegraph/RDF4J/Jena (GHSA-8vgg-8mr4-r236, #911) are covered by separate, still-open PRs, as is the unauthenticated-Explorer-API fix referenced above (GHSA-j4mq-hprp-987v, #909, already merged) + - **CI/CD supply-chain hardening against mutable-tag Action compromise (LiteLLM/Trivy-class attack)** (#824) by @KaifAhmad1 - Every third-party GitHub Action across all 8 workflows is now pinned to a full commit SHA instead of a mutable tag (`@v7` → `@3d3c42e... # v7`), closing the exact vector used against LiteLLM in March 2026 (a compromised Trivy Action tag stole a long-lived publishing token) - Added `verify-action-pins.yml` + `.github/scripts/verify-action-pins.sh`: a CI check that fails closed on any `uses:` reference that isn't a full SHA (catching a newly introduced mutable tag, not just auditing existing pins) and re-verifies every pin against the GitHub API on each workflow change, on push to `main`, and weekly; an unresolvable API lookup is treated as a failure rather than a silent skip