Fix VoicePrint auth bypass & audio upload (FRE-5003)

P1-2: Add onRequest auth hook to reject anonymous requests on all 7
VoicePrint endpoints. Previously, the auth middleware always attached
a placeholder user (id='anonymous'), so per-route userId checks passed
for unauthenticated clients.

P1-3: Replace JSON body parsing with @fastify/multipart for POST
/endpoints (/enroll, /analyze, /batch). Fastify JSON parser cannot
produce Buffer from request.body; multipart/form-data is required
for audio file uploads. Added 50MB file size limit.
This commit is contained in:
Security Reviewer
2026-05-10 03:20:31 -04:00
committed by Michael Freno
parent fb82dc68d7
commit 4d30bacc53
3 changed files with 283 additions and 70 deletions

View File

@@ -12,19 +12,20 @@
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/helmet": "^13.0.1",
"@fastify/multipart": "^7.7.3",
"@fastify/rate-limit": "^9.0.0",
"@fastify/sensible": "^6.0.1",
"@shieldai/db": "workspace:*",
"@shieldai/types": "workspace:*",
"@shieldai/correlation": "workspace:*",
"@shieldai/report": "workspace:*",
"fastify": "^5.2.0",
"@shieldai/darkwatch": "workspace:*",
"@shieldai/db": "workspace:*",
"@shieldai/monitoring": "workspace:*",
"@shieldai/report": "workspace:*",
"@shieldai/types": "workspace:*",
"@shieldai/voiceprint": "workspace:*",
"@shieldai/monitoring": "workspace:*"
"fastify": "^5.2.0"
},
"devDependencies": {
"vitest": "^4.1.5",
"@vitest/coverage-v8": "^4.1.5"
"@vitest/coverage-v8": "^4.1.5",
"vitest": "^4.1.5"
}
}

View File

