diff --git a/src/features/auth/helpers/return-to.helper.spec.ts b/src/features/auth/helpers/return-to.helper.spec.ts new file mode 100644 index 00000000..af8a79bc --- /dev/null +++ b/src/features/auth/helpers/return-to.helper.spec.ts @@ -0,0 +1,40 @@ +import { resolveReturnTo } from '@/features/auth/helpers/return-to.helper'; + +const FALLBACK = 'https://caquick.site'; +const ALLOWED = ['https://caquick.site', 'http://localhost:3000']; + +describe('resolveReturnTo', () => { + it.each([ + ['https://caquick.site/mypage', 'https://caquick.site/mypage'], + ['http://localhost:3000/a?b=1', 'http://localhost:3000/a?b=1'], + [' https://caquick.site/x ', 'https://caquick.site/x'], + ])('허용 오리진이면 그 주소로 보낸다: %s', (raw, expected) => { + expect(resolveReturnTo(raw, FALLBACK, ALLOWED)).toBe(expected); + }); + + it.each([ + ['접두가 같은 다른 호스트', 'https://caquick.site.evil.com/x'], + ['userinfo로 위장한 호스트', 'https://caquick.site@evil.com/x'], + ['이어 붙인 호스트', 'https://caquick.siteevil.com'], + ['스킴 불일치', 'http://caquick.site/x'], + ['포트 불일치', 'http://localhost:3001/x'], + ['스킴 상대 주소', '//evil.com'], + ['상대 경로', '/mypage'], + ['javascript 스킴', 'javascript:alert(1)'], + ['빈 문자열', ''], + ['공백', ' '], + ['undefined', undefined], + ])('%s(%s)는 기본 주소로 보낸다', (_label, raw) => { + expect(resolveReturnTo(raw, FALLBACK, ALLOWED)).toBe(FALLBACK); + }); + + it('기본 주소의 오리진은 허용 목록에 없어도 허용한다', () => { + expect( + resolveReturnTo( + 'https://front.example/a', + 'https://front.example', + ALLOWED, + ), + ).toBe('https://front.example/a'); + }); +}); diff --git a/src/features/auth/helpers/return-to.helper.ts b/src/features/auth/helpers/return-to.helper.ts new file mode 100644 index 00000000..422b2bcc --- /dev/null +++ b/src/features/auth/helpers/return-to.helper.ts @@ -0,0 +1,24 @@ +/** + * 로그인 뒤 돌아갈 주소. 접두 비교는 `https://caquick.site.evil.com`·`https://caquick.site@evil.com`을 통과시키므로 + * URL로 파싱해 오리진이 정확히 같을 때만 허용하고, 아니면 기본 프론트 주소로 보낸다. + */ +export function resolveReturnTo( + raw: string | undefined, + fallback: string, + allowed: readonly string[], +): string { + const url = parseHttpUrl(raw?.trim() ?? ''); + if (!url) return fallback; + + const origins = [fallback, ...allowed].map((u) => parseHttpUrl(u)?.origin); + return origins.includes(url.origin) ? url.href : fallback; +} + +function parseHttpUrl(raw: string): URL | null { + try { + const url = new URL(raw); + return url.protocol === 'https:' || url.protocol === 'http:' ? url : null; + } catch { + return null; + } +} diff --git a/src/features/auth/services/oidc-login.service.ts b/src/features/auth/services/oidc-login.service.ts index 53bd0ec5..30c51a1c 100644 --- a/src/features/auth/services/oidc-login.service.ts +++ b/src/features/auth/services/oidc-login.service.ts @@ -7,6 +7,7 @@ import type { AuthConfig } from '@/config/auth.config'; import { ALLOWED_RETURN_TO_DOMAINS } from '@/features/auth/constants/auth.constants'; import { AuthCookieOptions } from '@/features/auth/helpers/auth-cookie-options.helper'; import { AuthCookie } from '@/features/auth/helpers/auth-cookie.helper'; +import { resolveReturnTo } from '@/features/auth/helpers/return-to.helper'; import { ACCOUNT_REPOSITORY, type IAccountRepository, @@ -202,12 +203,7 @@ export class OidcLoginService { /** 오픈 리다이렉트 방지. */ private normalizeReturnTo(raw: string | undefined): string { const frontend = this.config.getOrThrow('auth').frontendBaseUrl; - - if (!raw || raw.trim().length === 0) return frontend; - - const allowed = [frontend, ...ALLOWED_RETURN_TO_DOMAINS]; - const ok = allowed.some((prefix) => raw.startsWith(prefix)); - return ok ? raw : frontend; + return resolveReturnTo(raw, frontend, ALLOWED_RETURN_TO_DOMAINS); } private pickCallbackParams(req: Request): Record {