diff --git a/apps/api/src/modules/auth/application/__tests__/generate-kyc-upload-urls.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/generate-kyc-upload-urls.handler.spec.ts index 5c32a44..599f7ee 100644 --- a/apps/api/src/modules/auth/application/__tests__/generate-kyc-upload-urls.handler.spec.ts +++ b/apps/api/src/modules/auth/application/__tests__/generate-kyc-upload-urls.handler.spec.ts @@ -1,8 +1,8 @@ +import { type IMediaStorageService } from '../../../../listings/infrastructure/services/media-storage.service'; import { UserEntity } from '../../domain/entities/user.entity'; import { type IUserRepository } from '../../domain/repositories/user.repository'; import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo'; import { Phone } from '../../domain/value-objects/phone.vo'; -import { type IMediaStorageService } from '../../../../listings/infrastructure/services/media-storage.service'; import { GenerateKycUploadUrlsCommand } from '../commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command'; import { GenerateKycUploadUrlsHandler } from '../commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler'; @@ -42,6 +42,7 @@ describe('GenerateKycUploadUrlsHandler', () => { getPresignedUploadUrl: vi.fn(), generatePresignedUpload: vi.fn(), getPublicUrl: vi.fn(), + isTrustedUrl: vi.fn().mockReturnValue(true), }; mockLogger = { error: vi.fn(), diff --git a/apps/api/src/modules/auth/application/__tests__/submit-kyc.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/submit-kyc.handler.spec.ts index 283fcae..ed57172 100644 --- a/apps/api/src/modules/auth/application/__tests__/submit-kyc.handler.spec.ts +++ b/apps/api/src/modules/auth/application/__tests__/submit-kyc.handler.spec.ts @@ -1,8 +1,8 @@ +import { type IMediaStorageService } from '../../../../listings/infrastructure/services/media-storage.service'; import { UserEntity } from '../../domain/entities/user.entity'; import { type IUserRepository } from '../../domain/repositories/user.repository'; import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo'; import { Phone } from '../../domain/value-objects/phone.vo'; -import { type IMediaStorageService } from '../../../../listings/infrastructure/services/media-storage.service'; import { SubmitKycCommand } from '../commands/submit-kyc/submit-kyc.command'; import { SubmitKycHandler } from '../commands/submit-kyc/submit-kyc.handler'; @@ -43,6 +43,7 @@ describe('SubmitKycHandler', () => { getPresignedUploadUrl: vi.fn(), generatePresignedUpload: vi.fn(), getPublicUrl: vi.fn(), + isTrustedUrl: vi.fn().mockReturnValue(true), }; mockCache = { invalidate: vi.fn().mockResolvedValue(undefined), @@ -137,6 +138,27 @@ describe('SubmitKycHandler', () => { expect(result.message).toBeTruthy(); expect(user.kycStatus).toBe('PENDING'); }); + + it('rejects untrusted image URL hosts', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockMediaStorage.isTrustedUrl.mockImplementation((url: string) => + url.startsWith('https://minio/'), + ); + + const command = new SubmitKycCommand( + 'user-1', + 'CCCD', + '012345678901', + undefined, + undefined, + undefined, + { frontImageUrl: 'https://evil.example.com/kyc/front.jpg' }, + ); + + await expect(handler.execute(command)).rejects.toThrow(); + expect(mockUserRepo.update).not.toHaveBeenCalled(); + }); }); describe('legacy file upload flow', () => { diff --git a/apps/api/src/modules/auth/application/commands/submit-kyc/submit-kyc.handler.ts b/apps/api/src/modules/auth/application/commands/submit-kyc/submit-kyc.handler.ts index c6d6552..9e1c45a 100644 --- a/apps/api/src/modules/auth/application/commands/submit-kyc/submit-kyc.handler.ts +++ b/apps/api/src/modules/auth/application/commands/submit-kyc/submit-kyc.handler.ts @@ -49,6 +49,17 @@ export class SubmitKycHandler implements ICommandHandler { frontImageUrl = command.imageUrls.frontImageUrl; backImageUrl = command.imageUrls.backImageUrl ?? null; selfieUrl = command.imageUrls.selfieUrl ?? null; + + // Validate URL hosts match our MinIO bucket (reject SSRF / tampering) + const untrusted: string[] = []; + if (!this.mediaStorage.isTrustedUrl(frontImageUrl)) untrusted.push('frontImageUrl'); + if (backImageUrl && !this.mediaStorage.isTrustedUrl(backImageUrl)) untrusted.push('backImageUrl'); + if (selfieUrl && !this.mediaStorage.isTrustedUrl(selfieUrl)) untrusted.push('selfieUrl'); + if (untrusted.length > 0) { + throw new ValidationException( + `URL khong hop le (${untrusted.join(', ')}): chi chap nhan URL tu MinIO bucket cua he thong`, + ); + } } else if (command.frontImage) { // Legacy file upload flow: upload buffers server-side const folder = `${KYC_FOLDER}/${command.userId}`; diff --git a/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts b/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts index 88c7947..5f099db 100644 --- a/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts +++ b/apps/api/src/modules/listings/infrastructure/services/media-storage.service.ts @@ -30,6 +30,7 @@ export interface IMediaStorageService { expiresInSeconds?: number, ): Promise; getPublicUrl(objectKey: string): string; + isTrustedUrl(url: string): boolean; } function requireEnv(key: string): string { @@ -151,6 +152,45 @@ export class MinioMediaStorageService implements IMediaStorageService, OnModuleI return `${protocol}://${this.endpoint}:${this.port}/${this.bucket}/${objectKey}`; } + /** + * Validates that a URL points to our configured MinIO bucket. + * Accepts the primary endpoint, plus an optional comma-separated list of + * additional trusted hosts via `MINIO_TRUSTED_HOSTS` (e.g. public CDN domains). + * Also enforces the bucket is the first path segment. + */ + isTrustedUrl(url: string): boolean { + if (!url || typeof url !== 'string') { + return false; + } + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + + const allowedHosts = new Set(); + allowedHosts.add(this.endpoint.toLowerCase()); + allowedHosts.add(`${this.endpoint.toLowerCase()}:${this.port}`); + + const extra = process.env['MINIO_TRUSTED_HOSTS']; + if (extra) { + for (const h of extra.split(',')) { + const trimmed = h.trim().toLowerCase(); + if (trimmed) allowedHosts.add(trimmed); + } + } + + const host = parsed.host.toLowerCase(); + if (!allowedHosts.has(host) && !allowedHosts.has(parsed.hostname.toLowerCase())) { + return false; + } + + // Path must start with // + const expectedPrefix = `/${this.bucket}/`; + return parsed.pathname.startsWith(expectedPrefix) && parsed.pathname.length > expectedPrefix.length; + } + async delete(fileUrl: string): Promise { try { const urlObj = new URL(fileUrl);