Der eigene kompakte und sichere Web Server mit .NET 2.0 - Teil 1

Veröffentlicht: 05. Jul 2006

Von Dominick Baier

Windows Server 2003 und XP SP2 enthalten eine Komponente, HTTP.SYS, deren primäre Aufgabe es ist, HTTP-Anfragen entgegenzunehmen, und diese an interessierte Anwendungen weiterzuleiten. Produkte wie der IIS 6 und die Web Service Komponente in SQL Server 2005 benutzen diesen Systemdienst für die Abwicklung der gesamten HTTP-Kommunikation. Das .NET 2.0 Framework enthält einen managed Wrapper für den HTTP.SYS API, der es erlaubt, auf einfache Art und Weise seinen eigenen HTTP-basierten Server zu implementieren.

Auf dieser Seite

 Einsatzgebiet
 Architektur und Setup
 Requests verarbeiten
 Sandboxing
 Authentifizierung
 Impersonierung
 SSL und Client Zertifikate
 ACLs auf URIs !?
 Ausblick
 Zusammenfassung
 Der Autor

Einsatzgebiet

Es gibt mehrere Szenarien, in denen ein eigener kompakter Web Server sinnvoll ist:

  • Eingebetteter Web Server: Sie möchten eine (Server-)Anwendung über ein Web Interface konfigurierbar oder überwachbar machen. Der Client benötigt lediglich einen Browser um dieses Interface zu benutzen.

  • Unit Testing: Um eine Web Anwendung zu testen, könnte ein kompakter Web Server für automatisierte Unit/Integrations-Tests zum Einsatz kommen.

  • Immer dann, wenn Sie HTTP-Anfragen in Ihrer Software verarbeiten müssen

Für all diese Szenarien will man unabhängig von IIS sein.

Für die Implementierung eines solchen Servers gibt es prinzipiell zwei Möglichkeiten. Zum einen kann man direkt auf Sockets aufsetzend einen kompletten Web Server neu programmieren. Dies hat zur Folge, dass man eine ganze Menge „Infrastruktur“-Code schreiben muss und das Parsing mitsamt allen Eigenheiten des HTTP-Protokolls selbst implementiert (und dies ist nicht trivial).

Die Alternative wäre das Benutzen der neuen HttpListener Klasse, die den HTTP.SYS API kapselt und es somit erlaubt die gleiche HTTP-Parsing-Funktionalität, wie sie z.B. im IIS zum Einsatz kommt, in seine eigenen Anwendungen zu integrieren. Zusätzlich wird die HTTP-Kommunikation in einem einfachen Objekt-Modell abstrahiert und man erhält zusätzlich, frei Haus sozusagen, Unterstützung für die gängigen Authentifizierungs-Protokolle (Basic, Digest, Integrated) sowie für SSL mitsamt Client Zertifikaten. Dies spart einem mehrere tausend Zeilen Code.

In beiden Fällen lässt sich ohne allzu großen Aufwand ASP.NET integrieren, was zusätzliche mächtige Funktionalitäten möglich macht.

Das nachfolgende Beispiel zeigt, wie man den HTTP.SYS API verwenden kann, um folgende Features zu realisieren:

  • Web Server für dynamisch erzeugte sowie statische HTML-Seiten

  • Basic, Digest und Integrated Authentifizierung über HTTP

  • Unterstützung für Windows Accounts sowie Membership und Role Provider für Basic Authentifizierung

  • Automatische Client Impersonierung für Requests

  • SSL und Client Zertifikate

  • Request Sandboxing mit CAS

Abgesehen davon, dass ein solcher Web Server für Sie nützlich sein könnte, zeigt die Implementierung auch schön, wie man Sicherheit mit .NET-Mitteln in Diensten implementieren kann. Es geht natürlich wie immer um Authentifizierung, Autorisierung und geschützte Datenübertragung.

 

Architektur und Setup

HTTP.SYS ist ein Kernel-Mode Treiber, der auf Nachfrage auf bestimmten Ports und URIs nach HTTP-Verkehr „lauscht“, sowie diesen parsed und validiert. Anwendungen, die an diesem Dienst interessiert sind, registrieren einen sog. URI-Namensraum an HTTP.SYS, z.B. http://*:80/MeineAnwendung für den /MeineAnwendung URL auf Port 80 aller lokalen Netzwerk-Schnittstellen (Schritt 1 in Abbildung 1). Alle HTTP-Anfragen für diesen URL (und seinen Unterverzeichnissen) werden danach von HTTP.SYS an die interessierte Anwendung weitergeleitet (Schritt 2). Die HTTP-Antworten werden dann von dieser Anwendung, wiederum über den Kernel Treiber, an den Client zurückgesendet (Schritt 3).

Dabei ist zu bemerken, dass mehrere Anwendungen URLs für den gleichen Port registrieren können (solange die URLs eindeutig sind). Dadurch, dass keine dieser Anwendung direkt den Port öffnet, wird dieser nicht monopolisiert und kann zwischen mehreren Anwendungen geteilt werden. Dies macht es z.B. möglich, IIS und SQL Server auf der gleichen Maschine zu nutzen und erlaubt es auch Ihren Anwendungen, mit Port 80 zu arbeiten, selbst wenn IIS installiert ist.

HTTP.SYS Architektur
Abbildung 1: HTTP.SYS Architektur

Die HttpListener-Klasse erlaubt es über die Prefixes Collection beliebig viele URIs zu registrieren. Danach wird über Start Methode HTTP.SYS signalisiert, dass die Anwendung jetzt die Anfragen entgegennehmen möchte. Die GetContext Methode von HttpListener ist ein blockierender Aufruf und wartet auf eingehende Anfragen. Eine HTTP-Kommunikation wird in der HttpListenerContext-Klasse abstrahiert und bietet für ASP.NET Programmierer vertraute Eigenschaften wir Request, Response und User. Diese Unterklassen erlauben den Zugriff auf den ein- und ausgehenden Datenstrom sowie auf die Client Identität wenn Authentifizierung aktiviert ist. Folgender Code zeigt die Registrierung sowie die typische Vorgehensweise, wie Sie mit Hilfe des Async Patterns einen nicht-blockierenden Listener implementieren können.

private static HttpListener _listener;

static void Main(string[] args)
{
    // HTTP listener initialisieren
    _listener = new HttpListener();
    _listener.Prefixes.Add(String.Format("http://*:{0}{1}", 
      80, "/MeineAnwendung/"));

    // listener starten
    _listener.Start();

    // auf eingehende requests warten
    // BeginGetContext benutzt dafür ein ThreadPool thread
    _listener.BeginGetContext(new 
      AsyncCallback(ContextReceivedCallback), null);

    // server beenden
    Console.ReadLine();
}

private static void ContextReceivedCallback(IAsyncResult asyncResult)
{
    HttpListenerContext context;
    
    // HttpListenerContext abholen
    context = _listener.EndGetContext(asyncResult);
    
    // neuen thread für eingehende requests starten
    _listener.BeginGetContext(new 
      AsyncCallback(ContextReceivedCallback), null);

    Console.WriteLine("Request für: {0}", 
      context.Request.Url.LocalPath);

    // request verarbeiten
}

 

Requests verarbeiten

Die HttpListenerContext-Klasse verpackt alle Details des HTTP-Protokolls in ein Objekt-Modell. Über den Kontext kann man u.a. auf folgende Eigenschaften des Requests zugreifen:

Eigenschaft

Bedeutung

ContentType

MIME Typ des Requests

HttpMethod

Benutztes HTTP Verb, z.B. GET oder POST

Cookies

Gesendete Cookies

Headers

Gesendete HTTP Header

InputStream

Eingabe Strom für den Entity Body

QueryString

Query String Parameter

UserHostAddress

IP Adresse des Clients

UserAgent

User Agent Zeichenkette

UrlReferrer

Referrer URL

Ihre Aufgabe ist es nun, aufgrund dieser Informationen einen Ausgabe-Datenstrom zu erzeugen und diesen an den Client zurückzusenden. Dazu wird das Response Objekt verwendet. Auch hier finden Sie Eigenschaften und Methoden, um die HTTP-Antwort zu modifizieren, z.B. für Cookies, Header und natürlich für das Antwort-Dokument. Dazu übergibt man einfach dem OutputStream Objekt der Response Klasse ein Byte Array und schließt die Verbindung, wenn man damit fertig ist. Dynamisch generierte Antworten müssen dazu mit der Encoding-Klasse in Bytes kodiert werden, Dateien kann man direkt als Byte Array von der Platte lesen.

Der nachfolgende Code zeigt, wie man ein HTML-Dokument von der Festplatte an den Browser zurückliefert. Außerdem kennt der Server noch „magische“ URLs, die bestimmte Verarbeitungs-Prozesse anstoßen können, in diesem Fall wird einfach die Server-Uhrzeit zurückgeliefert.

private static void ProcessRequest(HttpListenerContext ctx)
{
  // entfernen des /MeineAnwendung/ URL prefixes
  string resourcename = ctx.Request.Url.LocalPath.Remove(
    0, "/MeineAnwendung/".Length);

  byte[] responseBytes;
  string response;

  // prüfen auf "spezielle" URLs
  if (resourceName == "time.cmd")
  {
    response = "Server Zeit: " + DateTime.Now.ToString();
  }
  
  if (!string.IsNullOrEmpty(response))
  {
    responseBytes = Encoding.UTF8.GetBytes(response);
  }
  else
  {
    // datei von platte lesen
    responseBytes = GetResource(resourcename);
  }

  // antwort an client senden
  if (responseBytes != null)
  {
    ctx.Response.OutputStream.Write(
      responseBytes, 0, responseBytes.Length);
    ctx.Response.Close();
  }
  else
  {
    WriteError(ctx, 404);
    return;
  }
}

Die GetResource Methode liest eine Datei und gibt diese zurück. Damit man nicht Dateien der Server-Anwendung herunterladen kann, wird das Basisverzeichnis verschoben. Wie dies effektiv gegen Traversal-Attacken geschützt werden kann, zeige ich später. Wird keine passende Ressource gefunden, wird eine 404-Fehlermeldung (File not Found) an den Client zurückgegeben. Der Fehlertext befindet sich in diesem Beispiel in einer Resource-Datei.

private static byte[] GetResource(string resourceName)
{
  try
  {
    using (FileStream fs = new FileStream(
      "content/" + resourceName, 
      FileMode.Open, 
      FileAccess.Read, 
      FileShare.Read))
    {
      byte[] buf = new byte[fs.Length];
      fs.Read(buf, 0, (int)fs.Length);
      return buf;
    }
  }
  catch
  {
    return null;
  }
}

// fehlermeldung schreiben und ausgabestrom schliessen
private static void WriteError(
  HttpListenerContext context, int statusCode)
{
  try
  {
    // fehlermeldung aus resource datei lesen
    string description = Resources.ResourceManager.GetString(
      "http" + statusCode.ToString());
    byte[] descriptionBytes = Encoding.UTF8.GetBytes(description);

    context.Response.StatusCode = statusCode;
    context.Response.StatusDescription = description;
    context.Response.OutputStream.Write(
      descriptionBytes, 0, descriptionBytes.Length);
    context.Response.Close();
  }
  catch { } // alle schreibfehler ignorieren
}

 

Sandboxing

