Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ openai

requests>=2.33.0

mcp[cli]
mcp[cli]>=1.26,<2

fastmcp

Expand Down
46 changes: 41 additions & 5 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Comment on lines +614 to +624


@mcp.tool()
Expand Down Expand Up @@ -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
Comment on lines +814 to +826


class HostOriginValidator:
def __init__(self, asgi_app):
self.asgi_app = asgi_app
Expand All @@ -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
Expand Down
30 changes: 17 additions & 13 deletions test-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading