generated from ttipo/project-base
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:
@@ -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(
|
||||
/예시 값 그대로/,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user