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
326 changes: 228 additions & 98 deletions docs/architecture.md

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@
"jsonwebtoken": "^9.0.2",
"keyv": "^5.6.0",
"multer": "^2.2.0",
"nodemailer": "^8.0.7",
"nodemailer": "^9.0.1",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
Expand Down
24 changes: 23 additions & 1 deletion src/cache/cache-headers.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,38 @@ import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nes
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

const IMAGE_CACHE_DURATIONS: Record<string, number> = {
'image/avif': 86400 * 30,
'image/webp': 86400 * 7,
'image/jpeg': 3600,
'image/png': 3600,
'image/gif': 3600,
};

@Injectable()
export class CacheHeadersInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const res = context.switchToHttp().getResponse();
const req = context.switchToHttp().getRequest();
const start = Date.now();

return next.handle().pipe(
tap(() => {
res.setHeader('X-Cache-Time', `${Date.now() - start}ms`);
res.setHeader('Cache-Control', 'public, max-age=60');

const contentType = res.getHeader('content-type') as string | undefined;
const isImageResponse =
contentType?.startsWith('image/') ||
req.path?.includes('/uploads/');

if (isImageResponse) {
const format = contentType?.split(';')[0]?.trim() || 'image/jpeg';
const maxAge = IMAGE_CACHE_DURATIONS[format] || 3600;
res.setHeader('Cache-Control', `public, max-age=${maxAge}`);
res.setHeader('Vary', 'Accept');
} else {
res.setHeader('Cache-Control', 'public, max-age=60');
}
}),
);
}
Expand Down
148 changes: 146 additions & 2 deletions src/documents/document-upload.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ describe('DocumentUploadService', () => {
const req: UploadRequest = {
fileName: 'large.pdf',
mimeType: 'application/pdf',
fileSizeBytes: 11 * 1024 * 1024,
fileSizeBytes: 30 * 1024 * 1024,
};
expect(() => service.validate(req)).toThrow(BadRequestException);
expect(() => service.validate(req)).toThrow('File exceeds maximum allowed size');
expect(() => service.validate(req)).toThrow('File exceeds maximum allowed size of 25 MB for application/pdf');
});

it('should throw BadRequestException for empty file name', () => {
Expand All @@ -55,6 +55,34 @@ describe('DocumentUploadService', () => {
expect(() => service.validate(req)).toThrow(BadRequestException);
expect(() => service.validate(req)).toThrow('File name cannot be empty');
});

it('should throw BadRequestException for zero size file', () => {
const req: UploadRequest = {
fileName: 'empty.pdf',
mimeType: 'application/pdf',
fileSizeBytes: 0,
};
expect(() => service.validate(req)).toThrow(BadRequestException);
expect(() => service.validate(req)).toThrow('File size must be greater than zero');
});

it('should enforce type-specific size limits for images', () => {
const req: UploadRequest = {
fileName: 'big.png',
mimeType: 'image/png',
fileSizeBytes: 15 * 1024 * 1024,
};
expect(() => service.validate(req)).toThrow(BadRequestException);
});

it('should enforce type-specific size limits for docs', () => {
const req: UploadRequest = {
fileName: 'big.doc',
mimeType: 'application/msword',
fileSizeBytes: 20 * 1024 * 1024,
};
expect(() => service.validate(req)).toThrow(BadRequestException);
});
});

describe('prepareMetadata', () => {
Expand All @@ -72,4 +100,120 @@ describe('DocumentUploadService', () => {
expect(result).toHaveProperty('uploadedAt');
});
});

describe('validateMagicBytes', () => {
it('should validate PDF magic bytes', () => {
const pdfBuffer = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34]);
expect(service.validateMagicBytes(pdfBuffer, 'application/pdf')).toBe(true);
});

it('should reject wrong magic bytes', () => {
const buf = Buffer.from([0x00, 0x00, 0x00, 0x00]);
expect(service.validateMagicBytes(buf, 'application/pdf')).toBe(false);
});

it('should validate JPEG magic bytes', () => {
const jpegBuffer = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
expect(service.validateMagicBytes(jpegBuffer, 'image/jpeg')).toBe(true);
});

