Skip to content

Commit e021a4c

Browse files
committed
fix: address reproducible security and reliability bugs
1 parent 202c143 commit e021a4c

4 files changed

Lines changed: 124 additions & 17 deletions

File tree

cli.js

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const yauzl = require('yauzl');
1010
const { exec, execSync, spawn, spawnSync } = require('child_process');
1111
const http = require('http');
1212
const https = require('https');
13+
const net = require('net');
1314
const readline = require('readline');
1415
const {
1516
expandHomePath,
@@ -7315,20 +7316,57 @@ function cmdClaude(baseUrl, apiKey, model, silent = false) {
73157316
function commandExists(command, args = '') {
73167317
const cmd = typeof command === 'string' ? command.trim() : '';
73177318
const argText = typeof args === 'string' ? args.trim() : '';
7318-
if (!cmd || !/^[A-Za-z0-9._-]+$/.test(cmd)) {
7319+
if (!cmd || cmd.includes('\0') || /[\r\n]/.test(cmd)) {
73197320
return false;
73207321
}
7321-
if (argText && /[\r\n;&|<>`$]/.test(argText)) {
7322-
return false;
7322+
const argv = argText ? argText.split(/\s+/g).filter(Boolean) : [];
7323+
const hasSeparators = cmd.includes('/') || cmd.includes('\\');
7324+
const useShell = process.platform === 'win32' && !hasSeparators;
7325+
if (useShell) {
7326+
if (!/^[A-Za-z0-9._-]+$/.test(cmd)) return false;
7327+
if (argText && /[\r\n;&|<>`$]/.test(argText)) return false;
73237328
}
73247329
try {
7325-
execSync(`${cmd}${argText ? ` ${argText}` : ''}`, { stdio: 'ignore', shell: process.platform === 'win32' });
7326-
return true;
7327-
} catch (e) {
7330+
const probe = spawnSync(cmd, argv, {
7331+
stdio: 'ignore',
7332+
windowsHide: true,
7333+
timeout: 5000,
7334+
shell: useShell
7335+
});
7336+
return probe.status === 0;
7337+
} catch (_) {
73287338
return false;
73297339
}
73307340
}
73317341

7342+
function isPrivateNetworkHost(hostname) {
7343+
const host = typeof hostname === 'string' ? hostname.trim().toLowerCase() : '';
7344+
if (!host) return true;
7345+
if (host === 'localhost') return true;
7346+
const ipVer = net.isIP(host);
7347+
if (!ipVer) {
7348+
return false;
7349+
}
7350+
if (ipVer === 4) {
7351+
const parts = host.split('.').map((x) => parseInt(x, 10));
7352+
if (parts.length !== 4 || parts.some((x) => !Number.isFinite(x))) return true;
7353+
const [a, b] = parts;
7354+
if (a === 10) return true;
7355+
if (a === 127) return true;
7356+
if (a === 169 && b === 254) return true;
7357+
if (a === 192 && b === 168) return true;
7358+
if (a === 172 && b >= 16 && b <= 31) return true;
7359+
return false;
7360+
}
7361+
if (ipVer === 6) {
7362+
if (host === '::1') return true;
7363+
if (host.startsWith('fe80:')) return true;
7364+
if (host.startsWith('fc') || host.startsWith('fd')) return true;
7365+
return false;
7366+
}
7367+
return false;
7368+
}
7369+
73327370
function detectPreferredPackageManager() {
73337371
const userAgent = typeof process.env.npm_config_user_agent === 'string'
73347372
? process.env.npm_config_user_agent.trim().toLowerCase()
@@ -8643,6 +8681,20 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
86438681
if (!baseUrl) {
86448682
result = { error: 'Base URL is required' };
86458683
} else {
8684+
const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
8685+
const requesterIsLoopback = !remoteAddr
8686+
|| remoteAddr === '127.0.0.1'
8687+
|| remoteAddr === '::1'
8688+
|| remoteAddr === '::ffff:127.0.0.1';
8689+
if (!requesterIsLoopback) {
8690+
try {
8691+
const parsedUrl = new URL(baseUrl);
8692+
if (isPrivateNetworkHost(parsedUrl.hostname || '')) {
8693+
result = { error: 'Refusing to access private network baseUrl from non-loopback request' };
8694+
break;
8695+
}
8696+
} catch (_) {}
8697+
}
86468698
const res = await fetchModelsFromBaseUrl(baseUrl, apiKey);
86478699
if (res.error) {
86488700
result = { error: res.error, models: [], source: 'remote' };
@@ -9230,7 +9282,14 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
92309282
});
92319283
return;
92329284
}
9233-
9285+
const allowLegacy = process.env.CODEXMATE_ALLOW_LEGACY_DOWNLOAD === '1';
9286+
const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
9287+
const isLoopback = !remoteAddr || remoteAddr === '127.0.0.1' || remoteAddr === '::1' || remoteAddr === '::ffff:127.0.0.1';
9288+
if (!allowLegacy || !isLoopback) {
9289+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
9290+
res.end('Not Found');
9291+
return;
9292+
}
92349293
const tempDir = os.tmpdir();
92359294
const legacyFilePath = path.join(tempDir, decodedFileName);
92369295
if (!isPathInside(legacyFilePath, tempDir)) {
@@ -9304,8 +9363,12 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
93049363
}
93059364
console.log(' 退出: Ctrl+C\n');
93069365
if (isAnyAddressHost(host)) {
9307-
console.warn('! 安全提示: 当前监听所有网卡(无鉴权)。');
9308-
console.warn(' 建议仅在可信网络使用,或改用 --host 127.0.0.1。');
9366+
const tokenEnabled = typeof process.env.CODEXMATE_HTTP_TOKEN === 'string' && process.env.CODEXMATE_HTTP_TOKEN.trim().length > 0;
9367+
console.warn(`! 安全提示: 当前监听所有网卡(${tokenEnabled ? '已启用鉴权' : '无鉴权'})。`);
9368+
if (!tokenEnabled) {
9369+
console.warn(' 建议仅在可信网络使用,或改用 --host 127.0.0.1。');
9370+
console.warn(' 如需远程访问,请设置 CODEXMATE_HTTP_TOKEN。');
9371+
}
93099372
}
93109373

93119374
if (willOpenBrowser) {

cli/import-skills-url.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,17 @@ function extractHttpStatusFromError(err) {
9797
return Number.isFinite(value) ? value : 0;
9898
}
9999

100+
function isAllowedSkillsRedirectHost(originHost, nextHost) {
101+
const origin = typeof originHost === 'string' ? originHost.trim().toLowerCase() : '';
102+
const next = typeof nextHost === 'string' ? nextHost.trim().toLowerCase() : '';
103+
if (!origin || !next) return false;
104+
if (origin === next) return true;
105+
if (process.env.CODEXMATE_ALLOW_SKILLS_REDIRECT === '1') return true;
106+
if (origin === 'github.com' && next === 'codeload.github.com') return true;
107+
if (origin === 'github.com' && next.endsWith('.githubusercontent.com')) return true;
108+
return false;
109+
}
110+
100111
function downloadUrlToFile(targetUrl, filePath, options = {}) {
101112
const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
102113
? Math.floor(options.maxBytes)
@@ -141,8 +152,19 @@ function downloadUrlToFile(targetUrl, filePath, options = {}) {
141152
const nextUrl = redirectLocation.startsWith('http')
142153
? redirectLocation
143154
: `${parsed.origin}${redirectLocation}`;
155+
let originHost = typeof options.originHost === 'string' && options.originHost.trim()
156+
? options.originHost.trim()
157+
: parsed.host;
158+
try {
159+
const nextParsed = new URL(nextUrl);
160+
if (!isAllowedSkillsRedirectHost(originHost, nextParsed.host)) {
161+
res.resume();
162+
reject(new Error('Cross-origin redirect is not allowed'));
163+
return;
164+
}
165+
} catch (_) {}
144166
res.resume();
145-
downloadUrlToFile(nextUrl, filePath, { maxBytes, timeoutMs, maxRedirects: maxRedirects - 1 })
167+
downloadUrlToFile(nextUrl, filePath, { maxBytes, timeoutMs, maxRedirects: maxRedirects - 1, originHost })
146168
.then(resolve)
147169
.catch(reject);
148170
return;

lib/cli-path-utils.js

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const fs = require('fs');
22
const path = require('path');
3-
const { execSync } = require('child_process');
3+
const { spawnSync } = require('child_process');
44

55
function normalizePathForCompare(targetPath, options = {}) {
66
const ignoreCase = !!options.ignoreCase;
@@ -54,16 +54,25 @@ function resolveCopyTargetRoot(targetDir) {
5454
function commandExists(command, args = '') {
5555
const cmd = typeof command === 'string' ? command.trim() : '';
5656
const argText = typeof args === 'string' ? args.trim() : '';
57-
if (!cmd || !/^[A-Za-z0-9._-]+$/.test(cmd)) {
57+
if (!cmd || cmd.includes('\0') || /[\r\n]/.test(cmd)) {
5858
return false;
5959
}
60-
if (argText && /[\r\n;&|<>`$]/.test(argText)) {
61-
return false;
60+
const argv = argText ? argText.split(/\s+/g).filter(Boolean) : [];
61+
const hasSeparators = cmd.includes('/') || cmd.includes('\\');
62+
const useShell = process.platform === 'win32' && !hasSeparators;
63+
if (useShell) {
64+
if (!/^[A-Za-z0-9._-]+$/.test(cmd)) return false;
65+
if (argText && /[\r\n;&|<>`$]/.test(argText)) return false;
6266
}
6367
try {
64-
execSync(`${cmd}${argText ? ` ${argText}` : ''}`, { stdio: 'ignore', shell: process.platform === 'win32' });
65-
return true;
66-
} catch (e) {
68+
const probe = spawnSync(cmd, argv, {
69+
stdio: 'ignore',
70+
windowsHide: true,
71+
timeout: 5000,
72+
shell: useShell
73+
});
74+
return probe.status === 0;
75+
} catch (_) {
6776
return false;
6877
}
6978
}

lib/mcp-stdio.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,9 @@ function createMcpStdioServer(options = {}) {
280280
const stdout = options.stdout || process.stdout;
281281
const router = createMcpRequestRouter(options);
282282
const jsonRpcVersion = '2.0';
283+
const maxFrameBytes = Number.isFinite(options.maxFrameBytes) && options.maxFrameBytes > 0
284+
? Math.floor(options.maxFrameBytes)
285+
: 16 * 1024 * 1024;
283286

284287
let buffer = Buffer.alloc(0);
285288
let started = false;
@@ -379,6 +382,11 @@ function createMcpStdioServer(options = {}) {
379382
writeError(null, jsonRpcError(-32600, 'Invalid Content-Length header'));
380383
return;
381384
}
385+
if (length > maxFrameBytes) {
386+
buffer = Buffer.alloc(0);
387+
writeError(null, jsonRpcError(-32600, 'Content-Length too large'));
388+
return;
389+
}
382390

383391
const bodyOffset = headerEnd + 4;
384392
const frameLength = bodyOffset + length;
@@ -394,6 +402,11 @@ function createMcpStdioServer(options = {}) {
394402

395403
const onData = async (chunk) => {
396404
if (stopped) return;
405+
if (chunk && (buffer.length + chunk.length) > (maxFrameBytes + 64 * 1024)) {
406+
buffer = Buffer.alloc(0);
407+
writeError(null, jsonRpcError(-32600, 'Frame too large'));
408+
return;
409+
}
397410
buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]);
398411
try {
399412
await parseBuffer();

0 commit comments

Comments
 (0)