Skip to content

Commit ce20ff9

Browse files
committed
Harden npm installer: checksum verification, HTTPS-only redirects, no shell injection
- Add SHA256 checksum verification against checksums.txt (was missing, all other installers had it) - Validate URL scheme on every redirect hop (HTTPS-only, max 5 redirects) - Replace execSync string interpolation with execFileSync array args (eliminates shell injection vector) - Add path traversal check on extracted binary (tar-slip defense)
1 parent a89bf96 commit ce20ff9

File tree

1 file changed

+60
-9
lines changed

1 file changed

+60
-9
lines changed

pkg/npm/install.js

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
// Runs automatically via `postinstall` in package.json.
55

66
const https = require('https');
7+
const crypto = require('crypto');
78
const fs = require('fs');
89
const path = require('path');
910
const os = require('os');
10-
const { execSync } = require('child_process');
11+
const { execFileSync } = require('child_process');
1112

1213
const REPO = 'DeusData/codebase-memory-mcp';
1314
const VERSION = require('./package.json').version;
@@ -30,12 +31,25 @@ function getArch() {
3031
}
3132
}
3233

34+
// Security: only follow HTTPS URLs (defense-in-depth).
35+
function validateUrl(url) {
36+
if (!url.startsWith('https://')) {
37+
throw new Error(`Refusing non-HTTPS URL: ${url}`);
38+
}
39+
}
40+
3341
function download(url, dest) {
42+
validateUrl(url);
3443
return new Promise((resolve, reject) => {
35-
function follow(u) {
44+
function follow(u, depth) {
45+
if (depth > 5) return reject(new Error('Too many redirects'));
46+
validateUrl(u);
3647
https.get(u, (res) => {
3748
if (res.statusCode === 301 || res.statusCode === 302) {
38-
return follow(res.headers.location);
49+
const loc = res.headers.location;
50+
if (!loc) return reject(new Error('Redirect with no location'));
51+
const next = loc.startsWith('/') ? new URL(loc, u).href : loc;
52+
return follow(next, depth + 1);
3953
}
4054
if (res.statusCode !== 200) {
4155
return reject(new Error(`HTTP ${res.statusCode} for ${u}`));
@@ -46,10 +60,38 @@ function download(url, dest) {
4660
file.on('error', reject);
4761
}).on('error', reject);
4862
}
49-
follow(url);
63+
follow(url, 0);
5064
});
5165
}
5266

67+
// Fetch checksums.txt and verify the archive hash.
68+
async function verifyChecksum(archivePath, archiveName) {
69+
const url = `https://github.com/${REPO}/releases/download/v${VERSION}/checksums.txt`;
70+
const tmpChecksums = archivePath + '.checksums';
71+
try {
72+
await download(url, tmpChecksums);
73+
const lines = fs.readFileSync(tmpChecksums, 'utf-8').split('\n');
74+
const match = lines.find((l) => l.includes(archiveName));
75+
if (!match) return; // checksum line not found — non-fatal
76+
const expected = match.split(/\s+/)[0];
77+
const actual = crypto
78+
.createHash('sha256')
79+
.update(fs.readFileSync(archivePath))
80+
.digest('hex');
81+
if (expected !== actual) {
82+
throw new Error(
83+
`Checksum mismatch for ${archiveName}:\n expected: ${expected}\n actual: ${actual}`,
84+
);
85+
}
86+
process.stdout.write('codebase-memory-mcp: checksum verified.\n');
87+
} catch (err) {
88+
if (err.message.startsWith('Checksum mismatch')) throw err;
89+
// Non-fatal: checksum unavailable (network issue, pre-release, etc.)
90+
} finally {
91+
try { fs.unlinkSync(tmpChecksums); } catch (_) { /* ignore */ }
92+
}
93+
}
94+
5395
async function main() {
5496
const platform = getPlatform();
5597
const arch = getArch();
@@ -73,24 +115,33 @@ async function main() {
73115

74116
try {
75117
await download(url, tmpArchive);
118+
await verifyChecksum(tmpArchive, archive);
76119

120+
// Extract using execFileSync (array args — no shell injection).
77121
if (ext === 'tar.gz') {
78-
execSync(`tar -xzf "${tmpArchive}" -C "${tmpDir}"`);
122+
execFileSync('tar', ['-xzf', tmpArchive, '-C', tmpDir, '--no-same-owner']);
79123
} else {
80-
execSync(
81-
`powershell -NoProfile -Command "Expand-Archive -Path '${tmpArchive}' -DestinationPath '${tmpDir}' -Force"`,
82-
);
124+
execFileSync('powershell', [
125+
'-NoProfile', '-Command',
126+
`Expand-Archive -Path '${tmpArchive}' -DestinationPath '${tmpDir}' -Force`,
127+
]);
83128
}
84129

130+
// Validate extracted path doesn't escape tmpDir (tar-slip defense).
85131
const extracted = path.join(tmpDir, binName);
132+
const resolvedExtracted = path.resolve(extracted);
133+
const resolvedTmpDir = path.resolve(tmpDir);
134+
if (!resolvedExtracted.startsWith(resolvedTmpDir + path.sep)) {
135+
throw new Error(`Path traversal detected in archive: ${binName}`);
136+
}
86137
if (!fs.existsSync(extracted)) {
87138
throw new Error(`Binary not found after extraction at ${extracted}`);
88139
}
89140

90141
fs.copyFileSync(extracted, binPath);
91142
fs.chmodSync(binPath, 0o755);
92143

93-
process.stdout.write(`codebase-memory-mcp: ready.\n`);
144+
process.stdout.write('codebase-memory-mcp: ready.\n');
94145
} finally {
95146
fs.rmSync(tmpDir, { recursive: true, force: true });
96147
}

0 commit comments

Comments
 (0)