Un micro serveur mandataire en python qui gère les serveurs mandataires parents en authentification Digest🔗

Posted by Médéric Ribreux 🗓 In blog/ Sysadmin/

Introduction

Ok, je le reconnais, le titre de cet article est pourri ! Pourtant, il décrit précisément le problème que j'ai résolu il y a peu.

Je ne sais pas pour vous mais, à mon boulot, pour accéder à Internet, on utilise un serveur mandataire. C'est en général le cas dans les endroits où on a besoin de partager un accès Internet à plusieurs. Le serveur mandataire fait office de cache et il permet également de filtrer les accès et surtout de savoir qui fait quoi lorsqu'on le couple à une authentification. C'est ce schéma qui est appliqué là où je travaille. Il faut donc montrer patte blanche pour accéder à Internet et indiquer le bon login/mot de passe.

L'avantage premier c'est qu'on bénéficie d'une certaine sécurité car tout le flux Internet de la boîte passe par des choses connues et administrées. L'inconvénient majeur est lié à toute la flopée d'outils qui ont besoin de se connecter à Internet directement. En règle générale, l'implémentation de la gestion des serveurs mandataires est assez bien gérée dans les navigateurs web. Mais il en est tout autrement pour des choses aussi simples que curl, wget ou encore ne serait-ce que apt-get.

La source du problème est généralement liée au fait qu'il existe toujours un embryon de gestion des proxys dans ces outils mais que cette implémentation ne va pas assez loin. En effet, il existe plusieurs méthodes pour authentifier un utilisateur sur un serveur mandataire. Étant donné que nous sommes dans un domaine de travail qui est proche d'HTTP, les méthodes d'authentification des serveurs mandataires sont les mêmes que celles des serveurs HTTP. On trouve ainsi:

Dans la méthode Digest, le login/mot de passe ne circule pas en clair mais est chiffré en fonction de ce que le serveur demande. Cette méthode est plus complexe car, en plus de toute l'algorithmique liée au chiffrement, il faut gérer deux requêtes HTTP vers le serveur. La première sert d'introduction, la seconde permet de répondre au défi du serveur (renvoyé à l'aide d'un code HTTP 407). Cette complexité fait que, bien souvent, les développeurs se contentent de gérer l'authentification en mode Basic (quand la gestion de l'authentification existe !).

Une conséquence majeure c'est qu'apt-get ne fonctionne plus, car il ne gère que l'authentification en mode Basic. C'est un vrai problème lorsqu'on se retrouve derrière un tel serveur mandataire pour faire des mises à jour Debian. Un autre problème que je rencontre, c'est dans l'utilisation de pip (Python): impossible de télécharger le moindre paquet avec cet outil. Il fallait donc que je trouve une vraie solution pour que l'authentification Digest ne soit plus un problème pour ces outils.

Comment régler notre problème ?

Pour régler mon problème, je me suis dit que je devais simplement ajouter un intermédiaire entre le serveur mandataire et les outils. Cet intermédiaire est finalement un serveur mandataire lui aussi. En effet, son rôle consiste à récupérer des requêtes HTTP des clients, de les exposer vers le serveur mandataire Digest, récupérer sa réponse et la rebalancer au client. Cette description est justement celle d'un serveur mandataire minimaliste qui joue le rôle de "rustine" entre le client et le vrai serveur mandataire. En voici un petit schéma en Ascii (réalisé avec AsciiFlow):

.---------.
| Clients |                  .-----------.                  .-------------.
|---------| (5) Réponse HTTP | "Rustine" | (4) Réponse HTTP | Mandataire  |
| apt-get |<-----------------|           |<-----------------| Auth Digest |
| wget    |                  |           |                  |             |
| …       |----------------->|           |----------------->|             |
|         | (1) Accès direct '-----------' (2) Auth Digest  '-------------'
'---------'                                                        |
                                                  (3) Requête HTTP |
                                                                   |
                                                                   v
                                                               .-,(  ),-.    
                                                            .-(          )-. 
                                                           (    internet    )
                                                            '-(          ).-'
                                                               '-.( ).-'    

Avant de me mettre à coder, je me suis dit qu'un tel logiciel devait déjà exister et qu'il serait plus efficace de le trouver plutôt que d'essayer de développer quelque-chose. J'ai donc consulté Internet pour trouver des serveurs mandataires légers et faciles à configurer. J'ai fini par trouver des choses bien foutues comme Polipo ou Privoxy qui s'approchent assez de ce que je souhaite mettre en place. Mais après avoir parcouru leur documentation, je me suis rendu compte qu'aucun ne prenait en compte l'authentification Digest pour les serveurs mandataires parents. Comme d'habitude, le mode Digest est souvent peu implémenté.

