Skip to content

Commit d1fe154

Browse files
committed
fix: command injection in telegram bridge via stdin-based message passing
- Pass user messages and API key via stdin instead of shell interpolation - Add message length validation (4096 char limit) - Use execFileSync for ssh-config call (no shell interpretation) - Use cryptographically random temp file paths (prevent symlink race) - Export functions for testing (runAgentInSandbox, sanitizeSessionId) - Refactor config initialization for testability Fixes NVIDIA#118 See: PR NVIDIA#119, PR NVIDIA#320 (upstream)
1 parent 6fd3e2c commit d1fe154

1 file changed

Lines changed: 148 additions & 31 deletions

File tree

scripts/telegram-bridge.js

Lines changed: 148 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,57 @@
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;
2934

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;
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+
function initConfig(options = {}) {
41+
OPENSHELL = options.openshell || resolveOpenshell();
42+
if (!OPENSHELL) {
43+
console.error("openshell not found on PATH or in common locations");
44+
process.exit(1);
45+
}
46+
47+
TOKEN = options.token || process.env.TELEGRAM_BOT_TOKEN;
48+
API_KEY = options.apiKey || process.env.NVIDIA_API_KEY;
49+
SANDBOX = options.sandbox || process.env.SANDBOX_NAME || "nemoclaw";
50+
51+
try {
52+
validateName(SANDBOX, "SANDBOX_NAME");
53+
} catch (e) {
54+
console.error(e.message);
55+
process.exit(1);
56+
}
3757

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); }
58+
ALLOWED_CHATS = options.allowedChats || (process.env.ALLOWED_CHAT_IDS
59+
? process.env.ALLOWED_CHAT_IDS.split(",").map((s) => s.trim())
60+
: null);
61+
62+
if (!TOKEN) {
63+
console.error("TELEGRAM_BOT_TOKEN required");
64+
process.exit(1);
65+
}
66+
if (!API_KEY) {
67+
console.error("NVIDIA_API_KEY required");
68+
process.exit(1);
69+
}
70+
}
4071

4172
let offset = 0;
4273
const activeSessions = new Map(); // chatId → message history
@@ -96,34 +127,91 @@ async function sendTyping(chatId) {
96127

97128
// ── Run agent inside sandbox ──────────────────────────────────────
98129

99-
function runAgentInSandbox(message, sessionId) {
100-
return new Promise((resolve) => {
101-
const sshConfig = execFileSync(OPENSHELL, ["sandbox", "ssh-config", SANDBOX], { encoding: "utf-8" });
130+
/**
131+
* Sanitize session ID to contain only alphanumeric characters and hyphens.
132+
* Returns null if the result is empty after sanitization.
133+
* @param {string|number} sessionId - The session ID to sanitize
134+
* @returns {string|null} - Sanitized session ID or null if empty
135+
*/
136+
function sanitizeSessionId(sessionId) {
137+
const sanitized = String(sessionId).replace(/[^a-zA-Z0-9-]/g, "");
138+
return sanitized.length > 0 ? sanitized : null;
139+
}
102140

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 });
141+
/**
142+
* Run the OpenClaw agent inside the sandbox with the given message.
143+
*
144+
* SECURITY: This function passes user messages and API credentials via stdin
145+
* instead of shell string interpolation to prevent command injection attacks.
146+
* The remote script reads the API key from the first line of stdin and the
147+
* message from the remaining stdin, then uses them in double-quoted variables
148+
* which prevents shell interpretation.
149+
*
150+
* @param {string} message - The user message to send to the agent
151+
* @param {string|number} sessionId - The session identifier (typically Telegram chat ID)
152+
* @param {object} options - Optional overrides for testing
153+
* @param {string} options.apiKey - Override API key (defaults to process.env.NVIDIA_API_KEY)
154+
* @param {string} options.sandbox - Override sandbox name (defaults to SANDBOX)
155+
* @param {string} options.openshell - Override openshell path (defaults to OPENSHELL)
156+
* @returns {Promise<string>} - The agent's response
157+
*/
158+
function runAgentInSandbox(message, sessionId, options = {}) {
159+
const apiKey = options.apiKey || API_KEY;
160+
const sandbox = options.sandbox || SANDBOX;
161+
const openshell = options.openshell || OPENSHELL;
107162

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)}`;
163+
return new Promise((resolve) => {
164+
// Sanitize session ID - reject if empty after sanitization
165+
const safeSessionId = sanitizeSessionId(sessionId);
166+
if (!safeSessionId) {
167+
resolve("Error: Invalid session ID");
168+
return;
169+
}
170+
171+
// Get SSH config using execFileSync (no shell interpretation)
172+
const sshConfig = execFileSync(openshell, ["sandbox", "ssh-config", sandbox], { encoding: "utf-8" });
113173

114-
const proc = spawn("ssh", ["-T", "-F", confPath, `openshell-${SANDBOX}`, cmd], {
174+
// Write temp ssh config with cryptographically unpredictable path
175+
// to prevent symlink race attacks (CWE-377)
176+
const confDir = fs.mkdtempSync("/tmp/nemoclaw-tg-ssh-");
177+
const confPath = `${confDir}/config`;
178+
fs.writeFileSync(confPath, sshConfig, { mode: 0o600 });
179+
180+
// SECURITY FIX: Pass API key and message via stdin instead of shell interpolation.
181+
// The remote script:
182+
// 1. Reads API key from first line of stdin
183+
// 2. Exports it as environment variable
184+
// 3. Reads message from remaining stdin
185+
// 4. Passes message to nemoclaw-start in double quotes (no shell expansion)
186+
const remoteScript = [
187+
"read -r NVIDIA_API_KEY",
188+
"export NVIDIA_API_KEY",
189+
"MSG=$(cat)",
190+
`exec nemoclaw-start openclaw agent --agent main --local -m "$MSG" --session-id "tg-${safeSessionId}"`,
191+
].join(" && ");
192+
193+
const proc = spawn("ssh", ["-T", "-F", confPath, `openshell-${sandbox}`, remoteScript], {
115194
timeout: 120000,
116-
stdio: ["ignore", "pipe", "pipe"],
195+
stdio: ["pipe", "pipe", "pipe"], // Enable stdin
117196
});
118197

198+
// Write API key (first line) and message (remaining) to stdin
199+
proc.stdin.write(apiKey + "\n");
200+
proc.stdin.write(message);
201+
proc.stdin.end();
202+
119203
let stdout = "";
120204
let stderr = "";
121205

122206
proc.stdout.on("data", (d) => (stdout += d.toString()));
123207
proc.stderr.on("data", (d) => (stderr += d.toString()));
124208

125209
proc.on("close", (code) => {
126-
try { require("fs").unlinkSync(confPath); require("fs").rmdirSync(confDir); } catch { /* ignored */ }
210+
// Clean up temp files
211+
try {
212+
fs.unlinkSync(confPath);
213+
fs.rmdirSync(confDir);
214+
} catch { /* ignored */ }
127215

128216
// Extract the actual agent response — skip setup lines
129217
const lines = stdout.split("\n");
@@ -153,6 +241,11 @@ function runAgentInSandbox(message, sessionId) {
153241
});
154242

155243
proc.on("error", (err) => {
244+
// Clean up temp files on error
245+
try {
246+
fs.unlinkSync(confPath);
247+
fs.rmdirSync(confDir);
248+
} catch { /* ignored */ }
156249
resolve(`Error: ${err.message}`);
157250
});
158251
});
@@ -202,6 +295,16 @@ async function poll() {
202295
continue;
203296
}
204297

298+
// Message length validation
299+
if (msg.text.length > MAX_MESSAGE_LENGTH) {
300+
await sendMessage(
301+
chatId,
302+
`Message too long (${msg.text.length} chars). Maximum is ${MAX_MESSAGE_LENGTH} characters.`,
303+
msg.message_id,
304+
);
305+
continue;
306+
}
307+
205308
// Rate limiting: per-chat cooldown
206309
const now = Date.now();
207310
const lastTime = lastMessageTime.get(chatId) || 0;
@@ -250,6 +353,9 @@ async function poll() {
250353
// ── Main ──────────────────────────────────────────────────────────
251354

252355
async function main() {
356+
// Initialize configuration from environment
357+
initConfig();
358+
253359
const me = await tgApi("getMe", {});
254360
if (!me.ok) {
255361
console.error("Failed to connect to Telegram:", JSON.stringify(me));
@@ -273,4 +379,15 @@ async function main() {
273379
poll();
274380
}
275381

276-
main();
382+
// Only run main() if this is the entry point (not imported for testing)
383+
if (require.main === module) {
384+
main();
385+
}
386+
387+
// Export for testing
388+
module.exports = {
389+
runAgentInSandbox,
390+
sanitizeSessionId,
391+
initConfig,
392+
MAX_MESSAGE_LENGTH,
393+
};

0 commit comments

Comments
 (0)