mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Comprehensive security hardening across all installers
install.sh: - Wrap entire body in main() to prevent partial execution from curl|bash (interrupted download would execute truncated script) install.ps1: - Enforce TLS 1.2+ (older PowerShell defaults to TLS 1.0) PyPI (_cli.py): - Add SHA256 checksum verification against checksums.txt (was the only installer without checksums) npm (install.js): - Add SHA256 checksum verification against checksums.txt - Validate HTTPS on every redirect hop (max 5 redirects) - Replace execSync string interpolation with execFileSync array args (eliminates shell injection vector in tar/PowerShell calls) - Add path traversal check on extracted binary npm (bin.js): - Auto-download binary if missing (handles --ignore-scripts / pnpm) Go wrapper (main.go): - Custom HTTP client with CheckRedirect that rejects non-HTTPS redirects (Go's default http.Get follows redirects without scheme validation) - Fix variable name bug in error message (url → rawURL)
This commit is contained in:
1 parent
ce20ff9bbe
commit
c1eb3dd2d1
5 files changed
+85
-22
No files matched your search
@@ -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"
|
||||
|
||||
+10
@@ -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 "$@"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+13
-5
@@ -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), {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user