diff --git a/client/src/hooks/useSpeechHistory.js b/client/src/hooks/useSpeechHistory.js index 083fc5f4..e9409b27 100644 --- a/client/src/hooks/useSpeechHistory.js +++ b/client/src/hooks/useSpeechHistory.js @@ -136,12 +136,6 @@ const addMessage = useCallback((text) => { // Check existing message const existing = prev.find((m) => m.text === trimmed); - // Preserve existing ID if duplicate found - const entry = existing || { - id: crypto.randomUUID(), - text: trimmed, - timestamp, - }; // Preserve existing ID if duplicate found, but update timestamp // so re-spoken messages sort correctly after a page reload. const entry = existing diff --git a/package-lock.json b/package-lock.json index aadb405e..ca0ee42b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1547,20 +1547,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -1570,6 +1570,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/body-parser/node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -1622,19 +1635,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2759,9 +2759,9 @@ } }, "node_modules/lucide-react": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz", - "integrity": "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.20.0.tgz", + "integrity": "sha512-jhXLeC/7m0/tjL1nzMdKk6x256zWA6AtbhTVreHOiKPoeX2d6MK4FbyIQPpVq0E6iPWBisyy1TW+pEge/uMEuQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/server/controllers/voiceController.js b/server/controllers/voiceController.js index 54c216d5..9c8d48b6 100644 --- a/server/controllers/voiceController.js +++ b/server/controllers/voiceController.js @@ -1,6 +1,7 @@ // Implements ElevenLabs voice cloning and text-to-speech proxy handlers. import crypto from "crypto"; -import { getIsMock } from "../utils/mock.js"; // adjust path to actual location +import { isValidAudioBuffer } from "../middleware/upload.js"; +import { getIsMock } from "../utils/mock.js"; import { isValidLanguageCode } from "../utils/languages.js"; const ELEVENLABS_BASE_URL = "https://api.elevenlabs.io/v1"; @@ -130,6 +131,14 @@ export async function cloneVoice(request, response, next) { return; } + // The MIME type checked in upload.js comes from the client and can be + // spoofed. Verify the buffer begins with a known audio magic-byte + // signature so arbitrary binary data cannot be forwarded to ElevenLabs. + if (!isValidAudioBuffer(audioFile.buffer)) { + response.status(400).json({ error: "Uploaded file does not appear to be valid audio." }); + return; + } + // --- mock mode: return a deterministic fixture voice_id --- if (getIsMock()) { console.warn("[VoiceForge] MOCK_ELEVENLABS: skipping real voice clone, returning fixture."); diff --git a/server/middleware/upload.js b/server/middleware/upload.js index 17485dbd..19c2b49b 100644 --- a/server/middleware/upload.js +++ b/server/middleware/upload.js @@ -10,6 +10,39 @@ const ALLOWED_MIME_TYPES = [ "audio/flac" ]; +// Known magic-byte signatures for audio formats accepted by ElevenLabs. +// Each entry is { offset, bytes } where bytes is a Buffer to match at that +// position in the uploaded file. +const AUDIO_SIGNATURES = [ + // WebM / Matroska (EBML header: 0x1A 0x45 0xDF 0xA3) + { offset: 0, bytes: Buffer.from([0x1a, 0x45, 0xdf, 0xa3]) }, + // WAV (RIFF....WAVE) + { offset: 0, bytes: Buffer.from("RIFF", "ascii") }, + // MP3 with ID3 tag + { offset: 0, bytes: Buffer.from("ID3", "ascii") }, + // MP3 sync word (0xFF 0xFB / 0xFF 0xFA / 0xFF 0xF3 and similar) + { offset: 0, bytes: Buffer.from([0xff, 0xfb]) }, + { offset: 0, bytes: Buffer.from([0xff, 0xfa]) }, + { offset: 0, bytes: Buffer.from([0xff, 0xf3]) }, + { offset: 0, bytes: Buffer.from([0xff, 0xe3]) }, + // OGG (OggS) + { offset: 0, bytes: Buffer.from("OggS", "ascii") }, + // FLAC + { offset: 0, bytes: Buffer.from("fLaC", "ascii") }, + // AIFF + { offset: 0, bytes: Buffer.from("FORM", "ascii") }, + // MP4 / M4A ftyp box (bytes 4-7 are "ftyp") + { offset: 4, bytes: Buffer.from("ftyp", "ascii") }, +]; + +export function isValidAudioBuffer(buf) { + if (!buf || buf.length < 12) return false; + return AUDIO_SIGNATURES.some(({ offset, bytes }) => { + if (buf.length < offset + bytes.length) return false; + return buf.slice(offset, offset + bytes.length).equals(bytes); + }); +} + const upload = multer({ storage: multer.memoryStorage(), limits: { diff --git a/server/test/voiceController.mock-mode.test.js b/server/test/voiceController.mock-mode.test.js index 57ec2be0..8a6e9b08 100644 --- a/server/test/voiceController.mock-mode.test.js +++ b/server/test/voiceController.mock-mode.test.js @@ -47,8 +47,12 @@ test("MOCK_ELEVENLABS: cloneVoice returns fixture voice_id without an API key", t.after(restore); const request = createRequest({ body: { name: "Contributor test voice" } }); + // Buffer starting with WebM magic bytes (0x1A 0x45 0xDF 0xA3) followed by padding request.file = { - buffer: Buffer.from("fake-audio"), + buffer: Buffer.concat([ + Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), + Buffer.alloc(12) + ]), mimetype: "audio/webm", originalname: "test.webm" }; @@ -162,8 +166,12 @@ test("MOCK_ELEVENLABS is ignored in production: cloneVoice requires a real API k t.after(restore); const request = createRequest({ body: { name: "prod test" } }); + // Buffer starting with WebM magic bytes (0x1A 0x45 0xDF 0xA3) followed by padding request.file = { - buffer: Buffer.from("fake-audio"), + buffer: Buffer.concat([ + Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), + Buffer.alloc(12) + ]), mimetype: "audio/webm", originalname: "test.webm" };