Express 보안 설정 가이드: 배포 전 고칠 미들웨어 실수 7가지

Express 보안 설정에서 자주 틀리는 trust proxy, 미들웨어 순서, 본문 크기 제한, 에러 스택 노출 등 7가지를 잘못된 코드와 고친 코드로 비교해 배포 전 점검 기준을 제시합니다.

보안 점검 의뢰 팀 15분 분량

Express 서버를 배포하기 전에 먼저 볼 것은 helmet 설치 여부가 아니라 설정이 실제 인프라와 맞는지다. 핵심은 일곱 가지다. trust proxy를 프록시 구성에 맞추고, 레이트 리밋의 저장소와 범위를 정하고, 미들웨어 순서를 고정하고, 본문 크기를 라우트별로 제한하고, 에러 응답에서 내부 정보를 빼고, CORS 허용 목록을 만들고, 세션 쿠키를 운영 기준으로 바꾼다.

이 실수들은 패키지를 설치해도 그대로 남는다. 코드는 정상 동작하고 기능 테스트도 통과하기 때문이다. 아래에서는 각 실수를 잘못된 설정과 고친 설정으로 비교한다. 예시는 Express 5 기준이며 숫자는 서비스에 맞게 조정할 값이다.

클라이언트 식별을 흔드는 설정

실수 1: trust proxy를 true로 두거나 아예 설정하지 않기

// 잘못된 설정: 경고를 없애려고 전부 신뢰
app.set('trust proxy', true);

Express 공식 문서에 따르면 true일 때 클라이언트 IP는 X-Forwarded-For의 가장 왼쪽 값이다. 그런데 AWS ALB의 기본 모드는 기존 헤더 뒤에 클라이언트 IP를 덧붙이고, nginx의 $proxy_add_x_forwarded_for도 마찬가지다. 왼쪽 값은 클라이언트가 보낸 값일 수 있으니 req.ip를 위조할 여지가 생긴다.

반대로 기본값 false로 프록시 뒤에 두면 req.ip가 프록시 주소가 되어 모든 사용자가 같은 IP로 집계되고, req.protocol도 프록시와 앱 사이의 프로토콜을 따른다.

// 고친 설정: 실제 프록시 홉 수만 신뢰 (ALB 1대 = 1, ALB + nginx = 2)
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS ?? 0));

홉 수가 실제와 다르면 값이 틀어진다. 앱 포트로 직접 들어오는 경로처럼 길이가 다른 경로가 있어도 위조가 가능하므로, 앱은 프록시에서만 접근되도록 네트워크를 막는다. 프록시 주소가 고정이면 'loopback'이나 CIDR로 지정하는 방법도 좋다.

스테이징에서 req.ip를 돌려주는 임시 라우트를 만들어 검증하고, 끝나면 지운다.

curl -H "X-Forwarded-For: 203.0.113.10" https://staging.example.com/ip

내 실제 공인 IP가 나오면 정상이고, 203.0.113.10이 나오면 설정이 잘못된 것이다. 또한 trust proxy를 켜면 req.hostname이 X-Forwarded-Host를 따르므로, 비밀번호 재설정 링크의 도메인은 요청값 대신 환경변수로 고정한다.

실수 2: 전역 리밋 하나로 로그인까지 막았다고 생각하기

// 잘못된 설정: 전 경로 공통, 인스턴스별 메모리 저장
app.use(rateLimit({ windowMs: 15 * 60 * 1000, limit: 1000 }));

express-rate-limit의 기본 메모리 저장소는 인스턴스 간에 상태를 공유하지 않는다. 인스턴스가 4대면 실제 허용량은 최대 4배로 늘어나고, 재시작 시 집계도 일관되지 않다. 기본 키가 IP이므로 실수 1이 남아 있으면 리밋도 함께 흔들린다. 라이브러리는 trust proxy가 true이면 ERR_ERL_PERMISSIVE_TRUST_PROXY를 기록하니, 이 로그를 끄지 말고 원인을 고친다.

// 고친 설정: 민감 경로는 별도 기준, 저장소는 외부로
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 10,
  skipSuccessfulRequests: true,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  store: sharedStore, // Redis 등 인스턴스 공유 저장소
});
app.post('/login', loginLimiter, login);

Express 보안 가이드는 사용자명+IP별 연속 실패와 IP별 장기 실패를 함께 추적하라고 권한다. 여러 IP로 한 계정을 노리는 시도까지 보려면 IP 리밋만으로는 부족하다. 계정 잠금과 재설정 흐름은 로그인 보안 체크리스트에서 함께 점검하자.

순서와 입력 크기

실수 3: 라우트를 먼저 등록하고 인증을 뒤에 두기

공식 문서의 표현대로 먼저 로드된 미들웨어가 먼저 실행된다. 뒤에 놓인 미들웨어는 앞의 핸들러가 응답을 끝내면 실행되지 않는다.

