From 5d82e5896c21cc434942f0a3d61877947ba83ed8 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 11 Jun 2026 04:43:01 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(auth):=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?OIDC=20scope=EB=A5=BC=20provider=EB=B3=84=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(KOE205=20invalid=5Fscope=20=EC=88=98=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/services/oidc-client.service.spec.ts | 47 +++++++++++++++++++ .../auth/services/oidc-client.service.ts | 26 +++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/features/auth/services/oidc-client.service.spec.ts b/src/features/auth/services/oidc-client.service.spec.ts index 775b4cf..6469798 100644 --- a/src/features/auth/services/oidc-client.service.spec.ts +++ b/src/features/auth/services/oidc-client.service.spec.ts @@ -161,6 +161,53 @@ describe('OidcClientService', () => { code_challenge_method: 'S256', }); }); + + it('Kakao는 카카오 동의항목 scope를 사용해야 한다 (표준 email/profile 금지 — KOE205 방지)', async () => { + mockConfig.get.mockImplementation((key: string) => { + const config: Record = { + OIDC_KAKAO_ISSUER_URL: 'https://kauth.kakao.com', + OIDC_KAKAO_CLIENT_ID: 'kakao-client-id', + OIDC_KAKAO_CLIENT_SECRET: 'kakao-client-secret', + BACKEND_BASE_URL: 'http://localhost:4000', + }; + return config[key]; + }); + mockClient.authorizationUrl.mockReturnValue( + 'https://kauth.kakao.com/...', + ); + + await service.buildAuthorizationUrl('kakao'); + + expect(mockClient.authorizationUrl).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'openid account_email profile_nickname profile_image', + }), + ); + }); + + it('OIDC_KAKAO_SCOPE env 로 scope 를 덮어쓸 수 있어야 한다 (콘솔 동의항목과 정합)', async () => { + mockConfig.get.mockImplementation((key: string) => { + const config: Record = { + OIDC_KAKAO_ISSUER_URL: 'https://kauth.kakao.com', + OIDC_KAKAO_CLIENT_ID: 'kakao-client-id', + OIDC_KAKAO_CLIENT_SECRET: 'kakao-client-secret', + BACKEND_BASE_URL: 'http://localhost:4000', + OIDC_KAKAO_SCOPE: 'openid profile_nickname profile_image', + }; + return config[key]; + }); + mockClient.authorizationUrl.mockReturnValue( + 'https://kauth.kakao.com/...', + ); + + await service.buildAuthorizationUrl('kakao'); + + expect(mockClient.authorizationUrl).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'openid profile_nickname profile_image', + }), + ); + }); }); describe('exchangeCode', () => { diff --git a/src/features/auth/services/oidc-client.service.ts b/src/features/auth/services/oidc-client.service.ts index 0f5c7f1..0f6f537 100644 --- a/src/features/auth/services/oidc-client.service.ts +++ b/src/features/auth/services/oidc-client.service.ts @@ -62,7 +62,7 @@ export class OidcClientService { const codeChallenge = generators.codeChallenge(codeVerifier); const authorizationUrl = client.authorizationUrl({ - scope: 'openid email profile', + scope: this.getScope(provider), state, nonce, code_challenge: codeChallenge, @@ -72,6 +72,30 @@ export class OidcClientService { return { authorizationUrl, state, nonce, codeVerifier }; } + /** + * provider별 OIDC scope를 반환한다. + * + * 카카오는 표준 `email`/`profile`이 아니라 자체 동의항목 ID + * (`account_email`/`profile_nickname`/`profile_image`)를 사용한다. 표준 scope를 보내면 + * KOE205(invalid_scope)가 발생하므로 provider별로 분리한다. + * 콘솔에 활성화한 동의항목과 정확히 맞추도록 env(`OIDC_GOOGLE_SCOPE`/`OIDC_KAKAO_SCOPE`)로 + * 덮어쓸 수 있다(예: account_email 미승인 시 카카오 scope에서 제외). + * + * @param provider provider + */ + private getScope(provider: OidcProvider): string { + if (provider === 'google') { + return ( + this.config.get('OIDC_GOOGLE_SCOPE')?.trim() || + 'openid email profile' + ); + } + return ( + this.config.get('OIDC_KAKAO_SCOPE')?.trim() || + 'openid account_email profile_nickname profile_image' + ); + } + /** * OIDC callback 처리 후 TokenSet을 반환한다. * From 5c2997f0b05299c9f585e688b3de9abae6e87e2e Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 11 Jun 2026 04:43:06 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(auth):=20=EA=B0=80=EC=9E=85=20=EC=8B=9C?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20nickname=20sanitize=20(=EA=B3=B5?= =?UTF-8?q?=EB=B0=B1/=ED=8A=B9=EC=88=98=EB=AC=B8=EC=9E=90=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20+=20=EA=B8=B8=EC=9D=B4/=EC=9C=A0=EB=8B=88=ED=81=AC?= =?UTF-8?q?=20=EB=B3=B4=EC=9E=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../helpers/initial-nickname.helper.spec.ts | 54 +++++++++++++++++++ .../auth/helpers/initial-nickname.helper.ts | 36 +++++++++++++ .../repositories/account.repository.spec.ts | 13 +++-- .../auth/repositories/account.repository.ts | 4 +- 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 src/features/auth/helpers/initial-nickname.helper.spec.ts create mode 100644 src/features/auth/helpers/initial-nickname.helper.ts diff --git a/src/features/auth/helpers/initial-nickname.helper.spec.ts b/src/features/auth/helpers/initial-nickname.helper.spec.ts new file mode 100644 index 0000000..e671e56 --- /dev/null +++ b/src/features/auth/helpers/initial-nickname.helper.spec.ts @@ -0,0 +1,54 @@ +import { buildInitialNickname } from '@/features/auth/helpers/initial-nickname.helper'; + +// 앱 nickname 정책(영문/숫자/한글/_) — 생성 결과가 항상 이를 만족해야 한다. +const NICKNAME_REGEX = /^[A-Za-z0-9가-힣_]+$/; + +describe('buildInitialNickname', () => { + it('공백 포함 이름은 공백을 제거하고 accountId suffix 를 붙인다', () => { + expect(buildInitialNickname(5n, 'John Doe')).toBe('JohnDoe_5'); + }); + + it('특수문자는 제거한다', () => { + expect(buildInitialNickname(7n, 'user@123!')).toBe('user123_7'); + }); + + it('한글 이름은 유지한다', () => { + expect(buildInitialNickname(9n, '김 철수')).toBe('김철수_9'); + }); + + it('표시 이름이 비거나 모두 특수문자면 이메일 local part 를 정제해 사용한다', () => { + expect(buildInitialNickname(3n, '!!!', 'john.doe@example.com')).toBe( + 'johndoe_3', + ); + }); + + it('이름·이메일이 모두 없거나 무효면 user 로 폴백한다', () => { + expect(buildInitialNickname(11n)).toBe('user_11'); + expect(buildInitialNickname(12n, ' ', '@@@')).toBe('user_12'); + }); + + it('VarChar(50) 한도 내로 clamp 하되 accountId suffix 는 보존한다', () => { + const longName = 'a'.repeat(100); + const result = buildInitialNickname(123n, longName); + expect(result.length).toBeLessThanOrEqual(50); + expect(result.endsWith('_123')).toBe(true); + }); + + it('생성 결과는 항상 nickname 정책(regex)을 만족한다', () => { + const cases: [bigint, string | undefined, string | undefined][] = [ + [1n, 'John Doe', undefined], + [2n, 'user@!#$', 'a.b+c@x.com'], + [3n, '김 철수 ', undefined], + [4n, undefined, undefined], + ]; + for (const [id, name, email] of cases) { + expect(buildInitialNickname(id, name, email)).toMatch(NICKNAME_REGEX); + } + }); + + it('서로 다른 accountId 는 서로 다른 nickname 을 만든다 (유니크 보장)', () => { + expect(buildInitialNickname(1n, '철수')).not.toBe( + buildInitialNickname(2n, '철수'), + ); + }); +}); diff --git a/src/features/auth/helpers/initial-nickname.helper.ts b/src/features/auth/helpers/initial-nickname.helper.ts new file mode 100644 index 0000000..c0c343f --- /dev/null +++ b/src/features/auth/helpers/initial-nickname.helper.ts @@ -0,0 +1,36 @@ +// UserProfile.nickname 컬럼 한도 (prisma schema: VarChar(50)). +const MAX_NICKNAME_LENGTH = 50; + +// 앱 nickname 정책과 동일한 허용 문자 집합(영문/숫자/한글/_) 외 문자. +const DISALLOWED_CHARS = /[^A-Za-z0-9가-힣_]/g; + +/** + * OIDC 최초 가입 시 사용할 임시 nickname 을 생성한다. + * (사용자는 온보딩에서 본인 nickname 으로 교체한다.) + * + * provider 가 주는 이름/이메일에는 공백·특수문자가 있거나, 길이가 길거나, 흔한 이름이 + * 겹칠 수 있다. 이를 그대로 쓰면 (a) nickname 정책 위반 값 저장, (b) VarChar(50) 초과 + * insert 실패, (c) unique 제약 충돌로 가입 실패가 발생한다. 이를 모두 방지한다: + * - 허용 문자만 남기고 제거(공백/특수문자 제거) + * - `_{accountId}`(PK) suffix 로 유일성 보장 + * - VarChar(50) 한도 내로 clamp (suffix 는 보존) + * + * @param accountId 생성된 계정 PK (유일성 보장에 사용) + * @param displayName provider 표시 이름 (kakao nickname / google name 등) + * @param email provider 이메일 (표시 이름이 없을 때 local part 사용) + */ +export function buildInitialNickname( + accountId: bigint, + displayName?: string, + email?: string, +): string { + const sanitize = (raw: string): string => raw.replace(DISALLOWED_CHARS, ''); + + const fromName = sanitize(displayName?.trim() ?? ''); + const fromEmail = email ? sanitize(email.split('@')[0]) : ''; + const base = fromName || fromEmail || 'user'; + + const suffix = `_${accountId.toString()}`; + const baseMaxLength = Math.max(0, MAX_NICKNAME_LENGTH - suffix.length); + return `${base.slice(0, baseMaxLength)}${suffix}`; +} diff --git a/src/features/auth/repositories/account.repository.spec.ts b/src/features/auth/repositories/account.repository.spec.ts index 7c01918..850b175 100644 --- a/src/features/auth/repositories/account.repository.spec.ts +++ b/src/features/auth/repositories/account.repository.spec.ts @@ -99,7 +99,10 @@ describe('AccountRepository (real DB)', () => { expect(result.account).not.toBeNull(); expect(result.account!.email).toBe('new@example.com'); expect(result.account!.user_profile).not.toBeNull(); - expect(result.account!.user_profile!.nickname).toBe('New User'); + // 공백 제거 + accountId suffix (이전엔 'New User' 가 공백째 저장되던 회귀) + expect(result.account!.user_profile!.nickname).toBe( + `NewUser_${result.account!.id}`, + ); }); it('기존 Identity가 있으면 업데이트한다', async () => { @@ -142,7 +145,9 @@ describe('AccountRepository (real DB)', () => { emailVerified: true, }); - expect(result.account!.user_profile!.nickname).toBe('john'); + expect(result.account!.user_profile!.nickname).toBe( + `john_${result.account!.id}`, + ); }); it('displayName/email 모두 없으면 nickname이 "user"로 생성된다', async () => { @@ -152,7 +157,9 @@ describe('AccountRepository (real DB)', () => { emailVerified: false, }); - expect(result.account!.user_profile!.nickname).toBe('user'); + expect(result.account!.user_profile!.nickname).toBe( + `user_${result.account!.id}`, + ); }); it('기존 Identity + account email이 null + user_profile 없는 경우: profile 신규 생성 + email 주입', async () => { diff --git a/src/features/auth/repositories/account.repository.ts b/src/features/auth/repositories/account.repository.ts index 839e2f9..175c521 100644 --- a/src/features/auth/repositories/account.repository.ts +++ b/src/features/auth/repositories/account.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { AccountType, type IdentityProvider } from '@prisma/client'; import { ClockService } from '@/common/providers/clock.service'; +import { buildInitialNickname } from '@/features/auth/helpers/initial-nickname.helper'; import type { AccountForJwt, AccountIdentityWithAccount, @@ -224,8 +225,7 @@ export class AccountRepository implements IAccountRepository { email?: string, profileImageUrl?: string, ): Promise { - const nickname = - displayName?.trim() || (email ? email.split('@')[0] : 'user'); + const nickname = buildInitialNickname(accountId, displayName, email); await tx.userProfile.create({ data: { From 4e4df3e7de8deb4a968d1de741554833bef359ce Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 11 Jun 2026 04:43:10 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(user):=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20URL=EC=9D=B4=20=EB=B0=9C=EA=B8=89?= =?UTF-8?q?=EB=90=9C=20S3=20=EA=B0=9D=EC=B2=B4=EC=9D=B8=EC=A7=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20(=EC=9E=84=EC=9D=98=20URL=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/user-profile.service.spec.ts | 16 +++++++++- .../user/services/user-profile.service.ts | 6 ++++ src/global/storage/s3.service.spec.ts | 29 +++++++++++++++++++ src/global/storage/s3.service.ts | 14 +++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/features/user/services/user-profile.service.spec.ts b/src/features/user/services/user-profile.service.spec.ts index dcd1238..d22b5bd 100644 --- a/src/features/user/services/user-profile.service.spec.ts +++ b/src/features/user/services/user-profile.service.spec.ts @@ -25,6 +25,7 @@ describe('UserProfileService (real DB)', () => { beforeAll(async () => { s3Service = { createUploadUrl: jest.fn(), + isOwnedProfileImageUrl: jest.fn(), } as unknown as jest.Mocked; const { module, prisma: p } = await createTestingModuleWithRealDb({ @@ -412,9 +413,10 @@ describe('UserProfileService (real DB)', () => { // ─── updateMyProfileImage ─── describe('updateMyProfileImage', () => { - it('유효한 URL이면 프로필 이미지를 업데이트한다', async () => { + it('발급된(소유) URL이면 프로필 이미지를 업데이트한다', async () => { const account = await createAccount(prisma, { account_type: 'USER' }); await createUserProfile(prisma, { account_id: account.id }); + s3Service.isOwnedProfileImageUrl.mockReturnValue(true); const result = await service.updateMyProfileImage(account.id, { profileImageUrl: 'https://s3.example.com/profile.jpg', @@ -425,6 +427,18 @@ describe('UserProfileService (real DB)', () => { ); }); + it('발급되지 않은(소유 아님) URL이면 BadRequest 로 거절한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { account_id: account.id }); + s3Service.isOwnedProfileImageUrl.mockReturnValue(false); + + await expect( + service.updateMyProfileImage(account.id, { + profileImageUrl: 'https://evil.example.com/someone-else.jpg', + }), + ).rejects.toThrow(BadRequestException); + }); + // profileImageUrl 형식·길이 검증은 DTO (UpdateMyProfileImageInput) 로 이전됨. }); diff --git a/src/features/user/services/user-profile.service.ts b/src/features/user/services/user-profile.service.ts index eb75c5b..eb738a6 100644 --- a/src/features/user/services/user-profile.service.ts +++ b/src/features/user/services/user-profile.service.ts @@ -126,6 +126,12 @@ export class UserProfileService extends UserBaseService { // DTO 의 @Transform 이 trim, @MinLength/@MaxLength 가 길이를 보장. const profileImageUrl = input.profileImageUrl; + // 우리가 발급한 presigned URL(이 버킷·해당 계정 prefix)인지 검증 — + // 클라이언트가 임의 URL 을 프로필 이미지로 저장하는 것을 방지한다. + if (!this.s3Service.isOwnedProfileImageUrl(profileImageUrl, accountId)) { + throw new BadRequestException('Invalid profile image URL.'); + } + await this.repo.updateProfileImage({ accountId, profileImageUrl, diff --git a/src/global/storage/s3.service.spec.ts b/src/global/storage/s3.service.spec.ts index af74cb1..a99d2c7 100644 --- a/src/global/storage/s3.service.spec.ts +++ b/src/global/storage/s3.service.spec.ts @@ -277,6 +277,35 @@ describe('S3Service', () => { expect(lastCallArgs?.credentials).toBeUndefined(); }); }); + + describe('isOwnedProfileImageUrl', () => { + it('이 버킷·해당 계정 prefix 의 URL 이면 true', () => { + const url = + 'https://caquick-media-test.s3.ap-northeast-2.amazonaws.com/profile-images/1/2026-06-10/abc.jpg'; + expect(service.isOwnedProfileImageUrl(url, BigInt(1))).toBe(true); + }); + + it('다른 계정 prefix 면 false', () => { + const url = + 'https://caquick-media-test.s3.ap-northeast-2.amazonaws.com/profile-images/2/2026-06-10/abc.jpg'; + expect(service.isOwnedProfileImageUrl(url, BigInt(1))).toBe(false); + }); + + it('다른 버킷/외부 도메인 URL 이면 false', () => { + expect( + service.isOwnedProfileImageUrl( + 'https://evil.example.com/profile-images/1/x.jpg', + BigInt(1), + ), + ).toBe(false); + }); + + it('review-media 등 다른 prefix 면 false', () => { + const url = + 'https://caquick-media-test.s3.ap-northeast-2.amazonaws.com/review-media/images/1/x.jpg'; + expect(service.isOwnedProfileImageUrl(url, BigInt(1))).toBe(false); + }); + }); }); // Jest 커스텀 매처 diff --git a/src/global/storage/s3.service.ts b/src/global/storage/s3.service.ts index 08892da..e94c638 100644 --- a/src/global/storage/s3.service.ts +++ b/src/global/storage/s3.service.ts @@ -101,6 +101,20 @@ export class S3Service { } } + /** + * 주어진 URL 이 이 버킷에 발급된 "해당 계정의 프로필 이미지" URL 인지 검증한다. + * 클라이언트가 임의 URL(외부 링크·타인 key)을 프로필 이미지로 저장하는 것을 막는다. + * + * @param url 저장하려는 URL + * @param accountId 소유 계정 + */ + isOwnedProfileImageUrl(url: string, accountId: bigint): boolean { + const prefix = this.buildPublicUrl( + `${UPLOAD_POLICIES.PROFILE_IMAGE.keyPrefix}/${accountId.toString()}/`, + ); + return url.startsWith(prefix); + } + private validateContentType( contentType: string, allowedTypes: readonly string[], From f1242f5d380bf5aebc88c70ffd2330b2ad245d88 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 11 Jun 2026 04:54:37 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(user):=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20URL=20=EA=B2=80=EC=A6=9D=EC=9D=84?= =?UTF-8?q?=20path-traversal=20=EB=B0=A9=EC=96=B4=EB=A1=9C=20=EA=B0=95?= =?UTF-8?q?=ED=99=94=20(URL=20=ED=8C=8C=EC=8B=B1=C2=B7=EC=A0=95=EA=B7=9C?= =?UTF-8?q?=ED=99=94=20pathname=C2=B7%2e=20=EC=B0=A8=EB=8B=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/global/storage/s3.service.spec.ts | 24 ++++++++++++++++++++++++ src/global/storage/s3.service.ts | 25 ++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/global/storage/s3.service.spec.ts b/src/global/storage/s3.service.spec.ts index a99d2c7..b52c571 100644 --- a/src/global/storage/s3.service.spec.ts +++ b/src/global/storage/s3.service.spec.ts @@ -305,6 +305,30 @@ describe('S3Service', () => { 'https://caquick-media-test.s3.ap-northeast-2.amazonaws.com/review-media/images/1/x.jpg'; expect(service.isOwnedProfileImageUrl(url, BigInt(1))).toBe(false); }); + + it('path traversal(../)로 타 계정 key 를 가리키면 false (정규화 후 검증)', () => { + const url = + 'https://caquick-media-test.s3.ap-northeast-2.amazonaws.com/profile-images/1/../2/2026-06-10/x.jpg'; + expect(service.isOwnedProfileImageUrl(url, BigInt(1))).toBe(false); + }); + + it('인코딩된 dot(%2e)이 포함되면 false', () => { + const url = + 'https://caquick-media-test.s3.ap-northeast-2.amazonaws.com/profile-images/1/%2e%2e/2/x.jpg'; + expect(service.isOwnedProfileImageUrl(url, BigInt(1))).toBe(false); + }); + + it('http(비 https)면 false', () => { + const url = + 'http://caquick-media-test.s3.ap-northeast-2.amazonaws.com/profile-images/1/x.jpg'; + expect(service.isOwnedProfileImageUrl(url, BigInt(1))).toBe(false); + }); + + it('URL 형식이 아니면 false', () => { + expect(service.isOwnedProfileImageUrl('not a url', BigInt(1))).toBe( + false, + ); + }); }); }); diff --git a/src/global/storage/s3.service.ts b/src/global/storage/s3.service.ts index e94c638..85848f9 100644 --- a/src/global/storage/s3.service.ts +++ b/src/global/storage/s3.service.ts @@ -105,14 +105,33 @@ export class S3Service { * 주어진 URL 이 이 버킷에 발급된 "해당 계정의 프로필 이미지" URL 인지 검증한다. * 클라이언트가 임의 URL(외부 링크·타인 key)을 프로필 이미지로 저장하는 것을 막는다. * + * raw `startsWith` 비교는 path traversal(`/profile-images/1/../2/...`)로 우회 가능하므로 + * URL 을 파싱해 host·protocol 과 **정규화된 pathname** 을 검증하고, dot segment(`.`/`..`)와 + * 인코딩된 dot(`%2e`)은 거절한다. + * * @param url 저장하려는 URL * @param accountId 소유 계정 */ isOwnedProfileImageUrl(url: string, accountId: bigint): boolean { - const prefix = this.buildPublicUrl( - `${UPLOAD_POLICIES.PROFILE_IMAGE.keyPrefix}/${accountId.toString()}/`, + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + + // 인코딩된 path traversal(%2e) 차단 — URL 파서가 정규화하지 않는 케이스 방어. + // (literal `../`·`./` 는 new URL 이 정규화하므로, 아래 정규화된 pathname prefix 검사로 자동 차단됨) + if (/%2e/i.test(parsed.pathname)) return false; + + const expectedHost = `${this.bucket}.s3.${this.region}.amazonaws.com`; + const expectedPathPrefix = `/${UPLOAD_POLICIES.PROFILE_IMAGE.keyPrefix}/${accountId.toString()}/`; + + return ( + parsed.protocol === 'https:' && + parsed.host === expectedHost && + parsed.pathname.startsWith(expectedPathPrefix) ); - return url.startsWith(prefix); } private validateContentType(