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:
Martin Vogel committed 2026-04-14 21:00:42 +02:00
1 parent ce20ff9bbe
commit c1eb3dd2d1
5 files changed
+85 -22

No files matched your search

+3
View File
@@ -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
View File
@@ -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 "$@"
+16 -3
View File
@@ -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
View File
@@ -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), {
+43 -14
View File
@@ -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