chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1

Merged
ttipo merged 52 commits from feat/mail-module into main 2026-10-30 12:50:48 +09:00
10 changed files with 150 additions and 18 deletions
Showing only changes of commit 8fc73aa541 - Show all commits
+6 -1
View File
@@ -12,11 +12,15 @@ import type {
import { configureApp } from '@/app.setup';
import { ACCESS_TOKEN_COOKIE } from '@/modules/auth/auth.constants';
import { winstonConfig } from '@/shared/logger/winston.config';
import { parsePositiveInt } from '@/shared/utils/env';
import {
buildAllowedOrigins,
normalizeOrigin,
} from '@/shared/utils/request-origin';
/** PORT 가 없거나 비었을 때 듣는 포트 */
const DEFAULT_PORT = 3000;
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: WinstonModule.createLogger(winstonConfig),
@@ -80,7 +84,8 @@ async function bootstrap() {
// 종료 로그는 ShutdownLogger(OnApplicationShutdown)가 남긴다.
const port = Number(process.env.PORT ?? 3000);
// compose 는 정의되지 않은 변수를 빈 문자열로 넣는다 — Number('') 는 0 이라 parsePositiveInt 로 기본값을 쓴다
const port = parsePositiveInt(process.env.PORT, DEFAULT_PORT);
await app.listen(port, '0.0.0.0');
Logger.log(`🚀 Backend running on http://localhost:${port}/api`, 'Bootstrap');
Logger.log(
@@ -7,6 +7,8 @@
* 사내 주소의 로컬 부분 — 영문 소문자·숫자·`.`·`_`·`-` (앞뒤는 영문·숫자, 64자 이하).
* 특히 `%`·`:` 를 막는다. Stalwart 는 `<대상>%<계정>` 을 대리 접근으로 해석하므로,
* 로그인 주소에 `%` 가 섞이면 백엔드(신뢰 대역)를 통해 대리 접근 로그인이 시도될 수 있다.
* ⚠️ mail/scripts/setup.mjs 의 LOCAL_PART_PATTERN 과 같은 규칙이다 — MAIL_ADMIN_LOCAL_PART 를 두 쪽이 함께 읽으므로,
* 한쪽을 바꾸면 다른 쪽도 함께 바꾼다.
*/
const LOCAL_PART_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
@@ -53,6 +53,46 @@ describe('validateEnv', () => {
).not.toThrow();
});
it('숫자 항목은 앞자리 0·부호·소수점을 받지 않는다 — nginx 도 읽는 첨부·업로드 크기의 규칙으로 모두 맞춘다', () => {
for (const [key, value] of [
['MAIL_ATTACHMENT_MAX_SIZE_MB', '08'],
['UPLOAD_MAX_SIZE_MB', '05'],
['PORT', '+3000'],
['THROTTLE_LIMIT', '1e2'],
['DB_PORT', '5432.0'],
]) {
expect(() =>
validateEnv({ NODE_ENV: 'development', [key]: value }),
).toThrow(new RegExp(`${key} 는 양의 정수여야 합니다 — 앞자리 0`));
}
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_ATTACHMENT_MAX_SIZE_MB: '8',
UPLOAD_MAX_SIZE_MB: '10',
}),
).not.toThrow();
});
it('nginx 도 읽는 첨부·업로드 크기는 6자리(999999)까지만 받는다 — nginx 가 기동을 멈추는 값', () => {
for (const key of ['MAIL_ATTACHMENT_MAX_SIZE_MB', 'UPLOAD_MAX_SIZE_MB']) {
expect(() =>
validateEnv({ NODE_ENV: 'development', [key]: '1000000' }),
).toThrow(new RegExp(`${key} 가 너무 큽니다 — .*6자리\\(999999\\)`));
expect(() =>
validateEnv({ NODE_ENV: 'development', [key]: '999999' }),
).not.toThrow();
}
// nginx 가 읽지 않는 숫자는 자릿수를 묻지 않는다
expect(() =>
validateEnv({
NODE_ENV: 'development',
MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB: '1000000',
THROTTLE_TTL_MS: '86400000',
}),
).not.toThrow();
});
it('첨부 한도는 양의 정수여야 한다', () => {
expect(() =>
validateEnv({
+31 -5
View File
@@ -16,6 +16,7 @@ import {
import { isLocalPart } from '@/modules/auth/employee-address';
import { parseAccountList } from '@/modules/mail/mail.settings';
import { parseDurationSeconds } from '@/shared/utils/duration';
import { isPositiveIntString } from '@/shared/utils/env';
import { parseOriginList } from '@/shared/utils/request-origin';
import {
parseTrustProxy,
@@ -46,16 +47,30 @@ const REQUIRED_IN_PRODUCTION = [
'TRUST_PROXY',
] as const;
/** 값이 있다면 양의 정수여야 하는 키 */
/** 값이 있다면 양의 정수(앞자리 0 없이)여야 하는 키 — shared/utils/env.ts 의 isPositiveIntString */
const POSITIVE_INT_KEYS = [
'PORT',
'DB_PORT',
'THROTTLE_TTL_MS',
'THROTTLE_LIMIT',
// 앞단 nginx 도 읽는다 (업로드·첨부 요청 본문 한도) — 아래 NGINX_SHARED_INT_KEYS
'UPLOAD_MAX_SIZE_MB',
'MAIL_ATTACHMENT_MAX_SIZE_MB',
'MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB',
] as const;
/**
* 앞단 nginx 도 읽는 숫자 키 (frontend/nginx-env.envsh 가 본문 한도를 만든다) — nginx 는 6자리(999999)를 넘으면
* 기동을 멈춘다. 백엔드가 더 큰 값을 받으면 백엔드는 뜨고 frontend 만 뜨지 않으므로 같은 자릿수 상한을 둔다.
*/
const NGINX_SHARED_INT_KEYS: ReadonlySet<string> = new Set([
'UPLOAD_MAX_SIZE_MB',
'MAIL_ATTACHMENT_MAX_SIZE_MB',
]);
/** nginx-env.envsh 의 자릿수 상한 */
const NGINX_INT_MAX_DIGITS = 6;
/** 값이 있다면 기간 형식(숫자 + s·m·h·d)이어야 하는 키 — 쿠키 Max-Age 와 토큰 만료가 같은 값을 쓴다 */
const DURATION_KEYS = [
'JWT_ACCESS_EXPIRES_IN',
@@ -312,14 +327,25 @@ export function validateEnv(
const isProduction = readString(config, 'NODE_ENV') === 'production';
const errors: string[] = [];
// 1. 숫자여야 하는 값 — 비어 있으면 코드의 기본값을 쓰므로 통과시킨다
// 1. 숫자여야 하는 값 — 비어 있으면 코드의 기본값을 쓰므로 통과시킨다.
// 앞자리 0(08)·부호·소수점·지수 표기는 받지 않는다 — nginx 도 읽는 첨부·업로드 크기에서 nginx 가 기동을 거부하는
// 형식이라 숫자 env 는 모두 같은 표기로 맞춘다. nginx 의 6자리 상한은 그 두 키(NGINX_SHARED_INT_KEYS)에만 둔다
for (const key of POSITIVE_INT_KEYS) {
const value = readString(config, key);
if (value === undefined) continue;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
errors.push(`${key} 는 양의 정수여야 합니다. (현재 값: "${value}")`);
if (!isPositiveIntString(value)) {
errors.push(
`${key} 는 양의 정수여야 합니다 — 앞자리 0·부호·소수점 없이 숫자만 씁니다. (예: 20, 현재 값: "${value}")`,
);
} else if (
NGINX_SHARED_INT_KEYS.has(key) &&
value.length > NGINX_INT_MAX_DIGITS
) {
// 앞자리 0 이 없으므로 글자 수가 곧 자릿수다
errors.push(
`${key} 가 너무 큽니다 — 앞단 nginx 와 같은 한도로 ${NGINX_INT_MAX_DIGITS}자리(${'9'.repeat(NGINX_INT_MAX_DIGITS)}) 이하만 씁니다. (현재 값: "${value}")`,
);
}
}
@@ -30,4 +30,10 @@ describe('parseDurationSeconds', () => {
expect(parseDurationSeconds('0')).toBeNull();
expect(parseDurationSeconds('abc')).toBeNull();
});
it('숫자에 앞자리 0 이 있으면 null 이다 (env 숫자는 모두 같은 규칙)', () => {
expect(parseDurationSeconds('015m')).toBeNull();
expect(parseDurationSeconds('01h')).toBeNull();
expect(parseDurationSeconds('15m')).toBe(900);
});
});
+5 -2
View File
@@ -14,8 +14,11 @@ const SECONDS_PER_UNIT = {
type DurationUnit = keyof typeof SECONDS_PER_UNIT;
/** 지원 형식: 숫자만(초) 또는 숫자 + s·m·h·d — 예) '3600', '15m', '1h', '14d' */
const DURATION_PATTERN = /^(\d+)\s*([smhd]?)$/i;
/**
* 지원 형식: 숫자만(초) 또는 숫자 + s·m·h·d — 예) '3600', '15m', '1h', '14d'.
* 숫자는 앞자리 0 없이 쓴다 ('015m' 는 받지 않는다 — env 숫자는 모두 같은 규칙, shared/utils/env.ts)
*/
const DURATION_PATTERN = /^([1-9]\d*)\s*([smhd]?)$/i;
/**
* 기간 문자열을 초 단위 양의 정수로 바꾼다.
+29 -1
View File
@@ -1,4 +1,4 @@
import { parsePositiveInt } from './env';
import { isPositiveIntString, parsePositiveInt } from './env';
describe('parsePositiveInt', () => {
it('정상적인 양의 정수 문자열을 숫자로 변환한다', () => {
@@ -24,4 +24,32 @@ describe('parsePositiveInt', () => {
expect(parsePositiveInt('-5', 100)).toBe(100);
expect(parsePositiveInt('1.5', 100)).toBe(100);
});
it('앞자리 0·부호·지수·16진 표기는 받지 않는다 — nginx 의 앞자리 0 규칙과 같다 (앞뒤 공백은 무시 — nginx 는 거부)', () => {
for (const value of ['08', '020', '+5', '5.0', '1e3', '0x10', '1_000']) {
expect(parsePositiveInt(value, 100)).toBe(100);
}
expect(parsePositiveInt(' 20 ', 100)).toBe(20);
});
});
describe('isPositiveIntString', () => {
it('10진 양의 정수만 — 앞자리 0 없이', () => {
for (const value of ['1', '20', '60000', ' 7 ']) {
expect(isPositiveIntString(value)).toBe(true);
}
for (const value of [
'',
'0',
'08',
'-1',
'+1',
'1.0',
'1e2',
'abc',
'9'.repeat(20),
]) {
expect(isPositiveIntString(value)).toBe(false);
}
});
});
+23 -7
View File
@@ -5,18 +5,34 @@
* `Number('')` 는 0 이므로 `??` 만으로는 기본값이 적용되지 않는다 — 이 함수로 방어한다.
*/
/**
* env 의 양의 정수 표기 — 10진 숫자만, **앞자리 0 없이** (`08`·`+5`·`5.0`·`1e3`·`0x10` 은 받지 않는다).
* 숫자 env 는 모두 이 표기를 쓴다 — 같은 값을 서비스마다 다르게 읽지 않게.
*
* 첨부·업로드 크기(MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB)는 앞단 nginx 도 읽는다 (frontend/nginx-env.envsh).
* nginx 의 규칙은 "0 으로 시작하지 않는 숫자만, 6자리 이하"다 — 앞자리 0 은 이 표기가, 6자리 상한은 env 검증이
* (env.validation.ts 의 NGINX_SHARED_INT_KEYS) 같은 값만 받게 맞춘다. 다른 점 하나: 여기는 앞뒤 공백을 무시하고
* nginx 는 공백이 섞이면 기동을 멈춘다.
*/
const POSITIVE_INT_PATTERN = /^[1-9]\d*$/;
/** env 의 양의 정수 표기인가 (앞뒤 공백은 무시) — env 검증과 파서가 같은 규칙을 쓴다 */
export function isPositiveIntString(value: string): boolean {
const trimmed = value.trim();
return (
POSITIVE_INT_PATTERN.test(trimmed) && Number.isSafeInteger(Number(trimmed))
);
}
/**
* 환경변수를 양의 정수로 파싱한다.
* 값이 없거나(undefined·빈 문자열) 숫자가 아니거나 0 이하면 기본값을 반환한다.
* 값이 없거나(undefined·빈 문자열) 양의 정수 표기가 아니면(앞자리 0 포함) 기본값을 반환한다.
* (형식 오류는 env 검증이 기동 시점에 막는다 — 여기는 검증을 건너뛴 경우의 방어선이다)
*/
export function parsePositiveInt(
value: string | undefined,
fallback: number,
): number {
if (value === undefined || value.trim() === '') return fallback;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) return fallback;
return parsed;
if (value === undefined || !isPositiveIntString(value)) return fallback;
return Number(value.trim());
}
@@ -43,6 +43,8 @@ describe('parseTrustProxy', () => {
['슬래시 두 개', '10.0.0.0/8/1'],
['소수', '1.5'],
['콤마만', ','],
['앞자리 0 이 있는 홉 수', '01'],
['앞자리 0 이 있는 접두 길이', '10.0.0.0/08'],
])('%s 은 거부한다 (%s)', (_label, value) => {
expect(parseTrustProxy(value)).toMatchObject({ ok: false });
});
+6 -2
View File
@@ -32,7 +32,11 @@ export const MAX_TRUSTED_PROXY_HOPS = 5;
/** proxy-addr 가 아는 대역 이름 */
const RANGE_KEYWORDS = new Set(['loopback', 'linklocal', 'uniquelocal']);
const HOP_COUNT_PATTERN = /^\d+$/;
/** 홉 수 — 앞자리 0 없이 ('01' 은 받지 않는다 — env 숫자는 모두 같은 규칙, shared/utils/env.ts) */
const HOP_COUNT_PATTERN = /^(?:0|[1-9]\d*)$/;
/** CIDR 접두 길이 — 앞자리 0 없이 */
const PREFIX_LENGTH_PATTERN = /^(?:0|[1-9]\d{0,2})$/;
const IPV4_MAX_PREFIX = 32;
const IPV6_MAX_PREFIX = 128;
@@ -46,7 +50,7 @@ function isAddressOrCidr(entry: string): boolean {
if (family === 0) return false;
if (prefix === undefined) return true;
if (!/^\d{1,3}$/.test(prefix)) return false;
if (!PREFIX_LENGTH_PATTERN.test(prefix)) return false;
const maxPrefix = family === 4 ? IPV4_MAX_PREFIX : IPV6_MAX_PREFIX;
return Number(prefix) <= maxPrefix;
}