release r007 security hardening and redesigned UI

This commit is contained in:
Mikei386
2026-08-21 07:43:39 +02:00
parent 514310912c
commit 65922b7f57
12 changed files with 485 additions and 346 deletions
+128 -18
View File
@@ -18,14 +18,27 @@ import {
MUA_VERSION,
MUA_PROTOCOL_VERSION,
} from "./helpers";
import { TOOLS, toolByName } from "./tools";
import { checkAuth, isToolEnabled, getApiKey, getEnabledTools, setApiKey, setEnabledTools } from "./auth";
import { TOOLS, toolByName, getToolRisk } from "./tools";
import {
checkAuth,
isToolEnabled,
getEnabledTools,
getAllToolsEnabled,
getMaskedApiKey,
generateApiKey,
setEnabledTools,
} from "./auth";
import { randomBytes } from "node:crypto";
import { spawn } from "node:child_process";
import { statSync } from "node:fs";
import { appendFile, chmodSync, statSync, writeFileSync } from "node:fs";
const PORT = Number(process.env["MUA_PORT"] ?? 3002);
const HOST = process.env["MUA_HOST"] ?? "0.0.0.0";
const MAX_REQUEST_BYTES = Number(process.env["MUA_MAX_REQUEST_BYTES"] ?? 1_048_576);
const RATE_LIMIT_PER_MINUTE = Number(process.env["MUA_RATE_LIMIT_PER_MINUTE"] ?? 120);
const MAX_CONCURRENT_TOOL_CALLS = Number(process.env["MUA_MAX_CONCURRENT_TOOL_CALLS"] ?? 4);
const CORS_ORIGIN = process.env["MUA_CORS_ORIGIN"] ?? "";
const AUDIT_LOG = process.env["MUA_AUDIT_LOG"] ?? "/var/log/plugins/mua-audit.log";
// ── JSON-RPC Helpers ────────────────────────────────────────────────────
function rpcResult(id: number | string | null, result: unknown) {
@@ -37,6 +50,31 @@ function rpcError(id: number | string | null, code: number, message: string) {
// ── Session Management ──────────────────────────────────────────────────
const sessions = new Map<string, { createdAt: number; lastActivity: number }>();
const rateLimits = new Map<string, { startedAt: number; count: number }>();
let activeToolCalls = 0;
function allowRequest(client: string): boolean {
const now = Date.now();
const entry = rateLimits.get(client);
if (!entry || now - entry.startedAt >= 60_000) {
rateLimits.set(client, { startedAt: now, count: 1 });
return true;
}
entry.count += 1;
return entry.count <= RATE_LIMIT_PER_MINUTE;
}
function auditTool(name: string, ok: boolean, durationMs: number): void {
const line = JSON.stringify({
time: new Date().toISOString(),
event: "tool_call",
tool: name,
risk: getToolRisk(name),
ok,
duration_ms: durationMs,
}) + "\n";
appendFile(AUDIT_LOG, line, { mode: 0o600 }, () => {});
}
function newSessionId(): string {
return crypto.randomUUID();
@@ -124,8 +162,20 @@ async function handleMcpRequest(
sessionId: sessionId ?? "",
};
}
if (activeToolCalls >= MAX_CONCURRENT_TOOL_CALLS) {
return {
response: rpcResult(id, {
content: [{ type: "text", text: "ERROR: Server busy; retry later" }],
isError: true,
}),
sessionId: sessionId ?? "",
};
}
activeToolCalls += 1;
const startedAt = Date.now();
try {
const text = await tool.handler(args);
auditTool(toolName, true, Date.now() - startedAt);
const result: Record<string, unknown> = {
content: [{ type: "text", text }],
};
@@ -146,6 +196,7 @@ async function handleMcpRequest(
}
return { response: rpcResult(id, result), sessionId: sessionId ?? "" };
} catch (e) {
auditTool(toolName, false, Date.now() - startedAt);
return {
response: rpcResult(id, {
content: [{ type: "text", text: `ERROR: ${String(e)}` }],
@@ -153,6 +204,8 @@ async function handleMcpRequest(
}),
sessionId: sessionId ?? "",
};
} finally {
activeToolCalls -= 1;
}
}
@@ -183,15 +236,21 @@ const server = Bun.serve({
const method = req.method;
const sessionHeader = req.headers.get("mcp-session-id");
// CORS für lokale Nutzung
const corsHeaders: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
// Browserzugriffe sind standardmäßig deaktiviert. Bei Bedarf kann genau
// ein vertrauenswürdiger Origin per MUA_CORS_ORIGIN freigegeben werden.
const origin = req.headers.get("origin") ?? "";
const corsHeaders: Record<string, string> = CORS_ORIGIN && origin === CORS_ORIGIN ? {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Mcp-Session-Id",
};
"Access-Control-Allow-Headers": "Authorization, Content-Type, Mcp-Session-Id",
"Vary": "Origin",
} : {};
// ── OPTIONS (CORS Preflight) ────────────────────────────────────────
if (method === "OPTIONS") {
if (!CORS_ORIGIN || origin !== CORS_ORIGIN) {
return Response.json({ error: "CORS origin not allowed" }, { status: 403 });
}
return new Response(null, { status: 204, headers: corsHeaders });
}
@@ -212,6 +271,13 @@ const server = Bun.serve({
// ── Auth-Check für /mcp (POST + DELETE) — Bearer-Token ─────────────
if (path === "/mcp" || path === "/") {
const client = server.requestIP(req)?.address ?? "unknown";
if (!allowRequest(client)) {
return Response.json(
rpcError(null, -32002, "Rate limit exceeded"),
{ status: 429, headers: { ...corsHeaders, "Retry-After": "60" } },
);
}
if (!checkAuth(req)) {
return Response.json(
{
@@ -234,6 +300,13 @@ const server = Bun.serve({
if (path === "/mcp" || path === "/") {
// POST: JSON-RPC Request
if (method === "POST") {
const contentLength = Number(req.headers.get("content-length") ?? 0);
if (contentLength > MAX_REQUEST_BYTES) {
return Response.json(rpcError(null, -32600, "Request too large"), {
status: 413,
headers: corsHeaders,
});
}
let body: string;
try {
body = await req.text();
@@ -243,6 +316,12 @@ const server = Bun.serve({
{ status: 400, headers: corsHeaders },
);
}
if (new TextEncoder().encode(body).byteLength > MAX_REQUEST_BYTES) {
return Response.json(rpcError(null, -32600, "Request too large"), {
status: 413,
headers: corsHeaders,
});
}
let message: Record<string, unknown>;
try {
@@ -303,6 +382,14 @@ const server = Bun.serve({
// Kein Header-Spoofing, kein File-Permission-Problem.
// Wenn der Port belegt ist: Warnung + weiterlaufen (MCP-Server bleibt up).
const CONFIG_PORT = Number(process.env["MUA_CONFIG_PORT"] ?? 3013);
const ADMIN_TOKEN_FILE = process.env["MUA_ADMIN_TOKEN_FILE"] ?? "/var/run/mua-admin.token";
const adminToken = randomBytes(32).toString("hex");
try {
writeFileSync(ADMIN_TOKEN_FILE, adminToken + "\n", { mode: 0o600 });
chmodSync(ADMIN_TOKEN_FILE, 0o600);
} catch (e) {
console.error(`[MUA] Config-Token konnte nicht geschrieben werden: ${String(e)}`);
}
let configServer: ReturnType<typeof Bun.serve> | null = null;
try {
configServer = Bun.serve({
@@ -318,6 +405,10 @@ try {
return Response.json({ error: "Not found" }, { status: 404 });
}
if (req.headers.get("x-mua-admin-token") !== adminToken) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// POST /restart: Service neu starten (für WebGUI-Button)
if (path === "/restart" && method === "POST") {
const rcScript = process.env["MUA_RC_SCRIPT"] ?? "/etc/rc.d/rc.mua";
@@ -336,9 +427,15 @@ try {
// GET: Config lesen
if (method === "GET") {
return Response.json({
apiKey: getApiKey(),
apiKeyConfigured: true,
apiKeyMasked: getMaskedApiKey(),
enabledTools: getEnabledTools(),
allTools: TOOLS.map((t) => t.name),
allToolsEnabled: getAllToolsEnabled(),
allTools: TOOLS.map((t) => ({
name: t.name,
description: t.description,
risk: getToolRisk(t.name),
})),
});
}
@@ -352,21 +449,21 @@ try {
}
// "generate": neuen API-Key generieren
if (body["generate"] === true) {
setApiKey(randomBytes(32).toString("hex"));
} else if (typeof body["apiKey"] === "string" && (body["apiKey"] as string).length > 0) {
setApiKey(body["apiKey"] as string);
}
let generatedApiKey: string | undefined;
if (body["generate"] === true) generatedApiKey = generateApiKey();
if (Array.isArray(body["enabledTools"])) {
const known = new Set(TOOLS.map((t) => t.name));
const tools = (body["enabledTools"] as unknown[])
.filter((t): t is string => typeof t === "string");
setEnabledTools(tools);
.filter((t): t is string => typeof t === "string" && known.has(t));
setEnabledTools(tools, body["allToolsEnabled"] === true);
}
return Response.json({
ok: true,
apiKey: getApiKey(),
generatedApiKey,
apiKeyMasked: getMaskedApiKey(),
enabledTools: getEnabledTools(),
allToolsEnabled: getAllToolsEnabled(),
});
}
@@ -428,6 +525,19 @@ const updateCheckInterval = setInterval(
);
updateCheckInterval.unref();
// Abgelaufene Sessions und Rate-Limit-Einträge entfernen, damit lange
// Laufzeiten nicht durch beliebig viele Client-Adressen Speicher ansammeln.
const housekeepingInterval = setInterval(() => {
const now = Date.now();
for (const [id, session] of sessions) {
if (now - session.lastActivity > 60 * 60 * 1000) sessions.delete(id);
}
for (const [client, entry] of rateLimits) {
if (now - entry.startedAt > 2 * 60 * 1000) rateLimits.delete(client);
}
}, 5 * 60 * 1000);
housekeepingInterval.unref();
// Graceful shutdown
process.on("SIGTERM", () => {
console.log("[MUA] SIGTERM received, shutting down");