Skip to content

Commit c1eb3dd

Browse files
committed
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)
1 parent ce20ff9 commit c1eb3dd

File tree

5 files changed

+85
-22
lines changed

5 files changed

+85
-22
lines changed

install.ps1

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
$ErrorActionPreference = "Stop"
99

10+
# Enforce TLS 1.2+ (older PowerShell defaults to TLS 1.0 which GitHub rejects)
11+
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13
12+
1013
$Repo = "DeusData/codebase-memory-mcp"
1114
$InstallDir = "$env:LOCALAPPDATA\Programs\codebase-memory-mcp"
1215
$BinName = "codebase-memory-mcp.exe"

install.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ set -euo pipefail
1111
# Environment:
1212
# CBM_DOWNLOAD_URL Override base URL for downloads (for testing)
1313

14+
# Wrap in main() to prevent partial execution from piped downloads.
15+
# If curl|bash is interrupted mid-transfer, bash would execute the partial
16+
# script. With this wrapper, the function is defined but main() is never
17+
# called because the final line hasn't arrived yet.
18+
main() {
19+
1420
REPO="DeusData/codebase-memory-mcp"
1521
INSTALL_DIR="$HOME/.local/bin"
1622
VARIANT="standard"
@@ -202,3 +208,7 @@ fi
202208

203209
echo ""
204210
echo "Done! Restart your coding agent to start using codebase-memory-mcp."
211+
212+
} # end main()
213+
214+
main "$@"

pkg/go/cmd/codebase-memory-mcp/main.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,17 +181,30 @@ func validateURLScheme(rawURL string) error {
181181
return nil
182182
}
183183

