Skip to content
Closed
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
17 changes: 17 additions & 0 deletions backend/package-lock.json

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

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@aws-sdk/client-kms": "^3.1076.0",
"@aws-sdk/client-s3": "^3.1117.0",
"@aws-sdk/lib-storage": "^3.1076.0",
"@aws-sdk/s3-request-presigner": "^3.1117.0",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"@prisma/config": "^7.8.0",
Expand Down
133 changes: 133 additions & 0 deletions backend/src/controllers/uploadController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { Request, Response } from 'express';
import { fileUploadService, validateMagicBytes } from '../services/fileUploadService.js';
import { ApiResponse } from '../utils/response.js';
import logger from '../utils/logger.js';

export const uploadController = {
/**
* Request an S3 pre-signed upload URL for direct-to-S3 client uploads.
* Enforces 15-minute expiration, max file size limits (5MB images, 10MB deliverables),
* and magic byte validation before granting access.
*/
async getPresignedUploadUrl(req: Request, res: Response) {
try {
const { filename, contentType, fileSize, purpose = 'generic', fileHeaderBase64 } = req.body;

if (!filename || !contentType || !fileSize) {
return res.status(400).json(
ApiResponse.error('Missing required parameters: filename, contentType, and fileSize are required')
);
}

// If magic bytes sample is supplied, validate it immediately
if (fileHeaderBase64) {
try {
const buffer = Buffer.from(fileHeaderBase64, 'base64');
const validation = validateMagicBytes(buffer, contentType);
if (!validation.valid) {
logger.warn('File upload rejected by magic byte inspection', {
filename,
claimedMime: contentType,
detectedMime: validation.detectedMime,
error: validation.error,
userId: (req as any).user?.id,
});
return res.status(400).json(
ApiResponse.error(validation.error || 'Magic byte signature mismatch. File extension might be forged.')
);
}
} catch (err: any) {
return res.status(400).json(ApiResponse.error(`Failed to parse fileHeaderBase64: ${err.message}`));
}
}

const result = await fileUploadService.generatePresignedUploadUrl(
{
filename,
contentType,
fileSize: Number(fileSize),
purpose,
userId: (req as any).user?.id,
workspaceId: (req as any).workspaceId || 'default',
},
{ expiresInSeconds: 15 * 60 } // 15 minutes
);

return res.status(200).json(
ApiResponse.success('Presigned upload URL generated successfully', result)
);
} catch (error: any) {
logger.error('Error in getPresignedUploadUrl controller:', error);
return res.status(400).json(ApiResponse.error(error.message || 'Failed to generate upload URL'));
}
},

/**
* Validate uploaded file header magic bytes and extension alignment.
*/
async verifyMagicBytes(req: Request, res: Response) {
try {
const { fileHeaderBase64, claimedContentType, filename } = req.body;

if (!fileHeaderBase64 || !claimedContentType) {
return res.status(400).json(
ApiResponse.error('fileHeaderBase64 and claimedContentType are required')
);
}

const buffer = Buffer.from(fileHeaderBase64, 'base64');
const validation = validateMagicBytes(buffer, claimedContentType);

if (!validation.valid) {
return res.status(400).json({
status: 'error',
valid: false,
error: validation.error,
detectedMime: validation.detectedMime,
claimedMime: claimedContentType,
});
}

return res.status(200).json({
status: 'success',
valid: true,
detectedMime: validation.detectedMime,
claimedMime: claimedContentType,
});
} catch (error: any) {
logger.error('Error verifying magic bytes:', error);
return res.status(500).json(ApiResponse.error(error.message || 'Failed to verify file magic bytes'));
}
},

/**
* Notification webhook or callback invoked when a file upload completes in S3.
* Enqueues an asynchronous ClamAV virus scan job.
*/
async notifyUploadComplete(req: Request, res: Response) {
try {
const { fileKey, metadata = {} } = req.body;

if (!fileKey) {
return res.status(400).json(ApiResponse.error('fileKey is required'));
}

const jobId = await fileUploadService.enqueueVirusScan(fileKey, {
...metadata,
userId: (req as any).user?.id,
timestamp: new Date().toISOString(),
});

return res.status(202).json(
ApiResponse.success('File upload accepted and enqueued for virus scanning', {
fileKey,
scanJobId: jobId,
status: 'scan_enqueued',
})
);
} catch (error: any) {
logger.error('Error queueing virus scan on upload completion:', error);
return res.status(500).json(ApiResponse.error(error.message || 'Failed to enqueue file for virus scan'));
}
},
};
2 changes: 2 additions & 0 deletions backend/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import didRouter from './did.routes.js';
import policyRouter from './policy/policy.routes.js';
import relayerRouter from './relayer.routes.js';
import storageRouter from './storage.routes.js';
import uploadRouter from './upload.routes.js';
import passkeyRouter from './passkey.routes.js';
import webhooksRouter from './webhooks.js';
import canvasRouter from './canvas.routes.js';
Expand Down Expand Up @@ -101,6 +102,7 @@ router.use('/admin/dlq', adminDLQRouter);
router.use('/admin/courses', adminCoursesRouter);
router.use('/policy', policyRouter);
router.use('/storage', storageRouter);
router.use('/upload', uploadRouter);
router.use('/passkey', passkeyRouter);
router.use('/relayer', relayerRouter);
router.use('/subscriptions', subscriptionsRouter);
Expand Down
78 changes: 78 additions & 0 deletions backend/src/routes/upload.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Router } from 'express';
import { uploadController } from '../controllers/uploadController.js';
import { authenticateToken } from '../middleware/auth.js';

const uploadRouter: Router = Router();

/**
* @openapi
* /api/v1/upload/presign:
* post:
* summary: Generate a time-limited S3 pre-signed upload URL
* description: Enforces file size limits (5MB images, 10MB deliverables) and checks file extension/signature before generating a 15-minute presigned URL.
* tags: [Storage & Uploads]
* security:
* - BearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [filename, contentType, fileSize]
* properties:
* filename:
* type: string
* example: deliverable.pdf
* contentType:
* type: string
* example: application/pdf
* fileSize:
* type: number
* example: 204800
* purpose:
* type: string
* enum: [avatar, image, deliverable, assignment, generic]
* default: generic
* fileHeaderBase64:
* type: string
* description: Base64-encoded initial 32 bytes of the file for instant magic byte validation.
* responses:
* 200:
* description: Pre-signed upload URL successfully created
* 400:
* description: Validation error or forged extension
*/
uploadRouter.post('/presign', authenticateToken, uploadController.getPresignedUploadUrl);

/**
* @openapi
* /api/v1/upload/verify-magic-bytes:
* post:
* summary: Verify magic bytes against claimed MIME type
* description: Inspects raw binary header bytes to prevent forged extensions (e.g. .exe disguised as .png).
* tags: [Storage & Uploads]
* responses:
* 200:
* description: Valid file signature
* 400:
* description: Mismatched or executable file signature
*/
uploadRouter.post('/verify-magic-bytes', uploadController.verifyMagicBytes);

/**
* @openapi
* /api/v1/upload/complete:
* post:
* summary: Notify upload completion and enqueue async virus scan
* description: Triggers background ClamAV scanning job for an uploaded S3 object.
* tags: [Storage & Uploads]
* security:
* - BearerAuth: []
* responses:
* 202:
* description: Virus scan enqueued
*/
uploadRouter.post('/complete', authenticateToken, uploadController.notifyUploadComplete);

export default uploadRouter;
Loading