From e17169655d838e17fe7a94d16bd381d0d892f3d3 Mon Sep 17 00:00:00 2001
From: Remi <73385395+itsrubberduck@users.noreply.github.com>
Date: Thu, 18 Sep 2025 13:39:16 +0200
Subject: [PATCH] Harden runtime config and input validation
---
content/news/2025-09-18-runtime-hardening.md | 37 +++++++++
nuxt.config.ts | 7 ++
public/img/news/runtime-hardening.svg | 28 +++++++
server/api/atc/ptt.post.ts | 45 +++++++++--
server/api/atc/say.post.ts | 23 +++---
server/api/service/auth/register.post.ts | 17 +++-
server/utils/normalize.ts | 19 ++---
server/utils/openai.ts | 45 +++++++++--
server/utils/runtimeConfig.ts | 83 ++++++++++++++++++++
server/utils/validation.ts | 27 +++++++
10 files changed, 295 insertions(+), 36 deletions(-)
create mode 100644 content/news/2025-09-18-runtime-hardening.md
create mode 100644 public/img/news/runtime-hardening.svg
create mode 100644 server/utils/runtimeConfig.ts
create mode 100644 server/utils/validation.ts
diff --git a/content/news/2025-09-18-runtime-hardening.md b/content/news/2025-09-18-runtime-hardening.md
new file mode 100644
index 0000000..8de072f
--- /dev/null
+++ b/content/news/2025-09-18-runtime-hardening.md
@@ -0,0 +1,37 @@
+---
+title: "Runtime-Härtung & sichere Eingaben"
+date: "2025-09-18"
+summary: "Neue Konfigurationszentrale, sichere Audio-Uploads und strengere Passwörter machen OpenSquawk robuster."
+readingTime: "3 Min Lesezeit"
+banner: "runtime-hardening.svg"
+---
+
+Wir haben uns heute quer durch Backend, Runtime-Config und Onboarding gearbeitet, um OpenSquawk widerstandsfähiger zu machen. Drei Baustellen standen im Fokus: reproduzierbare Konfiguration, saubere Audio-Eingaben für den Funk-Workflow und ein härterer Schutz der Benutzerkonten.
+
+## Highlights
+
+- **Zentrale Runtime-Konfiguration:** Alle sensiblen Schlüssel (OpenAI, TTS, Piper & Speaches) laufen jetzt über eine gemeinsame Helper-Funktion. Fehlkonfigurationen fliegen sofort auf, inklusive klarer Fehlermeldung.
+- **Sicherere Funk-Eingaben:** Die PTT-API akzeptiert nur noch validiertes Base64-Audio bis 2 MB und standardisiert unbekannte Formate auf WAV, bevor Whisper loslegt.
+- **Stärkere Passwörter:** Registrierung prüft E-Mail-Format und Passwort-Qualität (Länge, Buchstaben/Zahlen, Sonderzeichen), damit Alpha-Zugänge nicht mit Trivialpasswörtern angelegt werden.
+
+## Details
+
+### Runtime-Konfiguration aufgeräumt
+
+Ein neues Utility (`server/utils/runtimeConfig.ts`) kapselt sämtliche Laufzeit-Variablen. `OpenAI`- und TTS-Clients ziehen daraus konsistent Schlüssel, Modelle, Voice-Defaults sowie Piper/Speaches-Schalter. Der Server stoppt mit einem erklärenden Fehler, falls `OPENAI_API_KEY` fehlt – besser früh scheitern als stumm 500er produzieren.
+
+### Audio-Endpunkte härter gemacht
+
+Die Push-to-Talk-Route prüft Base64-Eingaben, begrenzt Dateigrößen auf praxisnahe 2 MB (~60 s Funk) und erzwingt bekannte Audioformate. Das reduziert Risiko von Speicherausreißern und sorgt dafür, dass FFmpeg nur bei echten Konvertierungen anspringt. Gleichzeitig lesen TTS-Endpunkte ihre Einstellungen jetzt aus der Runtime-Config und respektieren lokale Piper-Ports sowie Speaches-Basis-URLs.
+
+### Onboarding abgesichert
+
+Die Registrierung verweigert invaliden Input ab sofort sofort: ungültige E-Mails, kurze Passwörter oder fehlende Sonderzeichen liefern verständliche Fehlermeldungen. So bleiben Test-Accounts verwaltbar und Sicherheitsstandards steigen, ohne den Flow zu bremsen.
+
+## Ausblick
+
+- Hotjar/Analytics-Opt-in an die neue Config-Logik anbinden.
+- Für den Login dieselben Passwort-Guidelines rückspiegeln (Feedback-UI).
+- Größere Audiodateien optional asynchron verarbeiten, falls Langform-Transkripte spannend werden.
+
+Wenn euch weitere Hardenings einfallen: gerne Issues aufmachen oder direkt PRs schicken!
diff --git a/nuxt.config.ts b/nuxt.config.ts
index d8b29af..2149a3f 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -15,8 +15,15 @@ export default defineNuxtConfig({
app: {head: {link: [{rel: 'icon', type: 'image/jpeg', href: '/img/logo.jpeg'}]}},
runtimeConfig: {
openaiKey: process.env.OPENAI_API_KEY,
+ openaiProject: process.env.OPENAI_PROJECT,
llmModel: process.env.LLM_MODEL || 'gpt-5-nano',
ttsModel: process.env.TTS_MODEL || 'tts-1',
+ defaultVoiceId: process.env.VOICE_ID || 'alloy',
+ usePiper: process.env.USE_PIPER,
+ piperPort: process.env.PIPER_PORT,
+ useSpeaches: process.env.USE_SPEACHES,
+ speachesBaseUrl: process.env.SPEACHES_BASE_URL,
+ speechModelId: process.env.SPEECH_MODEL_ID,
jwtSecret: process.env.JWT_SECRET,
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET,
manualInvitePassword: process.env.MANUAL_INVITE_PASSWORD,
diff --git a/public/img/news/runtime-hardening.svg b/public/img/news/runtime-hardening.svg
new file mode 100644
index 0000000..f4be7db
--- /dev/null
+++ b/public/img/news/runtime-hardening.svg
@@ -0,0 +1,28 @@
+
diff --git a/server/api/atc/ptt.post.ts b/server/api/atc/ptt.post.ts
index cdc27b2..c8f4958 100644
--- a/server/api/atc/ptt.post.ts
+++ b/server/api/atc/ptt.post.ts
@@ -5,11 +5,13 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
-import { openai, routeDecision } from "../../utils/openai";
+import { getOpenAIClient, routeDecision } from "../../utils/openai";
import { createReadStream } from "node:fs";
import { TransmissionLog } from "../../models/TransmissionLog";
import { getUserFromEvent } from "../../utils/auth";
+type AudioFormat = 'wav' | 'mp3' | 'ogg' | 'webm'
+
interface PTTRequest {
audio: string; // Base64 encoded audio
context: {
@@ -21,7 +23,7 @@ interface PTTRequest {
};
moduleId: string;
lessonId: string;
- format?: 'wav' | 'mp3' | 'ogg' | 'webm';
+ format?: AudioFormat;
autoDecide?: boolean;
}
@@ -44,6 +46,37 @@ async function sh(cmd: string, args: string[]) {
);
}
+const BASE64_AUDIO_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
+const MAX_AUDIO_BYTES = 2 * 1024 * 1024; // ~60 Sekunden 16kHz Mono
+const ALLOWED_AUDIO_FORMATS: AudioFormat[] = ['wav', 'mp3', 'ogg', 'webm'];
+const AUDIO_FORMAT_SET = new Set(ALLOWED_AUDIO_FORMATS);
+
+function resolveAudioFormat(format?: string | null): AudioFormat {
+ if (!format) {
+ return 'wav';
+ }
+ const normalized = format.trim().toLowerCase() as AudioFormat;
+ return AUDIO_FORMAT_SET.has(normalized) ? normalized : 'wav';
+}
+
+function decodeAudioPayload(encoded: string): Buffer {
+ const sanitized = encoded.replace(/\s+/g, '');
+ if (!sanitized) {
+ throw createError({ statusCode: 400, statusMessage: 'Audio payload is empty' });
+ }
+ if (!BASE64_AUDIO_REGEX.test(sanitized)) {
+ throw createError({ statusCode: 400, statusMessage: 'Audio payload is not valid base64' });
+ }
+ const buffer = Buffer.from(sanitized, 'base64');
+ if (!buffer.length) {
+ throw createError({ statusCode: 400, statusMessage: 'Decoded audio payload is empty' });
+ }
+ if (buffer.length > MAX_AUDIO_BYTES) {
+ throw createError({ statusCode: 413, statusMessage: 'Audio payload exceeds the 2 MB limit' });
+ }
+ return buffer;
+}
+
// Audio zu WAV konvertieren für bessere Whisper-Kompatibilität
async function convertToWav(inputPath: string, outputPath: string) {
await sh("ffmpeg", [
@@ -66,17 +99,18 @@ export default defineEventHandler(async (event) => {
}
const id = randomUUID();
- const tmpAudioInput = join(tmpdir(), `ptt-input-${id}.${body.format || 'wav'}`);
+ const format = resolveAudioFormat(body.format);
+ const tmpAudioInput = join(tmpdir(), `ptt-input-${id}.${format}`);
const tmpAudioWav = join(tmpdir(), `ptt-wav-${id}.wav`);
try {
// 1. Audio aus Base64 dekodieren und speichern
- const audioBuffer = Buffer.from(body.audio, 'base64');
+ const audioBuffer = decodeAudioPayload(body.audio);
await writeFile(tmpAudioInput, audioBuffer);
// 2. Zu WAV konvertieren falls nötig (nur wenn FFmpeg verfügbar)
let audioFileForWhisper = tmpAudioInput;
- if (body.format !== 'wav') {
+ if (format !== 'wav') {
try {
await convertToWav(tmpAudioInput, tmpAudioWav);
audioFileForWhisper = tmpAudioWav;
@@ -86,6 +120,7 @@ export default defineEventHandler(async (event) => {
}
// 3. OpenAI Whisper für Transkription
+ const openai = getOpenAIClient();
const transcription = await openai.audio.transcriptions.create({
file: createReadStream(audioFileForWhisper),
model: "whisper-1",
diff --git a/server/api/atc/say.post.ts b/server/api/atc/say.post.ts
index 6cf0117..afa3abe 100644
--- a/server/api/atc/say.post.ts
+++ b/server/api/atc/say.post.ts
@@ -5,15 +5,11 @@ import {existsSync} from "node:fs";
import {join} from "node:path";
import {randomUUID} from "node:crypto";
import {normalize, TTS_MODEL, normalizeATC} from "../../utils/normalize";
+import { getServerRuntimeConfig } from "../../utils/runtimeConfig";
import {request} from "node:http";
import { TransmissionLog } from "../../models/TransmissionLog";
import { getUserFromEvent } from "../../utils/auth";
-// dotenv config
-import {config} from "dotenv";
-
-config();
-
function outDir() {
return process.env.ATC_OUT_DIR?.trim() || join(process.cwd(), "storage", "atc");
@@ -60,12 +56,12 @@ function fmtToExt(fmt: AudioFmt): string {
}
// ---- Piper HTTP helper ----
-async function piperTTS(text: string, voice: string): Promise {
+async function piperTTS(text: string, voice: string, port: number): Promise {
return new Promise((resolve, reject) => {
const req = request(
{
hostname: "localhost",
- port: Number(process.env.PIPER_PORT ?? 5001),
+ port,
path: "/",
method: "POST",
headers: { "Content-Type": "application/json" }
@@ -117,6 +113,7 @@ async function speachesTTS(
}
export default defineEventHandler(async (event) => {
+ const runtimeConfig = getServerRuntimeConfig();
const body = await readBody<{
text?: string;
level?: number;
@@ -132,15 +129,15 @@ export default defineEventHandler(async (event) => {
if (!raw) throw createError({ statusCode: 400, statusMessage: "text required" });
const level = Math.max(1, Math.min(5, Math.floor(body?.level ?? 4)));
- const voice = (body?.voice || process.env.VOICE_ID || "alloy").trim();
+ const voice = (body?.voice || runtimeConfig.voiceId).trim();
const speed = Math.max(0.5, Math.min(2.0, body?.speed || 1.0));
const normalized = normalizeATC(raw);
if (!normalized) throw createError({ statusCode: 400, statusMessage: "normalized text empty" });
// Routing
- const useSpeaches = (process.env.USE_SPEACHES || "").toLowerCase() === "true";
- const usePiper = !useSpeaches && (process.env.USE_PIPER || "").toLowerCase() === "true";
+ const useSpeaches = runtimeConfig.useSpeaches;
+ const usePiper = !useSpeaches && runtimeConfig.usePiper;
// Format
const requestedFmt = (body?.format === "smallest" ? "mp3" : body?.format) as AudioFmt | undefined;
@@ -163,8 +160,8 @@ export default defineEventHandler(async (event) => {
if (useSpeaches) {
// Speaches (bevorzugt klein: MP3, alternativ FLAC/WAV/PCM)
- const baseUrl = process.env.SPEACHES_BASE_URL || "";
- const model = process.env.SPEECH_MODEL_ID || "speaches-ai/piper-en_US-ryan-low";
+ const baseUrl = runtimeConfig.speachesBaseUrl || "";
+ const model = runtimeConfig.speechModelId || "speaches-ai/piper-en_US-ryan-low";
if (!baseUrl) {
throw new Error("SPEACHES_BASE_URL not set");
}
@@ -174,7 +171,7 @@ export default defineEventHandler(async (event) => {
actualMime = fmtToMime(fmt);
} else if (usePiper) {
// Lokaler Piper
- audioBuffer = await piperTTS(normalized, voice);
+ audioBuffer = await piperTTS(normalized, voice, runtimeConfig.piperPort);
modelUsed = "piper-local";
// Piper liefert WAV
actualMime = "audio/wav";
diff --git a/server/api/service/auth/register.post.ts b/server/api/service/auth/register.post.ts
index 3bb0997..187f74b 100644
--- a/server/api/service/auth/register.post.ts
+++ b/server/api/service/auth/register.post.ts
@@ -3,6 +3,7 @@ import { hashPassword, issueAuthTokens } from '../../../utils/auth'
import { User } from '../../../models/User'
import { InvitationCode } from '../../../models/InvitationCode'
import { WaitlistEntry } from '../../../models/WaitlistEntry'
+import { isValidEmail, validatePasswordStrength } from '../../../utils/validation'
interface RegisterBody {
email?: string
@@ -15,12 +16,13 @@ interface RegisterBody {
export default defineEventHandler(async (event) => {
const body = await readBody(event)
- const email = body.email?.trim().toLowerCase()
- const password = body.password?.trim()
+ const emailInput = body.email?.trim() || ''
+ const password = body.password?.trim() || ''
const name = body.name?.trim()
const code = body.invitationCode?.trim().toUpperCase()
+ const email = emailInput.toLowerCase()
- if (!email || !password || !code) {
+ if (!emailInput || !password || !code) {
throw createError({ statusCode: 400, statusMessage: 'Bitte E-Mail, Passwort und Einladungscode angeben' })
}
@@ -28,6 +30,15 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'Bitte AGB und Datenschutz bestätigen' })
}
+ if (!isValidEmail(emailInput)) {
+ throw createError({ statusCode: 400, statusMessage: 'Bitte eine gültige E-Mail-Adresse angeben' })
+ }
+
+ const passwordValidation = validatePasswordStrength(password)
+ if (!passwordValidation.valid) {
+ throw createError({ statusCode: 400, statusMessage: passwordValidation.message || 'Passwort ist zu schwach' })
+ }
+
const existingUser = await User.findOne({ email })
if (existingUser) {
throw createError({ statusCode: 409, statusMessage: 'Für diese E-Mail existiert bereits ein Konto' })
diff --git a/server/utils/normalize.ts b/server/utils/normalize.ts
index b1da124..2fdd9d8 100644
--- a/server/utils/normalize.ts
+++ b/server/utils/normalize.ts
@@ -1,17 +1,18 @@
-// yarn add openai dotenv
+// yarn add openai
import OpenAI from "openai";
-import dotenv from "dotenv";
import fs from "node:fs";
+import { getServerRuntimeConfig } from "./runtimeConfig";
-dotenv.config();
+const { openaiKey, openaiProject, llmModel, ttsModel } = getServerRuntimeConfig();
+const normalizeClientOptions: ConstructorParameters[0] = { apiKey: openaiKey };
+if (openaiProject) {
+ normalizeClientOptions.project = openaiProject;
+}
-export const normalize = new OpenAI({
- apiKey: process.env.OPENAI_API_KEY!,
- project: process.env.OPENAI_PROJECT, // optional
-});
+export const normalize = new OpenAI(normalizeClientOptions);
-export const LLM_MODEL = process.env.LLM_MODEL || "gpt-5-nano";
-export const TTS_MODEL = process.env.TTS_MODEL || "tts-1";
+export const LLM_MODEL = llmModel;
+export const TTS_MODEL = ttsModel;
/* =========================
LLM PROMPTS (überarbeitet)
diff --git a/server/utils/openai.ts b/server/utils/openai.ts
index cbd177e..70d36be 100644
--- a/server/utils/openai.ts
+++ b/server/utils/openai.ts
@@ -1,12 +1,43 @@
// server/utils/openai.ts
import OpenAI from 'openai'
+import { getServerRuntimeConfig } from './runtimeConfig'
-const MODEL = process.env.LLM_MODEL || 'gpt-5-nano'
-export const openai = new OpenAI({apiKey: process.env.OPENAI_API_KEY!})
+let openaiClient: OpenAI | null = null
+let cachedModel: string | null = null
+
+function ensureOpenAI(): OpenAI {
+ if (!openaiClient) {
+ const { openaiKey, openaiProject, llmModel } = getServerRuntimeConfig()
+ if (!openaiKey) {
+ throw new Error('OPENAI_API_KEY fehlt. Bitte den Schlüssel setzen, bevor KI-Funktionen genutzt werden.')
+ }
+ const clientOptions: ConstructorParameters[0] = { apiKey: openaiKey }
+ if (openaiProject) {
+ clientOptions.project = openaiProject
+ }
+ openaiClient = new OpenAI(clientOptions)
+ cachedModel = llmModel
+ }
+ return openaiClient
+}
+
+function getModel(): string {
+ if (!cachedModel) {
+ const { llmModel } = getServerRuntimeConfig()
+ cachedModel = llmModel
+ }
+ return cachedModel
+}
+
+export function getOpenAIClient(): OpenAI {
+ return ensureOpenAI()
+}
export async function decide(system: string, user: string): Promise {
- const r = await openai.chat.completions.create({
- model: MODEL,
+ const client = ensureOpenAI()
+ const model = getModel()
+ const r = await client.chat.completions.create({
+ model,
messages: [
{role: 'system', content: system},
{role: 'user', content: user}
@@ -146,8 +177,10 @@ export async function routeDecision(input: LLMDecisionInput): Promise/?]/.test(trimmed)) {
+ return { valid: false, message: 'Mindestens ein Sonderzeichen erhöht die Sicherheit.' }
+ }
+ return { valid: true }
+}