Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / IIS

Protect non-.NET Assets Using a .NET Reverse Proxy with Forms Authentication and ISAPI

4.65/5 (12 votes)
27 Aug 200615 min read 1   601  
How to protect secure assets using a .NET Reverse Proxy, an ISAPI redirection filter and .NET Forms Authentication

Reverse Proxy Configuration Tool

Introduction

A friend of mine recently approached me with a problem, and for once, it wasn’t the kind that involved extensive therapy. He has a small corporate website that he wanted to lock down using .NET Forms Authentication and declarative security. No problem, I said, feeling relieved that he wasn’t asking me to counsel him through yet another failed relationship. I explained that .NET Forms Authentication is perfect for locking down an ASP.NET application. It provides role-based authorization, and is extremely simple to implement. But then, he pointed out that not all of his secure assets are .NET files. He also needed to secure PDF documents, HTML files, and a few classic ASP pages. I quickly realized that unlike most of his problems, this one would not be cured by therapy or alcohol, but by a careful helping of C++ and .NET.

About ASP.NET Forms Authentication

ASP.NET Forms Authentication is a very powerful and straightforward security mechanism for web applications, but since it runs inside the ASP.NET process, it isn’t much good for protecting non-.NET assets.

In Windows 2003, IIS6 provides a feature called Wildcard Mapping, which allows you to build a .NET HTTP Handler that intercepts every HTTP request. This can be used for authentication, among other things. If a secure, non-.NET asset such as a classic ASP file needs to be processed by an unmanaged ISAPI handler, the HTTP Handler returns control to IIS. Unfortunately, my friend’s website is running on Windows 2000, which means IIS5, which means no cool Wildcard Mapping feature, which means no quick fix, which means that I got to spend a beautiful summer weekend cooped away with my laptop and the obligatory bag of Fritos.

If you didn’t understand a word I said in the above paragraph (don’t worry, I’m used to being misunderstood), the following diagram should help to explain the problem with Forms Authentication:

Image 2

This illustrates the request chain for .NET Forms Authentication. As you can see, it can only be used to protect ASP.NET assets.

After much consideration and a couple of bags of Fritos, I decided that the way to resolve this problem was by creating a reverse proxy.

The Reverse Proxy Solution

Many people ask me: A reverse what? If you are reading this, you probably already know what a proxy server is. Typically, it sits between a client workstation and the outside world, intercepting outbound requests from the client and then forwarding them to the destination on behalf of the client. Proxy servers are used for a variety of reasons, including security, request modification, and logging. A reverse proxy is exactly the same thing as a standard proxy, except... um… in reverse.

Quite simply, the main function of a reverse proxy is the same as a bouncer at a nightclub. Nothing gets in without its approval. The main difference is that the reverse proxy doesn’t care if you are Paris Hilton. Its role is to intercept inbound requests to secure assets and then decide whether or not to let them through.

Typically, secure servers are configured to only allow requests from the reverse proxy, ensuring that malicious users cannot “bypass the bouncer”. The reverse proxy can also be used to obfuscate URLs; in other words, concealing the true address of a secure asset from the end user. Once the reverse proxy has intercepted and authenticated a request, it will obtain the secure asset and stream it back to the client, perhaps adding some response headers on the way out.

The following sequence diagram illustrates a very simple reverse proxy:

Image 3

By now, you have probably guessed that the reverse proxy server will be an ASP.NET application secured with .NET Forms Authentication, and that we will use the HTTPWebRequest and HTTPWebResponse objects to retrieve and serve up secure assets.

Ah, you might be saying, but how do we force the ASP.NET reverse proxy server to intercept every request before it gets to the default IIS handlers? The answer is to create a very simple ISAPI filter that redirects every inbound request to the ASP.NET proxy. Think of the ISAPI filter as a traffic cop whose job it is to make sure everybody is directed to the proxy before proceeding.

The following diagram illustrates how this works:

Image 4

With a reverse proxy, the actual content lives in a separate virtual or even on a physically separate server. This reminds me of an old girlfriend who would refuse to talk to me for days after an argument. During those quiet periods, her sister would often act as a mediator, passing messages between us. For some reason, reverse proxies always remind me of her. But I digress…

Putting It All Together

All this mumbo-jumbo is great in theory, but how does it really work?

  1. A user requests a page called http://www.mycomp.com/proxyweb/sales.html.
  2. The ISAPI filter installed at www.mycomp.com intercepts the request, and determines that the sales.html page is in a location being protected by the reverse proxy. The filter modifies the URL header on the HTTP request so that it points to http://www.mycomp.com/proxyweb/proxy.aspx?origUrl=/sales.html. All form post data and binary data in the request remain intact.
  3. If the user does not have a Forms Authentication cookie, proxy.aspx will redirect it to http://www.mycomp.com/proxyweb/login.aspx. The request to login.aspx is ignored by the ISAPI filter because the filter knows that login.aspx necessitates anonymous access.
  4. Once the user has been authenticated, proxy.aspx maps the relative URL (/sales.html) to the protected URL (http://secure.my.net/secure/sales.html). It generates an HTTPWebRequest object, captures the response from the secure server using an HTTPWebResponse object, and serves it up to the client. There is an assumption that the secure website or virtual will only accept requests from the reverse proxy. This can be accomplished programmatically, or by using IPSec, an impersonation account, or a firewall rule.

For simplicity’s sake, the code provided in this article is designed to run both the proxy and the secure applications on the same physical machine in different virtual folders. The basic architecture is illustrated in this diagram:

Image 5

Now that we have laid everything out, it is time for the fun part, where we start looking at some code. I bet you were wondering if I would ever get around to it, right?

The ISAPI Redirection Filter

The best place to start is with the ISAPI filter. The first thing to know about ISAPI filters and extensions is that they must be created in C++. The second thing to know about them is that they are event-driven modules that plug into IIS. The third thing to know is that their complexity can lead to migraines and long bouts of depression. But don’t worry. The ISAPI filter we are writing is probably the simplest you will ever come across.

At various stages during a request, IIS will raise various events that can be trapped by a filter and processed accordingly. The event we are interested in is SF_NOTIFY_PREPROC_HEADERS. This is invoked by IIS when it has competed pre-processing of the request header. Within this event handler, we have access to the URL that has been requested, as well as other header information. This allows us to actually modify the request before handing it back to IIS. The code for modifying the requested URL is as follows:

const char* PROXY_CONFIG_FILE = 
     "c:\\proxyconfig\\securitymap.txt";

CString clientHost = "";
CString realHost = "";
CString clientUrl = "";
CString realUrl = "";
CString loginPage = "";
CString logoutPage = "";
CString proxyUrl = "";

CUrlMap *configSettings;

BOOL hasLoadedConfig = false;

DWORD CTobyIsapiFilter::OnPreprocHeaders(
      CHttpFilterContext* pCtxt,
      PHTTP_FILTER_PREPROC_HEADERS pHeaderInfo)
{

    // Check to see if the globals have
    // been loaded. If not, then load them
    if (!hasLoadedConfig) {
        // Load the globals from a file
        loadConfigSettings();
        hasLoadedConfig=true;
    }

    // Get the URL contained in the request header.
    // TODO: Add some buffer checking here
    char buffer[1024];
    DWORD buffSize = sizeof(buffer);
    BOOL bHeader = pHeaderInfo->GetHeader(pCtxt->m_pFC, 
                               "url", buffer, &buffSize);

    CString urlString(buffer);
    urlString.MakeLower();

    // Get the URL for the proxy and append
    // the request URL to a query string
    // unless the request URL is not
    // to be proxied (for example, login and logout pages)
    CString newUrl = configSettings->getUrl(urlString);
    char *pNewUrlString = newUrl.GetBuffer(newUrl.GetLength());

    // Note that we can use SetHeader to add custom
    // headers as well, although they will be prefixed
    // with HTTP (for example, "ProxyConfig" becomes
    // "HTTP_ProxyConfig" when read from ServerVariables)
    pHeaderInfo->SetHeader(pCtxt->m_pFC, "url", pNewUrlString);
    
    char *pProxyUrlString = (char *)configSettings->proxyUrl;
    pHeaderInfo->SetHeader(pCtxt->m_pFC, 
                "proxyconfig:", pProxyUrlString);

    return SF_STATUS_REQ_HANDLED_NOTIFICATION;
}

//////////////////////////////////////////////////////////////////////
// getUrl(): Checks the target URL that the user is trying to access.
// Normally, this method returns a path to the proxy.aspx page with
// a query string identifying the target page. But there are some
// pages on the proxy server that are real (for example, login and
// logout pages). We obviously don't want to proxy those. So this
// method knows which pages to ignore. If it finds one of those
// pages, it just returns the original URL without any modifications
//////////////////////////////////////////////////////////////////////
CString CUrlMap::getUrl(CString origUrl)
{
    // We are not going to redirect if the
    // login form is specified, otherwise we will
    // get caught in an infinite redirection loop
    if (origUrl.Find(this->loginPage)!=-1  || 
       (origUrl.Find(this->logoutPage)!=-1)) {
        return origUrl;
    }

    // Make sure that we don't get caught
    // in an infinite redirection loop when
    // proxy.aspx consumes data from the
    // target site. You only need to include this
    // logic if the real site is in a virtual
    // directory on the same website as 
    // the proxy site. If the real content
    // is on a separate website that the ISAPI
    // filter is not protecting,
    // then we don't need to do the following:
    if (origUrl.Find(this->realUrl)!=-1) {
    // realUrl example="/myrealsite/"
        return origUrl;
    }

    // This is the default behavior for all requests
    // to the website. We take the original URL and
    // add it to a query string so
    // that proxy.aspx can pick it up
    CString newUrl = this->proxyUrl;
    newUrl.Insert(newUrl.GetLength() + 1, "?origUrl=");
    newUrl.Insert(newUrl.GetLength() + 1, origUrl);
    
    return newUrl;
}

void CTobyIsapiFilter::loadConfigSettings() {
    // Initialize the configSettings object,
    // which will be held in memory by IIS
    configSettings = new CUrlMap();

    // Read the configuration file
    ifstream myfile;
    myfile.open(PROXY_CONFIG_FILE);
    if (!myfile.good()) {
        // The file does not exist or cannot be read,
        // so populate default values
        clientHost = "http://localhost";
        realHost = "http://localhost";
        clientUrl = "/proxyweb/";
        realUrl = "/myrealsite/";
        loginPage = "/proxyweb/login.aspx";
        logoutPage = "/proxyweb/logout.aspx";
        proxyUrl = "/proxyweb/proxy.aspx";
        myfile.close();
    } else {        
        char str[1024];
        while (!myfile.eof()) {
            myfile.getline(str, 1024);
            CString m_line;
            m_line = str;
            if (m_line.Left(12) == "clienthost: ") {
                clientHost = m_line.Mid(12);
            } else if (m_line.Left(10) == "realhost: ") {
                realHost = m_line.Mid(10);
            } else if (m_line.Left(11) == "clienturl: ") {
                clientUrl = m_line.Mid(11);
            } else if (m_line.Left(9) == "realurl: ") {
                realUrl = m_line.Mid(9);
            } else if (m_line.Left(11) == "loginpage: ") {
                loginPage = m_line.Mid(11);
            } else if (m_line.Left(12) == "logoutpage: ") {
                logoutPage = m_line.Mid(12);
            } else if (m_line.Left(10) == "proxyurl: ") {
                proxyUrl = m_line.Mid(10);
            }

        }
        myfile.close();
    }

    configSettings->clientHost = clientHost;
    configSettings->clientUrl = clientUrl;
    configSettings->realUrl = realUrl;
    configSettings->loginPage = loginPage;
    configSettings->logoutPage = logoutPage;
    configSettings->proxyUrl = proxyUrl;
    configSettings->realHost = realHost;

    hasLoadedConfig=true;
}

Configuration Settings

The SecurityMap.txt file referred to at the beginning of the above code block describes a simple configuration file that describes our proxy settings. The default settings for the configuration file are as follows:

[CONFIGSETTINGS]
proxyurl: /proxyweb/proxy.aspx
logoutpage: /proxyweb/logout.aspx
loginpage: /proxyweb/login.aspx
realurl: /myrealsite/
clienturl: /proxyweb/
realhost: http://localhost
clienthost: http://localhost

Here is a brief overview of what each of these settings are for:

  • PROXYURL: Relative path to the page that acts as a reverse proxy. When appended to CLIENTHOST, this provides the fully qualified URL for the proxy.
  • LOGOUTPAGE: Relative path to the logout page. Requests to this page will not be redirected by the ISAPI filter.
  • LOGINPAGE: Relative path to the login page. Requests to this page will not be redirected by the ISAPI filter.
  • REALURL: Relative path to where the actual content lives. When appended to REALHOST, this provides the fully qualified URL for the secure website or virtual.
  • CLIENTURL: Relative path to where the reverse proxy lives. When appended to CLIENTHOST, this provides the fully qualified URL for the secure website or virtual.
  • REALHOST: The name of the host where the secure content lives.
  • CLIENTHOST: The name of the host where the reverse proxy lives.

The SecurityMap.txt file is consumed and cached by the ISAPI filter and by the ProxyWeb application itself when IIS starts. Therefore, if you make any changes to the configuration file, you must recycle IIS.

In the attached ZIP file, I have provided a utility that allows you to generate this file and validate your settings. The securitymap.txt file must be stored in a directory called c:\proxyconfig.

Authentication and Authorization

Now that the ISAPI filter is redirecting inbound requests to the reverse proxy, we need to think about the security model for the proxy itself. I’m not going to spend much time discussing the intricacies of .NET Forms Authentication, since there is plenty of great documentation already available on the web. For the purposes of this demo, I’ve included a very simple implementation of Forms Authentication, which relies upon plaintext credentials stored in the web.config file of the ProxyWeb application for authentication, and a file called Authorization.xml for authorization.

Let's start with the web.config file:

XML
<authorization>
    <deny users="?"/>
</authorization>

<authentication mode="Forms">
    <forms loginUrl="login.aspx" slidingExpiration="true">
        <credentials passwordFormat="Clear">
            <user name="toby" password="Toto"/>
            <user name="john" password="Toto"/>
            <user name="jane" password="Toto"/>
            <user name="paul" password="Toto"/>
        </credentials>
    </forms>
</authentication>

The <authorization> node tells the ASP.NET application to deny access to any unauthenticated users. The <authentication> node specifies the type of security to use, in this case Forms Authentication, and the URL that will be used to log in. There are four user accounts provided in the above snippet. In a real world scenario, you will want to store credentials securely in a database or a directory with hashed passwords. In other words, don’t try the above technique at home!

Authorization rules are provided in a file called c:\proxyconfig\authorization.xml. This specifies the roles for each user and specific access controls for individual assets:

XML
<?xml version="1.0"?>
<!-- This is a very simple declarative security model 
     showing how roles can be applied 
     to .NET Forms Authentication -->
<authorization>
    <roles>
        <!-- Users in this section should match 
             the users specified in the "web.config" -->
        <role name="administrators">
            <user name="Toby" />
        </role>
        <role name="creators">
            <user name="John" />
            <user name="Paul" />
        </role>
        <role name="readers">
            <user name="Jane" />
            <user name="John" />
        </role>
    </roles>
    <!-- Note that URLs have to be fully qualified and point 
         to the real (hidden) URL being locked down. 
         IMPORTANT: URLs must be in lower case -->
    <permissions allow="administrators,creators,readers">
        <location url="http://localhost/myrealsite/default.aspx" 
                     allow="*" />
        <location url="http://localhost/myrealsite/myphoto.gif" 
                     allow="readers,administrators" />
        <location url="http://localhost/myrealsite/creatorsonly.html" 
                     allow="creators" />
        <location url="http://localhost/myrealsite/readersonly.html" 
                     allow="readers" />
    </permissions></authorization>

The <roles> element should be self-explanatory. The <permissions> element contains a default rule that allows access to members of the administrators, creators, and readers roles. This permission is applied to all secure assets unless overridden by one of the subsequent <location> elements. For example, the default.aspx page will allow access to all authenticated users (denoted by the “*” wildcard), while the CreatorsOnly.html page will only allow access to members of the “creators” role.

The authorization.xml file, like the securitymap.txt file, is cached when the ProxyWeb application starts. This is handled in the Application_Start event of the global.asax file:

VB
Sub Application_Start(ByVal sender As Object, ByVal e As EventArgs)
    ' Retrieve settings from the security.map
    ' file and cache them in application state
    Dim sr As StreamReader = _
           File.OpenText("c:\proxyconfig\securitymap.txt")
    Do While sr.Peek > 0
        Dim thisLine As String = sr.ReadLine.ToLower
        If thisLine.StartsWith("clienthost: ") Then
            Application.Add("clienthost", _
               thisLine.Substring(thisLine.IndexOf(": ") + 2).Trim)
        ElseIf thisLine.StartsWith("realhost: ") Then
            Application.Add("realhost", _
               thisLine.Substring(thisLine.IndexOf(": ") + 2).Trim)
        ElseIf thisLine.StartsWith("clienturl: ") Then
            Application.Add("clienturl", _
               thisLine.Substring(thisLine.IndexOf(": ") + 2).Trim)
        ElseIf thisLine.StartsWith("realurl: ") Then
            Application.Add("realurl", _
               thisLine.Substring(thisLine.IndexOf(": ") + 2).Trim)
        ElseIf thisLine.StartsWith("loginpage: ") Then
            Application.Add("loginpage", _
               thisLine.Substring(thisLine.IndexOf(": ") + 2).Trim)
        ElseIf thisLine.StartsWith("logoutpage: ") Then
            Application.Add("logoutpage", _
               thisLine.Substring(thisLine.IndexOf(": ") + 2).Trim)
        ElseIf thisLine.StartsWith("proxyurl: ") Then
            Application.Add("proxyurl", _
               thisLine.Substring(thisLine.IndexOf(": ") + 2).Trim)
        End If
    Loop
    ' Cache the authorization XML file in application
    ' state so we don't have to keep loading it
    Dim xmlAuth As New XmlDocument
    xmlAuth.Load("c:\proxyconfig\authorization.xml")
    Application.Add("authorization", xmlAuth)
    sr.Close()
    
End Sub

The next thing we should look at is the login.aspx page, which is responsible for authenticating the user and issuing a Forms Authentication cookie:

VB
Partial Class Login
    Inherits System.Web.UI.Page

    Protected Sub Button1_Click(ByVal sender As Object, _
              ByVal e As System.EventArgs) Handles Button1.Click
        FormsAuthentication.Initialize()
        ' Authenticate using the credentials provided in the web.config file
        If FormsAuthentication.Authenticate(TextBox1.Text, _
                               TextBox2.Text) Then
            lblError.Text = String.Empty
            ' Issue the forms auth cookie for this
            ' session and redirect to the default page
            Dim authTicket As New FormsAuthenticationTicket(1, _
                           TextBox1.Text, DateTime.Now, _
                           DateTime.Now.AddMinutes(30), _
                           False, GetRoles(TextBox1.Text))
            Dim encryptedTicket As String = _
                FormsAuthentication.Encrypt(authTicket)
            Dim authCookie As New _
                HttpCookie(FormsAuthentication.FormsCookieName, _
                encryptedTicket)
            Context.Response.Cookies.Add(authCookie)
            Response.Redirect("default.aspx", True)
        Else
            lblError.Text = "Authentication Failed"
        End If
    End Sub

    Private Function GetRoles(ByVal username As String) As String
        ' Load the XML permissions file
        Dim perms As XmlDocument = _
            CType(Application("authorization"), XmlDocument)

        ' Get the user's roles from the XmlDocument object
        Dim xmlRoles As XmlNodeList = _
            perms.SelectNodes("authorization/roles/role")
        Dim userRoles() As String = New String() {}
        For Each xmlRole As XmlNode In xmlRoles
            Dim xmlRoleName As String = xmlRole.Attributes("name").Value
            For Each xmlRoleUser As XmlNode In xmlRole.ChildNodes
                Dim roleUser As String = xmlRoleUser.Attributes("name").Value
                If roleUser.ToLower.Equals(username.ToLower) Then
                    Array.Resize(userRoles, userRoles.Length + 1)
                    userRoles(userRoles.Length - 1) = xmlRoleName
                End If
            Next
        Next

        Return String.Join(",", userRoles)
    End Function
End Class

This snippet issues a Forms Authentication cookie that will be used to access the proxy itself (proxyweb\proxy.aspx). One of the parameters we provide to the constructor for a FormsAuthenticationTicket object is a comma-delimited list of the authenticated user’s roles. This is extracted from the authorization.xml file by the GetRoles() method.

There is one final step involved in the security model, and it brings us back to the global.asax file. When a request to the ProxyWeb application is authenticated, the Application_AuthenticateRequest event is fired. At this point, we will create a GenericPrincipal object containing information about the current user’s identity and add it to the HTTP context. This will allow us to perform declarative role-based authorization within the proxy itself.

VB
Protected Sub Application_AuthenticateRequest(ByVal sender _
              As Object, ByVal e As System.EventArgs)
    If Request.IsAuthenticated Then
        ' Get the roles from the FormsAuthenticationTicket
        ' and create a GenericPrincipal object
        ' that will be added to the current HTTP context
        Dim authCookie As HttpCookie = _
            Context.Request.Cookies(FormsAuthentication.FormsCookieName)
        Dim authTicket As FormsAuthenticationTicket = _
            FormsAuthentication.Decrypt(authCookie.Value)
        Dim userRoles() As String = authTicket.UserData.Split(CType(",", Char()))
        Dim identity As New FormsIdentity(authTicket)
        Dim userPrincipal As New _
            Principal.GenericPrincipal(identity, userRoles)
        Context.User = userPrincipal
    End If
End Sub

Now Just Add a Proxy

Now, the part you’ve been waiting for; the proxy code itself. This may be a bit of an anti-climax, since the code is actually quite straightforward. The ISAPI filter redirects to proxy.aspx with a query string called “origUrl” containing the requested URL. Proxy.aspx expects to receive this query string, and will convert it into a fully-qualified URL that it can provide to an HTTPWebRequest object. Upon obtaining the HTTP response, proxy.aspx will stream it back to the client. From the user’s perspective, all of this is completely transparent. They do not even see the query string in their browser.

VB
Private clientHost As String
Private realHost As String
Private clientUrl As String
Private realUrl As String
Private loginPage As String
Private logoutPage As String
Private proxyUrl As String

Private Const APP_CLIENTHOST As String = "clienthost"
Private Const APP_REALHOST As String = "realhost"
Private Const APP_CLIENTURL As String = "clientUrl"
Private Const APP_REALURL As String = "realUrl"
Private Const APP_LOGINPAGE As String = "loginpage"
Private Const APP_LOGOUTPAGE As String = "logoutpage"
Private Const APP_PROXYURL As String = "proxyUrl"
Private Const PRE_HTTP As String = "http://"
Private Const DEFAULT_PAGE As String = "default.aspx"
Private Const QS_ORIGURL As String = "origurl"
Private Const ERR_ACCESS_DENIED As String = _
        "<h1>Access Denied.</h1><h2>" & _ 
        "You do not have the appropriate role " & _ 
        "to access this resource</h2>"
Private Const HTTP_FORMSID As String = "FORMS-ID"
Private Const HTTP_ROLES As String = "ROLES"
Private Const ERR_PROXY As String = _
        "The following error occurred on the proxy: "
Private Const AUTH_ALLOW As String = "allow"
Private Const AUTH_DENY As String = "deny"

Protected Sub Page_Load(ByVal sender As Object, _
         ByVal e As System.EventArgs) Handles Me.Load
    ' This is to correct a weird bug with HTTP posts
    ' in the 2.0 version of HTTPWebRequest
    System.Net.ServicePointManager.Expect100Continue = False

    ' Set up the globals
    clientHost = Application(APP_CLIENTHOST).ToString
    realHost = Application(APP_REALHOST).ToString
    clientUrl = Application(APP_CLIENTURL).ToString
    realUrl = Application(APP_REALURL).ToString
    loginPage = Application(APP_LOGINPAGE).ToString
    logoutPage = Application(APP_LOGOUTPAGE).ToString
    proxyUrl = Application(APP_PROXYURL).ToString

    ' Establish the real URL that the user is trying to request
    Dim origUrl As String = String.Empty
    If Request.QueryString(QS_ORIGURL) Is Nothing OrElse _
       Request.QueryString(QS_ORIGURL).Length.Equals(0) Then
        ' This is just somewhere to send users
        ' when no URL has been provided
        origUrl = realUrl & "/default.aspx"
    Else
        origUrl = Request.QueryString(QS_ORIGURL)
    End If
    Dim targetUrl As String = GetTargetUrl(origUrl)

    ' Now we have the target URL and the user's roles,
    ' it is time to authorize them. If declarative security fails,
    ' then don't go any further
    If Not AuthorizeUser(targetUrl) Then
        Context.Response.Write(ERR_ACCESS_DENIED)
        Context.Response.End()
    End If

    ' It's SHOWTIME!
    Dim proxyRequest As HttpWebRequest = _
                     WebRequest.Create(targetUrl)

    ' Set up the request, starting by adding the true
    ' identity of the client to the request so that it can
    ' be read by the target application by
    ' reading the "HTTP_FORMS_ID" server variable.
    proxyRequest.Headers.Add(HTTP_FORMSID, _
                 Context.User.Identity.Name)

    ' Retrieve the roles for the authenticated user
    Dim authCookie As HttpCookie = _
        Request.Cookies(FormsAuthentication.FormsCookieName)
    Dim authTicket As FormsAuthenticationTicket = _
        FormsAuthentication.Decrypt(authCookie.Value)
    Dim userRoles As String = authTicket.UserData

    ' Add a header that describes the user's roles.
    ' This will be available via HTTP_ROLES
    proxyRequest.Headers.Add(HTTP_ROLES, userRoles)

    ' TODO: This is where you might use impersonation
    ' to ensure that only the proxy can request a secure
    ' page. You would do this by creating an instance
    ' of NetworkCredentials and applying it to the Credentials
    ' property of the HttpWebRequest object. But we're not
    ' doing that today (hey, do you need me to do everything
    ' for you?) Instead, we'll just use
    ' default credentials as a placeholder.
    proxyRequest.Credentials = CredentialCache.DefaultCredentials

    ' This tells the secure asset that the request originated
    ' with proxy.aspx, although it can be spoofed,
    ' so don't rely too much on it
    proxyRequest.Referer = realHost & proxyUrl

    proxyRequest.Accept = Request.Headers.Get("Accept")

    ' Add client cookies to the request
    ' in case the secure asset requires them
    proxyRequest.Headers.Add("Cookie", _
                 Context.Request.Headers.Get("Cookie"))

    ' Slightly different techniques are used for a GET
    ' and POST request to ensure that posted form
    ' fields can be provided to the secure asset
    Select Case Request.RequestType
        Case "GET"
            ' Now the request has been set up,
            ' get the response (or at least try to)
            Dim getResponse As HttpWebResponse = Nothing
            Try
                getResponse = proxyRequest.GetResponse
            Catch ex As WebException
                Dim errMsg As String = ex.Message
                Response.Write(ERR_PROXY & errMsg)
                Context.Response.End()
            End Try

            Dim getStream As Stream = getResponse.GetResponseStream

            ' Decide whether to stream binary content
            ' or simply write it to the client
            Dim contentType As String = getResponse.ContentType.ToLower
            Response.ContentType = contentType

            If (Not contentType.Contains("html")) _
                AndAlso (Not contentType.Contains("xml")) _
                AndAlso (Not contentType.Contains("javascript")) Then
                ' This is binary content
                Dim receiveBuffer(1024) As Byte
                ' Create 1K chunks to spit out to the client

                Dim byteFlag As Integer = 0
                Dim ms As New MemoryStream

                ' Read the binary content into a memorystream object
                Do
                    byteFlag = getStream.Read(receiveBuffer, _
                               0, receiveBuffer.Length)
                    ms.Write(receiveBuffer, 0, byteFlag)
                Loop Until byteFlag.Equals(0)

                ' Write the binary stream to the client
                Response.BinaryWrite(ms.ToArray)

            Else
                ' Return some cookies to the client,
                ' just because we love them...
                Context.Response.ClearHeaders()
                For i As Integer = 0 To getResponse.Headers.Count - 1
                  Context.Response.AddHeader(getResponse.Headers.Keys(i), _
                                             getResponse.Headers(i))
                Next

                ' Process the main content and stream it to the client
                Dim readStream As New StreamReader(getStream)
                Dim content As String = readStream.ReadToEnd
                Context.Response.Write(content)
                readStream.Close()
            End If

            ' Clean out the trash
            getResponse.Close()
            getStream.Close()

            Context.Response.End()
        Case "POST"
            ' This block is all about writing post data to the request
            proxyRequest.Method = "POST"
            proxyRequest.ContentType = Context.Request.ContentType
            proxyRequest.ContentLength = Context.Request.ContentLength
            Dim postStream As StreamReader = _
                New StreamReader(Context.Request.InputStream)
            Dim forwardStream As New _
                StreamWriter(proxyRequest.GetRequestStream)
            forwardStream.Write(postStream.ReadToEnd)
            forwardStream.Close()
            postStream.Close()

            ' From this point on, the code looks
            ' very similar to the "GET" scenario
            Dim getResponse As HttpWebResponse = Nothing

            Try
                getResponse = proxyRequest.GetResponse()
            Catch ex As WebException
                Dim errMsg As String = ex.Message
                Response.Write(ERR_PROXY & errMsg)
                Context.Response.End()
            End Try

            Dim getStream As Stream = _
                getResponse.GetResponseStream

            ' Send some cookies to the client,
            ' just because we love them...
            Context.Response.ClearHeaders()
            For i As Integer = 0 To getResponse.Headers.Count - 1
              Context.Response.AddHeader(getResponse.Headers.Keys(i), _
                                         getResponse.Headers(i))
            Next

            ' Process the main content and stream it to the client
            Dim readStream As New StreamReader(getStream)
            Dim content As String = readStream.ReadToEnd
            Context.Response.Write(content)

            ' Clean out the trash
            getResponse.Close()
            getStream.Close()
            readStream.Close()

            Context.Response.End()
    End Select

End Sub

Private Function GetTargetUrl(ByVal originalUrl As String) As String
    'First, we will replace references to the proxy
    'website with references to the real website
    originalUrl = originalUrl.Replace(clientUrl, realUrl)

    'Now, we do the same with the server name
    originalUrl = originalUrl.Replace(clientHost, realHost)

    'The URL must begin with "http://[realhost]/"
    If Not originalUrl.StartsWith(PRE_HTTP) Then
        originalUrl = realHost & originalUrl
    End If

    'Make sure that we are pointing at the real host
    If Not originalUrl.StartsWith(realHost) Then
        originalUrl = originalUrl.Replace(PRE_HTTP, realHost)
    End If

    'Make sure we have a default page
    If originalUrl.EndsWith("/") Then
        originalUrl &= DEFAULT_PAGE
    End If

    Return originalUrl

End Function

You will notice from the above code snippet that we do not even create the HTTPWebRequest object until we have ensured that the user is authorized to view the requested URL. This check is performed in the AuthorizeUser() method:

VB
Private Function AuthorizeUser(ByRef TargetUrl As String) As Boolean
    ' Deny access to all authenticated users by default
    Dim Allow As Boolean = False
    Dim xmlAuth As XmlDocument = Application("authorization")
    ' Get the default rule that is applied at the root of the website
    Dim defaultRule As String = _
        xmlAuth.SelectSingleNode("authorization" & _ 
        "/permissions").Attributes(AUTH_ALLOW).Value

    ' A wildcard means that everybody is allowed access
    ' by default. If a list of groups is specified,
    ' then they are allowed by default.
    If defaultRule.Equals("*") Then
        Allow = True
    Else
        For Each rootPermission As String In defaultRule.Split(",")
            If User.IsInRole(rootPermission) Then
                Allow = True
                Exit For
            End If
        Next
    End If

    ' Discover if the requested page has an
    ' explicit permission set in the XML rules
    Dim protectedPages As XmlNodeList = _
        xmlAuth.SelectNodes("authorization/permissions" & _ 
        "/location[@url='" & TargetUrl.ToLower & "']")
    ' If no rule exists for the current page,
    ' then just return the permission we already have
    If protectedPages Is Nothing OrElse _
       protectedPages.Count.Equals(0) Then
        Return Allow
    End If

    ' An authorization rule exists for the page,
    ' so assume that we will deny access unless an "allow" rule is in 
    ' place for the current user's role
    Allow = False

    ' Enumerate the rules that have been applied to the current page
    For Each urlNode As XmlNode In protectedPages
        Dim urlAllow As String = String.Empty
        Dim urlDeny As String = String.Empty
        If Not urlNode.Attributes(AUTH_ALLOW) Is Nothing Then
            urlAllow = urlNode.Attributes(AUTH_ALLOW).Value
        End If
        If Not urlNode.Attributes(AUTH_DENY) Is Nothing Then
            urlAllow = urlNode.Attributes(AUTH_DENY).Value
        End If

        ' Check the "allow" permissions for the URL
        If urlAllow.Length > 0 Then
            ' A wildcard means everybody is allowed
            If urlAllow.Equals("*") Then
                Allow = True
            Else
                ' See if the list of allowed roles
                ' matches one of the user's roles
                For Each urlPermission As String In urlAllow.Split(",")
                    If User.IsInRole(urlPermission) Then
                        Allow = True
                        Exit For
                    End If
                Next
            End If
        End If

        ' Now check "deny" permissions for the URL. Wildcards
        ' are pointless here, because we are
        ' already denying access by default.
        If urlDeny.Length > 0 Then
            For Each urlDenial As String In urlDeny.Split(",")
                If User.IsInRole(urlDenial) Then
                    Allow = False
                    Exit For
                End If
            Next
        End If

    Next

    Return Allow
End Function

Solution Manifest

The attached ZIP file contains the following code:

  • \ProxyWeb - This is the proxy website to be installed in IIS.
  • \MyRealSite - A demo website that will be protected by the proxy.
  • \ProxyConfig - Files that must be copied to C:\proxyconfig\.
  • \TobyISAPI - The ISAPI filter. This must be installed at the root of the website where the ProxyWeb application resides.
  • \ReverseProxyConfig - A utility for creating the securitymap.txt configuration file.

Installation

  1. First, you must install the ISAPI filter.
    • In Internet Services Manager, right click on the root of the website where the /proxyweb virtual folder will live. Select the ISAPI Filters tab, and install the TobyIsapiFilter.dll file.
    • Recycle IIS by typing IISRESET at a command line. The filter should now be loaded.
  2. Create a directory called C:\ProxyConfig. Copy SecurityMap.txt and Authorization.xml into it. Make sure that everyone has read access for the directory (at the very minimum, we will need to grant access for the ASPNET, System, IWAM, and IUSR accounts).
  3. In IIS, create a virtual folder called \ProxyWeb and another one called \MyRealSite. Both of these virtual folders should live beneath the root where the ISAPI filter is installed.
  4. Copy the contents of the \ProxyWeb and \MyRealSite folders into these virtual folders.

To test the application:

  1. Type http://localhost/proxyweb/test.asp. If you haven’t been authenticated, you should be redirected to the logon page.
  2. Enter the ID “Toby” and the Password “Toto” to get your authentication cookie.
  3. You will see that even though there is no page called “default.aspx” in the “/proxyweb” virtual folder, it will be served up to you as if it lived there.
  4. Try logging in with the other IDs (John, Jane, and Paul), each of which has different roles, and you will see how the authorization is applied.

Considerations

If you are developing an application that lives behind a reverse proxy, you should be mindful of the following considerations:

  1. In your HTML, links and paths to other web assets behind the proxy, such as images and stylesheets, must be relative. For example, if you hardcode a link to http://secure.my.net/images/mypage.jpg, the client will not be able to reach it, since there is a presumption that the secure.mycomp.com domain is fire-walled from the user and that only the reverse proxy can reach it directly. The correct way to implement this link would be /images/mypage.html. This would cause the client to see http://www.mycomp.com/proxyweb/images/mypage.jpg, which will ensure that the request is trapped by the ISAPI filter and redirected to Proxy.aspx.
  2. By definition, a reverse proxy is a single point of failure. If it goes down, you lose access to everything it is protecting, so make sure that you have a good disaster recovery plan in place. Also, you need to make sure that the proxy is thoroughly tested under load, and ensure that you have enough load-balanced servers to deal with all the traffic that will pass through the reverse proxy.

Future Enhancements

This is a very basic illustration of the reverse proxy solution that I implemented for my friend. The production version was a bit more complex, but this code formed the basis for it. In the meantime, here are a few ideas of how you could extend the solution:

  1. Instead of using proxy.aspx, implement the reverse proxy as an HTTPModule. This results in a performance boost, since requests will be intercepted at a higher level in the request chain.
  2. Modify the ISAPI filter and proxy.aspx code so that multiple secure websites can be supported by a single proxy instance.
  3. Enhance proxy.aspx so that it modifies HTML served up to the client. For example, you could ensure that any URL for images and links are pointing at the proxy, rather than the actual secure server, which should be invisible to the client.
  4. Use impersonation for additional security on the proxy. In the attached test.asp page, I am using the HTTP_REFERER server variable to ensure that it only processes requests originated by proxy.aspx. If you cannot afford a firewall or cannot implement IPSEC, impersonation is another easy way to achieve the same effect. Simply create a Windows account, and lock down your secure assets in NTFS to ensure that only the impersonated account can view them. In the proxy.aspx code, create a NetworkCredential object based on the Windows account, and attach it to the HTTPWebRequest object. This prevents unauthorized individuals from accessing your secure assets directly if you don’t have IPSEC or a firewall to protect them.

Conclusions

The attached code provided the basis for an extremely robust solution that my friend is now using to protect a variety of assets. It even provides security for a couple of JBoss servers, proving that this kind of solution is platform agnostic, even though the front-end solution is based upon .NET Forms Authentication.

Needless to say, my friend was thrilled, and he paid me in full. Yessir, one bottle of premium tequila is now mine. So that single weekend alone with my laptop and a steady supply of Fritos was for a good cause after all!

History

  • Version 1.0: August 27, 2006.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here