보안 솔루션으로 Zscaler를 사용하면, Node.JS 환경에서 HTTPS 요청 시 unable to get local issuer certificate 오류가 발생하는 경우가 있습니다. 보안은 항상 중요하지만, 이 문제가 작업 시 한번씩 튀어나와 은근한 스트레스가 되고 있었습니다. (그 외에도 http 1.1 로 강제 된다던가...)
There was an issue establishing a connection while requesting
(node:73839) UnhandledPromiseRejectionWarning: Error: unable to get local issuer certificate
at TLSSocket.onConnectSecure (_tls_wrap.js:1058:34)
at TLSSocket.emit (events.js:198:13)
at TLSSocket.EventEmitter.emit (domain.js:448:20)
at TLSSocket._finishInit (_tls_wrap.js:636:8)
문제의 원인
Node.js는 시스템의 CA(인증 기관) 를 사용하지 않고 내부적으로 CA 목록을 node_root_certs.h에 하드코딩하여 사용합니다. 하지만 Zscaler의 인증서는 기본적으로 여기에 포함되어 있지 않기 때문에, HTTPS 요청을 보낼 때 신뢰할 수 없는 인증서로 간주되어 오류가 발생합니다.
근런데 Zscaler의 인증서는 기업마다 다르게 발급되기 때문에, Node.js 공식 CA 목록에 포함되기 어렵습니다. 하지만, 그렇다고 해서 계속 이대로 지낼 수 는 없었습니다.
우린 답을 찾을 것이다. 늘 그랬듯이
Graceful 한 해결방법을 찾아간 과정
일반적으로 다음과 같은 방법으로 문제를 우회할 수 있었습니다.
NODE_TLS_REJECT_UNAUTHORIZED 비활성화 (비추천)
export NODE_TLS_REJECT_UNAUTHORIZED=0
이 방법은 가장 간편한 방법이지만 보안적으로 취약하며, MITM(중간자 공격) 위험이 있습니다. 또한, 일부 경우에 적용되지 않는 경우가 있습니다. 대표적으로 next/font 를 사용하는 경우 build 과정에서 서체 파일을 다운로드 하게 되는데 이 때 위 옵션을 사용 한 경우에도 아래와 같은 오류가 발생합니다.
[Error [FetchError]: request to https://~, reason: unable to get local issuer certificate] {
type: 'system',
errno: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
code: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
constructor: [Function: FetchError]
}
무엇보다 절대 운영 환경에서 사용하면 안된다고 설명 했음에도 해당 옵션을 운영 환경에서 사용하려는 작업자를 적발하였습니다. 다행히 코드 리뷰 과정에서 발견하였지만, 이러한 처리가 코드에 포함되지 않을 수 있는 다른 방법을 고민하게 되었습니다.
ps: zero trust 의 항상 신뢰할 수 없는 것으로 간주 에 대해 다시 한번 생각해 보게 되었습니다. (팀원도 믿으면 안된다)
NODE_EXTRA_CA_CERTS
export NODE_EXTRA_CA_CERTS=/path/to/zscaler-cert.pem
어느정도 요구사항을 만족 하였지만, 다른 불편함에 봉착 하였습니다.
- 1개의 파일만 지정할 수 있다. 여러 인증서를 사용 하려면 인증서를 합치는 작업이 필요하다.
- 인증서를 실수로 삭제하는 경우가 있다.
- 개발 환경 셋팅한지 오래된 사람의 경우 인증서를 추가 했다는 사실을 잊어서 신규 입사자 온보딩때 헤매는 경우가 있다.
- 일부 상황에서 여전히 발생한다.
특히, Next.js 에서 개발하는 경우에 오류 상황이 자주 재현 되었습니다.
이유는 Next.js 는 system 환경 변수 에 NODE_EXTRA_CA_CERTS 가 지정되어 있더라도 런타임에 이를 사용하지 않아 undefined 가 되며 --experimental-https 옵션을 사용할 경우 NODE_EXTRA_CA_CERTS 가 ~/Library/Application Support/mkcert/rootCA.pem 로 변경됩니다. 이 때문에 유독 Next.js 에서 인증서 문제가 지속적으로 발생 하였습니다.
NODE_OPTIONS=--use-openssl-ca
NODE_OPTIONS=--use-openssl-ca
위 방법의 경우에는 openssl 에 포함되어 있으나 Node.js CA 목록에 없는 경우에는 유효 하지만 이번과 같이 Zscaler 처럼 사설 인증서의 경우 해당되지 않습니다.
Node.js에 Zscaler 인증서 추가하여 직접 빌드하기
문득, 이런 생각을 했습니다. Node.js 에 Zscaler 인증서가 없는게 문제라면 추가하면 되는게 아닐까?
그래서 Node.js 코드를 수정해 직접 빌드 해보기로 했습니다.
1. Node.js 소스 코드 다운로드
# Node.js 최신 LTS 버전 다운로드
git clone [email protected]:nodejs/node.git
cd node
2. Zscaler 인증서 추가
Node.js는 src/node_root_certs.h 파일에 기본 CA 목록을 포함하고 있습니다. 여기에 회사의 Zscaler 인증서를 추가합니다. 마지막 줄에는 개행문자가 없고 끝에 쉼표가 있는것에 주의 합니다.
"-----BEGIN CERTIFICATE-----\n"
"(여기에 회사 Zscaler 인증서 내용을 추가)\n"
"-----END CERTIFICATE-----",
3. Node.js 빌드
BUILDING.md 를 참고하여 빌드를 진행합니다.
저는 nvm 을 통해 직접 빌드한 버전을 포함하여 여러 버전을 사용할 계획이기 때문에 추후 쉽게 구분하기 위해서 node_version.h 의 NODE_TAG 의 값을 변경하여 postfix 를 -zscaler 로 수정 하였습니다.
./configure
make -j$(nproc)
여기서 make -j$(nproc) 중 nproc 은 현재 시스템에서 사용 가능한 논리 프로세서 또는 CPU 코어의 개수를 출력하는 명령어 입니다.
mac 에서는 nproc 을 포함하여 일부 리눅스 명령어가 기본 제공되지 않기 떄문에 직접 숫자를 지정 하거나 GUN coreutils 를 설치해 gnproc 으로 대체할 수 잇습니다.
brew install coreutils
./configure
make -j$(gnproc)
# 또는
./configure
make -j4
이제 빌드된 Node.js 를 사용하면, Zscaler 인증서 문제 없이 HTTPS 요청을 정상적으로 처리할 수 있습니다.
# system 에 설치된 Node.js 를 교체 설치 하는 경우
sudo make install
그런데 저는 nvm 으로 관리 및 사내 공유를 위해 다음과 같이 진행 하였습니다.
make install DESTDIR=$(pwd)/node-dist
tar -czvf nodejs-v24.0.0-zscaler.tar.gz -C node-dist usr/local
이제 nodejs-v24.0.0-zscaler.tar.gz 파일을 공유하여 여러 사람이 동일 빌드를 사용할 준비가 되었습니다.
설치하는 쪽 에서는 다음과 같이 진행 합니다.
mkdir -p ~/.nvm/versions/node/v24.0.0-zscaler
tar -xzf ./nodejs-v24.0.0-zscaler.tar.gz -C ~/.nvm/versions/node/v24.0.0-zscaler --strip-components=2
nvm ls
이 때 중요한 점은 공식 배포판과 동일하게 ~/.nvm/versions/node/v24.0.0-zscaler 하위에 bin, lib, include, share 가 존재해야 합니다.
이제 nvm 을 통해 커스텀 빌드 버전과 정식 버전을 옮겨다닐 수 있습니다.
그리고 몇 일 전 feat: added support for reading certificates from macOS system store 이 PR 이 merge 되었습니다. 향후에는 이러한 과정 없이 인증서 문제를 해결 할 수 있기를 기대합니다.