// 잘못된 순서
app.use('/api/admin', adminRouter);   // 인증 없이 응답이 끝남
app.use(requireAuth);                 // 위 라우트에는 적용되지 않음
app.use(errorHandler);                // 아래 라우트의 에러는 못 잡음
app.use('/api/orders', orderRouter);
app.use(helmet());                    // 앞에서 끝난 응답에는 헤더 없음
// 고친 순서
app.set('trust proxy', hops);
app.get('/healthz', health);                 // 리밋 제외
app.use(helmet());                           // 에러 응답에도 헤더 적용
app.use(cors(corsOptions));                  // 프리플라이트, 429 응답 처리
app.use(apiLimiter);                         // 파싱 비용 전에 차단
app.post('/webhooks/payment',
  express.raw({ type: 'application/json', limit: '512kb' }), onWebhook);
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: false, parameterLimit: 100 }));
app.use(session(sessionOptions));
app.use('/api/public', publicRouter);
app.use('/api/admin', requireAuth, requireAdmin, adminRouter);
app.use(notFoundHandler);
app.use(errorHandler);                       // 항상 마지막

인증은 전역 위치에 기대기보다 라우터 마운트 줄에 직접 붙이는 편이 안전하다. 라우트를 옮겨도 인증이 함께 따라가고 리뷰에서도 한눈에 보인다. CORS를 인증이나 리밋 뒤에 두면 프리플라이트가 401이나 429로 끝나 원인 파악도 어려워진다.

helmet()은 CSP, HSTS 등을 기본으로 켠다. 기본 HSTS는 365일에 includeSubDomains이므로 HTTPS가 아닌 서브도메인이 있으면 먼저 정리하고, CSP는 인라인 스크립트가 깨지는지 스테이징에서 확인한다.

실수 4: 업로드 한 곳 때문에 전역 본문 제한을 올리기

express.json()의 기본 limit은 100kb다. 문제는 이 기본값을 개발 중에 풀어버리는 경우다. 전역 파서가 먼저 실행되므로 라우트에 큰 limit을 붙여도 소용없고, 결국 전역 값을 올리게 된다.

// 잘못된 설정
app.use(express.json({ limit: '50mb' }));   // 모든 경로가 50MB JSON 허용
app.use(express.json({ limit: Number(process.env.BODY_LIMIT) })); // NaN 가능
// 고친 설정: 큰 경로만 전역 파서 앞에 별도 지정
app.post('/api/import', requireAuth,
  express.json({ limit: '5mb' }), importHandler);
app.use(express.json({ limit: '100kb' }));

두 번째 잘못된 줄은 실제 취약점과 연결된다. CVE-2026-12590은 limit이 잘못된 값일 때 크기 검사가 조용히 건너뛰어지던 body-parser의 서비스 거부 문제로, 위험도는 낮음이며 1.20.6과 2.3.0에서 수정됐다. npm ls body-parser로 설치 버전을 확인하고 환경변수 값은 시작할 때 검증한다.

함께 볼 항목은 다음과 같다.

  • req.on('data')로 직접 읽는 스트림과 업로드 라이브러리에는 파서의 limit이 적용되지 않으므로 각각 제한한다.
  • extended: true면 값이 어떤 타입이든 될 수 있다. 중첩 객체가 필요 없다면 false를 유지한다.
  • Express 5에서는 본문이 없거나 Content-Type이 맞지 않으면 req.body가 undefined다. req.body?.email로 읽고 스키마 검증을 거친다.
  • nginx client_max_body_size의 기본값은 1m이고, 0은 검사를 끈다. 앱 제한이 있다고 프록시 쪽을 0으로 풀어 두지 않는다.

에러 응답으로 새는 정보

실수 5: 스택과 원본 메시지를 응답에 담기

기본 에러 핸들러는 NODE_ENV가 production일 때만 스택을 응답에서 뺀다. 컨테이너 이미지나 PM2 설정에서 이 값이 빠지면 err.stack이 그대로 클라이언트에 간다. 직접 만든 핸들러가 스택을 내보내는 경우는 환경변수로도 막지 못한다.

// 잘못된 설정: 경로, 쿼리, 테이블명이 응답에 노출
app.use((err, req, res, next) => {
  res.status(500).json({ message: err.message, stack: err.stack });
});
// 고친 설정
app.use((req, res) => res.status(404).json({ error: 'not_found' }));

app.use((err, req, res, next) => {
  if (res.headersSent) return next(err);

  const code = err.status ?? err.statusCode;
  const status = code >= 400 && code < 600 ? code : 500;
  const requestId = crypto.randomUUID();

  logger.error({
    requestId, path: req.path, type: err.type,
    message: err.message, stack: err.stack,
  });
  res.status(status).json({
    error: status < 500 ? 'request_error' : 'internal_error',
    requestId,
  });
});

세 가지를 놓치기 쉽다.

  • 네 번째 인자를 지우면 일반 미들웨어로 취급된다. 린터가 next를 미사용으로 지우지 않게 예외 처리한다.
  • err 객체를 통째로 로그에 남기지 않는다. body-parser의 파싱 실패 에러는 body 속성에 원문을 담으므로 비밀번호나 개인정보가 로그로 들어갈 수 있다. 위 코드처럼 필드를 골라 기록한다.
  • Express 5는 async 핸들러의 거부를 자동으로 넘기지만, 콜백 API는 여전히 next(err)를 직접 호출해야 한다. Express 4를 쓴다면 async 핸들러에서도 직접 넘겨야 한다.

