fix(backend): 운영 공개 URL 에 로컬 주소가 남으면 기동을 실패시킴
.env.production.example 이 개발용 값(localhost)을 그대로 담고 있어, 복사해 배포하면 서버는 정상 기동하지만 CORS 화이트리스트가 실제 도메인을 막고 프론트 번들은 localhost 를 호출한다 — 브라우저에서만 조용히 실패해 원인을 찾기 어렵다. - CORS_ORIGIN·BACKEND_API_URL 에 로컬 주소나 예시 도메인이 있으면 운영에서 기동 실패 - 예제 값을 도메인 형태로 교체 (그대로 두면 이 검사에 걸린다) - BACKEND_API_URL 은 앱이 쓰지 않지만 검사를 위해 compose 가 backend 에 주입한다 - 로컬에서 운영 이미지를 검증할 때만 ALLOW_LOCALHOST_URL=true 로 건너뛴다 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,55 @@ describe('validateEnv', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('공개 주소에 로컬 주소가 남아 있으면 실패한다', () => {
|
||||
expect(() =>
|
||||
validateEnv(prod({ CORS_ORIGIN: 'http://localhost:5173' })),
|
||||
).toThrow(/CORS_ORIGIN 가 운영에서 쓸 수 없는 주소입니다/);
|
||||
|
||||
expect(() =>
|
||||
validateEnv(prod({ BACKEND_API_URL: 'http://127.0.0.1:3000/api' })),
|
||||
).toThrow(/BACKEND_API_URL 가 운영에서 쓸 수 없는 주소입니다/);
|
||||
});
|
||||
|
||||
it('예시 파일의 example.com 이 그대로면 실패한다', () => {
|
||||
expect(() =>
|
||||
validateEnv(prod({ CORS_ORIGIN: 'https://app.example.com' })),
|
||||
).toThrow(/CORS_ORIGIN 가 운영에서 쓸 수 없는 주소입니다/);
|
||||
});
|
||||
|
||||
it('실제 도메인이면 통과한다', () => {
|
||||
expect(() =>
|
||||
validateEnv(
|
||||
prod({
|
||||
CORS_ORIGIN: 'https://app.myservice.io',
|
||||
BACKEND_API_URL: 'https://api.myservice.io/api',
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('ALLOW_LOCALHOST_URL=true 면 공개 주소 검사를 건너뛴다 (운영 이미지 로컬 검증용)', () => {
|
||||
expect(() =>
|
||||
validateEnv(
|
||||
prod({
|
||||
CORS_ORIGIN: 'http://localhost:5173',
|
||||
BACKEND_API_URL: 'http://localhost:3000/api',
|
||||
ALLOW_LOCALHOST_URL: 'true',
|
||||
}),
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('공개 주소 검사는 운영 환경에서만 동작한다', () => {
|
||||
expect(() =>
|
||||
validateEnv({
|
||||
NODE_ENV: 'development',
|
||||
CORS_ORIGIN: 'http://localhost:5173',
|
||||
BACKEND_API_URL: 'http://localhost:3000/api',
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('문제가 여러 개면 한 번에 모두 보여준다', () => {
|
||||
const partial = omit(prod(), 'DB_HOST', 'DB_NAME');
|
||||
|
||||
|
||||
@@ -33,6 +33,29 @@ const NO_PLACEHOLDER_IN_PRODUCTION = [
|
||||
'JWT_SECRET',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 운영에서 **공개 주소**여야 하는 키.
|
||||
* 로컬 주소가 남은 채 배포되면 서버는 정상 기동하지만
|
||||
* 브라우저가 API 에 닿지 못하거나 CORS 화이트리스트가 실제 도메인을 막아 **조용히** 실패한다.
|
||||
*/
|
||||
const PUBLIC_URL_IN_PRODUCTION = ['CORS_ORIGIN', 'BACKEND_API_URL'] as const;
|
||||
|
||||
/** 공개 주소로 쓸 수 없는 값 — 로컬 주소와 예시 파일의 예시 도메인 */
|
||||
const NON_PUBLIC_URL_PATTERNS = [
|
||||
/localhost/i,
|
||||
/127\.0\.0\.1/,
|
||||
/\[::1\]/,
|
||||
/0\.0\.0\.0/,
|
||||
/example\.com/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* 위 공개 주소 검사를 건너뛰는 탈출구.
|
||||
* 로컬에서 **운영 이미지를 검증**할 때만 쓴다 (예: graceful shutdown 확인 — .claude/skills/docker/SKILL.md).
|
||||
* 실제 배포에서는 절대 설정하지 않는다.
|
||||
*/
|
||||
const ALLOW_LOCALHOST_URL_KEY = 'ALLOW_LOCALHOST_URL';
|
||||
|
||||
function readString(
|
||||
config: Record<string, unknown>,
|
||||
key: string,
|
||||
@@ -47,6 +70,10 @@ function looksLikePlaceholder(value: string): boolean {
|
||||
return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function looksNonPublic(value: string): boolean {
|
||||
return NON_PUBLIC_URL_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경변수를 검증한다. 문제가 있으면 **모든 문제를 모아** 예외를 던진다.
|
||||
* (하나씩 고쳐가며 재기동하는 낭비를 막기 위해 한 번에 보여준다)
|
||||
@@ -85,6 +112,22 @@ export function validateEnv(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 공개 주소가 로컬 주소·예시 도메인으로 남은 채 배포되는 것을 막는다
|
||||
const allowLocalhostUrl =
|
||||
readString(config, ALLOW_LOCALHOST_URL_KEY) === 'true';
|
||||
|
||||
if (!allowLocalhostUrl) {
|
||||
for (const key of PUBLIC_URL_IN_PRODUCTION) {
|
||||
const value = readString(config, key);
|
||||
if (value !== undefined && looksNonPublic(value)) {
|
||||
errors.push(
|
||||
`${key} 가 운영에서 쓸 수 없는 주소입니다. 실제 도메인으로 교체해 주세요. (현재 값: "${value}")` +
|
||||
` — 로컬에서 운영 이미지를 검증하는 중이라면 ${ALLOW_LOCALHOST_URL_KEY}=true`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user