fix: Origin-Prüfung für HTTP und WebSocket, Härtung von STT/TTS

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1NXUpHbxTusqQmsjrFfbU
This commit is contained in:
Jeuner 2026-08-26 16:54:31 +02:00
parent 752198c5fa
commit 799c9c9394
6 changed files with 191 additions and 16 deletions

View file

@ -70,6 +70,29 @@ Weitere optionale Variablen mit ihren Defaults:
| `WHISPER_MODEL` | `~/whisper-models/ggml-large-v3-turbo.bin` | | `WHISPER_MODEL` | `~/whisper-models/ggml-large-v3-turbo.bin` |
| `WHISPER_LANG` | `de` | | `WHISPER_LANG` | `de` |
| `PIPER_MODEL` | `server/voices/de_DE-thorsten-high.onnx` | | `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 ## Entwicklung
@ -97,5 +120,5 @@ npm run typecheck
npm start # baut das Frontend und startet den Server npm start # baut das Frontend und startet den Server
``` ```
Der Server bindet bewusst nur an `127.0.0.1` — für externen Zugriff einen Der Server bindet nur an `127.0.0.1`. Zum Aussetzen ins Netz siehe
Reverse-Proxy davorschalten. [Sicherheit](#sicherheit).

View file

@ -10,13 +10,13 @@
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^10.0.1", "@fastify/cors": "^10.0.1",
"fastify": "^5.2.1" "fastify": "^5.2.1",
"ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.0", "@types/node": "^22.13.0",
"@types/ws": "^8.5.14", "@types/ws": "^8.5.14",
"tsx": "^4.19.3", "tsx": "^4.19.3",
"typescript": "^5.7.3", "typescript": "^5.7.3"
"ws": "^8.18.0"
} }
} }

View file

@ -6,12 +6,13 @@ import { readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import * as dbmod from "./db.js"; import * as dbmod from "./db.js";
import { streamChat, type OllamaOptions } from "./ollama.js"; import { streamChat, type OllamaOptions } from "./ollama.js";
import { transcribeAudio, synthesizeSpeech } from "./voice.js"; import { transcribeAudio, synthesizeSpeech, MAX_AUDIO_BYTES } from "./voice.js";
import { import {
streamOpenRouter, streamOpenRouter,
listOpenRouterModels, listOpenRouterModels,
getOpenRouterKey, getOpenRouterKey,
} from "./openrouter.js"; } from "./openrouter.js";
import { ALLOWED_ORIGINS, isOriginAllowed, createRateLimiter } from "./security.js";
// simple .env loader (project root) // simple .env loader (project root)
try { try {
@ -29,8 +30,22 @@ try {
const PORT = Number(process.env.PORT ?? 8787); const PORT = Number(process.env.PORT ?? 8787);
const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434"; const OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434";
const app = Fastify({ logger: false, bodyLimit: 32 * 1024 * 1024 }); const app = Fastify({ logger: true, bodyLimit: MAX_AUDIO_BYTES });
await app.register(cors, { origin: true });
// 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( app.addContentTypeParser(
["application/octet-stream", "audio/*", "video/*"], ["application/octet-stream", "audio/*", "video/*"],
@ -60,7 +75,8 @@ app.get("/api/model", async () => {
capabilities: data.capabilities ?? [], capabilities: data.capabilities ?? [],
}; };
} catch (err) { } 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 --- // --- Voice ---
app.post("/api/stt", async (req, reply) => { 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; const audio = req.body as Buffer;
if (!Buffer.isBuffer(audio) || audio.length === 0) { if (!Buffer.isBuffer(audio) || audio.length === 0) {
return reply.code(400).send({ ok: false, error: "Kein Audio empfangen" }); 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); const text = await transcribeAudio(audio);
return { ok: true, text }; return { ok: true, text };
} catch (err) { } 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 return reply
.code(500) .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) => { 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 }; const { text } = (req.body ?? {}) as { text?: string };
if (!text || !text.trim()) { if (!text || !text.trim()) {
return reply.code(400).send({ error: "text fehlt" }); 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 { try {
const wav = await synthesizeSpeech(text); const wav = await synthesizeSpeech(text);
reply.header("Content-Type", "audio/wav").send(wav); reply.header("Content-Type", "audio/wav").send(wav);
} catch (err) { } catch (err) {
app.log.error(err); app.log.error({ err }, "TTS fehlgeschlagen");
return reply return reply
.code(500) .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 { try {
return { ok: true, models: await listOpenRouterModels() }; return { ok: true, models: await listOpenRouterModels() };
} catch (err) { } 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; const server = app.server;
server.on("upgrade", (req, socket, head) => { server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url ?? "", "http://localhost"); 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") { if (url.pathname === "/ws") {
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req)); wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
} else { } else {

View file

@ -11,6 +11,9 @@ export interface StreamCallbacks {
onDone(): void; 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 { interface ChatChunk {
message?: { content?: string; thinking?: string }; message?: { content?: string; thinking?: string };
done?: boolean; 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", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),

60
server/src/security.ts Normal file
View file

@ -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<string, Bucket>();
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;
};
}

View file

@ -14,7 +14,48 @@ const PIPER_MODEL =
path.join(import.meta.dirname, "..", "voices", "de_DE-thorsten-high.onnx"); path.join(import.meta.dirname, "..", "voices", "de_DE-thorsten-high.onnx");
const WHISPER_LANG = process.env.WHISPER_LANG ?? "de"; 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<string> { export async function transcribeAudio(input: Buffer): Promise<string> {
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 dir = await mkdtemp(path.join(tmpdir(), "oxa-voice-"));
const rawPath = path.join(dir, "in.raw"); const rawPath = path.join(dir, "in.raw");
const wavPath = path.join(dir, "in.wav"); const wavPath = path.join(dir, "in.wav");
@ -22,7 +63,16 @@ export async function transcribeAudio(input: Buffer): Promise<string> {
await writeFile(rawPath, input); await writeFile(rawPath, input);
await pExecFile( await pExecFile(
"ffmpeg", "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 }, { timeout: 30_000 },
); );
const { stdout } = await pExecFile( const { stdout } = await pExecFile(