Skip to content

Commit bdeb0dd

Browse files
authored
Merge pull request #112 from SakuraByteCore/fix/harden-remote-hooks
fix: harden remote endpoints, SSRF, and DoS limits
1 parent 0d004f5 commit bdeb0dd

10 files changed

Lines changed: 639 additions & 50 deletions

cli.js

Lines changed: 401 additions & 37 deletions
Large diffs are not rendered by default.

cli/builtin-proxy.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,41 @@ function createBuiltinProxyRuntimeController(deps = {}) {
674674
return;
675675
}
676676

677+
const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
678+
const isLoopback = !remoteAddr
679+
|| remoteAddr === '127.0.0.1'
680+
|| remoteAddr === '::1'
681+
|| remoteAddr === '::ffff:127.0.0.1';
682+
if (!isLoopback) {
683+
const expected = typeof process.env.CODEXMATE_HTTP_TOKEN === 'string'
684+
? process.env.CODEXMATE_HTTP_TOKEN.trim()
685+
: '';
686+
if (!expected) {
687+
const body = JSON.stringify({ error: 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN)' });
688+
res.writeHead(403, {
689+
'Content-Type': 'application/json; charset=utf-8',
690+
'Content-Length': Buffer.byteLength(body, 'utf-8')
691+
});
692+
res.end(body, 'utf-8');
693+
return;
694+
}
695+
const headers = req && req.headers && typeof req.headers === 'object' ? req.headers : {};
696+
const rawAuth = typeof headers.authorization === 'string' ? headers.authorization.trim() : '';
697+
const match = rawAuth ? rawAuth.match(/^bearer\s+(.+)$/i) : null;
698+
const actual = match && match[1]
699+
? match[1].trim()
700+
: (rawAuth ? rawAuth : (typeof headers['x-codexmate-token'] === 'string' ? String(headers['x-codexmate-token']).trim() : ''));
701+
if (!actual || actual !== expected) {
702+
const body = JSON.stringify({ error: 'Unauthorized' });
703+
res.writeHead(401, {
704+
'Content-Type': 'application/json; charset=utf-8',
705+
'Content-Length': Buffer.byteLength(body, 'utf-8')
706+
});
707+
res.end(body, 'utf-8');
708+
return;
709+
}
710+
}
711+
677712
const incomingPath = parsedIncoming.pathname || '/';
678713
if (incomingPath === '/health' || incomingPath === '/status') {
679714
const body = JSON.stringify({

cli/claude-proxy.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,30 @@ function createBuiltinClaudeProxyRuntimeController(deps = {}) {
864864
function createBuiltinClaudeProxyServer(settings, upstream) {
865865
const connections = new Set();
866866
const server = http.createServer((req, res) => {
867+
const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
868+
const isLoopback = !remoteAddr
869+
|| remoteAddr === '127.0.0.1'
870+
|| remoteAddr === '::1'
871+
|| remoteAddr === '::ffff:127.0.0.1';
872+
if (!isLoopback) {
873+
const expected = typeof process.env.CODEXMATE_HTTP_TOKEN === 'string'
874+
? process.env.CODEXMATE_HTTP_TOKEN.trim()
875+
: '';
876+
if (!expected) {
877+
writeAnthropicProxyError(res, 403, 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN)', 'authentication_error');
878+
return;
879+
}
880+
const headers = req && req.headers && typeof req.headers === 'object' ? req.headers : {};
881+
const rawAuth = typeof headers.authorization === 'string' ? headers.authorization.trim() : '';
882+
const match = rawAuth ? rawAuth.match(/^bearer\s+(.+)$/i) : null;
883+
const actual = match && match[1]
884+
? match[1].trim()
885+
: (rawAuth ? rawAuth : (typeof headers['x-codexmate-token'] === 'string' ? String(headers['x-codexmate-token']).trim() : ''));
886+
if (!actual || actual !== expected) {
887+
writeAnthropicProxyError(res, 401, 'Unauthorized', 'authentication_error');
888+
return;
889+
}
890+
}
867891
handleBuiltinClaudeProxyRequest(req, res, settings, upstream).catch((err) => {
868892
if (res.headersSent) {
869893
try { res.destroy(err); } catch (_) {}

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;

cli/openai-bridge.js

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,10 @@ function normalizeResponsesInputToChatMessages(input) {
238238
out.push({ type: 'text', text: block.text });
239239
continue;
240240
}
241+
if ((type === 'reasoning' || type === 'reasoning_text' || type === 'reasoning_content') && typeof block.text === 'string') {
242+
out.push({ type: 'text', text: block.text });
243+
continue;
244+
}
241245
if (type === 'input_image') {
242246
const raw = block.image_url != null ? block.image_url : block.imageUrl;
243247
const url = typeof raw === 'string'
@@ -255,7 +259,21 @@ function normalizeResponsesInputToChatMessages(input) {
255259
}
256260
if (type === 'image_url' && block.image_url) {
257261
out.push({ type: 'image_url', image_url: block.image_url });
262+
continue;
263+
}
264+
const text = typeof block.text === 'string'
265+
? block.text
266+
: (typeof block.content === 'string' ? block.content : '');
267+
if (text) {
268+
out.push({ type: 'text', text });
269+
continue;
258270
}
271+
try {
272+
const raw = JSON.stringify(block);
273+
if (raw) {
274+
out.push({ type: 'text', text: raw.slice(0, 4000) });
275+
}
276+
} catch (_) {}
259277
}
260278
if (out.length === 0) return '';
261279
return out;
@@ -635,6 +653,9 @@ async function proxyRequestJson(targetUrl, options = {}) {
635653
const parsed = new URL(targetUrl);
636654
const transport = parsed.protocol === 'https:' ? https : http;
637655
const bodyText = options.body ? JSON.stringify(options.body) : '';
656+
const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
657+
? Math.floor(options.maxBytes)
658+
: 0;
638659
const headers = {
639660
'Accept': 'application/json',
640661
...(options.body ? { 'Content-Type': 'application/json' } : {}),
@@ -664,7 +685,21 @@ async function proxyRequestJson(targetUrl, options = {}) {
664685
agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
665686
}, (upstreamRes) => {
666687
const chunks = [];
667-
upstreamRes.on('data', (chunk) => chunk && chunks.push(chunk));
688+
let size = 0;
689+
upstreamRes.on('data', (chunk) => {
690+
if (!chunk) return;
691+
if (maxBytes > 0) {
692+
size += chunk.length;
693+
if (size > maxBytes) {
694+
chunks.length = 0;
695+
try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
696+
try { req.destroy(new Error('response too large')); } catch (_) {}
697+
finish({ ok: false, error: 'response too large' });
698+
return;
699+
}
700+
}
701+
chunks.push(chunk);
702+
});
668703
upstreamRes.on('end', () => {
669704
const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
670705
finish({
@@ -689,12 +724,16 @@ async function proxyRequestJson(targetUrl, options = {}) {
689724

690725
function createOpenaiBridgeHttpHandler(options = {}) {
691726
const settingsFile = options.settingsFile;
692-
const expectedToken = typeof options.expectedToken === 'string' && options.expectedToken.trim()
693-
? options.expectedToken.trim()
694-
: DEFAULT_BRIDGE_TOKEN;
727+
const expectedTokenRaw = typeof options.expectedToken === 'string' ? options.expectedToken.trim() : '';
728+
const expectedToken = Object.prototype.hasOwnProperty.call(options, 'expectedToken')
729+
? expectedTokenRaw
730+
: (expectedTokenRaw || DEFAULT_BRIDGE_TOKEN);
695731
const maxBodySize = Number.isFinite(options.maxBodySize) ? options.maxBodySize : 0;
696732
const httpAgent = options.httpAgent;
697733
const httpsAgent = options.httpsAgent;
734+
const maxUpstreamBytes = Number.isFinite(options.maxUpstreamBytes) && options.maxUpstreamBytes > 0
735+
? Math.floor(options.maxUpstreamBytes)
736+
: Math.max(16 * 1024 * 1024, maxBodySize > 0 ? maxBodySize * 4 : 0);
698737

699738
if (!settingsFile) {
700739
throw new Error('createOpenaiBridgeHttpHandler 缺少 settingsFile');
@@ -730,6 +769,11 @@ function createOpenaiBridgeHttpHandler(options = {}) {
730769
// 为避免在 LAN 暴露无鉴权的代理,这里仅允许 loopback 连接缺省 token。
731770
const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
732771
const isLoopback = isLoopbackAddress(remoteAddr);
772+
if (!isLoopback && !expectedToken) {
773+
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
774+
res.end(JSON.stringify({ error: 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN)' }));
775+
return;
776+
}
733777
if (!token && !isLoopback) {
734778
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
735779
res.end(JSON.stringify({ error: 'Unauthorized' }));
@@ -774,6 +818,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
774818
...(authHeader ? { Authorization: authHeader } : {}),
775819
...upstreamHeaders
776820
},
821+
maxBytes: maxUpstreamBytes,
777822
httpAgent,
778823
httpsAgent
779824
});
@@ -827,6 +872,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
827872
...(authHeader ? { Authorization: authHeader } : {}),
828873
...upstreamHeaders
829874
},
875+
maxBytes: maxUpstreamBytes,
830876
httpAgent,
831877
httpsAgent
832878
});
@@ -887,6 +933,7 @@ function createOpenaiBridgeHttpHandler(options = {}) {
887933
...(authHeader ? { Authorization: authHeader } : {}),
888934
...upstreamHeaders
889935
},
936+
maxBytes: maxUpstreamBytes,
890937
httpAgent,
891938
httpsAgent
892939
});

lib/automation.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const fs = require('fs');
22
const path = require('path');
33
const http = require('http');
44
const https = require('https');
5+
const net = require('net');
56

67
function isPlainObject(value) {
78
return !!value && typeof value === 'object' && !Array.isArray(value);
@@ -43,6 +44,32 @@ function expandEnvTemplate(value, env = process.env) {
4344
});
4445
}
4546

47+
function isPrivateNetworkHost(hostname) {
48+
const host = typeof hostname === 'string' ? hostname.trim().toLowerCase() : '';
49+
if (!host) return true;
50+
if (host === 'localhost') return true;
51+
const ipVer = net.isIP(host);
52+
if (!ipVer) return false;
53+
if (ipVer === 4) {
54+
const parts = host.split('.').map((x) => parseInt(x, 10));
55+
if (parts.length !== 4 || parts.some((x) => !Number.isFinite(x))) return true;
56+
const [a, b] = parts;
57+
if (a === 10) return true;
58+
if (a === 127) return true;
59+
if (a === 169 && b === 254) return true;
60+
if (a === 192 && b === 168) return true;
61+
if (a === 172 && b >= 16 && b <= 31) return true;
62+
return false;
63+
}
64+
if (ipVer === 6) {
65+
if (host === '::1') return true;
66+
if (host.startsWith('fe80:')) return true;
67+
if (host.startsWith('fc') || host.startsWith('fd')) return true;
68+
return false;
69+
}
70+
return false;
71+
}
72+
4673
function readAutomationConfig(configPath, options = {}) {
4774
const filePath = typeof configPath === 'string' ? configPath.trim() : '';
4875
if (!filePath) {
@@ -187,6 +214,13 @@ function httpPostJson(url, payload, headers = {}, options = {}) {
187214
} catch (_) {
188215
return Promise.resolve({ ok: false, error: 'invalid url' });
189216
}
217+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
218+
return Promise.resolve({ ok: false, error: 'invalid url protocol' });
219+
}
220+
const allowPrivate = process.env.CODEXMATE_ALLOW_AUTOMATION_PRIVATE_NETWORK === '1';
221+
if (!allowPrivate && isPrivateNetworkHost(parsed.hostname || '')) {
222+
return Promise.resolve({ ok: false, error: 'refusing to post to private network url' });
223+
}
190224
const transport = parsed.protocol === 'http:' ? http : https;
191225
const data = Buffer.from(JSON.stringify(payload || {}), 'utf-8');
192226
const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(200, Math.floor(options.timeoutMs)) : 4000;

lib/cli-path-utils.js

Lines changed: 21 additions & 5 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;
@@ -52,10 +52,27 @@ function resolveCopyTargetRoot(targetDir) {
5252
}
5353

5454
function commandExists(command, args = '') {
55+
const cmd = typeof command === 'string' ? command.trim() : '';
56+
const argText = typeof args === 'string' ? args.trim() : '';
57+
if (!cmd || cmd.includes('\0') || /[\r\n]/.test(cmd)) {
58+
return false;
59+
}
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;
66+
}
5567
try {
56-
execSync(`${command} ${args}`, { stdio: 'ignore', shell: process.platform === 'win32' });
57-
return true;
58-
} 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 (_) {
5976
return false;
6077
}
6178
}
@@ -66,4 +83,3 @@ module.exports = {
6683
resolveCopyTargetRoot,
6784
commandExists
6885
};
69-

lib/cli-sessions.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,25 @@ function isBootstrapLikeText(text) {
3838
return false;
3939
}
4040

41-
return BOOTSTRAP_TEXT_MARKERS.some(marker => normalized.includes(marker));
41+
if (normalized.length < 80) {
42+
return false;
43+
}
44+
let hits = 0;
45+
for (const marker of BOOTSTRAP_TEXT_MARKERS) {
46+
if (normalized.includes(marker)) {
47+
hits += 1;
48+
}
49+
}
50+
if (hits >= 2) {
51+
return true;
52+
}
53+
if (normalized.includes('<environment_context>')) {
54+
return true;
55+
}
56+
if (normalized.includes('agents.md instructions')) {
57+
return true;
58+
}
59+
return false;
4260
}
4361

4462
function removeLeadingSystemMessage(messages) {
@@ -300,6 +318,7 @@ function extractSessionDetailPreviewFromTailText(text, source, messageLimit) {
300318
});
301319
}
302320

321+
state.messages = removeLeadingSystemMessage(state.messages);
303322
return state;
304323
}
305324

0 commit comments

Comments
 (0)