feat(backend): 직원 인증(쿠키 JWT·DB 세션)과 웹메일 API(Stalwart JMAP 중계)

첫 사내 모듈인 웹메일의 백엔드다. 메일 엔진은 외부(Stalwart)에 두고 백엔드가 JMAP 으로 중계한다.

- 직원 로그인 = 메일 계정: 엔진으로 비밀번호를 확인한 뒤 HttpOnly 쿠키 JWT 를 발급한다.
  리프레시 세션은 DB 에 해시로 저장하고 1회용으로 회전한다 (이미 쓴 토큰이 오면 세션 폐기)
- 로그인 실패 한도는 엔진에 묻기 전에 원자적으로 차지하고, IPv6 는 /64 로 묶어 센다
- 상태를 바꾸는 요청은 출처(Sec-Fetch-Site·Origin)를 검사하고, 클라이언트 IP 는 TRUST_PROXY 로만 해석한다
- 메일함·목록·읽기·발송·첨부 API: 받은 HTML 은 정화해서 내보내고, 발송은 Idempotency-Key 로 중복을 막는다
- 메일 엔진 관리자 계정은 로그인·대리 접근 대상에서 막는다 (게이트웨이가 관리자까지 대리할 수 있다)
- 에러 코드 AUTH_004·MAIL_001~006 을 추가하고 클라이언트 에러 사전을 다시 생성했다

