From 7b56f25b9e7f635e499e38cadab657a042766616 Mon Sep 17 00:00:00 2001 From: P Date: Fri, 3 Apr 2026 12:28:50 -0400 Subject: [PATCH] security: harden defaults for network-exposed deployments Addresses a chain of vulnerabilities that combine into unauthenticated root RCE when repowise is deployed via Docker with default settings. Changes: - deps.py: fail-closed auth when binding 0.0.0.0 without API key, use hmac.compare_digest() for constant-time key comparison - schemas.py: validate local_path in RepoCreate (must be a real git repo, no path traversal via '..') - tool_why.py: sanitize stem passed to git log --grep, add '--' separator to prevent argument injection - Dockerfile: run as non-root user, copy Node.js from builder stage instead of piping curl|bash - docker-compose.yml: bind ports to 127.0.0.1, require REPOWISE_API_KEY and REPO_PATH, mount repos read-only Co-Authored-By: Claude Opus 4.6 (1M context) --- docker/Dockerfile | 15 +++++++--- docker/docker-compose.yml | 12 ++++++-- packages/server/src/repowise/server/deps.py | 28 +++++++++++++++++-- .../repowise/server/mcp_server/tool_why.py | 13 +++++++-- .../server/src/repowise/server/schemas.py | 15 +++++++++- 5 files changed, 71 insertions(+), 12 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index dc55b94..1bf9294 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -28,14 +28,16 @@ RUN npm run build # --------------------------------------------------------------------------- FROM python:3.12-slim AS runtime -# Install git (required by gitpython) and Node.js (required for Next.js server) +# Install git (required by gitpython) RUN apt-get update && apt-get install -y --no-install-recommends \ git \ - curl \ - && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ - && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* +# Copy Node.js from the official image instead of piping curl|bash +COPY --from=frontend-builder /usr/local/bin/node /usr/local/bin/node +COPY --from=frontend-builder /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm + WORKDIR /app # Install repowise Python package @@ -67,4 +69,9 @@ EXPOSE 7337 3000 COPY docker/entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh +# Run as non-root user (mitigates container escape → root escalation) +RUN groupadd -r repowise && useradd -r -g repowise -d /app -s /sbin/nologin repowise \ + && chown -R repowise:repowise /app /data +USER repowise + ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 3f5c0d4..712434f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -2,14 +2,22 @@ services: repowise: build: . ports: - - "7337:7337" # API - - "3000:3000" # Web UI + - "127.0.0.1:7337:7337" # API — loopback only by default + - "127.0.0.1:3000:3000" # Web UI — loopback only by default volumes: # Mount the .repowise directory from your indexed repo - ${REPOWISE_DATA:-./data}:/data + # Mount repo directory as read-only to prevent write-based attacks + - type: bind + source: ${REPO_PATH:?Set REPO_PATH to your repository directory} + target: /repos + read_only: true environment: - REPOWISE_DB_URL=sqlite+aiosqlite:///data/wiki.db - REPOWISE_EMBEDDER=${REPOWISE_EMBEDDER:-mock} + - REPOWISE_HOST=0.0.0.0 + # SECURITY: Set this to enable API authentication (required for non-loopback) + - REPOWISE_API_KEY=${REPOWISE_API_KEY:?Set REPOWISE_API_KEY for Docker deployments} # Set your LLM provider API key - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-} diff --git a/packages/server/src/repowise/server/deps.py b/packages/server/src/repowise/server/deps.py index 9e00250..65ad3df 100644 --- a/packages/server/src/repowise/server/deps.py +++ b/packages/server/src/repowise/server/deps.py @@ -9,6 +9,8 @@ from __future__ import annotations +import hmac +import logging import os from collections.abc import AsyncGenerator @@ -18,9 +20,21 @@ from repowise.core.persistence.database import get_session +logger = logging.getLogger(__name__) + _API_KEY = os.environ.get("REPOWISE_API_KEY") +_REPOWISE_HOST = os.environ.get("REPOWISE_HOST", "127.0.0.1") _header_scheme = APIKeyHeader(name="Authorization", auto_error=False) +# Warn at import time if server is network-exposed without authentication +if _API_KEY is None and _REPOWISE_HOST in ("0.0.0.0", "::"): + logger.warning( + "SECURITY WARNING: Server is binding to %s without REPOWISE_API_KEY set. " + "All endpoints are unauthenticated and network-accessible. " + "Set REPOWISE_API_KEY or bind to 127.0.0.1.", + _REPOWISE_HOST, + ) + async def get_db_session(request: Request) -> AsyncGenerator[AsyncSession, None]: """Yield an async DB session with auto-commit on success, rollback on error.""" @@ -42,14 +56,22 @@ async def get_fts(request: Request): async def verify_api_key( auth: str | None = Security(_header_scheme), ) -> None: - """Optional API key verification. + """API key verification. - When REPOWISE_API_KEY is not set, this is a no-op (fully open). + When REPOWISE_API_KEY is not set and server binds to loopback, this is a + no-op (local-only access). When binding to a non-loopback address without + a key, requests are rejected (fail-closed for network-exposed deployments). When set, requests must include ``Authorization: Bearer ``. """ if _API_KEY is None: + if _REPOWISE_HOST in ("0.0.0.0", "::"): + raise HTTPException( + status_code=503, + detail="Server is network-exposed but REPOWISE_API_KEY is not set. " + "Set REPOWISE_API_KEY or bind to 127.0.0.1.", + ) return if not auth or not auth.startswith("Bearer "): raise HTTPException(status_code=401, detail="Missing API key") - if auth[7:] != _API_KEY: + if not hmac.compare_digest(auth[7:], _API_KEY): raise HTTPException(status_code=401, detail="Invalid API key") diff --git a/packages/server/src/repowise/server/mcp_server/tool_why.py b/packages/server/src/repowise/server/mcp_server/tool_why.py index 2686e9f..ef15ada 100644 --- a/packages/server/src/repowise/server/mcp_server/tool_why.py +++ b/packages/server/src/repowise/server/mcp_server/tool_why.py @@ -464,7 +464,11 @@ async def _run_git_log( import subprocess def _sync_git_log() -> list[dict]: + import re + results: list[dict] = [] + # Sanitize stem to prevent argument injection via --grep + safe_stem = re.sub(r"[^a-zA-Z0-9_\-.]", "", stem) if stem else "" try: proc = subprocess.run( ["git", "log", "--follow", "--format=%H\t%an\t%ai\t%s", "-20", "--", file_path], @@ -487,9 +491,14 @@ def _sync_git_log() -> list[dict]: } ) - if stem and len(stem) >= 3: + if safe_stem and len(safe_stem) >= 3: proc2 = subprocess.run( - ["git", "log", "--all", "--grep", stem, "--format=%H\t%an\t%ai\t%s", "-10"], + [ + "git", "log", "--all", + "--grep", safe_stem, + "--format=%H\t%an\t%ai\t%s", "-10", + "--", # end of options — prevent argument injection + ], cwd=repo_path, capture_output=True, text=True, diff --git a/packages/server/src/repowise/server/schemas.py b/packages/server/src/repowise/server/schemas.py index bac0bfb..6dbe16e 100644 --- a/packages/server/src/repowise/server/schemas.py +++ b/packages/server/src/repowise/server/schemas.py @@ -4,8 +4,9 @@ import json from datetime import datetime +from pathlib import Path -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator # --------------------------------------------------------------------------- # Repository @@ -19,6 +20,18 @@ class RepoCreate(BaseModel): default_branch: str = "main" settings: dict | None = None + @field_validator("local_path") + @classmethod + def validate_local_path(cls, v: str) -> str: + resolved = Path(v).resolve() + if ".." in Path(v).parts: + raise ValueError("local_path must not contain '..' segments") + if not resolved.is_dir(): + raise ValueError(f"local_path does not exist or is not a directory: {resolved}") + if not (resolved / ".git").exists(): + raise ValueError(f"local_path is not a git repository (no .git found): {resolved}") + return str(resolved) + class RepoUpdate(BaseModel): name: str | None = None