release r008 add safe read-only shell

This commit is contained in:
Mikei386 committed 2026-08-21 07:53:34 +02:00
1 parent 65922b7f57
commit 6480b9678c
10 files changed
+203 -21

No files matched your search

+114 -1
View File
@@ -13,7 +13,7 @@ import { createConnection, type Socket } from "net";
// ── Konstanten ──────────────────────────────────────────────────────────
export const MUA_SERVER_NAME = "mua";
export const MUA_VERSION = "2026.08.21.r007";
export const MUA_VERSION = "2026.08.21.r008";
export const MUA_PROTOCOL_VERSION = "2025-03-26";
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
@@ -81,6 +81,119 @@ export async function runShell(cmd: string, timeoutSec = 60): Promise<string> {
}
}
const READ_ONLY_PROGRAMS = new Set([
"cat", "date", "df", "dmesg", "du", "file", "find", "free", "grep",
"head", "hostname", "id", "ip", "lsof", "ls", "lsblk", "lspci", "mount",
"ps", "readlink", "realpath", "sha256sum", "ss", "stat", "tail", "uname",
"uptime", "wc", "whoami",
]);
async function readStreamLimited(
stream: ReadableStream<Uint8Array>,
maxBytes: number,
): Promise<{ text: string; truncated: boolean }> {
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let kept = 0;
let truncated = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const available = Math.max(0, maxBytes - kept);
if (kept < maxBytes) {
const slice = value.subarray(0, available);
if (slice.length > 0) chunks.push(slice);
kept += slice.length;
}
if (value.length > available) truncated = true;
}
const combined = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
return { text: new TextDecoder().decode(combined), truncated };
}
/**
* Führt ausschließlich freigegebene Leseprogramme direkt als argv aus.
* Kein /bin/sh, keine Pipes, Umleitungen, Substitutionen oder Verkettungen.
*/
export async function runReadOnlyCommand(
program: string,
args: string[],
timeoutSec = 30,
): Promise<string> {
if (!READ_ONLY_PROGRAMS.has(program)) {
throw new Error(`Program is not allowed in read-only mode: ${program}`);
}
if (args.length > 64 || args.some((arg) => typeof arg !== "string" || arg.length > 4096)) {
throw new Error("Invalid or excessive arguments");
}
const lowered = args.map((arg) => arg.toLowerCase());
const reject = (message: string) => { throw new Error(message); };
if (["hostname", "whoami", "uptime"].includes(program) && args.length > 0) {
reject(`${program} does not accept arguments in read-only mode`);
}
if (program === "date") {
const safeDateFlags = new Set(["-u", "--utc", "-r", "--reference", "--rfc-email", "-d", "--date"]);
for (let i = 0; i < args.length; i++) {
const arg = lowered[i];
if (i > 0 && ["-d", "--date", "-r", "--reference"].includes(lowered[i - 1])) continue;
if (arg.startsWith("+") || safeDateFlags.has(arg) || arg.startsWith("--date=") ||
arg.startsWith("--iso-8601") || arg.startsWith("--rfc-3339")) continue;
reject("Only date display and parsing options are allowed");
}
}
if (program === "dmesg" && args.some((a) =>
["-C", "-D", "-E", "-n"].includes(a) ||
["--clear", "--read-clear", "--console-off", "--console-on", "--console-level"].includes(a.toLowerCase())
)) {
reject("Changing or clearing the kernel log is not allowed");
}
if (program === "ss" && lowered.some((a) => a === "-k" || a === "--kill")) {
reject("Killing sockets is not allowed");
}
if (program === "mount" && args.length > 0) {
reject("mount is display-only and accepts no arguments in read-only mode");
}
if (program === "ip") {
const safeObjects = new Set(["address", "addr", "route", "link", "neigh", "neighbor"]);
const mutating = new Set(["add", "append", "change", "delete", "del", "flush", "replace", "set"]);
if (args.length === 0 || !safeObjects.has(lowered[0]) || lowered.some((a) => mutating.has(a))) {
reject("Only read-only ip objects and show/list operations are allowed");
}
}
if (program === "find") {
const mutatingFind = ["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprintf", "-fls"];
if (lowered.some((a) => mutatingFind.some((blocked) => a === blocked || a.startsWith(blocked)))) {
reject("Mutating find actions are not allowed");
}
}
try {
const proc = spawn([program, ...args], { stdout: "pipe", stderr: "pipe", cwd: "/" });
const timeout = setTimeout(() => proc.kill(), timeoutSec * 1000);
const [stdout, stderr, code] = await Promise.all([
readStreamLimited(proc.stdout, 100_000),
readStreamLimited(proc.stderr, 20_000),
proc.exited,
]);
clearTimeout(timeout);
return JSON.stringify({
exit_code: code,
stdout: sanitizeLogOutput(stdout.text.trim(), 100_000),
stderr: sanitizeLogOutput(stderr.text.trim(), 20_000),
truncated: stdout.truncated || stderr.truncated,
mode: "read-only",
});
} catch (e) {
throw new Error(`read-only command failed: ${String(e)}`);
}
}
/**
* Docker-Befehl ausführen (kompakt).
*/