mirror of
https://github.com/Jeuners/agenttwo-tools.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_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).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
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");
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue