diff --git a/install.sh b/install.sh index b574e12..4689cab 100755 --- a/install.sh +++ b/install.sh @@ -194,7 +194,8 @@ wait_for_server() { done echo "❌ The container started but the MCP server did not respond within 120 seconds." echo " Check the container logs with: container logs coderunner" - echo " If coderunner.local does not resolve, verify DNS setup with: container system property list" + echo " If coderunner.local does not resolve, verify domain = \"local\" in ~/.config/container/config.toml" + echo " and check the DNS service with: container system dns list" return 1 } diff --git a/requirements.txt b/requirements.txt index 52ecaad..03f8d89 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ openai requests>=2.33.0 -mcp[cli] +mcp[cli]>=1.26,<2 fastmcp diff --git a/server.py b/server.py index 2f0f7b1..2088b97 100644 --- a/server.py +++ b/server.py @@ -5,14 +5,17 @@ import json import logging import os -import zipfile import pathlib +import shutil +import stat import time import uuid +import zipfile from typing import Dict, Optional, Set from dataclasses import dataclass, field from enum import Enum from datetime import datetime, timedelta +from urllib.parse import urlsplit import aiofiles import websockets @@ -35,7 +38,9 @@ # Extra hostnames (comma-separated) that may be used to reach this server, # e.g. a LAN IP or a custom DNS name. EXTRA_ALLOWED_HOSTNAMES = [ - h.strip() for h in os.environ.get("CODERUNNER_EXTRA_HOSTS", "").split(",") if h.strip() + h.strip().lower() + for h in os.environ.get("CODERUNNER_EXTRA_HOSTS", "").split(",") + if h.strip() ] # Configure DNS rebinding protection to allow coderunner.local @@ -600,7 +605,23 @@ def _extract_skill_archive(archive_path: pathlib.Path) -> None: target = (destination / member.filename).resolve() if not target.is_relative_to(destination): raise ValueError(f"Unsafe path in skill archive: {member.filename}") - archive.extractall(destination) + + mode = member.external_attr >> 16 + file_type = stat.S_IFMT(mode) + if file_type not in (0, stat.S_IFREG, stat.S_IFDIR): + raise ValueError(f"Unsupported file type in skill archive: {member.filename}") + + for member in archive.infolist(): + target = destination / member.filename + if member.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + + target.parent.mkdir(parents=True, exist_ok=True) + if target.is_symlink(): + raise ValueError(f"Unsafe symlink target in skill archive: {member.filename}") + with archive.open(member, "r") as source, target.open("wb") as output: + shutil.copyfileobj(source, output) @mcp.tool() @@ -790,6 +811,21 @@ async def report_progress(self, progress: int, message: str): ALLOWED_HOSTNAMES = {"localhost", "127.0.0.1", "coderunner.local", "0.0.0.0", *EXTRA_ALLOWED_HOSTNAMES} +def _header_hostname(value: str, *, origin: bool = False) -> Optional[str]: + try: + parsed = urlsplit(value if origin else f"//{value}") + if origin and parsed.scheme.lower() not in {"http", "https"}: + return None + if parsed.username is not None or parsed.password is not None: + return None + if parsed.path or parsed.query or parsed.fragment: + return None + parsed.port + return parsed.hostname.lower() if parsed.hostname else None + except ValueError: + return None + + class HostOriginValidator: def __init__(self, asgi_app): self.asgi_app = asgi_app @@ -804,12 +840,12 @@ async def __call__(self, scope, receive, send): @staticmethod def _is_allowed(scope) -> bool: headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]} - host = headers.get("host", "").rsplit(":", 1)[0] + host = _header_hostname(headers.get("host", "")) if host not in ALLOWED_HOSTNAMES: return False origin = headers.get("origin") if origin: - origin_host = origin.split("://", 1)[-1].rsplit(":", 1)[0] + origin_host = _header_hostname(origin, origin=True) if origin_host not in ALLOWED_HOSTNAMES: return False return True diff --git a/test-e2e.sh b/test-e2e.sh index c7f1eb0..5ce41d9 100755 --- a/test-e2e.sh +++ b/test-e2e.sh @@ -83,26 +83,30 @@ fi # 8. Zip archives cannot write outside the user skills directory if [ -d "$SKILLS_DIR" ]; then - marker="$(dirname "$SKILLS_DIR")/zip-slip-check" - rm -f "$marker" - python3 - "$SKILLS_DIR/unsafe.zip" <<'PY' + if [ -z "$sid" ]; then + check "skill archive traversal is rejected (no MCP session)" "session" "none" + else + marker="$(dirname "$SKILLS_DIR")/zip-slip-check" + rm -f "$marker" + python3 - "$SKILLS_DIR/unsafe.zip" <<'PY' import sys import zipfile with zipfile.ZipFile(sys.argv[1], "w") as archive: archive.writestr("../zip-slip-check", "unsafe") PY - curl -s -o /dev/null -X POST "$BASE/mcp" \ - -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ - -H "mcp-session-id: $sid" \ - -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_skills","arguments":{}}}' - if [ -e "$marker" ]; then - check "skill archive traversal is rejected" "rejected" "leaked" - rm -f "$marker" - else - check "skill archive traversal is rejected" "rejected" "rejected" + curl -s -o /dev/null -X POST "$BASE/mcp" \ + -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $sid" \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_skills","arguments":{}}}' + if [ -e "$marker" ]; then + check "skill archive traversal is rejected" "rejected" "leaked" + rm -f "$marker" + else + check "skill archive traversal is rejected" "rejected" "rejected" + fi + rm -f "$SKILLS_DIR/unsafe.zip" fi - rm -f "$SKILLS_DIR/unsafe.zip" fi # 9. Jupyter must not be reachable from outside the container