generated from ttipo/project-base
fix(backend): 숫자 env 를 앞자리 0 없는 10진수로만 받기
같은 값을 읽는 nginx 템플릿(frontend/nginx-env.envsh)은 셸 산술 때문에 `08` 같은 값에서 기동을 멈추는데
백엔드는 받아들여, 백엔드만 뜨고 frontend 가 뜨지 않는 조합이 있었다.
- 숫자 env 는 앞자리 0 없는 10진수만 받는다 (기간의 숫자, TRUST_PROXY 홉 수·CIDR 접두 포함) — `08`·`+5`·`5.0`·`1e3` 은 기동 실패
- nginx 도 읽는 MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB 는 nginx 와 같이 6자리 이하
- PORT 도 parsePositiveInt 로 읽는다 — compose 가 빈 문자열을 넣으면 Number('') 가 0 이라 임의 포트로 떴다
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 기간 문자열을 초 단위 양의 정수로 바꾼다.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user