it('should validate PNG magic bytes', () => {
const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
expect(service.validateMagicBytes(pngBuffer, 'image/png')).toBe(true);
});

it('should validate WebP magic bytes', () => {
const webpBuffer = Buffer.from([0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00]);
expect(service.validateMagicBytes(webpBuffer, 'image/webp')).toBe(true);
});

it('should return true for unknown MIME types', () => {
const buf = Buffer.from([0x00, 0x00]);
expect(service.validateMagicBytes(buf, 'application/octet-stream')).toBe(true);
});

it('should handle buffer shorter than signature', () => {
const shortBuf = Buffer.from([0x25]);
expect(service.validateMagicBytes(shortBuf, 'application/pdf')).toBe(false);
});
});

describe('sanitizeFilename', () => {
it('should remove path traversal sequences', () => {
expect(service.sanitizeFilename('../../../etc/passwd')).not.toContain('..');
});

it('should remove null bytes', () => {
expect(service.sanitizeFilename('file\x00name.pdf')).not.toContain('\x00');
});

it('should remove slashes', () => {
expect(service.sanitizeFilename('path/to/file.pdf')).not.toContain('/');
});

it('should replace special characters with underscores', () => {
const result = service.sanitizeFilename('hello world!@#.pdf');
expect(result).not.toContain(' ');
expect(result).not.toContain('!');
});

it('should handle empty input with fallback', () => {
const result = service.sanitizeFilename('...');
expect(result).toMatch(/^upload_\d+$/);
});

it('should lowercase output', () => {
expect(service.sanitizeFilename('FILE.PDF')).toBe('file.pdf');
});
});

describe('validateFileSize', () => {
it('should pass for files within limits', () => {
const buf = Buffer.alloc(1024);
expect(() => service.validateFileSize(buf, 'application/pdf')).not.toThrow();
});

it('should throw for oversized files', () => {
const buf = Buffer.alloc(30 * 1024 * 1024);
expect(() => service.validateFileSize(buf, 'application/pdf')).toThrow(BadRequestException);
});
});

describe('scanForThreats', () => {
it('should detect script tags', () => {
const buf = Buffer.from('<script>alert("xss")</script>');
const result = service.scanForThreats(buf);
expect(result.safe).toBe(false);
});

it('should detect javascript protocol', () => {
const buf = Buffer.from('javascript:void(0)');
const result = service.scanForThreats(buf);
expect(result.safe).toBe(false);
});

it('should detect iframe tags', () => {
const buf = Buffer.from('<iframe src="evil.com">');
const result = service.scanForThreats(buf);
expect(result.safe).toBe(false);
});

it('should detect object tags', () => {
const buf = Buffer.from('<object data="evil.swf">');
const result = service.scanForThreats(buf);
expect(result.safe).toBe(false);
});

it('should return safe for clean content', () => {
const buf = Buffer.from('Hello world, this is a normal document.');
const result = service.scanForThreats(buf);
expect(result.safe).toBe(true);
});

it('should handle binary content without false positives', () => {
const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const result = service.scanForThreats(buf);
expect(result.safe).toBe(true);
});
});
});
121 changes: 113 additions & 8 deletions src/documents/document-upload.service.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,50 @@
// @ts-nocheck

import { Injectable, BadRequestException } from '@nestjs/common';
import { Injectable, BadRequestException, Logger } from '@nestjs/common';

const ALLOWED_MIME_TYPES = new Set([
'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
'image/avif',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
]);

const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
const MIME_SIZE_LIMITS: Record<string, number> = {
'image/jpeg': 10 * 1024 * 1024,
'image/png': 10 * 1024 * 1024,
'image/webp': 10 * 1024 * 1024,
'image/avif': 10 * 1024 * 1024,
'application/pdf': 25 * 1024 * 1024,
'application/msword': 15 * 1024 * 1024,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 15 * 1024 * 1024,
};

