feat: harden media upload validation
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
uploadBunnyVideo,
|
||||
} from '../providers/bunny.js';
|
||||
import { config } from '../config.js';
|
||||
import { VideoUploadValidationError, validateVideoUpload } from '../uploads/video.js';
|
||||
import {
|
||||
BunnyStorageConfigurationError,
|
||||
BunnyStorageRequestError,
|
||||
@@ -23,6 +24,7 @@ const createVideoSchema = z.object({ title: z.string().trim().min(1).max(255) })
|
||||
const videoParamsSchema = z.object({ videoId: z.string().uuid() });
|
||||
|
||||
function providerError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) {
|
||||
if (error instanceof VideoUploadValidationError) return reply.code(error.statusCode).send({ error: error.message });
|
||||
if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: error.message });
|
||||
if (error instanceof BunnyRequestError) return reply.code(502).send({ error: 'Bunny Stream could not complete this request. Please try again.' });
|
||||
throw error;
|
||||
@@ -51,6 +53,23 @@ async function persistBunnyStatus(video: { id: string; status: string; durationS
|
||||
}
|
||||
}
|
||||
|
||||
async function assertVideoAccess(videoId: string, user: { id: string; role: string }) {
|
||||
if (user.role === 'admin') return;
|
||||
const result = await pool.query<{ owner_id: string }>('select owner_id from bunny_video_uploads where video_id = $1', [videoId]);
|
||||
if (result.rows[0]?.owner_id === user.id) return;
|
||||
const legacy = await pool.query<{ instructor_id: string }>(
|
||||
`select c.instructor_id
|
||||
from lesson_media lm
|
||||
join lessons l on l.id = lm.lesson_id
|
||||
join courses c on c.id = l.course_id
|
||||
where lm.provider = 'bunny' and lm.external_id = $1
|
||||
limit 1`,
|
||||
[videoId],
|
||||
);
|
||||
if (legacy.rows[0]?.instructor_id === user.id) return;
|
||||
throw new VideoUploadValidationError('You do not have access to this video.', 403);
|
||||
}
|
||||
|
||||
export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) };
|
||||
|
||||
@@ -89,6 +108,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
const input = createVideoSchema.parse(request.body);
|
||||
try {
|
||||
const video = await createBunnyVideo(input.title);
|
||||
await pool.query('insert into bunny_video_uploads (video_id, owner_id) values ($1, $2)', [video.id, request.user.id]);
|
||||
await recordAudit({ actorId: request.user.id, action: 'media.bunny.created', subjectType: 'video', subjectId: video.id, metadata: { title: video.title }, ipAddress: request.ip });
|
||||
return reply.code(201).send({ data: video });
|
||||
} catch (error) {
|
||||
@@ -98,6 +118,11 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
app.put('/bunny/videos/:videoId/upload', manageAccess, async (request, reply) => {
|
||||
const { videoId } = videoParamsSchema.parse(request.params);
|
||||
try {
|
||||
await assertVideoAccess(videoId, request.user);
|
||||
} catch (error) {
|
||||
return providerError(reply, error);
|
||||
}
|
||||
const contentLength = Number(request.headers['content-length'] || 0);
|
||||
const maxBytes = config.BUNNY_MAX_UPLOAD_MB * 1024 * 1024;
|
||||
if (contentLength > maxBytes) {
|
||||
@@ -105,7 +130,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const video = await uploadBunnyVideo(videoId, request.body as ReadableStream, request.headers['content-type']);
|
||||
const body = validateVideoUpload(request.body as import('node:stream').Readable, request.headers['content-type'], maxBytes);
|
||||
const video = await uploadBunnyVideo(videoId, body as unknown as ReadableStream, request.headers['content-type']);
|
||||
await persistBunnyStatus(video);
|
||||
await recordAudit({ actorId: request.user.id, action: 'media.bunny.uploaded', subjectType: 'video', subjectId: video.id, metadata: { status: video.status }, ipAddress: request.ip });
|
||||
return { data: video };
|
||||
@@ -117,6 +143,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/bunny/videos/:videoId', manageAccess, async (request, reply) => {
|
||||
const { videoId } = videoParamsSchema.parse(request.params);
|
||||
try {
|
||||
await assertVideoAccess(videoId, request.user);
|
||||
const video = await getBunnyVideo(videoId);
|
||||
await persistBunnyStatus(video);
|
||||
return { data: video };
|
||||
@@ -128,7 +155,9 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.delete('/bunny/videos/:videoId', manageAccess, async (request, reply) => {
|
||||
const { videoId } = videoParamsSchema.parse(request.params);
|
||||
try {
|
||||
await assertVideoAccess(videoId, request.user);
|
||||
await deleteBunnyVideo(videoId);
|
||||
await pool.query('delete from bunny_video_uploads where video_id = $1', [videoId]);
|
||||
await recordAudit({ actorId: request.user.id, action: 'media.video.deleted', subjectType: 'video', subjectId: videoId, ipAddress: request.ip });
|
||||
return reply.code(204).send();
|
||||
} catch (error) {
|
||||
|
||||
62
server/src/uploads/video.ts
Normal file
62
server/src/uploads/video.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Transform, type Readable } from 'node:stream';
|
||||
|
||||
const allowedContentTypes = new Set(['video/mp4', 'video/webm', 'video/quicktime']);
|
||||
|
||||
export class VideoUploadValidationError extends Error {
|
||||
constructor(message: string, public readonly statusCode: 403 | 413 | 415) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const isRecognizedVideo = (header: Buffer) => {
|
||||
// ISO Base Media (MP4/MOV): bytes 4–7 identify the file type box.
|
||||
const isMp4Family = header.length >= 12 && header.subarray(4, 8).toString('ascii') === 'ftyp';
|
||||
// WebM/Matroska EBML signature.
|
||||
const isWebm = header.length >= 4 && header.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]));
|
||||
return isMp4Family || isWebm;
|
||||
};
|
||||
|
||||
class ValidatedVideoStream extends Transform {
|
||||
private totalBytes = 0;
|
||||
private header = Buffer.alloc(0);
|
||||
private validated = false;
|
||||
|
||||
constructor(private readonly maxBytes: number) {
|
||||
super();
|
||||
}
|
||||
|
||||
override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null, data?: Buffer) => void) {
|
||||
this.totalBytes += chunk.length;
|
||||
if (this.totalBytes > this.maxBytes) {
|
||||
callback(new VideoUploadValidationError('Video is larger than the configured upload limit.', 413));
|
||||
return;
|
||||
}
|
||||
if (!this.validated) {
|
||||
this.header = Buffer.concat([this.header, chunk]).subarray(0, 32);
|
||||
if (this.header.length >= 12) {
|
||||
if (!isRecognizedVideo(this.header)) {
|
||||
callback(new VideoUploadValidationError('Only valid MP4, WebM, and MOV video files are accepted.', 415));
|
||||
return;
|
||||
}
|
||||
this.validated = true;
|
||||
}
|
||||
}
|
||||
callback(null, chunk);
|
||||
}
|
||||
|
||||
override _flush(callback: (error?: Error | null) => void) {
|
||||
if (!this.validated) {
|
||||
callback(new VideoUploadValidationError('The uploaded file is not a valid video.', 415));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
export function validateVideoUpload(input: Readable, contentType: string | undefined, maxBytes: number) {
|
||||
const normalizedType = contentType?.split(';')[0]?.toLowerCase();
|
||||
if (!normalizedType || !allowedContentTypes.has(normalizedType)) {
|
||||
throw new VideoUploadValidationError('Only MP4, WebM, and MOV video uploads are accepted.', 415);
|
||||
}
|
||||
return input.pipe(new ValidatedVideoStream(maxBytes));
|
||||
}
|
||||
Reference in New Issue
Block a user