diff --git a/install.ps1 b/install.ps1 index 915dbdc8c..52668e407 100644 --- a/install.ps1 +++ b/install.ps1 @@ -7,6 +7,9 @@ $ErrorActionPreference = "Stop" +# Enforce TLS 1.2+ (older PowerShell defaults to TLS 1.0 which GitHub rejects) +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 + $Repo = "DeusData/codebase-memory-mcp" $InstallDir = "$env:LOCALAPPDATA\Programs\codebase-memory-mcp" $BinName = "codebase-memory-mcp.exe" diff --git a/install.sh b/install.sh index cf9228491..3d62249a6 100755 --- a/install.sh +++ b/install.sh @@ -11,6 +11,12 @@ set -euo pipefail # Environment: # CBM_DOWNLOAD_URL Override base URL for downloads (for testing) +# Wrap in main() to prevent partial execution from piped downloads. +# If curl|bash is interrupted mid-transfer, bash would execute the partial +# script. With this wrapper, the function is defined but main() is never +# called because the final line hasn't arrived yet. +main() { + REPO="DeusData/codebase-memory-mcp" INSTALL_DIR="$HOME/.local/bin" VARIANT="standard" @@ -202,3 +208,7 @@ fi echo "" echo "Done! Restart your coding agent to start using codebase-memory-mcp." + +} # end main() + +main "$@" diff --git a/pkg/go/cmd/codebase-memory-mcp/main.go b/pkg/go/cmd/codebase-memory-mcp/main.go index 1eb889910..f79ba7e76 100644 --- a/pkg/go/cmd/codebase-memory-mcp/main.go +++ b/pkg/go/cmd/codebase-memory-mcp/main.go @@ -181,17 +181,30 @@ func validateURLScheme(rawURL string) error { return nil } +// httpsOnlyClient returns an HTTP client that rejects non-HTTPS redirects. +var httpsOnlyClient = &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + return fmt.Errorf("refusing non-https redirect to %s", req.URL) + } + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + return nil + }, +} + func httpGet(rawURL, dest string) error { if err := validateURLScheme(rawURL); err != nil { return err } - resp, err := http.Get(rawURL) //nolint:gosec + resp, err := httpsOnlyClient.Get(rawURL) //nolint:gosec if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("HTTP %d for %s", resp.StatusCode, url) + return fmt.Errorf("HTTP %d for %s", resp.StatusCode, rawURL) } f, err := os.Create(dest) if err != nil { @@ -206,7 +219,7 @@ func fetchChecksums(url string) (map[string]string, error) { if err := validateURLScheme(url); err != nil { return nil, err } - resp, err := http.Get(url) //nolint:gosec + resp, err := httpsOnlyClient.Get(url) //nolint:gosec if err != nil { return nil, err } diff --git a/pkg/npm/bin.js b/pkg/npm/bin.js index 2b352a026..a87b33c9f 100644 --- a/pkg/npm/bin.js +++ b/pkg/npm/bin.js @@ -1,6 +1,7 @@ #!/usr/bin/env node 'use strict'; // CLI shim: resolves the downloaded binary and replaces the current process with it. +// If the binary is missing (e.g. --ignore-scripts), attempts a one-time download. const path = require('path'); const fs = require('fs'); @@ -11,11 +12,18 @@ const binName = isWindows ? 'codebase-memory-mcp.exe' : 'codebase-memory-mcp'; const binPath = path.join(__dirname, 'bin', binName); if (!fs.existsSync(binPath)) { - process.stderr.write( - 'codebase-memory-mcp: binary not found.\n' + - 'Try reinstalling: npm install -g codebase-memory-mcp\n' - ); - process.exit(1); + // Binary missing — try running the install script (handles --ignore-scripts case) + process.stderr.write('codebase-memory-mcp: binary not found, downloading...\n'); + const installResult = spawnSync(process.execPath, [path.join(__dirname, 'install.js')], { + stdio: 'inherit', + }); + if (installResult.status !== 0 || !fs.existsSync(binPath)) { + process.stderr.write( + 'codebase-memory-mcp: download failed.\n' + + 'Try reinstalling: npm install -g codebase-memory-mcp\n' + ); + process.exit(1); + } } const result = spawnSync(binPath, process.argv.slice(2), { diff --git a/pkg/pypi/src/codebase_memory_mcp/_cli.py b/pkg/pypi/src/codebase_memory_mcp/_cli.py index d06e74737..db4b79283 100644 --- a/pkg/pypi/src/codebase_memory_mcp/_cli.py +++ b/pkg/pypi/src/codebase_memory_mcp/_cli.py @@ -1,5 +1,6 @@ """Downloads the codebase-memory-mcp binary on first run, then exec's it.""" +import hashlib import os import sys import platform @@ -36,16 +37,12 @@ def _safe_extract_tar(tf, dest: str) -> None: manual per-member path validation on older Pythons. Mitigates the classic tar-slip / Zip Slip vulnerability (CWE-22). """ - # Python 3.12+: use the built-in 'data' filter which rejects absolute - # paths, '..' components, symlinks pointing outside dest, etc. if hasattr(tf, "extraction_filter") or sys.version_info >= (3, 12): tf.extractall(dest, filter="data") return - # Fallback for Python <3.12: validate each member before extracting. dest_abs = os.path.abspath(dest) for member in tf.getmembers(): - # Reject symlinks and hardlinks outright (they can escape dest). if member.issym() or member.islnk(): sys.exit( f"codebase-memory-mcp: refusing unsafe tar entry " @@ -61,11 +58,7 @@ def _safe_extract_tar(tf, dest: str) -> None: def _safe_extract_zip(zf, dest: str) -> None: - """Extract a zipfile to dest, rejecting path-traversal entries. - - zipfile.ZipFile has no built-in extraction filter; mirrors the tar - fallback logic — validate each member before extracting. - """ + """Extract a zipfile to dest, rejecting path-traversal entries.""" dest_abs = os.path.abspath(dest) for name in zf.namelist(): member_abs = os.path.abspath(os.path.join(dest_abs, name)) @@ -77,6 +70,42 @@ def _safe_extract_zip(zf, dest: str) -> None: zf.extractall(dest) +def _verify_checksum(archive_path: str, archive_name: str, version: str) -> None: + """Verify SHA256 checksum against checksums.txt from the release.""" + url = f"https://github.com/{REPO}/releases/download/v{version}/checksums.txt" + try: + _validate_url_scheme(url) + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp: + tmp_path = tmp.name + urllib.request.urlretrieve(url, tmp_path) # noqa: S310 — scheme validated above + with open(tmp_path) as f: + for line in f: + if archive_name in line: + expected = line.split()[0] + h = hashlib.sha256() + with open(archive_path, "rb") as af: + for chunk in iter(lambda: af.read(65536), b""): + h.update(chunk) + actual = h.hexdigest() + if expected != actual: + sys.exit( + f"codebase-memory-mcp: CHECKSUM MISMATCH for {archive_name}\n" + f" expected: {expected}\n" + f" actual: {actual}" + ) + print("codebase-memory-mcp: checksum verified.", file=sys.stderr) + break + except SystemExit: + raise + except Exception: + pass # Non-fatal: checksum unavailable + finally: + try: + os.unlink(tmp_path) + except Exception: + pass + + def _version() -> str: try: from importlib.metadata import version @@ -124,10 +153,8 @@ def _download(version: str) -> Path: os_name = _os_name() arch = _arch() ext = "zip" if os_name == "windows" else "tar.gz" - url = ( - f"https://github.com/{REPO}/releases/download/v{version}" - f"/codebase-memory-mcp-{os_name}-{arch}.{ext}" - ) + archive = f"codebase-memory-mcp-{os_name}-{arch}.{ext}" + url = f"https://github.com/{REPO}/releases/download/v{version}/{archive}" _validate_url_scheme(url) dest = _bin_path(version) @@ -149,6 +176,8 @@ def _download(version: str) -> Path: f"See https://github.com/{REPO}/releases for available versions." ) + _verify_checksum(tmp_archive, archive, version) + if ext == "tar.gz": import tarfile with tarfile.open(tmp_archive) as tf: @@ -161,7 +190,7 @@ def _download(version: str) -> Path: bin_name = "codebase-memory-mcp.exe" if os_name == "windows" else "codebase-memory-mcp" extracted = os.path.join(tmp, bin_name) if not os.path.exists(extracted): - sys.exit(f"codebase-memory-mcp: binary not found after extraction") + sys.exit("codebase-memory-mcp: binary not found after extraction") shutil.copy2(extracted, dest) current = dest.stat().st_mode