브라우저 경계와 세션

실수 6: CORS의 origin을 그대로 되돌려주기

credentials: true에는 *를 쓸 수 없다. 그 에러를 없애려고 origin: true로 바꾸는 경우가 많은데, cors 패키지에서 이 값은 요청의 Origin을 그대로 반사한다.

// 잘못된 설정: 어느 사이트든 로그인된 사용자의 응답을 읽을 수 있음
app.use(cors({ origin: true, credentials: true }));
// 고친 설정: 정확히 일치하는 목록만 허용
const allowed = new Set([
  'https://app.example.com',
  'https://admin.example.com',
]);
app.use(cors({
  origin: (origin, cb) => cb(null, allowed.has(origin)),
  credentials: true,
}));

정규식을 쓴다면 /example\.com$/은 evil-example.com도 통과시키니 주의한다. 또한 CORS는 브라우저가 집행할 뿐 접근 제어가 아니므로 서버의 인증과 권한 검사는 별도로 필요하다. 상태 변경 요청 방어는 CSRF 방어 글에서 이어서 볼 수 있다.

실수 7: 세션을 예제 설정 그대로 올리기

// 잘못된 설정
app.use(session({ secret: 'keyboard cat', resave: true, saveUninitialized: true }));

express-session 문서는 기본 MemoryStore가 운영용이 아니라고 명시한다. 메모리가 새고 단일 프로세스를 넘어 확장되지 않는다. 쿠키 이름도 기본값 connect.sid로 남는다.

// 고친 설정
app.use(session({
  store: sessionStore,                 // Redis 등 호환 저장소
  name: 'sid',
  secret: process.env.SESSION_SECRETS.split(','), // 첫 값으로 서명, 나머지는 검증용
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true, httpOnly: true, sameSite: 'lax',
    maxAge: 2 * 60 * 60 * 1000,
  },
}));

secure: true인데 프록시 뒤에서 trust proxy가 없으면 쿠키가 설정되지 않아 로그인이 풀린다. 이때 secure를 끄지 말고 실수 1을 고친다. 로그인에 성공하면 req.session.regenerate()로 세션 ID도 교체한다.

배포 전 점검 체크리스트

  • 스테이징에서 위조한 X-Forwarded-For가 req.ip에 반영되지 않는다
  • 앱 포트가 프록시 외 경로로 열려 있지 않다
  • 로그인, 비밀번호 재설정에 별도 리밋이 있고 저장소가 공유된다
  • 모든 보호 라우터의 마운트 줄에 인증과 권한 미들웨어가 있다
  • 에러 핸들러가 라우트 뒤 맨 마지막에 있고 인자가 4개다
  • 큰 본문이 필요한 경로만 별도 limit을 쓰고, limit 값을 검증한다
  • 운영 프로세스에 NODE_ENV=production이 실제로 적용돼 있다
  • 없는 경로와 깨진 JSON 요청의 응답에 스택과 내부 경로가 없다
  • CORS 허용 목록에 불필요한 출처가 없고 origin: true가 없다
  • 세션 쿠키에 Secure, HttpOnly, SameSite가 붙어 내려온다
  • npm audit을 CI에서 돌리고 지원되는 Express 버전을 쓴다

자주 묻는 질문

helmet만 넣으면 Express 보안은 충분한가요?

아니다. helmet은 응답 헤더를 설정할 뿐 인증, 입력 검증, IP 판별, 에러 노출 문제는 다루지 않는다. 유용한 기본값이지만 이 글의 일곱 가지와는 별개다.

trust proxy 값은 몇으로 해야 하나요?

사용자와 앱 사이의 프록시 수다. 1에서 시작해 위의 방식으로 검증하고, 인프라 구성이 바뀌면 다시 확인한다. CDN을 추가하면 홉 수가 달라진다.

Express 4를 계속 써도 되나요?

Express 2.x와 3.x는 유지보수가 끝났으니 올려야 한다. 4.x를 유지한다면 body-parser가 1.20.6 이상인지 확인하고 async 에러를 직접 넘기는지 점검한다. 5로 옮길 때는 req.body 기본값과 extended 기본값 변화를 먼저 살핀다.

정리

Express 보안은 패키지 목록보다 설정이 실제 배포 구조와 맞는지에 달려 있다. trust proxy와 리밋, 세션은 프록시 구성에 맞춰 함께 검토하고, 순서는 인증과 에러 처리가 빠지지 않는 형태로 고정한다. 본문 제한과 에러 응답은 넓은 기본값 대신 라우트 단위로 좁힌다. 직접 점검이 어렵다면 무료 분석 신청으로 현재 설정을 확인받아 볼 수 있다.

참고 자료

함께 읽으면 좋은 글