Langue: Français
IR-08 · security
Pushkar Kumar 13 min de lecture
Unable to get local issuer certificate (curl, git, npm, pip)
Pourquoi curl, git, npm et pip renvoient unable to get local issuer certificate, quel magasin lit chacun, et comment ajouter une AC sans le remplacer.
- security
- tls
- certificates
- debugging
Sur cette page
- Que signifie « unable to get local issuer certificate » ?
- Pourquoi la même URL échoue-t-elle dans un client et pas dans un autre ?
- Quel magasin de confiance chaque client lit-il ?
- Comment confirmer la cause ?
- Quelles sont les causes, par ordre de fréquence ?
- Cause 1 : un proxy d’inspection TLS re-signe-t-il votre trafic ?
- Cause 2 : le serveur omet-il son certificat intermédiaire ?
- Cause 3 : pourquoi l’échec ne se produit-il que dans une image Docker ?
- Pourquoi -k, GIT_SSL_NO_VERIFY ou strict-ssl=false ne sont-ils pas un correctif ?
- Que vérifier, et dans quel ordre ?
Premier matin dans un nouveau poste, vous clonez un dépôt, et la commande s’arrête net au moment de la poignée de main TLS :
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.htmlPuis git clone, npm install et pip install échouent à leur tour. Le navigateur, lui, charge toujours la page. « Unable to get local issuer certificate » est une seule et même erreur OpenSSL, mais chaque client la vérifie dans un magasin de confiance différent : voilà pourquoi le correctif qui marche pour un outil échoue sur le suivant.
TL;DR
- L’erreur correspond au code 20 d’OpenSSL : la chaîne s’arrête sur un certificat dont l’émetteur est absent du magasin de confiance de ce client-là.
- Lancez
openssl s_client -showcertscontre l’hôte et lisez les lignesi:. Un seul certificat : il manque un intermédiaire. Un émetteur d’entreprise ou d’éditeur : un proxy d’inspection TLS.- curl, git, Node/npm et pip lisent chacun un magasin différent ; installer une autorité de certification (AC) à un endroit corrige donc rarement les quatre.
--cacert,http.sslCAInfoet lecafilede npm remplacent le magasin.NODE_EXTRA_CA_CERTSy ajoute.
Que signifie « unable to get local issuer certificate » ?
Le serveur envoie un certificat feuille et, s’il est bien configuré, les intermédiaires. Votre client construit une chaîne en remontant depuis la feuille, et le sommet de cette chaîne doit être une racine qu’il détient déjà localement. OpenSSL lève l’erreur 20, X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY, lorsque le certificat du sommet n’est pas auto-signé et qu’aucun émetteur correspondant n’existe dans le magasin local.
Cela recouvre deux situations distinctes. Si le serveur n’a envoyé que la feuille, la chaîne s’arrête à la profondeur 0, faute d’intermédiaire. Si la feuille et l’intermédiaire sont bien arrivés mais que la racine manque à votre magasin, la chaîne s’arrête à la profondeur 1 ou 2. C’est le cas de l’AC privée et du proxy.
Ses voisines dans le fichier x509_txt.c d’OpenSSL méritent qu’on les connaisse :
18 self-signed certificate
19 self-signed certificate in certificate chain
20 unable to get local issuer certificate
21 unable to verify the first certificateUn proxy qui envoie aussi sa propre racine produit l’erreur 19 au lieu de la 20. Le mécanisme commun à toutes ces erreurs est le parcours de chaîne expliqué dans x509: certificate signed by unknown authority, la formulation Go du même échec.
Pourquoi la même URL échoue-t-elle dans un client et pas dans un autre ?
Sous Linux ou macOS, curl, git et Python s’arrêtent à la première erreur de vérification : un serveur qui n’envoie que la feuille leur renvoie donc le code 20. openssl s_client, lui, continue : contre incomplete-chain.badssl.com, il affiche num=20, puis num=21, et se termine par Verify return code: 21 (unable to verify the first certificate).
Node, à l’inverse, remonte la dernière erreur. Un serveur qui n’envoie que la feuille apparaît donc dans Node 24.16.0 sous la forme unable to verify the first certificate (UNABLE_TO_VERIFY_LEAF_SIGNATURE), et Node suggère lui-même d’essayer --use-system-ca si l’AC racine est installée localement. Quand npm ou Node affiche UNABLE_TO_GET_ISSUER_CERT_LOCALLY, les intermédiaires sont arrivés et c’est la racine qui manque au magasin de Node : la signature typique d’un proxy ou d’une AC privée.
Windows se comporte encore autrement. Le curl 8.4.0 embarqué par Git for Windows et le curl.exe de System32 utilisent tous deux Schannel, et tous deux ont renvoyé 200 pour la même chaîne incomplète, car Schannel va chercher les intermédiaires manquants et lit le magasin de Windows. Le Python 3.13 de python.org sous Windows est passé lui aussi. Ce qui échoue, c’est git lui-même, que Git for Windows configure avec http.sslBackend=openssl, ainsi que WSL et les conteneurs.
| Ce que vous voyez | Où | Cause la plus probable |
|---|---|---|
Erreur 20, navigateur OK, s_client n’affiche qu’un certificat | curl, git, pip | Intermédiaire manquant sur le serveur |
| Tous les hôtes HTTPS échouent, seulement sur le réseau du bureau ou le VPN | n’importe quel client | Proxy d’inspection TLS |
UNABLE_TO_GET_ISSUER_CERT_LOCALLY | npm, Node | Proxy ou AC privée |
unable to verify the first certificate | npm, Node | Intermédiaire manquant sur le serveur |
| Fonctionne avec le curl Windows/Schannel, échoue dans git, WSL ou un conteneur | Windows | Schannel a réparé la chaîne ou fait confiance au magasin Windows |
Échoue seulement dans docker build ou un conteneur | image | Le magasin de l’image ne contient pas l’AC |
Quel magasin de confiance chaque client lit-il ?
La plupart des listes de correctifs font l’impasse sur ce point. Il n’existe pas de « magasin de confiance système » unique qui servirait tous les outils : chaque client a son propre magasin par défaut et son propre mécanisme de surcharge, avec une sémantique différente.
| Client | Magasin par défaut | Surcharge, et son effet |
|---|---|---|
| curl (compilé avec OpenSSL) | Fichier bundle d’AC choisi à la compilation | --cacert, CURL_CA_BUNDLE : remplacent |
| git, backend openssl | Le bundle livré avec Git ou celui de l’OS | http.sslCAInfo, GIT_SSL_CAINFO : remplacent |
| Node, npm | Liste d’AC Mozilla figée à la sortie de la version de Node | NODE_EXTRA_CA_CERTS : ajoute. cafile de npm : remplace |
| pip 24.2+ sur Python 3.10+ | certifi plus le magasin de l’OS | --cert, PIP_CERT : ajoutent un bundle |
| requests | certifi | REQUESTS_CA_BUNDLE : remplace |
| curl.exe, git avec schannel | Magasin de certificats Windows | Géré par Windows ou par stratégie de groupe |
Par défaut, Node ne lit pas le magasin de l’OS (documentation CLI de Node) : installer une AC d’entreprise dans Windows, macOS ou Debian ne corrige donc pas npm. --use-system-ca (v23.8.0 et v22.15.0, Linux à partir de v23.9.0) et NODE_USE_SYSTEM_CA=1 (v24.6.0 et v22.19.0) changent la donne. Côté Python, requests passe explicitement le chemin de certifi, si bien que SSL_CERT_FILE ne l’atteint pas.
pip fait exception : sous truststore, --cert ajoute. pip 26.0.1 sur Python 3.13 atteignait encore PyPI avec --cert pointé sur une seule racine sans rapport ; avec --use-deprecated=legacy-certs, la même commande échouait avec cette erreur.
Attention : une surcharge de type « remplacement » pointée sur un fichier qui ne contient que votre AC d’entreprise corrige l’hôte derrière le proxy et casse tous les hôtes publics. Avec
GIT_SSL_CAINFOréglé sur un fichier à racine unique,git ls-remote https://github.com/git/git.gitéchoue avec cette même erreur. Pointez les options de remplacement vers un bundle complet qui contient aussi votre AC.
Comment confirmer la cause ?
Demandez au serveur ce qu’il envoie, en visant l’hôte que votre client a appelé. Gardez -servername pour que le SNI sélectionne le bon certificat :
openssl s_client -connect registry.npmjs.org:443 -servername registry.npmjs.org -showcerts </dev/nullLisez les paires numérotées s: (sujet) et i: (émetteur) :
- Un seul certificat, émetteur public,
depth=0sur la ligne d’erreur : il manque un intermédiaire côté serveur. - L’émetteur du sommet est votre employeur ou un éditeur de sécurité comme Zscaler : un proxy d’inspection TLS, même si seule la feuille est arrivée.
Pour lire la chaîne sans plisser les yeux devant du PEM, collez la transcription complète dans le Certificate Decoder ; il ignore le texte qui entoure les certificats.
Sur une capture de incomplete-chain.badssl.com ne contenant que la feuille, il lève une erreur missing intermediate et nomme ce qui manque : The chain is missing the intermediate that issued *.badssl.com: "C=US, O=Let's Encrypt, CN=YR2". Son message indique que les runtimes échouent avec « unable to get local issuer certificate ». Node est l’exception décrite dans la section précédente.
La limite, en toute honnêteté : le décodeur ne voit pas le magasin de confiance de votre client. Si le proxy envoie son intermédiaire, la chaîne est cohérente en elle-même et le décodeur affiche chain order OK · 1 signature verified, racine non incluse. Un proxy qui n’envoie que la feuille obtient missing intermediate, mais un émetteur d’entreprise désigne toujours un proxy, pas un bug du serveur. Dans tous les cas, lisez le nom de l’émetteur.
Quelles sont les causes, par ordre de fréquence ?
Trois causes, en commençant par la plus fréquente sur les réseaux d’entreprise. Chacune a son indice, son correctif et sa vérification.
Cause 1 : un proxy d’inspection TLS re-signe-t-il votre trafic ?
Sur un réseau d’entreprise, c’est le coupable habituel, et la première hypothèse de l’équipe de la CLI npm dans npm/cli#7326 : « This is usually because of a proxy you are in that is not providing valid ssl certificates. » Le proxy termine la connexion TLS et re-signe les sites inspectés avec sa propre AC. Votre navigateur fait confiance à cette AC via la politique de la DSI ; vos outils en ligne de commande, non.
Indice : tous les hôtes publics échouent, l’émetteur du sommet dans s_client est une AC d’entreprise ou d’éditeur, et npm affiche 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 certificatepip enveloppe le même texte OpenSSL ; le numéro de ligne de _ssl.c varie selon la build de Python :
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1028)Correctif : récupérez auprès de la DSI le certificat racine du proxy (au format PEM), installez-le une fois, puis donnez à chaque client une option qui conserve les racines publiques :
# 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.crtSous Git for Windows, git config --global http.sslBackend schannel fait utiliser à git le magasin Windows, que la DSI a souvent déjà alimenté. pip 24.2 et versions ultérieures sur Python 3.10+ lisent aussi le magasin de l’OS en plus de certifi (documentation de pip).
Important :
NODE_EXTRA_CA_CERTSn’est lu qu’au démarrage du processus, et Node l’ignore lorsqu’une optioncaexplicite est définie. Lecafilede npm devient justement cette optionca: définir les deux fait disparaître les certificats supplémentaires sans le moindre avertissement. PréférezNODE_EXTRA_CA_CERTS.
Vérification : openssl s_client -connect registry.npmjs.org:443 -servername registry.npmjs.org -CAfile /etc/ssl/certs/ca-certificates.crt </dev/null doit se terminer par Verify return code: 0 (ok). Relancez ensuite la commande d’origine. Python 3.13 active VERIFY_X509_STRICT : une vieille AC de proxy faite maison peut donc encore y échouer, avec une erreur différente, une fois approuvée.
Cause 2 : le serveur omet-il son certificat intermédiaire ?
Indice : s_client n’affiche qu’un certificat, l’erreur se situe à depth=0, le navigateur charge la page et Node affiche unable to verify the first certificate. git l’affiche ainsi :
fatal: unable to access 'https://incomplete-chain.badssl.com/x.git/': SSL certificate problem: unable to get local issuer certificateCorrectif : côté serveur. Le fichier de certificat doit contenir la feuille suivie de tous les intermédiaires, ce qui, pour Let’s Encrypt, signifie fullchain.pem et non cert.pem.
La première cause de l’article sur x509 donne les lignes nginx. Si le serveur ne vous appartient pas, envoyez la sortie de s_client à son propriétaire. Ajouter l’intermédiaire à votre propre bundle ne fait que masquer un bug sur lequel tous les autres clients OpenSSL buteront.
Vérification : relancez s_client ; vous devez voir au moins deux certificats et Verify return code: 0 (ok).
Astuce : pour un serveur Git interne signé par une AC privée, limitez l’option à cet hôte :
git config --global http.https://git.corp.example/.sslCAInfo ~/corp-ca-bundle.pem. Les dépôts distants publics continuent d’utiliser le bundle par défaut, donc un fichier à racine unique ne pose pas de problème ici.
Cause 3 : pourquoi l’échec ne se produit-il que dans une image Docker ?
Un conteneur embarque son propre magasin de confiance, et l’AC d’entreprise de l’hôte ne l’y suit pas. Dans docker build, npm et pip échouent face au même proxy auquel votre portable fait déjà confiance. Si l’image n’a aucun bundle, commencez par la section conteneurs de l’article sur x509.
Indice : la commande réussit sur l’hôte et échoue dans une étape RUN ou dans un conteneur en cours d’exécution. Pour identifier l’étape RUN en échec, voir docker build “failed to solve”.
Correctif : ajoutez la racine sous un nom en .crt (update-ca-certificates ignore silencieusement les .pem, d’après la page de manuel Debian), puis indiquez à Node et à requests où se trouve le bundle régénéré :
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.crtupdate-ca-certificates seul n’aide pas Node, qui conserve sa liste compilée. Sur les images RHEL ou UBI, copiez la racine dans /etc/pki/ca-trust/source/anchors/ et lancez update-ca-trust extract.
Vérification : docker run --rm <image> ls /etc/ssl/certs/ca-certificates.crt, puis relancez l’étape en échec.
Pourquoi -k, GIT_SSL_NO_VERIFY ou strict-ssl=false ne sont-ils pas un correctif ?
Chaque client a son interrupteur : curl -k, GIT_SSL_NO_VERIFY=true, npm config set strict-ssl false, NODE_TLS_REJECT_UNAUTHORIZED=0 et le --trusted-host de pip. Ils font disparaître l’erreur en supprimant la vérification qui l’a levée, pour chaque hôte que la commande contacte.
Derrière un proxy d’inspection TLS, cette vérification est la seule chose qui distingue votre proxy de n’importe qui d’autre dans la même position. Une fois désactivée, une installation accepte n’importe quel certificat qu’on lui présente. Au sujet de --insecure, les recommandations de curl sont claires : ne jamais sauter la vérification en production.
Le vrai correctif tient en un fichier d’AC et une variable. Traitez un strict-ssl=false dans un .npmrc partagé ou un modèle de CI comme une anomalie à signaler, pas comme un réglage à recopier.
Que vérifier, et dans quel ordre ?
- Lancez
openssl s_client -showcertscontre l’hôte exact et comptez les certificats. - Un seul certificat et
depth=0: il manque l’intermédiaire côté serveur. Corrigez-le là-bas. - L’émetteur du sommet est une AC d’entreprise ou d’éditeur : demandez cette racine au format PEM à la DSI.
- Installez-la dans le magasin de l’OS, avec un nom en
.crtsur les systèmes de la famille Debian. - Donnez à chaque client un bundle complet ou une option d’ajout :
http.sslCAInfo,NODE_EXTRA_CA_CERTS,PIP_CERT,REQUESTS_CA_BUNDLE. - Ne pointez jamais une option de remplacement vers un fichier qui ne contient que la racine d’entreprise.
- Dans les images, répétez les étapes 4 et 5 dans le Dockerfile.
- Vérifiez
Verify return code: 0 (ok), puis supprimez tout-koustrict-ssl=falseresté en place.
La prochaine fois qu’une poignée de main échoue, collez la sortie de s_client dans le Certificate Decoder et lisez l’émetteur avant de toucher au moindre réglage.
Dans votre stack, quel client a été le dernier à découvrir l’AC d’entreprise, et combien de temps a-t-il fallu pour que quelqu’un s’en aperçoive ?
Articles liés
-
8 min de lecture
Commande chown sous Linux : changer le propriétaire et le groupe
La commande chown sous Linux expliquée : syntaxe user:group, chown -R et liens symboliques, --reference, chgrp, chmod vs chown, et corriger les permissions des volumes Docker avec des ID numériques.
-
11 min de lecture
Commande chmod sous Linux : syntaxe, exemples et erreurs courantes
La commande chmod sous Linux expliquée : modes octal et symbolique, chmod +x, chmod -R et ses pièges, setuid, setgid et sticky bit, umask, et comment corriger Permission denied.
-
12 min de lecture
Kubernetes Service has no endpoints : le bug du selector
Un Service Kubernetes sans endpoints, ou un Deployment rejeté par selector does not match template labels ? Un bug, deux symptômes, et comment le corriger.