Je savais pourtant que cURL gérait très bien ce type de requêtes puisque j'étais capable de récupérer des fichiers directement grâce à quelques éléments de configuration. Sachant que j'ai des notions de Python et que mes souvenirs m'indiquaient qu'il y avait une implémentation de serveur HTTP dans la bibliothèque standard de Python, je me suis mis à essayer de lier les deux.

J'ai donc réalisé un bout de code qui utilise l'implémentation Python de cURL (pycurl) pour faire la requête vers le serveur mandataire qui demande une authentification Digest. L'autre bout de code s'occupe de balancer le résultat de pycurl vers le client initial. Bien sûr, vu le temps imparti (3h), l'implémentation est très crade avec plein de trucs en dur, mais elle a le mérite de fonctionner.

Le code

Voici le code Python (version 2) qui permet de faire ça. Vous aurez besoin du paquet python-pycurl pour qu'il fonctionne. Je me suis fait avoir avec la gestion du code 407: pas mal d'applications qui reçoivent un code 407 s'arrêtent directement.

#!/usr/bin/python
# -*- coding: utf-8 -*-
# a truly minimal HTTP proxy
# with Digest Auth backend

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.

# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>

import SocketServer
import SimpleHTTPServer
import pycurl
import cStringIO
import re

LISTEN_PORT = 8080
DIGEST_PROXY_URL="http://mon.digest.proxy.example.com"
DIGEST_PROXY_PORT = 8080
USERNAME= "moi"
PASSWORD= "mon_mot_de_passe"