@@ -1,36 +1,65 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import fastifyMultipart from '@fastify/multipart';
import {
voiceEnrollmentService,
analysisService,
batchAnalysisService,
voicePrintEnv,
AnalysisJobStatus,
} from '../services/voiceprint';
interface AuthenticatedRequest extends FastifyRequest {
user?: { id: string; email: string; role: string };
authType?: 'jwt' | 'api-key' | 'anonymous';
}
export async function voiceprintRoutes(fastify: FastifyInstance) {
// P1-2 fix: Require authentication on all VoicePrint routes
fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as AuthenticatedRequest;
if (authReq.authType === 'anonymous' || !authReq.user?.id || authReq.user.id === 'anonymous') {
return reply.code(401).send({ error: 'Authentication required' });
}
});
// P1-3 fix: Register multipart for audio file uploads
await fastify.register(fastifyMultipart, {
limits: {
fileSize: voicePrintEnv.ENROLLMENT_MAX_DURATION_SEC > 0
? 50 * 1024 * 1024 // 50MB max file size for audio
: 50 * 1024 * 1024,
},
});
// Enroll a new voice profile
fastify.post('/enroll', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const authReq = request as AuthenticatedRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
const body = request.body as {
name: string;
audio: Buffer;
};
// P1-3 fix: Parse multipart form-data for audio upload
let name: string | undefined;
let audioBuffer: Buffer | undefined;
if (!body.name || !body.audio) {
return reply.code(400).send({ error: 'name and audio are required' });
for await (const part of request.files()) {
if (part.type === 'file') {
audioBuffer = await part.toBuffer();
name = name || part.filename || 'voice_enrollment';
} else if (part.fieldname === 'name') {
name = part.value;
}
}
if (!audioBuffer || audioBuffer.length === 0) {
return reply.code(400).send({ error: 'audio file is required' });
}
try {
const enrollment = await voiceEnrollmentService.enroll(
userId,
body.name,
body.audio
name || 'voice_enrollment',
audioBuffer
);
return reply.code(201).send({
enrollment: {
@@ -48,7 +77,7 @@ export async function voiceprintRoutes(fastify: FastifyInstance) {
// List user's voice enrollments
fastify.get('/enrollments', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const authReq = request as AuthenticatedRequest;
const userId = authReq.user?.id;
if (!userId) {
@@ -79,7 +108,7 @@ export async function voiceprintRoutes(fastify: FastifyInstance) {
// Remove an enrollment
fastify.delete('/enrollments/:id', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const authReq = request as AuthenticatedRequest;
const userId = authReq.user?.id;
if (!userId) {
@@ -108,27 +137,36 @@ export async function voiceprintRoutes(fastify: FastifyInstance) {
// Analyze a single audio file
fastify.post('/analyze', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const authReq = request as AuthenticatedRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
const body = request.body as {
audio: Buffer;
enrollmentId?: string;
audioUrl?: string;
};
// P1-3 fix: Parse multipart form-data for audio upload
let audioBuffer: Buffer | undefined;
let enrollmentId: string | undefined;
let audioUrl: string | undefined;
if (!body.audio) {
return reply.code(400).send({ error: 'audio is required' });
for await (const part of request.files()) {
if (part.type === 'file') {
audioBuffer = await part.toBuffer();
} else if (part.fieldname === 'enrollmentId') {
enrollmentId = part.value;
} else if (part.fieldname === 'audioUrl') {
audioUrl = part.value;
}
}
if (!audioBuffer || audioBuffer.length === 0) {
return reply.code(400).send({ error: 'audio file is required' });
}
try {
const result = await analysisService.analyze(userId, body.audio, {
enrollmentId: body.enrollmentId,
audioUrl: body.audioUrl,
const result = await analysisService.analyze(userId, audioBuffer, {
enrollmentId,
audioUrl,
});
return reply.code(201).send({
analysis: {
@@ -147,7 +185,7 @@ export async function voiceprintRoutes(fastify: FastifyInstance) {
// Get analysis result by ID
fastify.get('/results/:id', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const authReq = request as AuthenticatedRequest;
const userId = authReq.user?.id;
if (!userId) {
@@ -174,7 +212,7 @@ export async function voiceprintRoutes(fastify: FastifyInstance) {
// Get analysis history
fastify.get('/history', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const authReq = request as AuthenticatedRequest;
const userId = authReq.user?.id;
if (!userId) {
@@ -207,37 +245,42 @@ export async function voiceprintRoutes(fastify: FastifyInstance) {
// Batch analyze multiple audio files
fastify.post('/batch', async (request: FastifyRequest, reply: FastifyReply) => {
const authReq = request as FastifyRequest & { user?: { id: string } };
const authReq = request as AuthenticatedRequest;
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
}
const body = request.body as {
files: Array<{
name: string;
audio: Buffer;
audioUrl?: string;
}>;
enrollmentId?: string;
};
// P1-3 fix: Parse multipart form-data for multiple audio uploads
const files: Array<{ name: string; buffer: Buffer; audioUrl?: string }> = [];
let enrollmentId: string | undefined;
if (!body.files || body.files.length === 0) {
return reply.code(400).send({ error: 'files array is required' });
for await (const part of request.files()) {
if (part.type === 'file') {
const buffer = await part.toBuffer();
files.push({
name: part.filename || `file_${files.length}`,
buffer,
});
} else if (part.fieldname === 'enrollmentId') {
enrollmentId = part.value;
} else if (part.fieldname === 'audioUrl') {
if (files.length > 0) {
files[files.length - 1].audioUrl = part.value;
}
}
}
if (files.length === 0) {
return reply.code(400).send({ error: 'at least one audio file is required' });
}
try {
const result = await batchAnalysisService.analyzeBatch(
userId,
body.files.map((f) => ({
name: f.name,
buffer: f.audio,
audioUrl: f.audioUrl,
})),
{
enrollmentId: body.enrollmentId,
}
files,
{ enrollmentId }
);
return reply.code(201).send({