mirror of
https://github.com/Jeuners/agenttwo.git
synced 2026-09-09 15:02:31 +02:00
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:
parent
752198c5fa
commit
799c9c9394
6 changed files with 191 additions and 16 deletions
27
README.md
27
README.md
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
60
server/src/security.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue