fix(mcp): harden remote service boundaries

Harden the HTTP ingest upload path by validating host/origin and method before ticket consumption, enforcing request body limits, and returning 413 for oversized uploads. Hide local runtime/log paths from default remote responses while preserving internal paths for local OpenCode prompts. Add conservative runtime cleanup for tickets, uploads, jobs, and logs, plus MemoryMax on the managed systemd unit.

Validation: python3 -m unittest tests/test_mcp_server_remote.py tests/test_mcp_ingest.py tests/test_mcp_query.py tests/test_mcp_service.py -v; python3 -m unittest discover -v; python3 -m compileall obsidian_wiki tests scripts; git diff --check. Restarted obsidian-wiki-mcp.service and confirmed ActiveState=active, SubState=running, MemoryMax=2147483648, Restart=on-failure.
This commit is contained in:
Codex
2026-07-08 16:37:32 +00:00
parent 688bebb64c
commit 1150107aa3
10 changed files with 595 additions and 37 deletions
+140
View File
@@ -4,6 +4,7 @@
from __future__ import annotations
import argparse
import json
import os
import secrets
import shlex
@@ -11,6 +12,7 @@ import shutil
import socket
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
@@ -36,11 +38,14 @@ DEFAULT_INGEST_OPENCODE_MODEL = "openai/gpt-5.5"
DEFAULT_INGEST_OPENCODE_VARIANT = "high"
DEFAULT_QUERY_OPENCODE_MODEL = "openai/gpt-5.5"
DEFAULT_QUERY_OPENCODE_VARIANT = "minimal"
DEFAULT_SERVICE_MEMORY_MAX = "2G"
DEFAULT_RUNTIME_CLEANUP_DAYS = 30
TOOL_FILE_ASSETS = "file-assets"
TOOL_INGEST = "ingest"
TOOL_QUERY = "query"
AVAILABLE_TOOL_GROUPS = (TOOL_FILE_ASSETS, TOOL_INGEST, TOOL_QUERY)
DEFAULT_ENABLED_TOOLS = (TOOL_FILE_ASSETS,)
TERMINAL_JOB_STATES = {"succeeded", "failed", "cancelled"}
PROXY_ENV_NAMES = ("http_proxy", "https_proxy", "all_proxy", "no_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY")
@@ -181,6 +186,7 @@ def render_service_unit(
path: str = DEFAULT_PATH,
uv_cache_dir: str = DEFAULT_UV_CACHE_DIR,
uv_path: Path | None = None,
memory_max: str = DEFAULT_SERVICE_MEMORY_MAX,
) -> str:
resolved_uv_path = uv_path or find_uv_path()
command = " ".join(
@@ -218,6 +224,7 @@ def render_service_unit(
f"ExecStart={command}\n"
"Restart=on-failure\n"
"RestartSec=5\n"
f"MemoryMax={memory_max}\n"
"\n"
"[Install]\n"
"WantedBy=default.target\n"
@@ -677,6 +684,132 @@ def service_status() -> None:
run_systemctl(["status", SERVICE_NAME], check=False)
def cleanup_runtime(project_root: Path, *, max_age_days: int = DEFAULT_RUNTIME_CLEANUP_DAYS) -> str:
if max_age_days <= 0:
raise RuntimeError("max_age_days must be positive")
root = project_root.expanduser().resolve()
cutoff = time.time() - (max_age_days * 24 * 60 * 60)
ingest_root = root / ".runtime" / "mcp-ingest"
query_root = root / ".runtime" / "mcp-query"
active_upload_ids = _active_ingest_upload_ids(ingest_root / "jobs")
result = {
"expired_upload_tickets_deleted": _delete_expired_upload_tickets(ingest_root / "upload-tickets"),
"old_ingest_uploads_deleted": _delete_old_ingest_uploads(
ingest_root / "uploads",
cutoff=cutoff,
active_upload_ids=active_upload_ids,
),
"old_ingest_jobs_deleted": _delete_old_terminal_jobs(ingest_root / "jobs", cutoff=cutoff),
"old_query_jobs_deleted": _delete_old_terminal_jobs(query_root / "jobs", cutoff=cutoff),
}
return "\n".join(
[
"MCP runtime cleanup.",
"",
f"Project root: {root}",
f"Max age: {max_age_days} days",
f"Expired upload tickets deleted: {result['expired_upload_tickets_deleted']}",
f"Old ingest uploads deleted: {result['old_ingest_uploads_deleted']}",
f"Old ingest jobs/logs deleted: {result['old_ingest_jobs_deleted']}",
f"Old query jobs/logs deleted: {result['old_query_jobs_deleted']}",
]
) + "\n"
def _active_ingest_upload_ids(jobs_root: Path) -> set[str]:
upload_ids: set[str] = set()
for job in _iter_job_records(jobs_root):
if job.get("state") in TERMINAL_JOB_STATES:
continue
upload_ids.update(str(upload_id) for upload_id in job.get("upload_ids", []))
return upload_ids
def _delete_expired_upload_tickets(ticket_root: Path) -> int:
if not ticket_root.exists():
return 0
deleted = 0
now = time.time()
for ticket_path in ticket_root.glob("*.json"):
meta = _read_json(ticket_path)
if meta is None:
continue
try:
expired = now > float(meta.get("expires_epoch", 0))
except (TypeError, ValueError):
expired = False
if not expired:
continue
ticket_path.unlink(missing_ok=True)
deleted += 1
return deleted
def _delete_old_ingest_uploads(upload_root: Path, *, cutoff: float, active_upload_ids: set[str]) -> int:
if not upload_root.exists():
return 0
deleted = 0
for meta_path in upload_root.glob("*/*/upload.json"):
upload_dir = meta_path.parent
meta = _read_json(meta_path)
if meta is None:
continue
if str(meta.get("upload_id", "")) in active_upload_ids:
continue
if _tree_mtime(upload_dir) >= cutoff:
continue
shutil.rmtree(upload_dir, ignore_errors=True)
deleted += 1
return deleted
def _delete_old_terminal_jobs(jobs_root: Path, *, cutoff: float) -> int:
if not jobs_root.exists():
return 0
deleted = 0
for job in _iter_job_records(jobs_root):
if job.get("state") not in TERMINAL_JOB_STATES:
continue
job_id = str(job.get("job_id", ""))
job_dir = jobs_root / job_id
if not job_dir.exists() or _tree_mtime(job_dir) >= cutoff:
continue
shutil.rmtree(job_dir, ignore_errors=True)
deleted += 1
return deleted
def _iter_job_records(jobs_root: Path) -> list[dict[str, object]]:
jobs: list[dict[str, object]] = []
if not jobs_root.exists():
return jobs
for job_path in jobs_root.glob("*/job.json"):
record = _read_json(job_path)
if record is not None:
jobs.append(record)
return jobs
def _read_json(path: Path) -> dict[str, object] | None:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def _tree_mtime(path: Path) -> float:
if not path.exists():
return time.time()
latest = path.stat().st_mtime
for child in path.rglob("*"):
try:
latest = max(latest, child.stat().st_mtime)
except OSError:
continue
return latest
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Manage the readonly HTTP obsidian-wiki MCP user service.")
subparsers = parser.add_subparsers(dest="command", required=True)
@@ -734,6 +867,10 @@ def main(argv: Sequence[str] | None = None) -> int:
subparsers.add_parser("status", help="Show systemd status for the managed service.")
cleanup_parser = subparsers.add_parser("cleanup", help="Prune expired tickets and old MCP runtime uploads/jobs/logs.")
cleanup_parser.add_argument("--project-root", default=Path(__file__).resolve().parent.parent, type=Path)
cleanup_parser.add_argument("--max-age-days", default=DEFAULT_RUNTIME_CLEANUP_DAYS, type=int)
tools_parser = subparsers.add_parser("tools", help="List or update enabled MCP tool groups in the service config.")
tools_subparsers = tools_parser.add_subparsers(dest="tools_command", required=True)
@@ -791,6 +928,9 @@ def main(argv: Sequence[str] | None = None) -> int:
if args.command == "status":
service_status()
return 0
if args.command == "cleanup":
print(cleanup_runtime(args.project_root, max_age_days=args.max_age_days), end="")
return 0
if args.command == "tools":
if args.tools_command == "list":
print(list_tools(args.project_root), end="")