diff --git a/backend/src/main.ts b/backend/src/main.ts index 9fa7b1f..1780a8c 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -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(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( diff --git a/backend/src/modules/auth/employee-address.ts b/backend/src/modules/auth/employee-address.ts index a9a685c..9d06b4f 100644 --- a/backend/src/modules/auth/employee-address.ts +++ b/backend/src/modules/auth/employee-address.ts @@ -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])?$/; diff --git a/backend/src/shared/config/env.validation.spec.ts b/backend/src/shared/config/env.validation.spec.ts index 87cf6dd..b7c0224 100644 --- a/backend/src/shared/config/env.validation.spec.ts +++ b/backend/src/shared/config/env.validation.spec.ts @@ -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({ diff --git a/backend/src/shared/config/env.validation.ts b/backend/src/shared/config/env.validation.ts index adb8e1b..3d51093 100644 --- a/backend/src/shared/config/env.validation.ts +++ b/backend/src/shared/config/env.validation.ts @@ -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 = 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}")`, + ); } } diff --git a/backend/src/shared/utils/duration.spec.ts b/backend/src/shared/utils/duration.spec.ts index e05ddc9..d0064d0 100644 --- a/backend/src/shared/utils/duration.spec.ts +++ b/backend/src/shared/utils/duration.spec.ts @@ -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); + }); }); diff --git a/backend/src/shared/utils/duration.ts b/backend/src/shared/utils/duration.ts index ca040e1..246ae6c 100644 --- a/backend/src/shared/utils/duration.ts +++ b/backend/src/shared/utils/duration.ts @@ -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; /** * 기간 문자열을 초 단위 양의 정수로 바꾼다. diff --git a/backend/src/shared/utils/env.spec.ts b/backend/src/shared/utils/env.spec.ts index f88e1bb..71075fb 100644 --- a/backend/src/shared/utils/env.spec.ts +++ b/backend/src/shared/utils/env.spec.ts @@ -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); + } + }); }); diff --git a/backend/src/shared/utils/env.ts b/backend/src/shared/utils/env.ts index 366f163..d06339d 100644 --- a/backend/src/shared/utils/env.ts +++ b/backend/src/shared/utils/env.ts @@ -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()); } diff --git a/backend/src/shared/utils/trust-proxy.spec.ts b/backend/src/shared/utils/trust-proxy.spec.ts index af1ddcd..6c5333c 100644 --- a/backend/src/shared/utils/trust-proxy.spec.ts +++ b/backend/src/shared/utils/trust-proxy.spec.ts @@ -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 }); }); diff --git a/backend/src/shared/utils/trust-proxy.ts b/backend/src/shared/utils/trust-proxy.ts index 39e26ac..42d1b66 100644 --- a/backend/src/shared/utils/trust-proxy.ts +++ b/backend/src/shared/utils/trust-proxy.ts @@ -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; }