class Proxy(SimpleHTTPServer.SimpleHTTPRequestHandler):
  def do_GET(self):
	  headers=[]
	  # On récupère la requête
	  print "Requête: "+self.command+" "+self.path

	  for f in self.headers:
		  headers.append(str(f)+": "+str(self.headers.getheaders(str(f))[0]))
	  #print headers

	  # Quelques objets dont nous aurons besoin plus tard
	  buf = cStringIO.StringIO()
	  answer = cStringIO.StringIO()

	  # consutrction de la requête CURL
	  c = pycurl.Curl()
	  c.setopt(c.URL, self.path)
	  if len(headers)>0:
		  c.setopt(c.HTTPHEADER, headers)
	  c.setopt(c.HEADER, 1)
	  c.setopt(c.PROXY, DIGEST_PROXY_URL)
	  c.setopt(c.PROXYPORT, DIGEST_PROXY_PORT)
	  c.setopt(c.HTTP_VERSION, c.CURL_HTTP_VERSION_1_1)
	  c.setopt(c.PROXYUSERPWD, USERNAME+':'+PASSWORD)
	  c.setopt(c.PROXYAUTH, pycurl.HTTPAUTH_DIGEST)
	  c.setopt(c.WRITEFUNCTION, buf.write)
	  c.setopt(c.CONNECTTIMEOUT, 30)
	  c.setopt(c.IPRESOLVE, c.IPRESOLVE_V4)
	  c.setopt(c.TIMEOUT, 80)
	  c.perform()

	  # Pour virer les erreurs 407 et ne garder qu'à partir de codes HTTP différents:  
	  #print buf.getvalue()
	  buf.seek(0)
	  send = False
	  for line in buf:
		  if re.match('^HTTP/1.[10] [^4][0-9]{2}', line):
			  send = True
		  if send :
			  answer.write(line)

	  answer.seek(0)
	  #self.copyfile(urllib.urlopen(self.path), self.wfile) 
	  self.copyfile(answer,self.wfile)
	  answer.close()
	  buf.close()
	  c.close()

  def do_POST(self):
	  '''Gestion des requêtes POST'''
	  post_body=False
	  headers=[]
	  print "Requête POST: "+self.path

	  # Gestion des formulaires POST
	  if self.headers.getheaders('content-length'):
		  content_len = int(self.headers.getheaders('content-length')[0])
		  post_body = self.rfile.read(content_len)
	  else:
		  headers.append('content-length: 0')

	  for f in self.headers:
		  headers.append(str(f)+": "+str(self.headers.getheaders(str(f))[0]))
	  print headers

	  # Quelques objets dont nous aurons besoin plus tard
	  buf = cStringIO.StringIO()
	  answer = cStringIO.StringIO()

	  # Construction de la requête
	  c = pycurl.Curl()
	  c.setopt(c.URL, self.path)
	  c.setopt(c.HEADER, 1)
	  c.setopt(c.POST, 1)
	  if len(headers)>0:
		  print "Headers"
		  c.setopt(c.HTTPHEADER, headers)
	  if post_body:
		  print "post_body="+post_body
		  c.setopt(c.POSTFIELDS, post_body)
	  c.setopt(c.PROXY, DIGEST_PROXY_URL)
	  c.setopt(c.PROXYPORT, DIGEST_PROXY_PORT)
	  c.setopt(c.HTTP_VERSION, c.CURL_HTTP_VERSION_1_1)
	  c.setopt(c.PROXYUSERPWD, USERNAME+':'+PASSWORD)
	  c.setopt(c.PROXYAUTH, pycurl.HTTPAUTH_DIGEST)
	  c.setopt(c.WRITEFUNCTION, buf.write)
	  c.setopt(c.CONNECTTIMEOUT, 30)
	  c.setopt(c.IPRESOLVE, c.IPRESOLVE_V4)
	  c.setopt(c.TIMEOUT, 80)
	  c.perform()

	  #print buf.getvalue()
	  buf.seek(0)
	  send = False
	  for line in buf:
		  if re.match('^HTTP/1.[10] [^4][0-9]{2}', line):
			  send = True
		  if send :
			  answer.write(line)

	  answer.seek(0)
	  #self.copyfile(urllib.urlopen(self.path), self.wfile) 
	  self.copyfile(answer,self.wfile)
	  answer.close()
	  buf.close()
	  c.close()

  def do_PUT(self):
	  post_body=False
	  content_len=False
	  headers=[]
	  print "Requête PUT: "+self.path

	  # Gestion des formulaires P
	  if self.headers.getheaders('content-length'):
		  content_len = int(self.headers.getheaders('content-length')[0])
	  #    post_body = self.rfile.read(content_len)
	  #else:
	  #    headers.append('content-length: 0')

	  for f in self.headers:
		  headers.append(str(f)+": "+str(self.headers.getheaders(str(f))[0]))

	  # Quelques objets dont nous aurons besoin plus tard
	  buf = cStringIO.StringIO()
	  answer = cStringIO.StringIO()

	  # Construction de la requête
	  c = pycurl.Curl()
	  c.setopt(c.URL, self.path)
	  c.setopt(c.HEADER, 1)
	  c.setopt(c.UPLOAD, 1)
	  if len(headers)>0:
		  print headers
		  c.setopt(c.HTTPHEADER, headers)
	  c.setopt(c.READFUNCTION, self.rfile.read)
	  if content_len:
		  print "Content-Length:"+str(content_len)
		  c.setopt(c.INFILESIZE, content_len)
	  c.setopt(c.PROXY, DIGEST_PROXY_URL)
	  c.setopt(c.PROXYPORT, DIGEST_PROXY_PORT)
	  c.setopt(c.HTTP_VERSION, c.CURL_HTTP_VERSION_1_1)
	  c.setopt(c.PROXYUSERPWD, USERNAME+':'+PASSWORD)
	  c.setopt(c.PROXYAUTH, pycurl.HTTPAUTH_DIGEST)
	  c.setopt(c.WRITEFUNCTION, buf.write)
	  c.setopt(c.CONNECTTIMEOUT, 30)
	  c.setopt(c.IPRESOLVE, c.IPRESOLVE_V4)
	  c.setopt(c.TIMEOUT, 80)
	  c.perform()

	  print buf.getvalue()
	  buf.seek(0)
	  send = False
	  for line in buf:
		  if re.match('^HTTP/1.[10] [^4][0-9]{2}', line):
			  send = True
		  if send :
			  answer.write(line)

	  answer.seek(0)
	  #self.copyfile(urllib.urlopen(self.path), self.wfile) 
	  self.copyfile(answer,self.wfile)
	  answer.close()
	  buf.close()
	  c.close()

#httpd = SocketServer.ForkingTCPServer(('', PORT), Proxy)
httpd = SocketServer.TCPServer(('', LISTEN_PORT), Proxy)
print "serving at port", LISTEN_PORT
httpd.serve_forever()

Conclusion

Le code ci-dessus fonctionne à peu près correctement. Je suis sûr qu'il serait fortement améliorable. Par exemple, il ne gère pas les connexions TLS ce qui peut se révéler limite parfois. Néanmoins, il me permet de pouvoir légitimement mettre à jour une distribution GNU/Linux ou de faire fonctionner des outils qui ne savent parler qu'à des proxys sans authentification ou avec une authentification basique.

J'avais déjà identifié ce problème il y a près de dix ans. Force est de constater qu'en 2013, l'authentification Digest dans les serveurs mandataires n'a toujours pas pris au niveau de l'implémentation des clients. C'est un problème important, car ce mode d'authentification est le seul qui soit à peu près sécurisé et qui empêche la capture de mots de passe sur le réseau ! Même si je conçois bien que c'est plus complexe techniquement à gérer pour le développeur, ce problème aurait déjà dû être réglé depuis quelques années.

Pour nuancer ce propos, sachez que tout ce qui fonctionne avec la libcurl est susceptible de fonctionner en mode Digest, pour peu que les bons paramètres aient été passés aux fonctions de connexion.