diff --git a/README.en.md b/README.en.md index d6a7427b..6b1aaa6b 100644 --- a/README.en.md +++ b/README.en.md @@ -150,7 +150,7 @@ Customers end up hopping between platforms, combining screenshots, edits, and ex - Production runs on a **home server (Mac mini, OrbStack)**. - The compose file in [`infra/`](./infra/) brings up api, worker, MySQL, Redis, RabbitMQ, the backup container, and the observability stack together. - - No inbound ports are opened; external traffic arrives only through a **Cloudflare Tunnel** (`api.caquick.site`). + - No inbound ports are opened; external traffic arrives only through a **Cloudflare Tunnel** (`api.caquick.com`). - **AWS S3**: media storage (presigned URL uploads) and daily database backups (14-day retention) - **Terraform**: S3 buckets, two least-privilege IAM users, and the GitHub repository and branch protection settings - Observability stack diff --git a/README.md b/README.md index 526a828c..1684bcfb 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ - **홈서버(맥미니, OrbStack)** 가 운영 환경입니다. - [`infra/`](./infra/)의 compose 파일이 api·worker·MySQL·Redis·RabbitMQ·백업 컨테이너와 관측 스택을 함께 띄웁니다. - - 인바운드 포트는 열지 않으며, 외부 요청은 **Cloudflare Tunnel**(`api.caquick.site`)로만 들어옵니다. + - 인바운드 포트는 열지 않으며, 외부 요청은 **Cloudflare Tunnel**(`api.caquick.com`)로만 들어옵니다. - **AWS S3**: 미디어 저장(Presigned URL 업로드)과 일일 DB 백업(14일 보존) - **Terraform**: S3 버킷, 최소 권한 IAM 사용자 2개, GitHub 레포·브랜치 보호 설정 - 관측 스택 diff --git a/infra/app.env.example b/infra/app.env.example index 9ba651e3..1ae6082a 100644 --- a/infra/app.env.example +++ b/infra/app.env.example @@ -4,8 +4,8 @@ # ---- 앱: 서버 ---- NODE_ENV=production -BACKEND_BASE_URL=https://api.caquick.site -FRONTEND_BASE_URL=https://www.caquick.site +BACKEND_BASE_URL=https://api.caquick.com +FRONTEND_BASE_URL=https://caquick.com TRUST_PROXY_HOPS=1 # ---- 앱: 연결(컨테이너 이름) ---- diff --git a/src/config/cors-origins.spec.ts b/src/config/cors-origins.spec.ts index 059ffc8c..79914b86 100644 --- a/src/config/cors-origins.spec.ts +++ b/src/config/cors-origins.spec.ts @@ -12,6 +12,13 @@ describe('resolveAllowedOrigins', () => { expect(origins).not.toContain('http://evil.example'); }); + it.each(['https://caquick.com', 'https://admin.caquick.com'])( + '운영 목록은 새 도메인 오리진(%s)을 포함한다', + (origin) => { + expect(resolveAllowedOrigins(true, [])).toContain(origin); + }, + ); + it.each([ [['http://localhost:5173'], ['http://localhost:5173']], [ diff --git a/src/config/cors-origins.ts b/src/config/cors-origins.ts index e25f4057..147af523 100644 --- a/src/config/cors-origins.ts +++ b/src/config/cors-origins.ts @@ -1,8 +1,11 @@ /** * CORS 허용 오리진. 운영은 고정 목록(env로 바뀌지 않게), 비운영은 FRONTEND_BASE_URL 목록이고 비어 있으면 로컬 FE 기본값. - * 관리자 웹(admin.caquick.site)은 refresh 쿠키가 same-site라 오리진만 열면 된다 — 쿠키 도메인 변경 불필요. + * 관리자 웹은 refresh 쿠키가 API와 same-site라 오리진만 열면 된다 — 쿠키 도메인 변경 불필요. + * caquick.site는 2026-11-29 만료까지 caquick.com과 병행한다(전환 전에 받은 페이지가 옛 API를 부른다). */ export const PROD_ALLOWED_ORIGINS: readonly string[] = [ + 'https://caquick.com', + 'https://admin.caquick.com', 'https://www.caquick.site', 'https://caquick.site', 'https://caquick-fe.vercel.app', diff --git a/src/features/auth/constants/auth.constants.ts b/src/features/auth/constants/auth.constants.ts index 1da15d5c..dfb939b3 100644 --- a/src/features/auth/constants/auth.constants.ts +++ b/src/features/auth/constants/auth.constants.ts @@ -2,6 +2,7 @@ export const OIDC_TEMP_COOKIE_MAX_AGE_MS = 10 * 60 * 1000; /** 오픈 리다이렉트 방지. */ export const ALLOWED_RETURN_TO_DOMAINS = [ + 'https://caquick.com', 'https://www.caquick.site', 'https://caquick.site', 'http://localhost:3000', 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.spec.ts b/src/features/auth/services/oidc-login.service.spec.ts index 60e09041..ef6087e1 100644 --- a/src/features/auth/services/oidc-login.service.spec.ts +++ b/src/features/auth/services/oidc-login.service.spec.ts @@ -140,6 +140,26 @@ describe('OidcLoginService', () => { expect(returnToCookie![1]).toBe('http://localhost:3000'); }); + it.each(['https://caquick.com/mypage', 'https://caquick.site/mypage'])( + '운영 프론트 도메인 returnTo(%s)는 그대로 쓴다', + async (returnTo) => { + const mockRes = { cookie: jest.fn() } as unknown as Response; + mockOidc.buildAuthorizationUrl.mockResolvedValue({ + authorizationUrl: 'https://a', + state: 's', + nonce: 'n', + codeVerifier: 'v', + }); + + await service.startOidcLogin('google', returnTo, mockRes); + + const returnToCookie = (mockRes.cookie as jest.Mock).mock.calls.find( + (c) => c[0] === 'caquick_oidc_return_to', + ); + expect(returnToCookie![1]).toBe(returnTo); + }, + ); + it('returnTo가 undefined이면 기본 프론트 URL을 사용한다', async () => { const mockRes = { cookie: jest.fn() } as unknown as Response; mockConfig.getOrThrow.mockReturnValue( 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 { diff --git a/terraform/aws.tf b/terraform/aws.tf index 8ceee2b8..afe5c82c 100644 --- a/terraform/aws.tf +++ b/terraform/aws.tf @@ -92,7 +92,7 @@ resource "aws_s3_bucket_cors_configuration" "media" { cors_rule { allowed_headers = ["*"] allowed_methods = ["PUT", "GET"] - allowed_origins = ["http://localhost:3000", "http://localhost:5173", "https://www.caquick.site", "https://caquick.site", "https://admin.caquick.site"] + allowed_origins = ["http://localhost:3000", "http://localhost:5173", "https://caquick.com", "https://admin.caquick.com", "https://www.caquick.site", "https://caquick.site", "https://admin.caquick.site"] expose_headers = ["ETag"] max_age_seconds = 3000 }