Das sicherheitserfahrene Auge erkennt bei dem vorangegangenen Code ein großes Problem. Der Server nimmt Ressourcen-Namen (in diesem Fall für Dateien) entgegen, die er bereitwillig öffnet und an den Client zurücksendet. Hier gilt es sich effektiv gegen sog. Directory Traversal-Attacken schützen, was bedeuten würde, dass ein böswilliger Client durch geschickten Einsatz von „../“ Zeichenketten aus dem Content-Verzeichnis ausbrechen kann und evtl. der Zugriff auf Anwendungs- und Betriebssystem-Daten möglich ist.

Zuerst sei gesagt, dass der HTTP.SYS Parser die offensichtlichsten dieser Angriffs-Variationen aus den URL herausfiltert. Aber um einen wirklich wasserdichten Schutz für diesen Fall einzurichten, kann man sich einem leider oft zu sehr vernachlässigtem Sicherheits-Dienst der CLR bedienen, Code Access Security.

Mit Hilfe einer CAS Permission kann man die ProcessRequest Methode in einer Art und Weise einschränken, dass lediglich der lesende Zugriff auf das Content-Verzeichnis zulässig ist. Alle anderen .NET APIs und Zugriffe auf andere Bereiche der Festplatte lösen sofort eine SecurityException aus. Einfach und Effektiv.

// ein CAS permission set erzeugen
PermissionSet ps = new PermissionSet(PermissionState.None);

// lesender zugriff auf das content verzeichnis erlauben
ps.AddPermission(new FileIOPermission(
  FileIOPermissionAccess.Read, 
  Environment.CurrentDirectory + "\\content"));

// alle nicht enthaltenen permissions verweigern
ps.PermitOnly();

// request verarbeiten
ProcessRequest(context);

 

Authentifizierung

Die grundlegenden Funktionen sind jetzt implementiert. Allerdings kann im Moment jeder auf den Server zugreifen und dessen Information abrufen. Der nächste Schritt ist das Hinzufügen von Authentifizierung.

Das Schöne an HttpListener ist, das alle populären HTTP-Authentifizierungs-Protokolle bereits implementiert sind. Integrierte Authentifizierung (NTLM und Kerberos) und Digest machen automatisch eine Benutzer-Validierung gegen einen Windows Account. Basic Authentifizierung macht dem Code den Benutzernamen sowie das Passwort zugänglich und man könnte z.B. seine eigene Windows Authentifizierung mit LogonUser durchführen, oder einen Membership Provider benutzen.

Die Authentifizierungs-Methode wird mit der AuthenticationSchemes-Eigenschaft der HttpListener-Klasse festgelegt.

_listener.AuthenticationSchemes = AuthenticationSchemes.Negotiate;

Das übliche Design-Muster für Authentifizierung und rollen-basierte Sicherheit in .NET sieht vor, dass Infrastruktur-Code (in diesem Fall unser Server) die Client Identität auf Thread.CurrentPrincipal ablegt. Dadurch wird diese Information dem Request Verarbeitungs-Code zugänglich gemacht, der dann wiederum Sicherheits-Entscheidungen über die üblichen IsInRole-basierten Methoden durchführen kann.

Das Ergebnis der HttpListener-Authentifizierung findet man in der User.Identity Eigenschaft des HttpListenerContexts. Im Fall von Basic wird dort ein Objekt vom Typ HttpListenerBasicIdentity abgelegt, das über Username und Password Properties verfügt. Bei Integrierter- und Digest-Authentifizierung findet man dort stattdessen eine WindowsIdentity. Bei anonymen Requests sollte aus Konsistenz-Gründen ein anonymer Windows-Benutzer erzeugt werden.

Folgender Code implementiert diese Logik:

