From 43b207c1c56f3f94c24884c646e2413847fd1925 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Tue, 18 Aug 2026 13:58:32 +0530 Subject: [PATCH 1/7] fix(security): address privately disclosed zip-slip, SQLi, SSRF, XSS, and SPARQLi findings Fixes a set of runtime trust-boundary issues from a private security disclosure (checkout 7c3372c0): tarball restore path traversal, latent SQL injection in the DB exporter, a DNS-rebinding TOCTOU gap in the shared SSRF guard, unescaped HTML in report generation, and unvalidated SPARQL object IRIs in AnzoStore, plus several lower-severity hardening items found in the same review. --- mcp/server.py | 14 +- semantica/cli.py | 63 +++- semantica/export/report_generator.py | 22 +- semantica/graph_store/age_store.py | 11 + semantica/graph_store/neo4j_store.py | 19 ++ semantica/ingest/db_ingestor.py | 86 +++++- semantica/ingest/ssrf.py | 285 +++++++++++++++++- semantica/parse/media_parser.py | 10 +- semantica/triplet_store/anzo_store.py | 4 +- .../test_auth_header_redirect_security.py | 44 +-- tests/ingest/test_cookbook_integration.py | 2 +- tests/ingest/test_feed_ingestor.py | 4 +- tests/ingest/test_ssrf_protection.py | 2 +- tests/ingest/test_submodules.py | 4 +- 14 files changed, 507 insertions(+), 63 deletions(-) diff --git a/mcp/server.py b/mcp/server.py index 3cd935be..bf84dd24 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -93,7 +93,14 @@ def _handle_tools_call(req_id: Any, params: dict) -> dict: result = tool["_handler"](args) except Exception as exc: log.exception("Tool %s raised an exception", name) - return _err(req_id, _INTERNAL_ERROR, str(exc)) + # The exception's class name (e.g. "ValidationError", "TimeoutError") + # is safe to surface — unlike str(exc), it never carries paths, + # connection strings, or other internal detail — and lets the + # client distinguish failure kinds without a full message. + return _err( + req_id, _INTERNAL_ERROR, + f"Tool '{name}' failed ({type(exc).__name__}). See server logs for details.", + ) # MCP spec: content must be a list of content items return _ok(req_id, { @@ -171,7 +178,10 @@ class SemanticaMCPServer: log.exception("Unhandled error in method %s", method) if req_id is None: return None - return _err(req_id, _INTERNAL_ERROR, str(exc)) + return _err( + req_id, _INTERNAL_ERROR, + f"Method '{method}' failed ({type(exc).__name__}). See server logs for details.", + ) # ------------------------------------------------------------------ def run(self) -> None: diff --git a/semantica/cli.py b/semantica/cli.py index 7b944dc6..403971e0 100644 --- a/semantica/cli.py +++ b/semantica/cli.py @@ -20,7 +20,7 @@ if sys.platform == "win32": sys.stderr.reconfigure(encoding="utf-8", errors="replace") from dataclasses import asdict, dataclass, field, is_dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Tuple import yaml @@ -3933,15 +3933,68 @@ def backup_restore(cli_ctx: CLIContext, source: str, local_dry: bool) -> None: try: if _tf.is_tarfile(str(work_path)): - restore_root = Path.cwd() + restore_root = Path.cwd().resolve() with _tf.open(str(work_path), "r:*") as tar: # Dry-run listing was already handled above; extract now for member in tar.getmembers(): # Strip the leading "semantica-backup/" prefix member.name = member.name.replace("semantica-backup/", "", 1) - if member.name: - tar.extract(member, path=str(restore_root)) - console.print(f" restored: {member.name}") + if not member.name: + continue + + # Reject members whose resolved path escapes the + # restore root (path traversal / absolute paths), + # regardless of the "semantica-backup/" prefix. + member_path = (restore_root / member.name).resolve() + try: + member_path.relative_to(restore_root) + except ValueError: + raise click.ClickException( + f"Refusing to restore '{member.name}': " + "path escapes the restore directory." + ) + + # Reject symlink/hardlink members whose target + # escapes the restore root. Checked two ways: + # lexically (linkname itself, so an absolute path or + # a literal ".." segment is rejected outright, with + # no dependence on what else does or doesn't already + # exist on disk) and by resolution (catches any + # remaining traversal the lexical check misses). + if member.issym() or member.islnk(): + linkname = member.linkname or "" + linkname_parts = PurePosixPath( + linkname.replace("\\", "/") + ).parts + if ( + not linkname + or os.path.isabs(linkname) + or PureWindowsPath(linkname).is_absolute() + or ".." in linkname_parts + ): + raise click.ClickException( + f"Refusing to restore '{member.name}': " + "link target is absolute or traverses " + "out of the archive." + ) + link_target = ( + member_path.parent / linkname + ).resolve() + try: + link_target.relative_to(restore_root) + except ValueError: + raise click.ClickException( + f"Refusing to restore '{member.name}': " + "link target escapes the restore directory." + ) + + extract_kwargs: Dict[str, Any] = {"path": str(restore_root)} + if hasattr(_tf, "data_filter"): + # Python >=3.12: also reject device files, and + # further harden the traversal/ownership checks. + extract_kwargs["filter"] = "data" + tar.extract(member, **extract_kwargs) + console.print(f" restored: {member.name}") elif src.is_dir(): restore_root = Path.cwd() for f in src.rglob("*"): diff --git a/semantica/export/report_generator.py b/semantica/export/report_generator.py index d4b67920..fcee11a1 100644 --- a/semantica/export/report_generator.py +++ b/semantica/export/report_generator.py @@ -23,6 +23,7 @@ Author: Semantica Contributors License: MIT """ +import html import json from datetime import datetime from pathlib import Path @@ -362,7 +363,7 @@ class ReportGenerator: ' ' ) title = data.get("title", "Report") - lines.append(f" {title}") + lines.append(f" {html.escape(str(title))}") lines.append("