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

@ -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"
}
}

View file

@ -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 {

View file

@ -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),

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");
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> {
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<string> {
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(