private static void EstablishSecurityContext(
  HttpListenerContext context)
{
  // anonymer benutzer wenn kein authentifizierungs aktiviert ist
  if (_listener.AuthenticationSchemes ==   
       AuthenticationSchemes.Anonymous ||
      _listener.AuthenticationSchemes == 
       AuthenticationSchemes.None)
  {
    WindowsPrincipal p = new WindowsPrincipal(
      WindowsIdentity.GetAnonymous());
    Thread.CurrentPrincipal = p;
  }
  else
  {
    // custom authentifizierung für basic 
    if (_listener.AuthenticationSchemes == 
         AuthenticationSchemes.Basic)
    {
      HttpListenerBasicIdentity client = 
        context.User.Identity as HttpListenerBasicIdentity;

      // z.B. mit Membership oder LogonUser etc.
      IPrincipal p = AuthenticateBasicClient(
        client.Name, client.Password);
      if (p != null)
        Thread.CurrentPrincipal = p;
      else
        throw new ArgumentException("Invalid Credentials");
    }
    else
    {
      // bei integrated und digest gibt es bereits einen 
      // authentifizierten benutzer
      Thread.CurrentPrincipal = context.User;
    }
  }
}

Die Request-Verarbeitungsroutine kann sich nun auf Thread.CurrentPrincipal stützen, um z.B. Rollenüberprüfungen durchzuführen (Autorisierung).

// prüfen auf "spezielle" URLs
if (resourceName == "time.cmd")
{
  if (Thread.CurrentPrincipal.IsInRole("Administratoren"))
    response = "Server Zeit: " + DateTime.Now.ToString();
}

 

Impersonierung

Ähnlich dem IIS, können Sie auch den Client für die Dauer des Requests impersonieren. Dadurch wird sichergestellt, dass nur Ressourcen angefragt werden können, für die der Client mindestens Leserechte besitzt.

Dies funktioniert allerdings nur, wenn Sie eine windowsbasierte Authentifizierungs-Methode gewählt haben.

Schließen Sie den Impersonierungs-Code in ein using Statement ein, um sicher zu stellen, dass die Impersonierung wieder rückgängig gemacht wird.

WindowsIdentity id = Thread.CurrentPrincipal.Identity 
  as WindowsIdentity;

if (id != null)
{
  using (id.Impersonate())
  {
    Console.WriteLine("Impersonieren: {0}", id.Name);
    ProcessRequest(context);
  }
}

 

SSL und Client Zertifikate

Nach Authentifizierung und Autorisierung fehlt die letzte Zutat: Sichere Datenübertragung.

Der Standard bei HTTP ist hier SSL, das folgende Features bietet:

  • Server-Authentifizierung

  • Geschützte Datenübertragung

  • Optionale Client-Authentifizierung mit einem Zertifikat

SSL wird von HTTP.SYS nativ unterstützt, und wir müssen lediglich das gewünschte Server-Zertifikat konfigurieren, sowie einen https:// URL registrieren. Am eigentlichen Anwendungs-Code ändert sich dadurch nichts.

Für die Server-Authentifizierung benötigen Sie ein Zertifikat mit dem Verwendungstyp „Server Authentication“ sowie einem dazugehörigen privaten Schlüssel. Das Zertifikat muss im „Persönliche Zertifikate“-Ordner im Computer-Zertifikat-Speicher abgelegt sein. Sie können das Zertifikate MMC Snap-In für die Konfigurations-Schritte benutzen.

Um das Server-Zertifikat mit dem Port, auf dem wir SSL sprechen möchten, zu verknüpfen, muss ein Kommando-Zeilen-Tool mit Namen httpcfg.exe herangezogen werden. Dieses finden Sie in den Support Tools auf der Windows Server CD. Die Syntax ist wie folgt:

httpcfg set ssl –i 0.0.0.0:Port –h xxxxxx
  • -i gibt das Netzwerk-Interface an, an das Sie binden möchten (0.0.0.0 umfasst alle).

  • -h ist der SHA1 Hash des Zertifikates, das sie verknüpfen möchten.

Sie finden den Hash im Thumbprint Feld des Zertifikat-Dialogs.

Um Browser anzuweisen den Client-Zertifikate Dialog anzuzeigen, muss zusätzlich noch der f Parameter ergänzt werden.

httpcfg set ssl –i 0.0.0.0:Port –h xxxxxx –f 2

