Skip to content

Commit 44f3a36

Browse files
committed
fix: command injection in telegram bridge via stdin-based message passing
Replace shell string interpolation with stdin-based message and API key passing in runAgentInSandbox() to prevent command injection attacks. Security changes: - Pass user messages via stdin (not shell interpolation) - Pass API key via stdin (first line) instead of shellQuote - Add sanitizeSessionId() with leading-hyphen stripping - Add message length validation (4096 char cap) - Use execFileSync for openshell commands (no shell) - Use mkdtempSync + mode 0o600 for temp SSH configs - Clean up temp files in both close and error handlers - Export functions with require.main guard for testability Fixes: NVIDIA#118 Refs: PR NVIDIA#119
1 parent a308420 commit 44f3a36

1 file changed

Lines changed: 183 additions & 31 deletions

File tree

scripts/telegram-bridge.js

Lines changed: 183 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,82 @@
1717
*/
1818

1919
const https = require("https");
20+
const fs = require("fs");
2021
const { execFileSync, spawn } = require("child_process");
2122
const { resolveOpenshell } = require("../bin/lib/resolve-openshell");
22-
const { shellQuote, validateName } = require("../bin/lib/runner");
23+
const { validateName } = require("../bin/lib/runner");
2324

24-
const OPENSHELL = resolveOpenshell();
25-
if (!OPENSHELL) {
26-
console.error("openshell not found on PATH or in common locations");
27-
process.exit(1);
28-
}
25+
// Maximum message length (matches Telegram's limit)
26+
const MAX_MESSAGE_LENGTH = 4096;
27+
28+
// Configuration - validated at startup when running as main module
29+
let OPENSHELL = null;
30+
let TOKEN = null;
31+
let API_KEY = null;
32+
let SANDBOX = null;
33+
let ALLOWED_CHATS = null;
34+
35+
/**
36+
* Initialize configuration from environment variables.
37+
* Called automatically when running as main module.
38+
* Can be called manually for testing with custom values.
39+
*
40+
* @param {object} [options] - Optional overrides for testing
41+
* @param {string} [options.openshell] - Override openshell path
42+
* @param {string} [options.token] - Override Telegram bot token
43+
* @param {string} [options.apiKey] - Override NVIDIA API key
44+
* @param {string} [options.sandbox] - Override sandbox name
45+
* @param {string[]} [options.allowedChats] - Override allowed chat IDs
46+
* @param {boolean} [options.exitOnError=true] - Whether to exit on validation errors (set false for testing)
47+
* @returns {object} - The resolved configuration object
48+
*/
49+
function initConfig(options = {}) {
50+
const exitOnError = options.exitOnError !== false;
51+
52+
OPENSHELL = options.openshell || resolveOpenshell();
53+
if (!OPENSHELL) {
54+
if (exitOnError) {
55+
console.error("openshell not found on PATH or in common locations");
56+
process.exit(1);
57+
}
58+
throw new Error("openshell not found on PATH or in common locations");
59+
}
60+
61+
TOKEN = options.token || process.env.TELEGRAM_BOT_TOKEN;
62+
API_KEY = options.apiKey || process.env.NVIDIA_API_KEY;
63+
SANDBOX = options.sandbox || process.env.SANDBOX_NAME || "nemoclaw";
2964

30-
const TOKEN = process.env.TELEGRAM_BOT_TOKEN;
31-
const API_KEY = process.env.NVIDIA_API_KEY;
32-
const SANDBOX = process.env.SANDBOX_NAME || "nemoclaw";
33-
try { validateName(SANDBOX, "SANDBOX_NAME"); } catch (e) { console.error(e.message); process.exit(1); }
34-
const ALLOWED_CHATS = process.env.ALLOWED_CHAT_IDS
35-
? process.env.ALLOWED_CHAT_IDS.split(",").map((s) => s.trim())
36-
: null;
65+
try {
66+
validateName(SANDBOX, "SANDBOX_NAME");
67+
} catch (e) {
68+
if (exitOnError) {
69+
console.error(e.message);
70+
process.exit(1);
71+
}
72+
throw e;
73+
}
3774

38-
if (!TOKEN) { console.error("TELEGRAM_BOT_TOKEN required"); process.exit(1); }
39-
if (!API_KEY) { console.error("NVIDIA_API_KEY required"); process.exit(1); }
75+
ALLOWED_CHATS = options.allowedChats || (process.env.ALLOWED_CHAT_IDS
76+
? process.env.ALLOWED_CHAT_IDS.split(",").map((s) => s.trim())
77+
: null);
78+
79+
if (!TOKEN) {
80+
if (exitOnError) {
81+
console.error("TELEGRAM_BOT_TOKEN required");
82+
process.exit(1);
83+
}
84+
throw new Error("TELEGRAM_BOT_TOKEN required");
85+
}
86+
if (!API_KEY) {
87+
if (exitOnError) {
88+
console.error("NVIDIA_API_KEY required");
89+
process.exit(1);
90+
}
91+
throw new Error("NVIDIA_API_KEY required");
92+
}
93+
94+
return { OPENSHELL, TOKEN, API_KEY, SANDBOX, ALLOWED_CHATS };
95+
}
4096

4197
let offset = 0;
4298
const activeSessions = new Map(); // chatId → message history
@@ -96,34 +152,101 @@ async function sendTyping(chatId) {
96152

97153
// ── Run agent inside sandbox ──────────────────────────────────────
98154

99-
function runAgentInSandbox(message, sessionId) {
100-
return new Promise((resolve) => {
101-
const sshConfig = execFileSync(OPENSHELL, ["sandbox", "ssh-config", SANDBOX], { encoding: "utf-8" });
155+
/**
156+
* Sanitize session ID to contain only alphanumeric characters and hyphens.
157+
* Returns null if the result is empty after sanitization.
158+
*
159+
* SECURITY: Leading hyphens are stripped to prevent option injection attacks.
160+
* For example, a session ID of "--help" would otherwise be interpreted as a
161+
* command-line flag by nemoclaw-start. This differs from SANDBOX_NAME validation
162+
* (which uses validateName() requiring lowercase alphanumeric with internal hyphens)
163+
* because session IDs come from Telegram chat IDs which can be negative numbers.
164+
*
165+
* @param {string|number} sessionId - The session ID to sanitize
166+
* @returns {string|null} - Sanitized session ID or null if empty
167+
*/
168+
function sanitizeSessionId(sessionId) {
169+
// Strip all non-alphanumeric characters except hyphens
170+
const sanitized = String(sessionId).replace(/[^a-zA-Z0-9-]/g, "");
171+
// Remove leading hyphens to prevent option injection (e.g., "--help", "-v")
172+
const noLeadingHyphen = sanitized.replace(/^-+/, "");
173+
return noLeadingHyphen.length > 0 ? noLeadingHyphen : null;
174+
}
102175

103-
// Write temp ssh config with unpredictable name
104-
const confDir = require("fs").mkdtempSync("/tmp/nemoclaw-tg-ssh-");
105-
const confPath = `${confDir}/config`;
106-
require("fs").writeFileSync(confPath, sshConfig, { mode: 0o600 });
176+
/**
177+
* Run the OpenClaw agent inside the sandbox with the given message.
178+
*
179+
* SECURITY: This function passes user messages and API credentials via stdin
180+
* instead of shell string interpolation to prevent command injection attacks.
181+
* The remote script reads the API key from the first line of stdin and the
182+
* message from the remaining stdin, then uses them in double-quoted variables
183+
* which prevents shell interpretation.
184+
*
185+
* @param {string} message - The user message to send to the agent
186+
* @param {string|number} sessionId - The session identifier (typically Telegram chat ID)
187+
* @param {object} [options] - Optional overrides for testing
188+
* @param {string} [options.apiKey] - Override API key (defaults to process.env.NVIDIA_API_KEY)
189+
* @param {string} [options.sandbox] - Override sandbox name (defaults to SANDBOX)
190+
* @param {string} [options.openshell] - Override openshell path (defaults to OPENSHELL)
191+
* @returns {Promise<string>} - The agent's response
192+
*/
193+
function runAgentInSandbox(message, sessionId, options = {}) {
194+
const apiKey = options.apiKey || API_KEY;
195+
const sandbox = options.sandbox || SANDBOX;
196+
const openshell = options.openshell || OPENSHELL;
107197

108-
// Pass message and API key via stdin to avoid shell interpolation.
109-
// The remote command reads them from environment/stdin rather than
110-
// embedding user content in a shell string.
111-
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9-]/g, "");
112-
const cmd = `export NVIDIA_API_KEY=${shellQuote(API_KEY)} && nemoclaw-start openclaw agent --agent main --local -m ${shellQuote(message)} --session-id ${shellQuote("tg-" + safeSessionId)}`;
198+
return new Promise((resolve) => {
199+
// Sanitize session ID - reject if empty after sanitization
200+
const safeSessionId = sanitizeSessionId(sessionId);
201+
if (!safeSessionId) {
202+
resolve("Error: Invalid session ID");
203+
return;
204+
}
113205

114-
const proc = spawn("ssh", ["-T", "-F", confPath, `openshell-${SANDBOX}`, cmd], {
206+
// Get SSH config using execFileSync (no shell interpretation)
207+
const sshConfig = execFileSync(openshell, ["sandbox", "ssh-config", sandbox], { encoding: "utf-8" });
208+
209+
// Write temp ssh config with cryptographically unpredictable path
210+
// to prevent symlink race attacks (CWE-377)
211+
const confDir = fs.mkdtempSync("/tmp/nemoclaw-tg-ssh-");
212+
const confPath = `${confDir}/config`;
213+
fs.writeFileSync(confPath, sshConfig, { mode: 0o600 });
214+
215+
// SECURITY FIX: Pass API key and message via stdin instead of shell interpolation.
216+
// The remote script:
217+
// 1. Reads API key from first line of stdin
218+
// 2. Exports it as environment variable
219+
// 3. Reads message from remaining stdin
220+
// 4. Passes message to nemoclaw-start in double quotes (no shell expansion)
221+
const remoteScript = [
222+
"read -r NVIDIA_API_KEY",
223+
"export NVIDIA_API_KEY",
224+
"MSG=$(cat)",
225+
`exec nemoclaw-start openclaw agent --agent main --local -m "$MSG" --session-id "tg-${safeSessionId}"`,
226+
].join(" && ");
227+
228+
const proc = spawn("ssh", ["-T", "-F", confPath, `openshell-${sandbox}`, remoteScript], {
115229
timeout: 120000,
116-
stdio: ["ignore", "pipe", "pipe"],
230+
stdio: ["pipe", "pipe", "pipe"], // Enable stdin
117231
});
118232

233+
// Write API key (first line) and message (remaining) to stdin
234+
proc.stdin.write(apiKey + "\n");
235+
proc.stdin.write(message);
236+
proc.stdin.end();
237+
119238
let stdout = "";
120239
let stderr = "";
121240

122241
proc.stdout.on("data", (d) => (stdout += d.toString()));
123242
proc.stderr.on("data", (d) => (stderr += d.toString()));
124243

125244
proc.on("close", (code) => {
126-
try { require("fs").unlinkSync(confPath); require("fs").rmdirSync(confDir); } catch { /* ignored */ }
245+
// Clean up temp files
246+
try {
247+
fs.unlinkSync(confPath);
248+
fs.rmdirSync(confDir);
249+
} catch { /* ignored */ }
127250

128251
// Extract the actual agent response — skip setup lines
129252
const lines = stdout.split("\n");
@@ -153,6 +276,11 @@ function runAgentInSandbox(message, sessionId) {
153276
});
154277

155278
proc.on("error", (err) => {
279+
// Clean up temp files on error
280+
try {
281+
fs.unlinkSync(confPath);
282+
fs.rmdirSync(confDir);
283+
} catch { /* ignored */ }
156284
resolve(`Error: ${err.message}`);
157285
});
158286
});
@@ -202,6 +330,16 @@ async function poll() {
202330
continue;
203331
}
204332

333+
// Message length validation
334+
if (msg.text.length > MAX_MESSAGE_LENGTH) {
335+
await sendMessage(
336+
chatId,
337+
`Message too long (${msg.text.length} chars). Maximum is ${MAX_MESSAGE_LENGTH} characters.`,
338+
msg.message_id,
339+
);
340+
continue;
341+
}
342+
205343
// Rate limiting: per-chat cooldown
206344
const now = Date.now();
207345
const lastTime = lastMessageTime.get(chatId) || 0;
@@ -250,6 +388,9 @@ async function poll() {
250388
// ── Main ──────────────────────────────────────────────────────────
251389

252390
async function main() {
391+
// Initialize configuration from environment
392+
initConfig();
393+
253394
const me = await tgApi("getMe", {});
254395
if (!me.ok) {
255396
console.error("Failed to connect to Telegram:", JSON.stringify(me));
@@ -273,4 +414,15 @@ async function main() {
273414
poll();
274415
}
275416

276-
main();
417+
// Only run main() if this is the entry point (not imported for testing)
418+
if (require.main === module) {
419+
main();
420+
}
421+
422+
// Export for testing
423+
module.exports = {
424+
runAgentInSandbox,
425+
sanitizeSessionId,
426+
initConfig,
427+
MAX_MESSAGE_LENGTH,
428+
};

0 commit comments

Comments
 (0)