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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user