Programmatischen Zugriff auf Client Zertifikate hat man mit der GetClientCertificate Methode des Kontexts, die einen alten Bekannten zurückliefert, X509Certificate2.

if (context.Request.IsSecureConnection)
{
  X509Certificate2 clientCert = 
    context.Request.GetClientCertificate();

  if (clientCert != null)
    Console.WriteLine(clientCert.Subject);
}

 

ACLs auf URIs !?

Wenn Sie bis jetzt den Code fleißig mit ausprobiert haben sollten, hat es entweder nicht funktioniert, oder Sie arbeiten als Administrator an Ihrem Entwickler-System (nein – das glaube ich nicht…).

HTTP.SYS erlaubt es nur Administratoren, einen URI-Namensraum zu registrieren. Dies hat gute Gründe, ansonsten wäre dieser API äußerst attraktiv für Viren und andere Schädlinge, um sich hinter bereits geöffneten Ports (z.B. 80) zu verstecken.

Wenn Sie aber trotzdem Ihren Server nicht mit dem Administrator-Konto starten wollen – und dafür gibt es ebenfalls gute Gründe – können Sie den URI als Administrator vor-registrieren und mit einer Zugriffs-Liste schützen. Diese ACL bestimmt, welche Nicht-Administrator-Konten den URI mit dem HTTP.SYS API (z.B. HttpListener) öffnen dürfen. Hier kann man dann das Konto des Service Accounts bzw. für Testzwecke die allgemeine Gruppe „Authentifizierte Benutzer“ angeben.

Wiederum kommt hier das httpcfg.exe Tool mit folgender Syntax zu Einsatz:

httpcfg.exe set urlacl –u http://*:80/MeineAnwendung/ - a SDDL_String

Das Problem an diesem Aufruf ist, das httpcfg den URL im SDDL Format (Security Descriptor Description Language) erwartet, was wiederum das Arbeiten mit SIDs (Security Identifier) impliziert. Glücklicherweise gibt es für diese Arbeit ein Tool, das den Benutzer durch diesen Prozess führt und das Setzen bzw. Löschen von ACLs automatisch vornimmt. Das Tool ist zu finden unter http://www.leastprivilege.com/content/binary/HttpCfgAcl2.1.zip.

Nach Ausführen dieses letzten Konfigurationsschrittes sollte der Web Server nun mit einem ganz normalen Benutzer/Service-Account ausführbar sein.

 

Ausblick

Ausgehend von dieser soliden Plattform ist der Weg zur ASP.NET-Integration nicht mehr weit. Dies erlaubt es, die gesamte Power mitsamt .aspx Seiten, WebServices und der HTTP zu nutzen.

Damit wird sich der zweite Teil dieses Artikels beschäftigen. Bleiben Sie gespannt.

 

Zusammenfassung

HttpListener ist der managed Wrapper für den HTTP.SYS System-Dienst. Wenn Sie eine Anwendung schreiben, die mit HTTP Requests umgehen können muss, ist dies der eleganteste und einfachste Weg. Die Unterstützung für Authentifizierung und SSL sind weitere Features, die man sich sonst (sehr) mühsam erarbeiten müsste bzw. zu komplex sind, um sie selbst zu implementieren.

 

Der Autor

Dominick Baier entwickelt ASP.NET- und Security-Kurse bei DevelopMentor, einer Entwickler-Schulungsfirma. Darüber hinaus unterstützt er Unternehmen beim Schreiben von sicheren verteilten Anwendungen. Als Referent kann man ihn auf diversen Konferenzen antreffen (BASTA, WinDev, DevWeek u.a.); Online findet man ihn auf seinem Blog unter www.leastprivilege.com.

Artikel-Serie „Der eigene kompakte und sichere Web Server mit .NET 2.0“ im Überblick:

Der eigene kompakte und sichere Web Server mit .NET 2.0 (Teil 1)

Der eigene kompakte und sichere Web Server mit .NET 2.0 (Teil 2): ASP.NET-Integration