const MAGIC_BYTES: Record<string, number[][]> = {
'application/pdf': [[0x25, 0x50, 0x44, 0x46]],
'image/jpeg': [[0xff, 0xd8, 0xff]],
'image/png': [
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
],
'image/webp': [
[0x52, 0x49, 0x46, 0x46],
],
'image/avif': [
[0x00, 0x00, 0x00],
],
};

const THREAT_PATTERNS = [
/<script[\s>]/i,
/javascript:/i,
/on\w+\s*=/i,
/<iframe[\s>]/i,
/<object[\s>]/i,
/<embed[\s>]/i,
/<applet[\s>]/i,
];

export interface UploadRequest {
fileName: string;
Expand All @@ -29,13 +62,19 @@ export interface UploadMetadata {

@Injectable()
export class DocumentUploadService {
private readonly logger = new Logger(DocumentUploadService.name);

validate(request: UploadRequest): void {
if (!ALLOWED_MIME_TYPES.has(request.mimeType)) {
throw new BadRequestException(`Unsupported file type: ${request.mimeType}`);
}
if (request.fileSizeBytes > MAX_FILE_SIZE_BYTES) {
if (request.fileSizeBytes <= 0) {
throw new BadRequestException('File size must be greater than zero');
}
const limit = MIME_SIZE_LIMITS[request.mimeType] ?? 10 * 1024 * 1024;
if (request.fileSizeBytes > limit) {
throw new BadRequestException(
`File exceeds maximum allowed size of ${MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB`,
`File exceeds maximum allowed size of ${Math.round(limit / (1024 * 1024))} MB for ${request.mimeType}`,
);
}
if (!request.fileName.trim()) {
Expand All @@ -45,15 +84,81 @@ export class DocumentUploadService {

prepareMetadata(request: UploadRequest): UploadMetadata {
this.validate(request);
const sanitisedName = request.fileName
.trim()
.replace(/[^a-zA-Z0-9._-]/g, '_')
.toLowerCase();
const sanitisedName = this.sanitizeFilename(request.fileName);

return {
...request,
sanitisedName,
uploadedAt: new Date().toISOString(),
};
}

/**
* Validate file signature (magic bytes) against the expected MIME type.
*/
validateMagicBytes(buffer: Buffer, expectedMime: string): boolean {
const signatures = MAGIC_BYTES[expectedMime];
if (!signatures) {
return true;
}
for (const sig of signatures) {
if (buffer.length < sig.length) {
continue;
}
let match = true;
for (let i = 0; i < sig.length; i++) {
if (buffer[i] !== sig[i]) {
match = false;
break;
}
}
if (match) {
return true;
}
}
return false;
}

/**
* Enhanced filename sanitization with path traversal prevention.
*/
sanitizeFilename(filename: string): string {
let name = filename.trim();
name = name.replace(/\0/g, '');
name = name.replace(/\.\./g, '');
name = name.replace(/[/\\]/g, '');
name = name.replace(/[^a-zA-Z0-9._-]/g, '_');
name = name.replace(/_{2,}/g, '_');
name = name.replace(/^[._-]+/, '');
if (!name || name.length === 0) {
name = `upload_${Date.now()}`;
}
return name.toLowerCase();
}

/**
* Validate file size against type-specific limits.
*/
validateFileSize(buffer: Buffer, mimeType: string): void {
const limit = MIME_SIZE_LIMITS[mimeType] ?? 10 * 1024 * 1024;
if (buffer.length > limit) {
throw new BadRequestException(
`File size ${Math.round(buffer.length / (1024 * 1024))}MB exceeds limit of ${Math.round(limit / (1024 * 1024))}MB for ${mimeType}`,
);
}
}

/**
* Basic malware/threat scan looking for embedded scripts and suspicious patterns.
*/
scanForThreats(buffer: Buffer): { safe: boolean; reason?: string } {
const content = buffer.toString('utf-8', 0, Math.min(buffer.length, 1024 * 1024));
for (const pattern of THREAT_PATTERNS) {
if (pattern.test(content)) {
this.logger.warn(`Threat pattern detected: ${pattern.source}`);
return { safe: false, reason: `Potentially dangerous pattern detected: ${pattern.source}` };
}
}
return { safe: true };
}
}
Loading
Loading