Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/features/auth/helpers/return-to.helper.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
24 changes: 24 additions & 0 deletions src/features/auth/helpers/return-to.helper.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
8 changes: 2 additions & 6 deletions src/features/auth/services/oidc-login.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -202,12 +203,7 @@ export class OidcLoginService {
/** 오픈 리다이렉트 방지. */
private normalizeReturnTo(raw: string | undefined): string {
const frontend = this.config.getOrThrow<AuthConfig>('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<string, string | string[]> {
Expand Down
Loading