feat(auth): validate KYC image URL hosts match MinIO bucket

Closes TEC-2725. Backend KYC presign + submit endpoints already landed in
8f8e20f; this adds the remaining acceptance criterion — host validation on
presigned URLs accepted via /auth/kyc/submit.

- Add IMediaStorageService.isTrustedUrl(url) — host+bucket check, supports
  MINIO_TRUSTED_HOSTS for CDN aliases
- SubmitKycHandler rejects imageUrls pointing outside our MinIO bucket
- Update handler specs with mock + new untrusted-host test

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-18 00:32:02 +07:00
parent db8ac9c592
commit 6a8e75effe
4 changed files with 76 additions and 2 deletions

View File

@@ -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(),

View File

@@ -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', () => {

View File

@@ -49,6 +49,17 @@ export class SubmitKycHandler implements ICommandHandler<SubmitKycCommand> {
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}`;