Idioma: Português (BR)
IR-08 · security
Pushkar Kumar 13 min de leitura
Unable to get local issuer certificate: curl, git, npm e pip
Por que curl, git, npm e pip mostram unable to get local issuer certificate, qual trust store cada um lê e as correções que adicionam uma CA sem substituí-lo.
- security
- tls
- certificates
- debugging
Nesta página
- O que significa “unable to get local issuer certificate”?
- Por que a mesma URL falha num cliente e funciona em outro?
- Qual trust store cada cliente lê?
- Como confirmar qual é a sua causa?
- Quais são as causas, em ordem?
- Causa 1: um proxy de inspeção TLS está reassinando seu tráfego?
- Causa 2: o servidor está sem o certificado intermediário?
- Causa 3: por que falha só dentro de uma imagem Docker?
- Por que -k, GIT_SSL_NO_VERIFY ou strict-ssl=false não são uma correção?
- O que verificar, em ordem?
É o seu primeiro dia no emprego novo, você vai clonar um repositório e o comando para logo no handshake:
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.htmlEm seguida, git clone, npm install e pip install também falham. O navegador continua abrindo a página normalmente. “Unable to get local issuer certificate” é um único erro do OpenSSL, mas cada cliente faz a verificação contra um trust store (o repositório de certificados confiáveis) diferente, e é por isso que a mesma correção funciona numa ferramenta e não na seguinte.
TL;DR
- O erro é o código 20 do OpenSSL: a cadeia termina num certificado cujo emissor não está no trust store deste cliente.
- Rode
openssl s_client -showcertscontra o host e leia as linhasi:. Um único certificado: falta o intermediário. Um emissor corporativo ou de fornecedor: um proxy de inspeção TLS.- curl, git, Node/npm e pip leem cada um um trust store diferente, então instalar uma CA num lugar raramente resolve os quatro.
--cacert,http.sslCAInfoe ocafiledo npm substituem o trust store.NODE_EXTRA_CA_CERTSacrescenta.
O que significa “unable to get local issuer certificate”?
O servidor envia um certificado folha (leaf) e, se estiver bem configurado, os intermediários. O seu cliente monta a cadeia de baixo para cima a partir da folha e precisa que o topo seja uma raiz que ele já tenha localmente. O OpenSSL gera o erro 20, X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY, quando o certificado do topo não é autoassinado e não existe nenhum emissor para ele no trust store local.
Isso cobre duas situações diferentes. Se o servidor enviou só a folha, a cadeia para na profundidade 0 porque falta o intermediário. Se a folha e o intermediário chegaram, mas a raiz não está no seu trust store, a cadeia para na profundidade 1 ou 2. Esse é o caso da CA privada e do proxy.
Vale reconhecer os vizinhos dele no x509_txt.c do OpenSSL:
18 self-signed certificate
19 self-signed certificate in certificate chain
20 unable to get local issuer certificate
21 unable to verify the first certificateUm proxy que também envia a própria raiz produz o 19 em vez do 20. O mecanismo por trás de todos eles é a verificação da cadeia explicada em x509: certificate signed by unknown authority, que é como o Go descreve a mesma falha.
Por que a mesma URL falha num cliente e funciona em outro?
curl, git e Python no Linux ou no macOS param no primeiro erro de verificação, então um servidor que envia só a folha dá a eles o código 20. O openssl s_client segue em frente: contra incomplete-chain.badssl.com ele imprime num=20, depois num=21, e termina com Verify return code: 21 (unable to verify the first certificate).
O Node, por sua vez, informa o último erro. Por isso, um servidor que envia só a folha aparece no Node 24.16.0 como unable to verify the first certificate (UNABLE_TO_VERIFY_LEAF_SIGNATURE), e o próprio Node sugere tentar --use-system-ca se a CA raiz estiver instalada localmente. Quando o npm ou o Node mostra UNABLE_TO_GET_ISSUER_CERT_LOCALLY, os intermediários chegaram e a raiz está faltando no trust store do Node, que é a assinatura do proxy ou da CA privada.
O Windows é outra história. O curl 8.4.0 que vem com o Git for Windows e o curl.exe do System32 usam Schannel, e os dois retornaram 200 para a mesma cadeia incompleta, porque o Schannel busca os intermediários que faltam e lê o repositório de certificados do Windows. O Python 3.13 do python.org no Windows também passou. O que falha é o próprio git, que o Git for Windows configura com http.sslBackend=openssl, além do WSL e dos contêineres.
| O que você vê | Onde | Causa mais provável |
|---|---|---|
Erro 20, navegador ok, s_client mostra um certificado | curl, git, pip | Servidor sem o intermediário |
| Todo host HTTPS falha, só na rede do escritório ou na VPN | qualquer cliente | Proxy de inspeção TLS |
UNABLE_TO_GET_ISSUER_CERT_LOCALLY | npm, Node | Proxy ou CA privada |
unable to verify the first certificate | npm, Node | Servidor sem o intermediário |
| Funciona com o curl do Windows/Schannel, falha no git, no WSL ou num contêiner | Windows | O Schannel completou a cadeia ou confia no repositório do Windows |
Falha só no docker build ou num contêiner | imagem | Trust store da imagem sem a CA |
Qual trust store cada cliente lê?
A maioria das listas de correções pula esta parte. Não existe um único “trust store do sistema” que atenda todas as ferramentas: cada cliente tem o seu padrão e a sua própria forma de sobrescrevê-lo, com semânticas diferentes.
| Cliente | Trust store padrão | Como sobrescrever, e o que isso faz |
|---|---|---|
| curl (build com OpenSSL) | Arquivo de bundle de CAs definido na compilação | --cacert, CURL_CA_BUNDLE: substituem |
| git, backend openssl | O bundle que vem com o Git ou o bundle do sistema | http.sslCAInfo, GIT_SSL_CAINFO: substituem |
| Node, npm | Lista de CAs da Mozilla fixada no lançamento do Node | NODE_EXTRA_CA_CERTS: acrescenta. cafile do npm: substitui |
| pip 24.2+ no Python 3.10+ | certifi mais o repositório do sistema | --cert, PIP_CERT: adicionam um bundle |
| requests | certifi | REQUESTS_CA_BUNDLE: substitui |
| curl.exe, git com schannel | Repositório de certificados do Windows | Gerenciado pelo Windows ou por Group Policy |
O Node não lê o repositório do sistema operacional por padrão (documentação da CLI do Node), então instalar uma CA corporativa no Windows, no macOS ou no Debian não resolve o npm. --use-system-ca (v23.8.0 e v22.15.0, no Linux a partir da v23.9.0) e NODE_USE_SYSTEM_CA=1 (v24.6.0 e v22.19.0) mudam isso. No Python, o requests passa o caminho do certifi explicitamente, então o SSL_CERT_FILE não chega até ele.
O pip é diferente: com o truststore, o --cert adiciona. O pip 26.0.1 no Python 3.13 continuou alcançando o PyPI com --cert apontando para uma única raiz sem relação; com --use-deprecated=legacy-certs, o mesmo comando falhou com este erro.
Atenção: uma opção do tipo “substitui” apontada para um arquivo que contém só a sua CA corporativa resolve o host que passa pelo proxy e quebra todos os públicos. Com
GIT_SSL_CAINFOapontando para um arquivo com uma única raiz,git ls-remote https://github.com/git/git.gitfalha com esse mesmo erro. Aponte as opções que substituem para um bundle completo que também contenha a sua CA.
Como confirmar qual é a sua causa?
Pergunte ao servidor o que ele envia, usando o host que o seu cliente chamou. Mantenha o -servername para que o SNI escolha o certificado certo:
openssl s_client -connect registry.npmjs.org:443 -servername registry.npmjs.org -showcerts </dev/nullLeia os pares numerados s: (subject) e i: (issuer):
- Um certificado, emissor público,
depth=0na linha de erro: falta o intermediário no servidor. - O emissor do topo é a sua empresa ou um fornecedor de segurança como a Zscaler: um proxy de inspeção TLS, mesmo quando só a folha chegou.
Para ler a cadeia sem forçar a vista no PEM, cole a transcrição inteira no Certificate Decoder; ele ignora o texto em volta dos certificados.
Numa captura de incomplete-chain.badssl.com só com a folha, ele gera um erro missing intermediate e diz o que está faltando: The chain is missing the intermediate that issued *.badssl.com: "C=US, O=Let's Encrypt, CN=YR2". A mensagem diz que os runtimes falham com “unable to get local issuer certificate”. O Node é a exceção descrita na seção acima.
O limite, sendo honesto: o decoder não enxerga o trust store do seu cliente. Se o proxy envia o intermediário, a cadeia é internamente válida e o resultado é chain order OK · 1 signature verified, sem a raiz. Um proxy que envia só a folha recebe missing intermediate, mas um emissor corporativo continua significando proxy, não um bug no servidor. Em qualquer caso, leia o nome do emissor.
Quais são as causas, em ordem?
Três causas, começando pela mais comum em redes corporativas. Cada uma tem um sinal, uma correção e uma verificação.
Causa 1: um proxy de inspeção TLS está reassinando seu tráfego?
Em redes corporativas, esse é o culpado de sempre, e o primeiro palpite da equipe da CLI do npm em npm/cli#7326: “This is usually because of a proxy you are in that is not providing valid ssl certificates.” O proxy encerra o TLS e reassina os sites inspecionados com a própria CA. O seu navegador confia nessa CA por uma política da TI; as suas ferramentas de linha de comando, não.
Sinal: todo host público falha, o emissor do topo no s_client é uma CA corporativa ou de fornecedor, e o npm mostra UNABLE_TO_GET_ISSUER_CERT_LOCALLY:
npm error code UNABLE_TO_GET_ISSUER_CERT_LOCALLY
npm error errno UNABLE_TO_GET_ISSUER_CERT_LOCALLY
npm error request to https://registry.npmjs.org/serve failed, reason: unable to get local issuer certificateO pip embrulha o mesmo texto do OpenSSL; o número da linha do _ssl.c varia conforme o build do Python:
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1028)Correção: peça à TI o certificado raiz do proxy (em PEM), instale-o uma vez e depois dê a cada cliente uma opção que preserve as raízes públicas:
# Debian/Ubuntu/WSL: add the root to the OS bundle (.crt extension required)
sudo cp corp-root.pem /usr/local/share/ca-certificates/corp-root.crt
sudo update-ca-certificates
# curl and git: point at the full bundle, which now includes the corporate root
curl --cacert /etc/ssl/certs/ca-certificates.crt https://registry.npmjs.org/
git config --global http.sslCAInfo /etc/ssl/certs/ca-certificates.crt
# Node and npm: append to Node's own list instead of replacing it
export NODE_EXTRA_CA_CERTS="$HOME/corp-root.pem"
# pip and requests
export PIP_CERT=/etc/ssl/certs/ca-certificates.crt
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crtNo Git for Windows, git config --global http.sslBackend schannel faz o git usar o repositório do Windows, que muitas vezes a TI já preencheu. O pip 24.2 ou mais recente no Python 3.10+ também lê o repositório do sistema junto com o certifi (documentação do pip).
Importante: o
NODE_EXTRA_CA_CERTSsó é lido quando o processo inicia, e o Node o ignora quando uma opçãocaexplícita está definida. Ocafiledo npm vira essa opçãoca, então definir os dois descarta os certificados extras sem avisar. Prefira oNODE_EXTRA_CA_CERTS.
Verificação: openssl s_client -connect registry.npmjs.org:443 -servername registry.npmjs.org -CAfile /etc/ssl/certs/ca-certificates.crt </dev/null deve terminar com Verify return code: 0 (ok). Depois, rode de novo o comando original. O Python 3.13 ativa o VERIFY_X509_STRICT, então uma CA de proxy antiga e feita em casa ainda pode falhar ali com outro erro depois de passar a ser confiável.
Causa 2: o servidor está sem o certificado intermediário?
Sinal: o s_client mostra um único certificado, o erro está em depth=0, o navegador abre a página e o Node diz unable to verify the first certificate. O git mostra assim:
fatal: unable to access 'https://incomplete-chain.badssl.com/x.git/': SSL certificate problem: unable to get local issuer certificateCorreção: no servidor. O arquivo de certificado precisa conter a folha seguida de todos os intermediários, o que no Let’s Encrypt significa fullchain.pem, não cert.pem.
A primeira causa do post sobre x509 traz as linhas do nginx. Se o servidor não é seu, envie a saída do s_client para quem cuida dele. Adicionar o intermediário ao seu próprio bundle só esconde um bug que todos os outros clientes OpenSSL vão encontrar.
Verificação: rode o s_client de novo; você deve ver pelo menos dois certificados e Verify return code: 0 (ok).
Dica: para um servidor Git interno assinado por uma CA privada, restrinja a opção a esse host:
git config --global http.https://git.corp.example/.sslCAInfo ~/corp-ca-bundle.pem. Os remotes públicos continuam usando o bundle padrão, então um arquivo com uma única raiz é seguro ali.
Causa 3: por que falha só dentro de uma imagem Docker?
Um contêiner carrega o próprio trust store, e a CA corporativa do host não vai junto para dentro dele. Dentro do docker build, o npm e o pip falham contra o mesmo proxy em que o seu notebook já confia. Se a imagem não tem bundle nenhum, comece pela seção sobre contêineres do post de x509.
Sinal: o comando funciona no host e falha num passo RUN ou num contêiner em execução. Como descobrir qual RUN falhou está explicado em docker build “failed to solve”.
Correção: adicione a raiz com nome .crt (o update-ca-certificates ignora .pem sem avisar, segundo a man page do Debian) e depois diga ao Node e ao requests onde está o bundle regenerado:
COPY corp-root.pem /usr/local/share/ca-certificates/corp-root.crt
RUN update-ca-certificates
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crtO update-ca-certificates sozinho não ajuda o Node, que mantém a lista compilada nele. Em imagens RHEL ou UBI, copie a raiz para /etc/pki/ca-trust/source/anchors/ e rode update-ca-trust extract.
Verificação: docker run --rm <image> ls /etc/ssl/certs/ca-certificates.crt e, em seguida, rode de novo o passo que falhou.
Por que -k, GIT_SSL_NO_VERIFY ou strict-ssl=false não são uma correção?
Todo cliente tem um botão de desligar: curl -k, GIT_SSL_NO_VERIFY=true, npm config set strict-ssl false, NODE_TLS_REJECT_UNAUTHORIZED=0 e o --trusted-host do pip. Eles fazem o erro sumir removendo a verificação que o gerou, para todos os hosts que o comando acessa.
Atrás de um proxy de inspeção TLS, essa verificação é a única coisa que diferencia o seu proxy de qualquer outro que esteja na mesma posição. Com ela desligada, uma instalação aceita qualquer certificado que receber. A orientação do próprio curl sobre o --insecure é nunca pular a verificação em produção.
A correção de verdade é um arquivo de CA e uma variável. Trate um strict-ssl=false num .npmrc compartilhado ou num template de CI como um achado de auditoria, não como uma configuração a copiar.
O que verificar, em ordem?
- Rode
openssl s_client -showcertscontra o host exato e conte os certificados. - Um certificado e
depth=0: falta o intermediário no servidor. Corrija lá. - O emissor do topo é uma CA corporativa ou de fornecedor: peça essa raiz em PEM à TI.
- Instale-a no repositório do sistema, com nome
.crtem sistemas da família Debian. - Dê a cada cliente um bundle completo ou uma opção que acrescente:
http.sslCAInfo,NODE_EXTRA_CA_CERTS,PIP_CERT,REQUESTS_CA_BUNDLE. - Nunca aponte uma opção que substitui para um arquivo que contém só a raiz corporativa.
- Em imagens, repita os passos 4 e 5 no Dockerfile.
- Confirme
Verify return code: 0 (ok)e depois remova qualquer-koustrict-ssl=falseque tenha ficado para trás.
Da próxima vez que um handshake falhar, cole a saída do s_client no Certificate Decoder e leia o emissor antes de mexer em qualquer configuração.
Qual cliente da sua stack foi o último a ficar sabendo da CA corporativa, e quanto tempo levou para alguém perceber?
Posts relacionados
-
8 min de leitura
Comando chown no Linux: mudar dono e grupo de arquivos
O comando chown no Linux explicado: sintaxe user:group, chown -R e links simbólicos, --reference, chgrp, chmod vs chown e como corrigir permissões de volumes Docker com IDs numéricos.
-
11 min de leitura
Comando chmod no Linux: sintaxe, exemplos e erros comuns
O comando chmod no Linux explicado: modos octal e simbólico, chmod +x, chmod -R e suas armadilhas, setuid, setgid e sticky bit, umask e como resolver Permission denied.
-
11 min de leitura
Kubernetes Service has no endpoints: ache o bug do selector
Service do Kubernetes sem endpoints, ou Deployment com selector does not match template labels? Um bug, dois sintomas e como levar os dois até a correção.