Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions client/src/hooks/useSpeechHistory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 25 additions & 25 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion server/controllers/voiceController.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.");
Expand Down
33 changes: 33 additions & 0 deletions server/middleware/upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor | ⚑ Quick win

RIFF/FORM alone are generic container markers; verify the audio sub-type.

RIFF (Line 11) also identifies AVI and WebP, and FORM (Line 24) is the generic IFF chunk used by many non-audio formats. Matching only these 4 bytes at offset 0 lets non-audio containers pass the check, which undercuts the goal of this PR. WAV requires WAVE and AIFF requires AIFF at offset 8. The current single {offset, bytes} + .some model can't enforce a compound match, so the table needs a small reshape to require both parts.

πŸ›‘οΈ Proposed compound-signature check
 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 …)
-  { 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") },
+  // Each entry matches when ALL of its parts match.
+  // WebM / Matroska  (EBML header: 0x1A 0x45 0xDF 0xA3)
+  { parts: [{ offset: 0, bytes: Buffer.from([0x1a, 0x45, 0xdf, 0xa3]) }] },
+  // WAV              (RIFF....WAVE)
+  { parts: [
+    { offset: 0, bytes: Buffer.from("RIFF", "ascii") },
+    { offset: 8, bytes: Buffer.from("WAVE", "ascii") },
+  ] },
+  // MP3 with ID3 tag
+  { parts: [{ offset: 0, bytes: Buffer.from("ID3", "ascii") }] },
+  // MP3 sync words   (0xFF 0xFB / 0xFF 0xFA / 0xFF 0xF3 / 0xFF 0xE3)
+  { parts: [{ offset: 0, bytes: Buffer.from([0xff, 0xfb]) }] },
+  { parts: [{ offset: 0, bytes: Buffer.from([0xff, 0xfa]) }] },
+  { parts: [{ offset: 0, bytes: Buffer.from([0xff, 0xf3]) }] },
+  { parts: [{ offset: 0, bytes: Buffer.from([0xff, 0xe3]) }] },
+  // OGG              (OggS)
+  { parts: [{ offset: 0, bytes: Buffer.from("OggS", "ascii") }] },
+  // FLAC
+  { parts: [{ offset: 0, bytes: Buffer.from("fLaC", "ascii") }] },
+  // AIFF             (FORM....AIFF)
+  { parts: [
+    { offset: 0, bytes: Buffer.from("FORM", "ascii") },
+    { offset: 8, bytes: Buffer.from("AIFF", "ascii") },
+  ] },
+  // MP4 / M4A ftyp box (bytes 4-7 are "ftyp")
+  { parts: [{ offset: 4, bytes: Buffer.from("ftyp", "ascii") }] },
 ];

And update the matcher accordingly:

 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);
-  });
+  return AUDIO_SIGNATURES.some(({ parts }) =>
+    parts.every(({ offset, bytes }) =>
+      buf.length >= offset + bytes.length &&
+      buf.subarray(offset, offset + bytes.length).equals(bytes)
+    )
+  );
 }

Also applies to: 24-24

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/middleware/upload.js` at line 11, The current magic-signature table
uses single 4-byte checks (e.g., { offset: 0, bytes: Buffer.from("RIFF","ascii")
}) which lets non-audio containers pass; change the table to express compound
signatures per format (e.g., WAV:
[{offset:0,bytes:Buffer.from("RIFF","ascii")},{offset:8,bytes:Buffer.from("WAVE","ascii")}],
AIFF:
[{offset:0,bytes:Buffer.from("FORM","ascii")},{offset:8,bytes:Buffer.from("AIFF","ascii")}])
and update the matcher function (the signatures array and the function that
currently uses .some to test entries) so it requires all sub-signatures for a
given format to match (use .every for sub-signatures) instead of accepting any
single 4-byte hit; apply the same compound change for the existing FORM entry at
line 24.

@cubic-dev-ai cubic-dev-ai Bot Jun 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Container checks are too broad; matching RIFF/FORM/ftyp alone still allows non-audio payloads past the new validation gate.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At server/middleware/upload.js, line 11:

<comment>Container checks are too broad; matching `RIFF`/`FORM`/`ftyp` alone still allows non-audio payloads past the new validation gate.</comment>

<file context>
@@ -1,12 +1,54 @@
+  // 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") },
</file context>
Fix with cubic

// 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]) },
Comment on lines +24 to +27

@cubic-dev-ai cubic-dev-ai Bot Jun 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: MP3 signature list is incomplete; valid 0xFF 0xF2 and 0xFF 0xE2 files can be falsely rejected.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At server/middleware/upload.js, line 15:

<comment>MP3 signature list is incomplete; valid `0xFF 0xF2` and `0xFF 0xE2` files can be falsely rejected.</comment>

<file context>
@@ -1,12 +1,54 @@
+  // 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]) },
</file context>
Suggested change
{ 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]) },
{ 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, 0xf2]) },
{ offset: 0, bytes: Buffer.from([0xff, 0xe3]) },
{ offset: 0, bytes: Buffer.from([0xff, 0xe2]) },
Fix with cubic

// 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: {
Expand Down
12 changes: 10 additions & 2 deletions server/test/voiceController.mock-mode.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
};
Expand Down Expand Up @@ -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"
};
Expand Down
Loading