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
2 changes: 1 addition & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 레포·브랜치 보호 설정
- 관측 스택
Expand Down
4 changes: 2 additions & 2 deletions infra/app.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ---- 앱: 연결(컨테이너 이름) ----
Expand Down
7 changes: 7 additions & 0 deletions src/config/cors-origins.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']],
[
Expand Down
5 changes: 4 additions & 1 deletion src/config/cors-origins.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/features/auth/constants/auth.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
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;
}
}
20 changes: 20 additions & 0 deletions src/features/auth/services/oidc-login.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
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
2 changes: 1 addition & 1 deletion terraform/aws.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading