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

@@ -30,6 +30,7 @@ export interface IMediaStorageService {
expiresInSeconds?: number,
): Promise<PresignedUploadResult>;
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<string>();
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 /<bucket>/
const expectedPrefix = `/${this.bucket}/`;
return parsed.pathname.startsWith(expectedPrefix) && parsed.pathname.length > expectedPrefix.length;
}
async delete(fileUrl: string): Promise<void> {
try {
const urlObj = new URL(fileUrl);