계약: docs/mail/api-contract.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 01:31:38 +09:00
parent 238d0ba838
commit 71f2e01d83
115 changed files with 15985 additions and 174 deletions
+2 -1
View File
@@ -71,7 +71,8 @@ export function buildDatabaseOptions(): DataSourceOptions {
synchronize: shouldSynchronize(),
// 운영 배포 시 미적용 마이그레이션을 자동 실행한다
migrationsRun: isProduction,
logging: !isProduction,
// SQL 로그는 개발에서만 — 테스트(e2e·CI)는 수천 줄의 쿼리·파라미터(주소 등)가 출력을 덮는다
logging: !isProduction && process.env.NODE_ENV !== 'test',
extra: { ...POOL_OPTIONS },
entities: paths.entities,
migrations: paths.migrations,
@@ -12,6 +12,13 @@ const prod = (extra: Record<string, string> = {}) => ({
DB_USER: 'app_user',
DB_PASSWORD: 'a-real-secret',
DB_NAME: 'project_db',
JWT_SECRET: 'q7Vd2mXk9LpR4tWz8nBc6yHs3gJf5aEu',
MAIL_JMAP_URL: 'http://mail:8080',
MAIL_DOMAIN: 'arasystem.it.kr',
MAIL_GATEWAY_USER: 'mail-gateway@arasystem.it.kr',
MAIL_GATEWAY_PASSWORD: 'Gw7xQ2pLm9Rt4Vz8Kc3N',
CORS_ORIGIN: 'https://mail.arasystem.it.kr',
TRUST_PROXY: '2',
...extra,
});
@@ -32,6 +39,247 @@ describe('validateEnv', () => {
validateEnv({ NODE_ENV: 'development', THROTTLE_LIMIT: '' }),
).not.toThrow();
});
it('메일·JWT 설정이 없어도 기동한다 (CI e2e 는 메일 엔진 없이 돈다)', () => {
expect(() =>
validateEnv({
NODE_ENV: 'test',
MAIL_JMAP_URL: '',
MAIL_DOMAIN: '',
MAIL_GATEWAY_USER: '',
MAIL_GATEWAY_PASSWORD: '',
JWT_SECRET: '',
}),
).not.toThrow();
});
it('첨부 한도는 양의 정수여야 한다', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_ATTACHMENT_MAX_SIZE_MB: '20MB',
}),
).toThrow(/MAIL_ATTACHMENT_MAX_SIZE_MB 는 양의 정수/);
});
it('토큰 만료 기간 형식이 틀리면 환경과 무관하게 실패한다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', JWT_ACCESS_EXPIRES_IN: '1.5h' }),
).toThrow(/JWT_ACCESS_EXPIRES_IN 는 '숫자 \+ s·m·h·d' 형식/);
expect(() =>
validateEnv({
NODE_ENV: 'development',
JWT_ACCESS_EXPIRES_IN: '1h',
JWT_REFRESH_EXPIRES_IN: '14d',
}),
).not.toThrow();
});
it('액세스 토큰 만료는 5분~1시간만 허용한다', () => {
for (const value of ['4m', '61m', '2h', '1d']) {
expect(() =>
validateEnv({
NODE_ENV: 'development',
JWT_ACCESS_EXPIRES_IN: value,
}),
).toThrow(/JWT_ACCESS_EXPIRES_IN 은 5분~1시간 사이/);
}
for (const value of ['5m', '15m', '3600']) {
expect(() =>
validateEnv({
NODE_ENV: 'development',
JWT_ACCESS_EXPIRES_IN: value,
}),
).not.toThrow();
}
});
it('리프레시 토큰 만료는 30일(세션 절대 만료) 이하만 허용한다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', JWT_REFRESH_EXPIRES_IN: '31d' }),
).toThrow(/JWT_REFRESH_EXPIRES_IN 은 30일 이하/);
expect(() =>
validateEnv({ NODE_ENV: 'development', JWT_REFRESH_EXPIRES_IN: '30d' }),
).not.toThrow();
});
it('리프레시는 액세스보다 길어야 한다 — 기본값과 섞여도 비교한다', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
JWT_ACCESS_EXPIRES_IN: '30m',
JWT_REFRESH_EXPIRES_IN: '30m',
}),
).toThrow(
/JWT_REFRESH_EXPIRES_IN\(30분\)은 JWT_ACCESS_EXPIRES_IN\(30분\)보다 길어야/,
);
// 액세스 기본값(1시간)보다 짧은 리프레시
expect(() =>
validateEnv({ NODE_ENV: 'development', JWT_REFRESH_EXPIRES_IN: '20m' }),
).toThrow(/보다 길어야/);
});
it('TRUST_PROXY 형식이 틀리면 환경과 무관하게 실패한다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', TRUST_PROXY: 'nginx' }),
).toThrow(/TRUST_PROXY 홉 수, 또는 IP·CIDR·키워드/);
expect(() =>
validateEnv({ NODE_ENV: 'development', TRUST_PROXY: '9' }),
).toThrow(/TRUST_PROXY 홉 수는 0~5/);
expect(() =>
validateEnv({
NODE_ENV: 'development',
TRUST_PROXY: 'loopback, 172.16.0.0/12',
}),
).not.toThrow();
});
it('개발에서는 TRUST_PROXY 가 없어도·true 여도 기동한다', () => {
expect(() => validateEnv({ NODE_ENV: 'development' })).not.toThrow();
expect(() =>
validateEnv({ NODE_ENV: 'development', TRUST_PROXY: 'true' }),
).not.toThrow();
});
it('CORS_ORIGIN 은 출처 목록이어야 한다', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
CORS_ORIGIN: 'localhost:5173',
}),
).toThrow(/CORS_ORIGIN 은 http\(s\) 출처/);
expect(() =>
validateEnv({
NODE_ENV: 'development',
CORS_ORIGIN: 'http://localhost:5173, http://10.0.0.5:5173',
}),
).not.toThrow();
});
it('첨부 합계 한도는 파일 하나 한도보다 작을 수 없다', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_ATTACHMENT_MAX_SIZE_MB: '20',
MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB: '10',
}),
).toThrow(
/MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB\(10\)는 MAIL_ATTACHMENT_MAX_SIZE_MB\(20\)보다 작을 수 없습니다/,
);
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB: '0',
}),
).toThrow(/MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB 는 양의 정수/);
});
it('메일 설정 형식이 틀리면 환경과 무관하게 실패한다', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', MAIL_JMAP_URL: 'mail:8080' }),
).toThrow(/MAIL_JMAP_URL 은 http\(s\) 주소/);
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_DOMAIN: '@arasystem.test',
}),
).toThrow(/MAIL_DOMAIN 은 도메인 이름/);
});
it('메일 관리자 아이디(MAIL_ADMIN_LOCAL_PART)에 @ 가 있으면 기동을 실패시킨다 — 주소가 아니라 아이디만 쓴다', () => {
for (const value of ['admin@arasystem.test', '@admin', 'admin@']) {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_ADMIN_LOCAL_PART: value,
}),
).toThrow(/MAIL_ADMIN_LOCAL_PART 는 주소가 아니라 앞부분\(아이디\)만/);
}
});
it('메일 관리자 아이디는 주소의 로컬 부분 형식만 받는다 (대리 접근 구분자 % 등 거부)', () => {
for (const value of [
'admin%mail-gateway',
'ad min',
'admin:x',
'.admin',
'admin.',
'관리자',
'a'.repeat(65),
]) {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_ADMIN_LOCAL_PART: value,
}),
).toThrow(/MAIL_ADMIN_LOCAL_PART 는 영문·숫자/);
}
for (const value of [
'',
'admin',
'mail-admin',
'Postmaster',
'ara_ops.1',
]) {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_ADMIN_LOCAL_PART: value,
}),
).not.toThrow();
}
});
it('로그인을 막는 계정 목록(MAIL_BLOCKED_ACCOUNTS)은 주소 전체여야 한다 — 아이디만 쓰면 아무것도 막지 못한다', () => {
for (const value of [
'admin',
'ops@arasystem.test, root',
'a@b@arasystem.test',
]) {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_BLOCKED_ACCOUNTS: value,
}),
).toThrow(/MAIL_BLOCKED_ACCOUNTS 는 로그인을 막을 계정의 주소 전체/);
}
for (const value of ['', ' ops@arasystem.test , Audit@AraSystem.test,']) {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_BLOCKED_ACCOUNTS: value,
}),
).not.toThrow();
}
});
it('게이트웨이 계정은 주소 전체여야 한다 (대리 접근 형식 <직원>%<게이트웨이>)', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_GATEWAY_USER: 'mail-gateway',
}),
).toThrow(/MAIL_GATEWAY_USER 는 주소 전체/);
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_GATEWAY_USER: 'mail-gateway@arasystem.test',
MAIL_DOMAIN: 'arasystem.test',
MAIL_JMAP_URL: 'http://mail:8080',
}),
).not.toThrow();
});
});
describe('운영 환경', () => {
@@ -55,6 +303,68 @@ describe('validateEnv', () => {
).toThrow(/JWT_SECRET 가 예시 값 그대로입니다/);
});
it('메일 엔진·JWT 설정이 없으면 실패한다', () => {
for (const key of [
'JWT_SECRET',
'MAIL_JMAP_URL',
'MAIL_DOMAIN',
'MAIL_GATEWAY_USER',
'MAIL_GATEWAY_PASSWORD',
]) {
expect(() => validateEnv(omit(prod(), key))).toThrow(
new RegExp(`${key} 가 설정되지 않았습니다`),
);
}
});
it('CORS_ORIGIN·TRUST_PROXY 가 없으면 실패한다 (출처 검사 허용 목록·클라이언트 IP 기준)', () => {
expect(() => validateEnv(omit(prod(), 'CORS_ORIGIN'))).toThrow(
/CORS_ORIGIN 가 설정되지 않았습니다/,
);
expect(() => validateEnv(omit(prod(), 'TRUST_PROXY'))).toThrow(
/TRUST_PROXY 가 설정되지 않았습니다/,
);
});
it('TRUST_PROXY=true 는 X-Forwarded-For 위조를 허용하므로 운영에서 실패한다', () => {
expect(() => validateEnv(prod({ TRUST_PROXY: 'true' }))).toThrow(
/TRUST_PROXY=true 는 운영에서 쓸 수 없습니다/,
);
});
it('TRUST_PROXY=0·false 는 모든 사용자를 nginx IP 하나로 묶으므로 운영에서 실패한다', () => {
for (const value of ['0', 'false']) {
expect(() => validateEnv(prod({ TRUST_PROXY: value }))).toThrow(
/운영에서 쓸 수 없습니다 — 앞단 프록시/,
);
}
});
it('TRUST_PROXY 는 홉 수 또는 주소 목록이면 통과한다', () => {
for (const value of ['1', '2', 'loopback,172.18.0.0/16,198.51.100.10']) {
expect(() => validateEnv(prod({ TRUST_PROXY: value }))).not.toThrow();
}
});
it('게이트웨이 비밀번호가 예시 값 그대로면 실패한다', () => {
expect(() =>
validateEnv(prod({ MAIL_GATEWAY_PASSWORD: 'change-me-in-local-env' })),
).toThrow(/MAIL_GATEWAY_PASSWORD 가 예시 값 그대로입니다/);
});
it('JWT 서명 키가 짧으면 실패하고, 키 값은 메시지에 싣지 않는다', () => {
const shortSecret = 'too-short-secret-1234';
try {
validateEnv(prod({ JWT_SECRET: shortSecret }));
fail('예외가 발생해야 한다');
} catch (error) {
const message = (error as Error).message;
expect(message).toMatch(/JWT_SECRET 이 너무 짧습니다/);
expect(message).not.toContain(shortSecret);
}
});
it('약한 기본값(password)도 placeholder 로 간주한다', () => {
expect(() => validateEnv(prod({ DB_PASSWORD: 'password' }))).toThrow(
/예시 값 그대로/,
+261 -1
View File
@@ -6,16 +6,44 @@
*
* `ConfigModule.forRoot({ validate })` 에 연결되어 있다 (app.module.ts).
*/
import {
ACCESS_TTL_MAX_SECONDS,
ACCESS_TTL_MIN_SECONDS,
DEFAULT_ACCESS_TTL_SECONDS,
DEFAULT_REFRESH_TTL_SECONDS,
REFRESH_TTL_MAX_SECONDS,
} from '@/modules/auth/auth.constants';
import { isLocalPart } from '@/modules/auth/employee-address';
import { parseAccountList } from '@/modules/mail/mail.settings';
import { parseDurationSeconds } from '@/shared/utils/duration';
import { parseOriginList } from '@/shared/utils/request-origin';
import {
parseTrustProxy,
unsafeTrustProxyReason,
} from '@/shared/utils/trust-proxy';
/** 운영 배포 전에 반드시 교체해야 하는 예시 값들 — `*.example` 에서 복사한 흔적 */
const PLACEHOLDER_PATTERNS = [/change[-_]?me/i, /^password$/i, /^secret$/i];
/** 운영 환경에서 반드시 채워져야 하는 키 */
/**
* 운영 환경에서 반드시 채워져야 하는 키.
* 메일·JWT 는 비운영에서는 없어도 기동한다 — 메일 API 는 MAIL_001 을 돌려주고,
* JWT 서명 키는 임시 키로 대체된다 (CI e2e 는 메일 엔진 없이 돈다).
*/
const REQUIRED_IN_PRODUCTION = [
'DB_HOST',
'DB_USER',
'DB_PASSWORD',
'DB_NAME',
'JWT_SECRET',
'MAIL_JMAP_URL',
'MAIL_DOMAIN',
'MAIL_GATEWAY_USER',
'MAIL_GATEWAY_PASSWORD',
// 상태 변경 요청의 출처 검사·CORS 허용 목록 — 같은 주소 배포면 그 화면 주소 (Sec-Fetch-Site 를 보내지 않는 브라우저용)
'CORS_ORIGIN',
// 백엔드 앞의 프록시 — 클라이언트 IP(로그인 실패 한도·요청 수 한도의 기준)를 정한다
'TRUST_PROXY',
] as const;
/** 값이 있다면 양의 정수여야 하는 키 */
@@ -24,6 +52,14 @@ const POSITIVE_INT_KEYS = [
'DB_PORT',
'THROTTLE_TTL_MS',
'THROTTLE_LIMIT',
'MAIL_ATTACHMENT_MAX_SIZE_MB',
'MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB',
] as const;
/** 값이 있다면 기간 형식(숫자 + s·m·h·d)이어야 하는 키 — 쿠키 Max-Age 와 토큰 만료가 같은 값을 쓴다 */
const DURATION_KEYS = [
'JWT_ACCESS_EXPIRES_IN',
'JWT_REFRESH_EXPIRES_IN',
] as const;
/** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */
@@ -31,8 +67,17 @@ const NO_PLACEHOLDER_IN_PRODUCTION = [
'DB_PASSWORD',
'REDIS_PASSWORD',
'JWT_SECRET',
// 전 직원 메일함의 열쇠다 (mail/README.md "보안")
'MAIL_GATEWAY_PASSWORD',
] as const;
/**
* 운영 JWT 서명 키 최소 길이.
* HS256 은 토큰 하나만 있으면 오프라인으로 키를 대입해 볼 수 있다 — 짧은 키는 위조로 이어지고,
* 위조된 토큰은 백엔드가 게이트웨이로 대리 접근하는 **직원 메일함 전체**의 열쇠가 된다.
*/
const JWT_SECRET_MIN_LENGTH_IN_PRODUCTION = 32;
/**
* 운영에서 **공개 주소**여야 하는 키.
* 로컬 주소가 남은 채 배포되면 서버는 정상 기동하지만
@@ -74,6 +119,189 @@ function looksNonPublic(value: string): boolean {
return NON_PUBLIC_URL_PATTERNS.some((pattern) => pattern.test(value));
}
function isHttpUrl(value: string): boolean {
try {
const { protocol } = new URL(value);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
}
/** 초 → 사람이 읽는 기간 (오류 메시지용) */
function describeSeconds(seconds: number): string {
const units: [number, string][] = [
[24 * 60 * 60, '일'],
[60 * 60, '시간'],
[60, '분'],
];
for (const [size, label] of units) {
if (seconds % size === 0) return `${seconds / size}${label}`;
}
return `${seconds}초`;
}
/**
* 토큰 만료 범위 (모든 환경, 값이 있을 때) — .claude/rules/security.md "인증 · 토큰".
* 액세스는 서버 조회 없이 검증되므로(세션을 끊어도 만료까지 유효) 짧게, 리프레시는 세션 절대 만료(30일)를 넘지 않게.
*/
function validateTokenLifetimes(
config: Record<string, unknown>,
errors: string[],
): void {
const accessRaw = readString(config, 'JWT_ACCESS_EXPIRES_IN');
const refreshRaw = readString(config, 'JWT_REFRESH_EXPIRES_IN');
const access = parseDurationSeconds(accessRaw);
const refresh = parseDurationSeconds(refreshRaw);
// 형식 오류는 위에서 이미 알렸다 — 범위는 해석된 값만 본다
if (
access !== null &&
(access < ACCESS_TTL_MIN_SECONDS || access > ACCESS_TTL_MAX_SECONDS)
) {
errors.push(
`JWT_ACCESS_EXPIRES_IN 은 ${describeSeconds(ACCESS_TTL_MIN_SECONDS)}~${describeSeconds(ACCESS_TTL_MAX_SECONDS)} 사이여야 합니다. (현재 값: "${accessRaw}")`,
);
}
if (refresh !== null && refresh > REFRESH_TTL_MAX_SECONDS) {
errors.push(
`JWT_REFRESH_EXPIRES_IN 은 ${describeSeconds(REFRESH_TTL_MAX_SECONDS)} 이하여야 합니다 — 세션 절대 만료보다 길 수 없습니다. (현재 값: "${refreshRaw}")`,
);
}
const effectiveAccess = access ?? DEFAULT_ACCESS_TTL_SECONDS;
const effectiveRefresh = refresh ?? DEFAULT_REFRESH_TTL_SECONDS;
if (effectiveRefresh <= effectiveAccess) {
errors.push(
`JWT_REFRESH_EXPIRES_IN(${describeSeconds(effectiveRefresh)})은 JWT_ACCESS_EXPIRES_IN(${describeSeconds(effectiveAccess)})보다 길어야 합니다.`,
);
}
}
/** 프록시 신뢰 설정 — 형식은 모든 환경, 위험한 값은 운영에서만 막는다 */
function validateTrustProxy(
config: Record<string, unknown>,
isProduction: boolean,
errors: string[],
): void {
const raw = readString(config, 'TRUST_PROXY');
const parsed = parseTrustProxy(raw);
if (!parsed.ok) {
errors.push(
`TRUST_PROXY ${parsed.reason}. (예: 2 — 외부 리버스 프록시 → 프론트 nginx → 백엔드, 현재 값: "${raw}")`,
);
return;
}
const unsafe =
isProduction && parsed.value !== undefined
? unsafeTrustProxyReason(parsed.value)
: null;
if (unsafe) {
errors.push(`TRUST_PROXY=${raw} 는 운영에서 쓸 수 없습니다 — ${unsafe}.`);
}
}
/** 허용 출처 목록 형식 (모든 환경, 값이 있을 때) — 스킴+호스트[+포트] 를 콤마로 */
function validateOrigins(
config: Record<string, unknown>,
errors: string[],
): void {
const { invalid } = parseOriginList(readString(config, 'CORS_ORIGIN'));
if (invalid.length > 0) {
errors.push(
`CORS_ORIGIN 은 http(s) 출처(스킴+호스트[+포트])를 콤마로 나열해야 합니다. (해석하지 못한 값: ${invalid.join(', ')})`,
);
}
}
/** 첨부 합계 한도는 파일 하나 한도보다 작을 수 없다 (파일 하나도 보내지 못하게 된다) */
function validateAttachmentLimits(
config: Record<string, unknown>,
errors: string[],
): void {
const perFile = Number(readString(config, 'MAIL_ATTACHMENT_MAX_SIZE_MB'));
const total = Number(readString(config, 'MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB'));
if (
Number.isInteger(perFile) &&
Number.isInteger(total) &&
perFile > 0 &&
total > 0 &&
total < perFile
) {
errors.push(
`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB(${total})는 MAIL_ATTACHMENT_MAX_SIZE_MB(${perFile})보다 작을 수 없습니다.`,
);
}
}
/** 도메인 이름 형식 (예: arasystem.it.kr, arasystem.test) — '@' 나 스킴이 섞이면 거부한다 */
const DOMAIN_PATTERN =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
/** 주소 전체 형식 — 로컬 부분 + '@' 하나 + 도메인 이름 */
function isAccountAddress(value: string): boolean {
const at = value.indexOf('@');
return (
at > 0 &&
value.indexOf('@', at + 1) === -1 &&
DOMAIN_PATTERN.test(value.slice(at + 1))
);
}
/**
* 메일 설정 형식 검사 (값이 있을 때만, 모든 환경).
* 형식이 틀리면 기동은 되는데 로그인이 전부 실패하는 조용한 장애가 되므로 먼저 막는다.
*/
function validateMailSettings(
config: Record<string, unknown>,
errors: string[],
): void {
const jmapUrl = readString(config, 'MAIL_JMAP_URL');
if (jmapUrl !== undefined && !isHttpUrl(jmapUrl)) {
errors.push(
`MAIL_JMAP_URL 은 http(s) 주소여야 합니다. (현재 값: "${jmapUrl}")`,
);
}
const domain = readString(config, 'MAIL_DOMAIN');
if (domain !== undefined && !DOMAIN_PATTERN.test(domain)) {
errors.push(
`MAIL_DOMAIN 은 도메인 이름이어야 합니다. (예: arasystem.it.kr, 현재 값: "${domain}")`,
);
}
// 대리 접근 형식이 <직원 주소>%<게이트웨이 주소> 라 아이디만 쓰면 모든 메일함 접근이 실패한다
const gatewayUser = readString(config, 'MAIL_GATEWAY_USER');
if (gatewayUser !== undefined && !gatewayUser.includes('@')) {
errors.push(
`MAIL_GATEWAY_USER 는 주소 전체여야 합니다. (예: mail-gateway@${domain ?? 'arasystem.it.kr'}, 현재 값: "${gatewayUser}")`,
);
}
// 메일 엔진 관리자 아이디 — 백엔드는 <아이디>@<MAIL_DOMAIN> 의 로그인을 막는다 (mail.settings.ts).
// 주소 전체를 쓰면 막는 주소가 admin@도메인@도메인 이 되어 관리자를 막지 못한다 (메일 엔진 설정 스크립트도 같은 값을 읽는다)
const adminLocalPart = readString(config, 'MAIL_ADMIN_LOCAL_PART');
if (adminLocalPart !== undefined && !isLocalPart(adminLocalPart)) {
errors.push(
adminLocalPart.includes('@')
? `MAIL_ADMIN_LOCAL_PART 는 주소가 아니라 앞부분(아이디)만 씁니다 — '@' 와 도메인을 빼 주세요. (예: admin, 현재 값: "${adminLocalPart}")`
: `MAIL_ADMIN_LOCAL_PART 는 영문·숫자·'.'·'_'·'-' 로 된 아이디여야 합니다 (앞뒤는 영문·숫자, 64자 이하). (예: admin, 현재 값: "${adminLocalPart}")`,
);
}
// 막는 계정은 주소 전체로 비교한다 — 아이디(admin)만 쓰면 아무것도 막지 못하는 조용한 설정 실수가 된다
const invalidBlocked = parseAccountList(
readString(config, 'MAIL_BLOCKED_ACCOUNTS'),
).filter((entry) => !isAccountAddress(entry));
if (invalidBlocked.length > 0) {
errors.push(
`MAIL_BLOCKED_ACCOUNTS 는 로그인을 막을 계정의 주소 전체를 콤마로 나열해야 합니다. (예: ops@${domain ?? 'arasystem.it.kr'}, 해석하지 못한 값: ${invalidBlocked.join(', ')})`,
);
}
}
/**
* 환경변수를 검증한다. 문제가 있으면 **모든 문제를 모아** 예외를 던진다.
* (하나씩 고쳐가며 재기동하는 낭비를 막기 위해 한 번에 보여준다)
@@ -95,6 +323,27 @@ export function validateEnv(
}
}
// 1-1. 기간 값 — 비어 있으면 코드의 기본값을 쓴다
for (const key of DURATION_KEYS) {
const value = readString(config, key);
if (value !== undefined && parseDurationSeconds(value) === null) {
errors.push(
`${key} 는 '숫자 + s·m·h·d' 형식이어야 합니다. (예: 15m, 1h, 14d, 현재 값: "${value}")`,
);
}
}
// 1-2. 토큰 만료 범위
validateTokenLifetimes(config, errors);
// 1-3. 메일 설정 형식
validateMailSettings(config, errors);
validateAttachmentLimits(config, errors);
// 1-4. 프록시 신뢰·허용 출처 형식
validateTrustProxy(config, isProduction, errors);
validateOrigins(config, errors);
if (isProduction) {
// 2. 운영 필수값 — 없으면 기동 실패
for (const key of REQUIRED_IN_PRODUCTION) {
@@ -113,6 +362,17 @@ export function validateEnv(
}
}
// 3-1. JWT 서명 키 강도 — 값 자체는 오류 메시지에 싣지 않는다
const jwtSecret = readString(config, 'JWT_SECRET');
if (
jwtSecret !== undefined &&
jwtSecret.length < JWT_SECRET_MIN_LENGTH_IN_PRODUCTION
) {
errors.push(
`JWT_SECRET 이 너무 짧습니다. ${JWT_SECRET_MIN_LENGTH_IN_PRODUCTION}자 이상의 무작위 값을 쓰세요. (현재 ${jwtSecret.length}자)`,
);
}
// 4. 공개 주소가 로컬 주소·예시 도메인으로 남은 채 배포되는 것을 막는다
const allowLocalhostUrl =
readString(config, ALLOW_LOCALHOST_URL_KEY) === 'true';
@@ -0,0 +1,37 @@
import {
currentRequestId,
runWithRequestContext,
traceTag,
} from './request-context';
describe('요청 컨텍스트', () => {
it('요청 밖에서는 추적 ID 가 없다', () => {
expect(currentRequestId()).toBeUndefined();
expect(traceTag()).toBe('');
});
it('비동기 흐름을 따라 추적 ID 가 이어진다', async () => {
const seen = await runWithRequestContext(
{ requestId: 'trace-1' },
async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
return traceTag();
},
);
expect(seen).toBe('[trace-1]');
});
it('동시에 처리되는 요청끼리 섞이지 않는다', async () => {
const run = (id: string, delay: number) =>
runWithRequestContext({ requestId: id }, async () => {
await new Promise((resolve) => setTimeout(resolve, delay));
return currentRequestId();
});
await expect(Promise.all([run('a', 10), run('b', 1)])).resolves.toEqual([
'a',
'b',
]);
});
});
@@ -0,0 +1,33 @@
import { AsyncLocalStorage } from 'async_hooks';
/**
* 요청 단위 컨텍스트 — 지금은 추적 ID(traceId)만 담는다.
*
* `requestIdMiddleware`(configureApp 의 첫 미들웨어)가 요청마다 시작하고, 그 요청에서 이어지는 비동기 흐름 어디서든
* `traceTag()` 로 꺼내 로그에 붙인다. 예외 필터를 거치지 않는 로그(서비스·클라이언트의 경고 등)도
* 사용자 문의의 traceId 로 찾을 수 있게 하기 위해서다 (.claude/rules/logging.md "필수 포함 정보").
*/
interface RequestContext {
readonly requestId: string;
}
const storage = new AsyncLocalStorage<RequestContext>();
/** 이 컨텍스트 안에서 `callback` 과 그 뒤의 비동기 흐름을 실행한다 */
export function runWithRequestContext<T>(
context: RequestContext,
callback: () => T,
): T {
return storage.run(context, callback);
}
/** 지금 처리 중인 요청의 추적 ID (요청 밖이면 undefined) */
export function currentRequestId(): string | undefined {
return storage.getStore()?.requestId;
}
/** 로그 앞에 붙이는 `[traceId]` — 요청 밖이면 빈 문자열 */
export function traceTag(): string {
const requestId = currentRequestId();
return requestId ? `[${requestId}]` : '';
}
@@ -0,0 +1,43 @@
import { clientIpKey } from './client-ip';
describe('clientIpKey', () => {
it('IPv4 는 그대로', () => {
expect(clientIpKey('203.0.113.7')).toBe('203.0.113.7');
expect(clientIpKey(' 198.51.100.1 ')).toBe('198.51.100.1');
});
it('IPv4-mapped IPv6 는 IPv4 로 본다 — 같은 클라이언트가 두 키로 따로 세지지 않는다', () => {
expect(clientIpKey('::ffff:203.0.113.7')).toBe('203.0.113.7');
expect(clientIpKey('::FFFF:203.0.113.7')).toBe('203.0.113.7');
// 16진 표기로 와도 같다
expect(clientIpKey('::ffff:cb00:7107')).toBe('203.0.113.7');
expect(clientIpKey('0:0:0:0:0:ffff:203.0.113.7')).toBe('203.0.113.7');
});
it('IPv6 는 /64 로 묶는다 — 같은 회선에서 주소를 바꿔도 같은 키', () => {
const key = clientIpKey('2001:db8:1:2::1');
expect(key).toBe('2001:db8:1:2::/64');
expect(clientIpKey('2001:db8:1:2:ffff:eeee:dddd:cccc')).toBe(key);
expect(clientIpKey('2001:0DB8:0001:0002:0:0:0:abcd')).toBe(key);
});
it('다른 /64 는 다른 키', () => {
expect(clientIpKey('2001:db8:1:3::1')).not.toBe(
clientIpKey('2001:db8:1:2::1'),
);
});
it('생략(::)·끝의 IPv4 표기·영역 표시를 풀어서 묶는다', () => {
expect(clientIpKey('::1')).toBe('0:0:0:0::/64');
expect(clientIpKey('2001:db8::')).toBe('2001:db8:0:0::/64');
expect(clientIpKey('64:ff9b::192.0.2.33')).toBe('64:ff9b:0:0::/64');
expect(clientIpKey('fe80::1%eth0')).toBe('fe80:0:0:0::/64');
});
it('해석할 수 없는 값은 그대로 — 한 묶음으로 센다', () => {
expect(clientIpKey('unknown')).toBe('unknown');
expect(clientIpKey('')).toBe('');
expect(clientIpKey('300.1.1.1')).toBe('300.1.1.1');
});
});
+84
View File
@@ -0,0 +1,84 @@
import { isIPv4, isIPv6 } from 'net';
/** IPv6 를 묶는 접두 길이 — /64 = 앞 16비트 묶음 4개 */
const IPV6_GROUP_PREFIX = 64;
const IPV6_GROUP_HEXTETS = 4;
const IPV6_HEXTETS = 8;
/** `::ffff:0:0/96` 의 표시 — 앞 80비트 0, 다음 16비트 ffff */
const IPV4_MAPPED_ZERO_HEXTETS = 5;
const IPV4_MAPPED_MARKER = 0xffff;
const BYTE_BITS = 8;
const BYTE_MASK = 0xff;
/**
* 한도를 세는 클라이언트 키 (순수 함수) — 로그인 실패 한도(LoginAttemptService)와
* 요청 수 한도(ClientIpThrottlerGuard)가 **같은 함수**로 클라이언트를 묶는다.
*
* - IPv4 → 그대로
* - IPv4-mapped IPv6(`::ffff:a.b.c.d`) → IPv4 로 (같은 클라이언트가 두 키로 따로 세지지 않게)
* - IPv6 → 앞 64비트(/64) 로 묶는다. 가정·사무실 회선 하나가 보통 /64 전체를 받으므로,
* 주소 하나씩 세면 같은 회선에서 주소를 바꿔 가며 한도를 무한히 피할 수 있다
* - 해석할 수 없는 값('unknown' 등) → 그대로 (한 묶음으로 센다)
*/
export function clientIpKey(ip: string): string {
const trimmed = ip.trim();
if (isIPv4(trimmed)) return trimmed;
// 링크 로컬 주소의 영역 표시(fe80::1%eth0)는 떼고 본다
const address = trimmed.split('%')[0].toLowerCase();
if (!isIPv6(address)) return trimmed;
const hextets = expandIPv6(address);
if (isIPv4Mapped(hextets)) {
const [high, low] = [hextets[6], hextets[7]];
return [
high >> BYTE_BITS,
high & BYTE_MASK,
low >> BYTE_BITS,
low & BYTE_MASK,
].join('.');
}
const prefix = hextets
.slice(0, IPV6_GROUP_HEXTETS)
.map((hextet) => hextet.toString(16))
.join(':');
return `${prefix}::/${IPV6_GROUP_PREFIX}`;
}
function isIPv4Mapped(hextets: readonly number[]): boolean {
return (
hextets
.slice(0, IPV4_MAPPED_ZERO_HEXTETS)
.every((hextet) => hextet === 0) &&
hextets[IPV4_MAPPED_ZERO_HEXTETS] === IPV4_MAPPED_MARKER
);
}
/**
* IPv6 문자열(isIPv6 로 검증된 것) → 16비트 묶음 8개.
* `::` 생략과 끝의 IPv4 표기(`::ffff:1.2.3.4`, `64:ff9b::1.2.3.4`)를 푼다.
*/
function expandIPv6(address: string): number[] {
let text = address;
const lastColon = text.lastIndexOf(':');
const tail = text.slice(lastColon + 1);
if (tail.includes('.')) {
const [a, b, c, d] = tail.split('.').map(Number);
const high = ((a << BYTE_BITS) | b).toString(16);
const low = ((c << BYTE_BITS) | d).toString(16);
text = `${text.slice(0, lastColon + 1)}${high}:${low}`;
}
const [head, rest] = text.split('::') as [string, string | undefined];
const parse = (part: string) =>
part === '' ? [] : part.split(':').map((hextet) => parseInt(hextet, 16));
const left = parse(head);
if (rest === undefined) return left;
const right = parse(rest);
const zeros = new Array<number>(
IPV6_HEXTETS - left.length - right.length,
).fill(0);
return [...left, ...zeros, ...right];
}
@@ -0,0 +1,43 @@
import { buildAttachmentDisposition } from './content-disposition';
describe('buildAttachmentDisposition', () => {
it('ASCII 이름은 그대로 두 형식 모두에 넣는다', () => {
expect(buildAttachmentDisposition('report.pdf')).toBe(
`attachment; filename="report.pdf"; filename*=UTF-8''report.pdf`,
);
});
it('한글 이름은 filename* 로 퍼센트 인코딩하고 ASCII 대체 이름을 함께 준다', () => {
const header = buildAttachmentDisposition('체크리스트.xlsx');
expect(header).toContain(`filename="_____.xlsx"`);
expect(header).toContain(
`filename*=UTF-8''${encodeURIComponent('체크리스트.xlsx')}`,
);
});
it('따옴표·역슬래시·퍼센트는 대체 이름에서 치환한다 (헤더 깨짐 방지)', () => {
const header = buildAttachmentDisposition('a"b\\c%d.txt');
expect(header).toContain(`filename="a_b_c_d.txt"`);
});
it('줄바꿈 같은 제어 문자로 헤더를 주입할 수 없다', () => {
const header = buildAttachmentDisposition('evil\r\nSet-Cookie: x=1.txt');
expect(header).not.toMatch(/[\r\n]/);
expect(header).toContain('%0D%0A');
});
it("RFC 5987 에서 허용되지 않는 '()* 도 인코딩한다", () => {
expect(buildAttachmentDisposition("it's (1)*.txt")).toContain(
`filename*=UTF-8''it%27s%20%281%29%2A.txt`,
);
});
it('빈 이름이면 기본 이름을 쓴다', () => {
expect(buildAttachmentDisposition('')).toBe(
`attachment; filename="download"; filename*=UTF-8''download`,
);
});
});
@@ -0,0 +1,42 @@
/**
* Content-Disposition 헤더 생성 유틸 (순수 함수).
*
* 한글 파일명은 RFC 5987 의 `filename*=UTF-8''…` 로 보내고,
* 이를 모르는 오래된 클라이언트를 위해 ASCII 로만 된 `filename="…"` 을 함께 준다 (RFC 6266).
*/
/** ASCII 대체 이름을 만들 수 없을 때 쓰는 이름 */
const FALLBACK_FILE_NAME = 'download';
/** 출력 가능한 ASCII(0x20~0x7E) 밖의 문자 — 대체 이름에서 '_' 로 바꾼다 */
const NON_PRINTABLE_ASCII = /[^\x20-\x7e]/g;
/** 따옴표 문자열 안에서 의미가 있는 문자 — 일부 브라우저는 '%' 도 해석하므로 함께 바꾼다 */
const QUOTED_STRING_SPECIALS = /["\\%]/g;
/** encodeURIComponent 가 남기지만 RFC 5987 attr-char 가 아닌 문자 */
const RFC5987_EXTRA = /['()*]/g;
function toAsciiFallback(fileName: string): string {
const replaced = fileName
.replace(NON_PRINTABLE_ASCII, '_')
.replace(QUOTED_STRING_SPECIALS, '_')
.trim();
return replaced === '' ? FALLBACK_FILE_NAME : replaced;
}
function encodeRfc5987(value: string): string {
return encodeURIComponent(value).replace(
RFC5987_EXTRA,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
/**
* 다운로드용 `attachment` 헤더 값을 만든다.
* 예) '체크리스트.xlsx' → `attachment; filename="_____.xlsx"; filename*=UTF-8''%EC%B2%B4...`
*/
export function buildAttachmentDisposition(fileName: string): string {
const name = fileName === '' ? FALLBACK_FILE_NAME : fileName;
return `attachment; filename="${toAsciiFallback(name)}"; filename*=UTF-8''${encodeRfc5987(name)}`;
}
+24
View File
@@ -0,0 +1,24 @@
import { readCookie } from './cookies';
describe('readCookie', () => {
it('문자열 쿠키를 꺼낸다', () => {
expect(
readCookie({ cookies: { access_token: 'abc' } }, 'access_token'),
).toBe('abc');
});
it('쿠키가 없거나 cookie-parser 가 등록되지 않았으면 undefined', () => {
expect(readCookie({ cookies: {} }, 'access_token')).toBeUndefined();
expect(readCookie({}, 'access_token')).toBeUndefined();
expect(readCookie({ cookies: null }, 'access_token')).toBeUndefined();
});
it('문자열이 아니거나 빈 값이면 undefined (JSON 쿠키 등)', () => {
expect(
readCookie({ cookies: { access_token: { x: 1 } } }, 'access_token'),
).toBeUndefined();
expect(readCookie({ cookies: { access_token: '' } }, 'access_token')).toBe(
undefined,
);
});
});
+16
View File
@@ -0,0 +1,16 @@
/**
* 쿠키 읽기 유틸 (순수 함수).
*
* `req.cookies` 는 cookie-parser 가 채우며 타입이 `Record<string, any>` 라,
* 값이 문자열인지 확인한 뒤에만 꺼낸다 (JSON 쿠키·미등록 미들웨어 대비).
*/
export function readCookie(
request: { cookies?: unknown },
name: string,
): string | undefined {
const { cookies } = request;
if (typeof cookies !== 'object' || cookies === null) return undefined;
const value: unknown = (cookies as Record<string, unknown>)[name];
return typeof value === 'string' && value !== '' ? value : undefined;
}
+33
View File
@@ -0,0 +1,33 @@
import { parseDurationSeconds } from './duration';
describe('parseDurationSeconds', () => {
it('단위가 없으면 초로 해석한다', () => {
expect(parseDurationSeconds('3600')).toBe(3600);
});
it('s·m·h·d 단위를 초로 바꾼다', () => {
expect(parseDurationSeconds('30s')).toBe(30);
expect(parseDurationSeconds('15m')).toBe(15 * 60);
expect(parseDurationSeconds('1h')).toBe(60 * 60);
expect(parseDurationSeconds('14d')).toBe(14 * 24 * 60 * 60);
});
it('대소문자와 앞뒤 공백을 허용한다', () => {
expect(parseDurationSeconds(' 2H ')).toBe(2 * 60 * 60);
});
it('값이 없으면 null 이다 (호출부가 기본값을 정한다)', () => {
expect(parseDurationSeconds(undefined)).toBeNull();
expect(parseDurationSeconds('')).toBeNull();
expect(parseDurationSeconds(' ')).toBeNull();
});
it('지원하지 않는 형식이나 0 이하는 null 이다', () => {
expect(parseDurationSeconds('1.5h')).toBeNull();
expect(parseDurationSeconds('2 days')).toBeNull();
expect(parseDurationSeconds('1w')).toBeNull();
expect(parseDurationSeconds('-1h')).toBeNull();
expect(parseDurationSeconds('0')).toBeNull();
expect(parseDurationSeconds('abc')).toBeNull();
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* 기간 문자열 파싱 유틸 (순수 함수).
*
* `JWT_ACCESS_EXPIRES_IN=1h` 처럼 env 에 적는 기간 값을 초 단위로 바꾼다.
* 쿠키 Max-Age 와 토큰 만료가 **같은 값**에서 나와야 하므로 한 곳에서 해석한다.
*/
const SECONDS_PER_UNIT = {
s: 1,
m: 60,
h: 60 * 60,
d: 24 * 60 * 60,
} as const;
type DurationUnit = keyof typeof SECONDS_PER_UNIT;
/** 지원 형식: 숫자만(초) 또는 숫자 + s·m·h·d — 예) '3600', '15m', '1h', '14d' */
const DURATION_PATTERN = /^(\d+)\s*([smhd]?)$/i;
/**
* 기간 문자열을 초 단위 양의 정수로 바꾼다.
* 값이 없거나(undefined·빈 문자열) 형식이 맞지 않거나 0 이하면 null 을 반환한다.
* (기본값 적용은 호출하는 쪽이 `??` 로 정한다 — 형식 오류는 env 검증이 기동 시점에 막는다)
*/
export function parseDurationSeconds(value: string | undefined): number | null {
if (value === undefined) return null;
const match = DURATION_PATTERN.exec(value.trim());
if (!match) return null;
const amount = Number(match[1]);
const unit = (match[2] || 's').toLowerCase() as DurationUnit;
const seconds = amount * SECONDS_PER_UNIT[unit];
return Number.isSafeInteger(seconds) && seconds > 0 ? seconds : null;
}
+25
View File
@@ -0,0 +1,25 @@
import { maskEmail } from './mask';
describe('maskEmail', () => {
it('첫 글자와 도메인만 남기고 나머지를 가린다', () => {
expect(maskEmail('ara.kim@arasystem.it.kr')).toBe('a***@arasystem.it.kr');
});
it('로컬 부분의 길이를 드러내지 않는다', () => {
expect(maskEmail('a@arasystem.test')).toBe('a***@arasystem.test');
expect(maskEmail('averyveryverylongname@arasystem.test')).toBe(
'a***@arasystem.test',
);
});
it('주소 형식이 아니면 전체를 가린다', () => {
expect(maskEmail('not-an-email')).toBe('***');
expect(maskEmail('@arasystem.test')).toBe('***');
expect(maskEmail('ara.kim@')).toBe('***');
expect(maskEmail('')).toBe('***');
});
it('서로게이트 쌍 문자를 반쪽으로 자르지 않는다', () => {
expect(maskEmail('😀abc@arasystem.test')).toBe('😀***@arasystem.test');
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* 로그용 마스킹 유틸 (순수 함수).
*
* 로그에는 개인을 특정할 수 있는 값을 원문 그대로 남기지 않는다 (.claude/rules/logging.md).
* 추적이 필요하면 traceId 로 찾는다.
*/
/** 가린 부분을 표시하는 문자열 — 원래 길이를 드러내지 않도록 고정 길이로 쓴다 */
const MASK = '***';
/**
* 이메일 주소를 로그용으로 가린다. 첫 글자와 도메인만 남긴다.
*
* 예) 'ara.kim@arasystem.it.kr' → 'a***@arasystem.it.kr'
* 주소 형식이 아니면 '***'
*/
export function maskEmail(email: string): string {
const at = email.lastIndexOf('@');
if (at <= 0 || at === email.length - 1) return MASK;
// 이모지 같은 서로게이트 쌍이 반쪽으로 잘리지 않도록 코드 포인트 단위로 자른다
const [first] = Array.from(email.slice(0, at));
return `${first}${MASK}@${email.slice(at + 1)}`;
}
@@ -0,0 +1,135 @@
import {
DEV_DEFAULT_ORIGINS,
buildAllowedOrigins,
checkRequestOrigin,
normalizeOrigin,
parseOriginList,
} from './request-origin';
const env =
(values: Record<string, string>) =>
(key: string): string | undefined =>
values[key];
describe('normalizeOrigin', () => {
it('스킴·호스트를 소문자로, 기본 포트는 떼어 비교한다', () => {
expect(normalizeOrigin('HTTPS://Mail.Arasystem.it.kr:443')).toBe(
'https://mail.arasystem.it.kr',
);
expect(normalizeOrigin('http://localhost:5173/')).toBe(
'http://localhost:5173',
);
});
it("출처가 아니면 null — 샌드박스 iframe 의 'null' 포함", () => {
expect(normalizeOrigin('null')).toBeNull();
expect(normalizeOrigin('file:///etc/passwd')).toBeNull();
expect(normalizeOrigin('그냥 문자열')).toBeNull();
});
});
describe('parseOriginList', () => {
it('콤마로 나눈 값을 정규화하고 중복을 없앤다', () => {
expect(
parseOriginList(
' https://mail.arasystem.it.kr , https://MAIL.arasystem.it.kr,',
),
).toEqual({ origins: ['https://mail.arasystem.it.kr'], invalid: [] });
});
it('해석하지 못한 값은 따로 모은다', () => {
expect(parseOriginList('mail.arasystem.it.kr')).toEqual({
origins: [],
invalid: ['mail.arasystem.it.kr'],
});
});
});
describe('buildAllowedOrigins', () => {
it('운영은 CORS_ORIGIN 만 허용한다 — localhost 기본값을 더하지 않는다', () => {
expect(
buildAllowedOrigins(
env({
NODE_ENV: 'production',
CORS_ORIGIN: 'https://mail.arasystem.it.kr',
}),
),
).toEqual(['https://mail.arasystem.it.kr']);
expect(buildAllowedOrigins(env({ NODE_ENV: 'production' }))).toEqual([]);
});
it('개발·테스트는 localhost 기본값에 CORS_ORIGIN 을 더한다', () => {
expect(
buildAllowedOrigins(
env({ NODE_ENV: 'development', CORS_ORIGIN: 'http://10.0.0.5:5173' }),
),
).toEqual([...DEV_DEFAULT_ORIGINS, 'http://10.0.0.5:5173']);
expect(buildAllowedOrigins(env({}))).toEqual([...DEV_DEFAULT_ORIGINS]);
});
});
describe('checkRequestOrigin', () => {
const allowed = new Set(['https://mail.arasystem.it.kr']);
it('같은 주소의 화면(same-origin)과 사용자가 직접 연 요청(none)은 통과한다', () => {
expect(checkRequestOrigin({ secFetchSite: 'same-origin' }, allowed)).toBe(
'allowed',
);
expect(checkRequestOrigin({ secFetchSite: 'None' }, allowed)).toBe(
'allowed',
);
});
it('다른 사이트에서 온 요청은 거부한다', () => {
expect(
checkRequestOrigin(
{ secFetchSite: 'cross-site', origin: 'https://evil.test' },
allowed,
),
).toBe('rejected');
// 같은 사이트의 다른 하위 도메인도 목록에 없으면 거부
expect(
checkRequestOrigin(
{
secFetchSite: 'same-site',
origin: 'https://evil.arasystem.it.kr',
},
allowed,
),
).toBe('rejected');
});
it('Sec-Fetch-Site 가 없는 브라우저는 Origin 이 허용 목록에 있어야 통과한다', () => {
expect(
checkRequestOrigin({ origin: 'https://mail.arasystem.it.kr' }, allowed),
).toBe('allowed');
expect(checkRequestOrigin({ origin: 'https://evil.test' }, allowed)).toBe(
'rejected',
);
expect(checkRequestOrigin({ origin: 'null' }, allowed)).toBe('rejected');
});
it('Sec-Fetch-Site 가 same-site 여도 Origin 이 목록에 있으면 통과한다 (개발: 다른 포트의 화면)', () => {
expect(
checkRequestOrigin(
{ secFetchSite: 'same-site', origin: 'https://mail.arasystem.it.kr' },
allowed,
),
).toBe('allowed');
});
it('출처 정보가 하나도 없으면 브라우저가 아닌 클라이언트로 보고 통과한다', () => {
expect(checkRequestOrigin({}, allowed)).toBe('no-browser-headers');
expect(checkRequestOrigin({ secFetchSite: ' ', origin: '' }, allowed)).toBe(
'no-browser-headers',
);
});
it('cross-site 인데 Origin 이 없으면 거부한다', () => {
expect(checkRequestOrigin({ secFetchSite: 'cross-site' }, allowed)).toBe(
'rejected',
);
});
});
@@ -0,0 +1,84 @@
/**
* 요청 출처(origin) 유틸 (순수 함수) — CORS 허용 목록과 상태 변경 요청의 출처 검사(CSRF)가 같은 목록을 쓴다.
*
* 출처 검사 규칙 (docs/mail/api-contract.md 머리말):
* - `Sec-Fetch-Site` 가 `same-origin`·`none` 이면 통과 (같은 주소의 화면, 사용자가 직접 연 요청)
* - 아니면 `Origin` 이 허용 목록(CORS_ORIGIN)에 있어야 통과
* - 둘 다 없으면 통과 — 브라우저가 아닌 클라이언트다 (쿠키를 훔쳐 오지 못하므로 CSRF 대상이 아니다)
*/
/** 개발에서만 기본으로 허용하는 출처 — vite dev 서버(호스트)와 백엔드 자신 */
export const DEV_DEFAULT_ORIGINS: readonly string[] = [
'http://localhost:5173',
'http://localhost:3000',
];
/** 출처를 비교할 수 있는 형태로 — 스킴·호스트 소문자, 기본 포트 생략. 출처가 아니면 null ('null' 포함) */
export function normalizeOrigin(value: string): string | null {
try {
const url = new URL(value.trim());
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return url.origin;
} catch {
return null;
}
}
/** 콤마로 나열한 출처 목록 (CORS_ORIGIN 형식) — 해석하지 못한 값은 따로 돌려준다 */
export function parseOriginList(raw: string | undefined): {
origins: string[];
invalid: string[];
} {
const origins: string[] = [];
const invalid: string[] = [];
for (const entry of (raw ?? '').split(',')) {
const trimmed = entry.trim();
if (trimmed === '') continue;
const origin = normalizeOrigin(trimmed);
if (origin === null) invalid.push(trimmed);
else if (!origins.includes(origin)) origins.push(origin);
}
return { origins, invalid };
}
/**
* 허용 출처 목록.
* 운영은 `CORS_ORIGIN` 만 — localhost 기본값은 개발·테스트에서만 더한다 (운영에 로컬 주소가 섞이지 않게).
*/
export function buildAllowedOrigins(
read: (key: string) => string | undefined,
): string[] {
const { origins } = parseOriginList(read('CORS_ORIGIN'));
if (read('NODE_ENV')?.trim() === 'production') return origins;
return [...new Set([...DEV_DEFAULT_ORIGINS, ...origins])];
}
export interface RequestOriginHeaders {
readonly secFetchSite?: string;
readonly origin?: string;
}
/** 출처 검사 결과 — `no-browser-headers` 는 통과지만 이유를 구분해 둔다 (테스트·로그용) */
export type OriginVerdict = 'allowed' | 'no-browser-headers' | 'rejected';
/** 브라우저가 붙이는 Sec-Fetch-Site 중 통과시키는 값 */
const TRUSTED_FETCH_SITES = new Set(['same-origin', 'none']);
export function checkRequestOrigin(
headers: RequestOriginHeaders,
allowedOrigins: ReadonlySet<string>,
): OriginVerdict {
const site = headers.secFetchSite?.trim().toLowerCase() || undefined;
const origin = headers.origin?.trim() || undefined;
if (site === undefined && origin === undefined) return 'no-browser-headers';
if (site !== undefined && TRUSTED_FETCH_SITES.has(site)) return 'allowed';
const normalized = origin === undefined ? null : normalizeOrigin(origin);
return normalized !== null && allowedOrigins.has(normalized)
? 'allowed'
: 'rejected';
}
@@ -0,0 +1,72 @@
import {
DEFAULT_TRUST_PROXY,
parseTrustProxy,
resolveTrustProxy,
unsafeTrustProxyReason,
} from './trust-proxy';
describe('parseTrustProxy', () => {
it('값이 없으면 미설정(undefined)으로 본다 — 빈 문자열 포함 (compose 가 빈 값을 주입한다)', () => {
expect(parseTrustProxy(undefined)).toEqual({ ok: true, value: undefined });
expect(parseTrustProxy(' ')).toEqual({ ok: true, value: undefined });
});
it('숫자는 홉 수로 바꾼다 (문자열 "2" 를 그대로 넘기면 Express 가 IP 로 해석해 실패한다)', () => {
expect(parseTrustProxy('2')).toEqual({ ok: true, value: 2 });
expect(parseTrustProxy('0')).toEqual({ ok: true, value: 0 });
});
it('홉 수가 너무 크면 거부한다 — 사실상 true 와 같다', () => {
expect(parseTrustProxy('6')).toMatchObject({ ok: false });
expect(parseTrustProxy('100')).toMatchObject({ ok: false });
});
it('true·false 를 받는다 (대소문자 무시)', () => {
expect(parseTrustProxy('TRUE')).toEqual({ ok: true, value: true });
expect(parseTrustProxy('false')).toEqual({ ok: true, value: false });
});
it('키워드·IP·CIDR 목록을 받는다', () => {
expect(
parseTrustProxy('Loopback, 172.18.0.0/24 ,203.0.113.7, fd00:1::/64, ::1'),
).toEqual({
ok: true,
value: ['loopback', '172.18.0.0/24', '203.0.113.7', 'fd00:1::/64', '::1'],
});
});
it.each([
['아무 문자열', 'nginx'],
['접두 길이 초과', '10.0.0.0/33'],
['잘못된 IP', '300.1.1.1'],
['숫자 아닌 접두 길이', '10.0.0.0/abc'],
['슬래시 두 개', '10.0.0.0/8/1'],
['소수', '1.5'],
['콤마만', ','],
])('%s 은 거부한다 (%s)', (_label, value) => {
expect(parseTrustProxy(value)).toMatchObject({ ok: false });
});
});
describe('unsafeTrustProxyReason', () => {
it('true 와 0·false 는 운영에서 쓸 수 없다', () => {
expect(unsafeTrustProxyReason(true)).toMatch(/X-Forwarded-For/);
expect(unsafeTrustProxyReason(false)).toMatch(/nginx/);
expect(unsafeTrustProxyReason(0)).toMatch(/nginx/);
});
it('홉 수·주소 목록은 통과한다', () => {
expect(unsafeTrustProxyReason(2)).toBeNull();
expect(unsafeTrustProxyReason(['loopback', '172.18.0.0/16'])).toBeNull();
});
});
describe('resolveTrustProxy', () => {
it('미설정이면 기본값(1)', () => {
expect(resolveTrustProxy(undefined)).toBe(DEFAULT_TRUST_PROXY);
});
it('형식이 틀리면 예외 — env 검증을 건너뛴 경우의 마지막 방어선', () => {
expect(() => resolveTrustProxy('nginx')).toThrow(/TRUST_PROXY/);
});
});
+120
View File
@@ -0,0 +1,120 @@
import { isIP } from 'net';
/**
* `TRUST_PROXY` env 해석 (순수 함수) — Express 의 `trust proxy` 설정값으로 바꾼다.
*
* 클라이언트 IP(`req.ip`)는 로그인 실패 한도와 요청 수 한도의 기준이다.
* 믿을 프록시를 너무 적게 잡으면 모든 사용자가 프록시 IP 하나로 묶여 **한 사람의 실패가 모두를 막고**,
* 너무 많이 잡으면 클라이언트가 보낸 `X-Forwarded-For` 를 그대로 믿어 **한도를 우회**한다.
*
* 받는 형식 (Express 와 같다):
* - 홉 수: `2` — 백엔드 앞의 프록시 개수 (예: 외부 리버스 프록시 → 프론트 nginx → 백엔드 = 2)
* - 주소 목록: `loopback, 172.18.0.0/24, 203.0.113.7` — 키워드(loopback·linklocal·uniquelocal)·IP·CIDR 를 콤마로.
* 믿을 프록시가 실제로 있는 **좁은** 대역만 적는다 (10.0.0.0/8 처럼 넓은 사설 대역을 통째로 믿으면
* 같은 대역의 다른 호스트·컨테이너가 X-Forwarded-For 를 꾸며 IP 를 바꿀 수 있다)
* - `true` / `false` — true 는 X-Forwarded-For 를 무조건 믿으므로 운영에서 쓰지 않는다
*/
export type TrustProxySetting = boolean | number | readonly string[];
export type TrustProxyParseResult =
| { readonly ok: true; readonly value: TrustProxySetting | undefined }
| { readonly ok: false; readonly reason: string };
/** 값이 없을 때(비운영)의 기본값 — 개발은 vite 프록시 한 단계, e2e 는 X-Forwarded-For 로 IP 를 나눈다 */
export const DEFAULT_TRUST_PROXY: TrustProxySetting = 1;
/**
* 홉 수 상한. 실제 배포에서 프록시가 이보다 많이 겹치는 일은 없다 —
* 큰 값은 사실상 `true` 와 같아 클라이언트가 보낸 X-Forwarded-For 를 믿게 된다.
*/
export const MAX_TRUSTED_PROXY_HOPS = 5;
/** proxy-addr 가 아는 대역 이름 */
const RANGE_KEYWORDS = new Set(['loopback', 'linklocal', 'uniquelocal']);
const HOP_COUNT_PATTERN = /^\d+$/;
const IPV4_MAX_PREFIX = 32;
const IPV6_MAX_PREFIX = 128;
/** IP 하나 또는 CIDR(주소/접두 길이)인지 */
function isAddressOrCidr(entry: string): boolean {
const [address, prefix, ...rest] = entry.split('/');
if (rest.length > 0) return false;
const family = isIP(address);
if (family === 0) return false;
if (prefix === undefined) return true;
if (!/^\d{1,3}$/.test(prefix)) return false;
const maxPrefix = family === 4 ? IPV4_MAX_PREFIX : IPV6_MAX_PREFIX;
return Number(prefix) <= maxPrefix;
}
export function parseTrustProxy(
raw: string | undefined,
): TrustProxyParseResult {
const value = raw?.trim();
if (!value) return { ok: true, value: undefined };
const lowered = value.toLowerCase();
if (lowered === 'true') return { ok: true, value: true };
if (lowered === 'false') return { ok: true, value: false };
if (HOP_COUNT_PATTERN.test(value)) {
const hops = Number(value);
return hops <= MAX_TRUSTED_PROXY_HOPS
? { ok: true, value: hops }
: {
ok: false,
reason: `홉 수는 0~${MAX_TRUSTED_PROXY_HOPS} 이어야 합니다`,
};
}
const entries = value
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry !== '');
const invalid = entries.filter(
(entry) =>
!RANGE_KEYWORDS.has(entry.toLowerCase()) && !isAddressOrCidr(entry),
);
if (entries.length === 0 || invalid.length > 0) {
return {
ok: false,
reason: `홉 수, 또는 IP·CIDR·키워드(loopback·linklocal·uniquelocal) 목록이어야 합니다 (해석하지 못한 값: ${invalid.join(', ') || value})`,
};
}
return {
ok: true,
value: entries.map((entry) =>
RANGE_KEYWORDS.has(entry.toLowerCase()) ? entry.toLowerCase() : entry,
),
};
}
/**
* 운영에서 쓸 수 없는 값인지 — 형식은 맞지만 한도를 무력화하는 설정.
* - `true`: 클라이언트가 X-Forwarded-For 를 꾸며 IP 를 마음대로 바꾼다
* - `false`·`0`: 앞단 nginx 가 항상 있으므로 모든 사용자가 nginx IP 하나로 묶인다
*/
export function unsafeTrustProxyReason(
setting: TrustProxySetting,
): string | null {
if (setting === true) {
return 'true 는 클라이언트가 보낸 X-Forwarded-For 를 그대로 믿어 IP 기준 한도를 우회할 수 있습니다';
}
if (setting === false || setting === 0) {
return '앞단 프록시(프론트 nginx)를 믿지 않으면 모든 사용자가 프록시 IP 하나로 묶여 한 사람의 로그인 실패가 전원을 막습니다';
}
return null;
}
/** Express `trust proxy` 에 넣을 값 — 형식 오류는 env 검증이 기동 시점에 막으므로 여기서는 예외로 끝낸다 */
export function resolveTrustProxy(raw: string | undefined): TrustProxySetting {
const parsed = parseTrustProxy(raw);
if (!parsed.ok) {
throw new Error(`TRUST_PROXY 값을 해석할 수 없습니다: ${parsed.reason}`);
}
return parsed.value ?? DEFAULT_TRUST_PROXY;
}