telegram bot: hardened auth, rate limiting, dangerous cmd blocking, private-only
This commit is contained in:
+56
-6
@@ -33,10 +33,39 @@ const MAX_HISTORY = 30;
|
|||||||
|
|
||||||
// ─── Auth middleware ───
|
// ─── Auth middleware ───
|
||||||
function isAuthorized(ctx: Context): boolean {
|
function isAuthorized(ctx: Context): boolean {
|
||||||
if (ALLOWED_USERS.length === 0) return true; // no whitelist = open
|
// Reject if no whitelist configured (fail-closed)
|
||||||
|
if (ALLOWED_USERS.length === 0) return false;
|
||||||
return ALLOWED_USERS.includes(ctx.from?.id || 0);
|
return ALLOWED_USERS.includes(ctx.from?.id || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Rate limiting ───
|
||||||
|
const rateLimits = new Map<number, number[]>();
|
||||||
|
const RATE_LIMIT_WINDOW = 60_000; // 1 minute
|
||||||
|
const RATE_LIMIT_MAX = 15; // max messages per window
|
||||||
|
|
||||||
|
function isRateLimited(userId: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const timestamps = (rateLimits.get(userId) || []).filter(t => now - t < RATE_LIMIT_WINDOW);
|
||||||
|
timestamps.push(now);
|
||||||
|
rateLimits.set(userId, timestamps);
|
||||||
|
return timestamps.length > RATE_LIMIT_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Blocked command patterns ───
|
||||||
|
const BLOCKED_PATTERNS = [
|
||||||
|
/rm\s+-rf\s+\/(?!\w)/i, // rm -rf /
|
||||||
|
/mkfs\./i, // format disk
|
||||||
|
/dd\s+if=.*of=\/dev/i, // overwrite disk
|
||||||
|
/:\(\)\s*\{\s*:\|:/, // fork bomb
|
||||||
|
];
|
||||||
|
|
||||||
|
function isDangerous(cmd: string): string | null {
|
||||||
|
for (const pattern of BLOCKED_PATTERNS) {
|
||||||
|
if (pattern.test(cmd)) return `⛔ Blocked: matches dangerous pattern \`${pattern.source}\``;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── SSH exec helper ───
|
// ─── SSH exec helper ───
|
||||||
async function sshExec(command: string, timeoutSec = 30): Promise<string> {
|
async function sshExec(command: string, timeoutSec = 30): Promise<string> {
|
||||||
const proc = Bun.spawn(
|
const proc = Bun.spawn(
|
||||||
@@ -117,6 +146,11 @@ async function chat(userId: number, message: string): Promise<string> {
|
|||||||
if (block.type === "tool_use") {
|
if (block.type === "tool_use") {
|
||||||
const input = block.input as { command: string; timeout?: number };
|
const input = block.input as { command: string; timeout?: number };
|
||||||
console.log(`🔧 [${userId}] run_command: ${input.command}`);
|
console.log(`🔧 [${userId}] run_command: ${input.command}`);
|
||||||
|
const danger = isDangerous(input.command);
|
||||||
|
if (danger) {
|
||||||
|
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: danger, is_error: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const output = await sshExec(input.command, input.timeout || 30);
|
const output = await sshExec(input.command, input.timeout || 30);
|
||||||
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: output || "(no output)" });
|
toolResults.push({ type: "tool_result", tool_use_id: block.id, content: output || "(no output)" });
|
||||||
@@ -148,9 +182,27 @@ async function chat(userId: number, message: string): Promise<string> {
|
|||||||
return text || "(no response)";
|
return text || "(no response)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Security: block groups, unauthorized, rate-limited ───
|
||||||
|
bot.use(async (ctx, next) => {
|
||||||
|
// Block group/channel messages — private only
|
||||||
|
if (ctx.chat?.type !== "private") return;
|
||||||
|
|
||||||
|
// Silent block for unauthorized users — no response, no leak
|
||||||
|
if (!isAuthorized(ctx)) {
|
||||||
|
console.log(`🚫 Blocked user: ${ctx.from?.id} (@${ctx.from?.username})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit
|
||||||
|
if (ctx.from && isRateLimited(ctx.from.id)) {
|
||||||
|
return ctx.reply("⏳ Slow down — rate limited.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Bot handlers ───
|
// ─── Bot handlers ───
|
||||||
bot.command("start", (ctx) => {
|
bot.command("start", (ctx) => {
|
||||||
if (!isAuthorized(ctx)) return ctx.reply("⛔ Not authorized.");
|
|
||||||
return ctx.reply(
|
return ctx.reply(
|
||||||
"🤖 *CR Management Bot*\n\n" +
|
"🤖 *CR Management Bot*\n\n" +
|
||||||
"I'm connected to your server. You can:\n" +
|
"I'm connected to your server. You can:\n" +
|
||||||
@@ -164,7 +216,6 @@ bot.command("start", (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bot.command("status", async (ctx) => {
|
bot.command("status", async (ctx) => {
|
||||||
if (!isAuthorized(ctx)) return ctx.reply("⛔ Not authorized.");
|
|
||||||
await ctx.reply("🔍 Checking server...");
|
await ctx.reply("🔍 Checking server...");
|
||||||
try {
|
try {
|
||||||
const output = await sshExec("uptime && echo '---' && df -h / && echo '---' && free -h | head -2 && echo '---' && incus list --format csv -c ns 2>/dev/null");
|
const output = await sshExec("uptime && echo '---' && df -h / && echo '---' && free -h | head -2 && echo '---' && incus list --format csv -c ns 2>/dev/null");
|
||||||
@@ -175,9 +226,10 @@ bot.command("status", async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bot.command("ssh", async (ctx) => {
|
bot.command("ssh", async (ctx) => {
|
||||||
if (!isAuthorized(ctx)) return ctx.reply("⛔ Not authorized.");
|
|
||||||
const cmd = ctx.match;
|
const cmd = ctx.match;
|
||||||
if (!cmd) return ctx.reply("Usage: `/ssh <command>`", { parse_mode: "Markdown" });
|
if (!cmd) return ctx.reply("Usage: `/ssh <command>`", { parse_mode: "Markdown" });
|
||||||
|
const danger = isDangerous(cmd);
|
||||||
|
if (danger) return ctx.reply(danger);
|
||||||
await ctx.reply(`⚡ Running: \`${cmd}\``, { parse_mode: "Markdown" });
|
await ctx.reply(`⚡ Running: \`${cmd}\``, { parse_mode: "Markdown" });
|
||||||
try {
|
try {
|
||||||
const output = await sshExec(cmd);
|
const output = await sshExec(cmd);
|
||||||
@@ -188,14 +240,12 @@ bot.command("ssh", async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bot.command("clear", (ctx) => {
|
bot.command("clear", (ctx) => {
|
||||||
if (!isAuthorized(ctx)) return ctx.reply("⛔ Not authorized.");
|
|
||||||
conversations.delete(ctx.from!.id);
|
conversations.delete(ctx.from!.id);
|
||||||
return ctx.reply("🧹 Conversation cleared.");
|
return ctx.reply("🧹 Conversation cleared.");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Free text → Claude
|
// Free text → Claude
|
||||||
bot.on("message:text", async (ctx) => {
|
bot.on("message:text", async (ctx) => {
|
||||||
if (!isAuthorized(ctx)) return ctx.reply("⛔ Not authorized.");
|
|
||||||
const userId = ctx.from!.id;
|
const userId = ctx.from!.id;
|
||||||
const msg = ctx.message!.text;
|
const msg = ctx.message!.text;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user