184+
// httpsOnlyClient returns an HTTP client that rejects non-HTTPS redirects.
185+
var httpsOnlyClient = &http.Client{
186+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
187+
if req.URL.Scheme != "https" {
188+
return fmt.Errorf("refusing non-https redirect to %s", req.URL)
189+
}
190+
if len(via) >= 10 {
191+
return fmt.Errorf("too many redirects")
192+
}
193+
return nil
194+
},
195+
}
196+
184197
func httpGet(rawURL, dest string) error {
185198
if err := validateURLScheme(rawURL); err != nil {
186199
return err
187200
}
188-
resp, err := http.Get(rawURL) //nolint:gosec
201+
resp, err := httpsOnlyClient.Get(rawURL) //nolint:gosec
189202
if err != nil {
190203
return err
191204
}
192205
defer resp.Body.Close()
193206
if resp.StatusCode != http.StatusOK {
194-
return fmt.Errorf("HTTP %d for %s", resp.StatusCode, url)
207+
return fmt.Errorf("HTTP %d for %s", resp.StatusCode, rawURL)
195208
}
196209
f, err := os.Create(dest)
197210
if err != nil {
@@ -206,7 +219,7 @@ func fetchChecksums(url string) (map[string]string, error) {
206219
if err := validateURLScheme(url); err != nil {
207220
return nil, err
208221
}
209-
resp, err := http.Get(url) //nolint:gosec
222+
resp, err := httpsOnlyClient.Get(url) //nolint:gosec
210223
if err != nil {
211224
return nil, err
212225
}

pkg/npm/bin.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env node
22
'use strict';
33
// CLI shim: resolves the downloaded binary and replaces the current process with it.
4+
// If the binary is missing (e.g. --ignore-scripts), attempts a one-time download.
45

56
const path = require('path');
67
const fs = require('fs');
@@ -11,11 +12,18 @@ const binName = isWindows ? 'codebase-memory-mcp.exe' : 'codebase-memory-mcp';
1112
const binPath = path.join(__dirname, 'bin', binName);
1213

1314
if (!fs.existsSync(binPath)) {
14-
process.stderr.write(
15-
'codebase-memory-mcp: binary not found.\n' +
16-
'Try reinstalling: npm install -g codebase-memory-mcp\n'
17-
);
18-
process.exit(1);
15+
// Binary missing — try running the install script (handles --ignore-scripts case)
16+
process.stderr.write('codebase-memory-mcp: binary not found, downloading...\n');
17+
const installResult = spawnSync(process.execPath, [path.join(__dirname, 'install.js')], {
18+
stdio: 'inherit',
19+
});
20+
if (installResult.status !== 0 || !fs.existsSync(binPath)) {
21+
process.stderr.write(
22+
'codebase-memory-mcp: download failed.\n' +
23+
'Try reinstalling: npm install -g codebase-memory-mcp\n'
24+
);
25+
process.exit(1);
26+
}
1927
}
2028

2129
const result = spawnSync(binPath, process.argv.slice(2), {

pkg/pypi/src/codebase_memory_mcp/_cli.py

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Downloads the codebase-memory-mcp binary on first run, then exec's it."""
22

3+
import hashlib
34
import os
45
import sys
56
import platform
@@ -36,16 +37,12 @@ def _safe_extract_tar(tf, dest: str) -> None:
3637
manual per-member path validation on older Pythons. Mitigates the
3738
classic tar-slip / Zip Slip vulnerability (CWE-22).
3839
"""
39-
# Python 3.12+: use the built-in 'data' filter which rejects absolute
40-
# paths, '..' components, symlinks pointing outside dest, etc.
4140
if hasattr(tf, "extraction_filter") or sys.version_info >= (3, 12):
4241
tf.extractall(dest, filter="data")
4342
return
4443

45-
# Fallback for Python <3.12: validate each member before extracting.
4644
dest_abs = os.path.abspath(dest)
4745
for member in tf.getmembers():
48-
# Reject symlinks and hardlinks outright (they can escape dest).
4946
if member.issym() or member.islnk():
5047
sys.exit(
5148
f"codebase-memory-mcp: refusing unsafe tar entry "
@@ -61,11 +58,7 @@ def _safe_extract_tar(tf, dest: str) -> None:
6158

6259

6360
def _safe_extract_zip(zf, dest: str) -> None:
64-
"""Extract a zipfile to dest, rejecting path-traversal entries.
65-
66-
zipfile.ZipFile has no built-in extraction filter; mirrors the tar
67-
fallback logic — validate each member before extracting.
68-
"""
61+
"""Extract a zipfile to dest, rejecting path-traversal entries."""
6962
dest_abs = os.path.abspath(dest)
7063
for name in zf.namelist():
7164
member_abs = os.path.abspath(os.path.join(dest_abs, name))
@@ -77,6 +70,42 @@ def _safe_extract_zip(zf, dest: str) -> None:
7770
zf.extractall(dest)
7871

7972

73+
def _verify_checksum(archive_path: str, archive_name: str, version: str) -> None:
74+
"""Verify SHA256 checksum against checksums.txt from the release."""
75+
url = f"https://github.com/{REPO}/releases/download/v{version}/checksums.txt"
76+
try:
77+
_validate_url_scheme(url)
78+
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
79+
tmp_path = tmp.name
80+
urllib.request.urlretrieve(url, tmp_path) # noqa: S310 — scheme validated above
81+
with open(tmp_path) as f:
82+
for line in f:
83+
if archive_name in line:
84+
expected = line.split()[0]
85+
h = hashlib.sha256()
86+
with open(archive_path, "rb") as af:
87+
for chunk in iter(lambda: af.read(65536), b""):
88+
h.update(chunk)
89+
actual = h.hexdigest()
90+
if expected != actual:
91+
sys.exit(
92+
f"codebase-memory-mcp: CHECKSUM MISMATCH for {archive_name}\n"
93+
f" expected: {expected}\n"
94+
f" actual: {actual}"
95+
)
96+
print("codebase-memory-mcp: checksum verified.", file=sys.stderr)
97+
break
98+
except SystemExit:
99+
raise
100+
except Exception:
101+
pass # Non-fatal: checksum unavailable
102+
finally:
103+
try:
104+
os.unlink(tmp_path)
105+
except Exception:
106+
pass
107+
108+
80109
def _version() -> str:
81110
try:
82111
from importlib.metadata import version
@@ -124,10 +153,8 @@ def _download(version: str) -> Path:
124153
os_name = _os_name()
125154
arch = _arch()
126155
ext = "zip" if os_name == "windows" else "tar.gz"
127-
url = (
128-
f"https://github.com/{REPO}/releases/download/v{version}"
129-
f"/codebase-memory-mcp-{os_name}-{arch}.{ext}"
130-
)
156+
archive = f"codebase-memory-mcp-{os_name}-{arch}.{ext}"
157+
url = f"https://github.com/{REPO}/releases/download/v{version}/{archive}"
131158
_validate_url_scheme(url)
132159

133160
dest = _bin_path(version)
@@ -149,6 +176,8 @@ def _download(version: str) -> Path:
149176
f"See https://github.com/{REPO}/releases for available versions."
150177
)
151178

179+
_verify_checksum(tmp_archive, archive, version)
180+
152181
if ext == "tar.gz":
153182
import tarfile
154183
with tarfile.open(tmp_archive) as tf:
@@ -161,7 +190,7 @@ def _download(version: str) -> Path:
161190
bin_name = "codebase-memory-mcp.exe" if os_name == "windows" else "codebase-memory-mcp"
162191
extracted = os.path.join(tmp, bin_name)
163192
if not os.path.exists(extracted):
164-
sys.exit(f"codebase-memory-mcp: binary not found after extraction")
193+
sys.exit("codebase-memory-mcp: binary not found after extraction")
165194

166195
shutil.copy2(extracted, dest)
167196
current = dest.stat().st_mode

0 commit comments

Comments
 (0)