From 799c9c9394ed514e68b0293e909facc14785a8ac Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:54:31 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Origin-Pr=C3=BCfung=20f=C3=BCr=20HTTP=20?= =?UTF-8?q?und=20WebSocket,=20H=C3=A4rtung=20von=20STT/TTS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Server lauscht nur auf 127.0.0.1, hatte aber keine Authentifizierung und mit CORS origin:true jede fremde Origin reflektiert. Da localhost aus jedem Browser-Tab erreichbar ist, konnte damit jede besuchte Webseite über /api/sessions und /api/sessions/:id/messages die komplette Chat-Historie auslesen. Der WebSocket-Upgrade prüfte die Origin ebenfalls nicht — WebSockets unterliegen nicht der Same-Origin-Policy, wodurch fremde Seiten Chats senden, Antworten mitlesen und Kosten auf dem OpenRouter-Key erzeugen konnten (CSWSH). - security.ts: Origin-Allowlist (localhost/127.0.0.1:5173 und :8787, per ALLOWED_ORIGINS erweiterbar) und einfacher In-Memory-Rate-Limiter - CORS auf die Allowlist begrenzt, zusätzlicher onRequest-Hook mit 403 - WebSocket-Handshake weist fremde Origins mit 403 ab - ffmpeg: Containerformat per Magic Bytes bestimmt statt geraten, dazu -protocol_whitelist file — verhindert, dass Demuxer wie concat oder HLS auf lokale Pfade und URLs im hochgeladenen Inhalt zugreifen - Rate-Limits auf /api/stt (10/min) und /api/tts (30/min), Textlänge bei TTS begrenzt, bodyLimit und MAX_AUDIO_BYTES auf 8 MB angeglichen - Fehlerdetails (lokale Pfade, Upstream-Antworten) nur noch ins Server-Log; dafür Fastify-Logger aktiviert, sonst wären sie stillschweigend verworfen Nebenbei: ollama.ts respektiert jetzt OLLAMA_URL statt localhost:11434 hartzukodieren, und ws wandert von devDependencies zu dependencies, da es zur Laufzeit importiert wird. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01S1NXUpHbxTusqQmsjrFfbU --- README.md | 27 +++++++++++++++++-- server/package.json | 6 ++--- server/src/index.ts | 57 ++++++++++++++++++++++++++++++++------- server/src/ollama.ts | 5 +++- server/src/security.ts | 60 ++++++++++++++++++++++++++++++++++++++++++ server/src/voice.ts | 52 +++++++++++++++++++++++++++++++++++- 6 files changed, 191 insertions(+), 16 deletions(-) create mode 100644 server/src/security.ts diff --git a/README.md b/README.md index 3231fc3..b465f7b 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,29 @@ Weitere optionale Variablen mit ihren Defaults: | `WHISPER_MODEL` | `~/whisper-models/ggml-large-v3-turbo.bin` | | `WHISPER_LANG` | `de` | | `PIPER_MODEL` | `server/voices/de_DE-thorsten-high.onnx` | +| `ALLOWED_ORIGINS` | (leer — siehe Sicherheit) | + +## Sicherheit + +Der Server hat **keine Authentifizierung** und lauscht deshalb bewusst nur auf +`127.0.0.1`. Das allein genügt aber nicht: Eine beliebige Webseite, die im +Browser geöffnet ist, kann `localhost` per `fetch()` oder WebSocket erreichen. +Deshalb prüfen sowohl die HTTP-Endpunkte als auch der WebSocket-Handshake die +`Origin` gegen eine Allowlist (`server/src/security.ts`) — Standard sind +`localhost`/`127.0.0.1` auf Port 5173 und 8787. + +Läuft das Frontend woanders, die Origin ergänzen: + +```bash +ALLOWED_ORIGINS=http://192.168.1.50:5173 +``` + +Weitere Maßnahmen: Rate-Limits auf `/api/stt` (10/min) und `/api/tts` (30/min), +Format-Whitelist per Magic Bytes vor dem `ffmpeg`-Aufruf, und Fehlerdetails +landen im Server-Log statt in der HTTP-Antwort. + +Für externen Zugriff reicht ein Reverse-Proxy **nicht** — davor gehört eine +echte Authentifizierung. ## Entwicklung @@ -97,5 +120,5 @@ npm run typecheck npm start # baut das Frontend und startet den Server ``` -Der Server bindet bewusst nur an `127.0.0.1` — für externen Zugriff einen -Reverse-Proxy davorschalten. +Der Server bindet nur an `127.0.0.1`. Zum Aussetzen ins Netz siehe +[Sicherheit](#sicherheit). diff --git a/server/package.json b/server/package.json index eb8c9db..48c3d56 100644 --- a/server/package.json +++ b/server/package.json @@ -10,13 +10,13 @@ }, "dependencies": { "@fastify/cors": "^10.0.1", - "fastify": "^5.2.1" + "fastify": "^5.2.1", + "ws": "^8.18.0" }, "devDependencies": { "@types/node": "^22.13.0", "@types/ws": "^8.5.14", "tsx": "^4.19.3", - "typescript": "^5.7.3", - "ws": "^8.18.0" + "typescript": "^5.7.3" } } diff --git a/server/src/index.ts b/server/src/index.ts index 7bd90b0..89d93fe 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,12 +6,13 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import * as dbmod from "./db.js"; import { streamChat, type OllamaOptions } from "./ollama.js"; -import { transcribeAudio, synthesizeSpeech } from "./voice.js"; +import { transcribeAudio, synthesizeSpeech, MAX_AUDIO_BYTES } from "./voice.js"; import { streamOpenRouter, listOpenRouterModels, getOpenRouterKey, } from "./openrouter.js"; +import { ALLOWED_ORIGINS, isOriginAllowed, createRateLimiter } from "./security.js"; // simple .env loader (project root) try { @@ -29,8 +30,22 @@ try { const PORT = Number(process.env.PORT ?? 8787); const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434"; -const app = Fastify({ logger: false, bodyLimit: 32 * 1024 * 1024 }); -await app.register(cors, { origin: true }); +const app = Fastify({ logger: true, bodyLimit: MAX_AUDIO_BYTES }); + +// origin:true würde jede fremde Origin reflektieren — damit könnte jede vom +// Nutzer geöffnete Webseite die komplette Chat-Historie auslesen. +await app.register(cors, { origin: [...ALLOWED_ORIGINS] }); + +// Zweite Verteidigungslinie: CORS schützt nur Browser-Clients, die den +// Response abwarten. Ein fremder Origin wird hier hart abgewiesen. +app.addHook("onRequest", async (req, reply) => { + if (!isOriginAllowed(req.headers.origin)) { + return reply.code(403).send({ error: "origin not allowed" }); + } +}); + +const sttLimiter = createRateLimiter(10, 60_000); +const ttsLimiter = createRateLimiter(30, 60_000); app.addContentTypeParser( ["application/octet-stream", "audio/*", "video/*"], @@ -60,7 +75,8 @@ app.get("/api/model", async () => { capabilities: data.capabilities ?? [], }; } catch (err) { - return { ok: false, error: String(err) }; + app.log.error({ err }, "Ollama /api/show fehlgeschlagen"); + return { ok: false, error: "Ollama nicht erreichbar" }; } }); @@ -81,6 +97,9 @@ app.delete("/api/sessions/:id", async (req) => { // --- Voice --- app.post("/api/stt", async (req, reply) => { + if (!sttLimiter(req.ip)) { + return reply.code(429).send({ ok: false, error: "Zu viele Anfragen" }); + } const audio = req.body as Buffer; if (!Buffer.isBuffer(audio) || audio.length === 0) { return reply.code(400).send({ ok: false, error: "Kein Audio empfangen" }); @@ -89,26 +108,35 @@ app.post("/api/stt", async (req, reply) => { const text = await transcribeAudio(audio); return { ok: true, text }; } catch (err) { - app.log.error(err); + // Details nur ins Server-Log: whisper/ffmpeg-Fehler enthalten lokale Pfade. + app.log.error({ err }, "STT fehlgeschlagen"); return reply .code(500) - .send({ ok: false, error: err instanceof Error ? err.message : String(err) }); + .send({ ok: false, error: "Transkription fehlgeschlagen (Details im Server-Log)" }); } }); +const TTS_MAX_CHARS = 8000; + app.post("/api/tts", async (req, reply) => { + if (!ttsLimiter(req.ip)) { + return reply.code(429).send({ error: "Zu viele Anfragen" }); + } const { text } = (req.body ?? {}) as { text?: string }; if (!text || !text.trim()) { return reply.code(400).send({ error: "text fehlt" }); } + if (text.length > TTS_MAX_CHARS) { + return reply.code(413).send({ error: `Text länger als ${TTS_MAX_CHARS} Zeichen` }); + } try { const wav = await synthesizeSpeech(text); reply.header("Content-Type", "audio/wav").send(wav); } catch (err) { - app.log.error(err); + app.log.error({ err }, "TTS fehlgeschlagen"); return reply .code(500) - .send({ error: err instanceof Error ? err.message : String(err) }); + .send({ error: "Sprachausgabe fehlgeschlagen (Details im Server-Log)" }); } }); @@ -120,7 +148,8 @@ app.get("/api/openrouter/models", async () => { try { return { ok: true, models: await listOpenRouterModels() }; } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; + app.log.error({ err }, "OpenRouter-Modellliste fehlgeschlagen"); + return { ok: false, error: "Modellliste nicht abrufbar" }; } }); @@ -290,6 +319,16 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => { const server = app.server; server.on("upgrade", (req, socket, head) => { const url = new URL(req.url ?? "", "http://localhost"); + + // WebSockets unterliegen nicht der Same-Origin-Policy: ohne diese Prüfung + // könnte jede fremde Seite eine Verbindung aufbauen, Chats senden, Antworten + // mitlesen und Kosten auf dem OpenRouter-Key erzeugen (CSWSH). + if (!isOriginAllowed(req.headers.origin)) { + socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + socket.destroy(); + return; + } + if (url.pathname === "/ws") { wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req)); } else { diff --git a/server/src/ollama.ts b/server/src/ollama.ts index ec4c5e2..65d77ef 100644 --- a/server/src/ollama.ts +++ b/server/src/ollama.ts @@ -11,6 +11,9 @@ export interface StreamCallbacks { onDone(): void; } +/** Muss zum OLLAMA_URL in index.ts passen — vorher war der Host hier hartkodiert. */ +const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434"; + interface ChatChunk { message?: { content?: string; thinking?: string }; done?: boolean; @@ -38,7 +41,7 @@ export async function streamChat( }, }; - const res = await fetch("http://localhost:11434/api/chat", { + const res = await fetch(`${OLLAMA_URL}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), diff --git a/server/src/security.ts b/server/src/security.ts new file mode 100644 index 0000000..280f175 --- /dev/null +++ b/server/src/security.ts @@ -0,0 +1,60 @@ +/** + * Schutz gegen Cross-Origin-Zugriff durch beliebige Webseiten. + * + * Der Server lauscht nur auf 127.0.0.1 — das verhindert aber keinen Zugriff + * durch Seiten, die der Nutzer im Browser geöffnet hat: Ein fetch() oder + * WebSocket aus einem fremden Tab erreicht localhost problemlos. Da es keine + * Authentifizierung gibt, ist die Origin-Prüfung die einzige Grenze. + */ + +const DEFAULT_ORIGINS = [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:8787", + "http://127.0.0.1:8787", +]; + +/** Zusätzliche Origins via ALLOWED_ORIGINS="https://a.example,https://b.example". */ +export const ALLOWED_ORIGINS: readonly string[] = [ + ...DEFAULT_ORIGINS, + ...(process.env.ALLOWED_ORIGINS ?? "") + .split(",") + .map((o) => o.trim()) + .filter(Boolean), +]; + +/** + * Same-Origin-Requests (curl, native Clients) senden gar keinen Origin-Header + * und werden zugelassen; ein *fremder* Origin-Header muss auf der Liste stehen. + */ +export function isOriginAllowed(origin: string | undefined): boolean { + if (!origin) return true; + return ALLOWED_ORIGINS.includes(origin); +} + +interface Bucket { + count: number; + resetAt: number; +} + +/** + * Einfacher In-Memory-Zähler pro Zeitfenster. Bremst teure Endpunkte + * (Whisper läuft bis zu 180 s) gegen versehentliche oder böswillige Fluten. + */ +export function createRateLimiter(limit: number, windowMs: number) { + const buckets = new Map(); + + return function allow(key: string): boolean { + const now = Date.now(); + const bucket = buckets.get(key); + + if (!bucket || now >= bucket.resetAt) { + buckets.set(key, { count: 1, resetAt: now + windowMs }); + return true; + } + if (bucket.count >= limit) return false; + + buckets.set(key, { count: bucket.count + 1, resetAt: bucket.resetAt }); + return true; + }; +} diff --git a/server/src/voice.ts b/server/src/voice.ts index 73e53d1..6282742 100644 --- a/server/src/voice.ts +++ b/server/src/voice.ts @@ -14,7 +14,48 @@ const PIPER_MODEL = path.join(import.meta.dirname, "..", "voices", "de_DE-thorsten-high.onnx"); const WHISPER_LANG = process.env.WHISPER_LANG ?? "de"; +/** + * Obergrenze für eine Aufnahme — schützt vor überlangen Whisper-Läufen. + * Wird in index.ts als bodyLimit gespiegelt, damit beide Grenzen übereinstimmen. + * 8 MB entsprechen rund 35 Minuten Opus-Audio. + */ +export const MAX_AUDIO_BYTES = 8 * 1024 * 1024; + +/** + * Erkennt das Containerformat anhand der Magic Bytes und gibt den passenden + * ffmpeg-Demuxer zurück. + * + * Ohne explizites -f rät ffmpeg das Format selbst und kann dabei bei Demuxern + * wie concat oder HLS landen, die ihrerseits auf Pfade und URLs im Dateiinhalt + * zugreifen. Da der Body von außen kommt, wird das Format hier festgenagelt. + */ +function detectAudioFormat(buf: Buffer): string | null { + if (buf.length < 12) return null; + if (buf.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))) { + return "matroska"; // webm (Chrome/Firefox MediaRecorder) + } + if (buf.subarray(0, 4).toString("latin1") === "OggS") return "ogg"; + if (buf.subarray(4, 8).toString("latin1") === "ftyp") { + return "mov,mp4,m4a,3gp,3g2,mj2"; // Safari MediaRecorder + } + if ( + buf.subarray(0, 4).toString("latin1") === "RIFF" && + buf.subarray(8, 12).toString("latin1") === "WAVE" + ) { + return "wav"; + } + return null; +} + export async function transcribeAudio(input: Buffer): Promise { + if (input.length > MAX_AUDIO_BYTES) { + throw new Error("Aufnahme zu groß"); + } + const format = detectAudioFormat(input); + if (!format) { + throw new Error("Nicht unterstütztes Audioformat"); + } + const dir = await mkdtemp(path.join(tmpdir(), "oxa-voice-")); const rawPath = path.join(dir, "in.raw"); const wavPath = path.join(dir, "in.wav"); @@ -22,7 +63,16 @@ export async function transcribeAudio(input: Buffer): Promise { await writeFile(rawPath, input); await pExecFile( "ffmpeg", - ["-y", "-i", rawPath, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", wavPath], + [ + "-y", + "-protocol_whitelist", "file", // keine http/hls/concat-Auflösung + "-f", format, // Format nicht raten lassen + "-i", rawPath, + "-ar", "16000", + "-ac", "1", + "-c:a", "pcm_s16le", + wavPath, + ], { timeout: 30_000 }, ); const { stdout } = await pExecFile(