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:
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:
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:
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?
- A user requests a page called http://www.mycomp.com/proxyweb/sales.html.
- 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.
- 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.
- 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:
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)
{
if (!hasLoadedConfig) {
loadConfigSettings();
hasLoadedConfig=true;
}
char buffer[1024];
DWORD buffSize = sizeof(buffer);
BOOL bHeader = pHeaderInfo->GetHeader(pCtxt->m_pFC,
"url", buffer, &buffSize);
CString urlString(buffer);
urlString.MakeLower();
CString newUrl = configSettings->getUrl(urlString);
char *pNewUrlString = newUrl.GetBuffer(newUrl.GetLength());
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;
}
CString CUrlMap::getUrl(CString origUrl)
{
if (origUrl.Find(this->loginPage)!=-1 ||
(origUrl.Find(this->logoutPage)!=-1)) {
return origUrl;
}
if (origUrl.Find(this->realUrl)!=-1) {
return origUrl;
}
CString newUrl = this->proxyUrl;
newUrl.Insert(newUrl.GetLength() + 1, "?origUrl=");
newUrl.Insert(newUrl.GetLength() + 1, origUrl);
return newUrl;
}
void CTobyIsapiFilter::loadConfigSettings() {
configSettings = new CUrlMap();
ifstream myfile;
myfile.open(PROXY_CONFIG_FILE);
if (!myfile.good()) {
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:
<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:
="1.0"
<authorization>
<roles>
<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>
<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:
Sub Application_Start(ByVal sender As Object, ByVal e As EventArgs)
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
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:
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()
If FormsAuthentication.Authenticate(TextBox1.Text, _
TextBox2.Text) Then
lblError.Text = String.Empty
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
Dim perms As XmlDocument = _
CType(Application("authorization"), XmlDocument)
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.
Protected Sub Application_AuthenticateRequest(ByVal sender _
As Object, ByVal e As System.EventArgs)
If Request.IsAuthenticated Then
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.
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
System.Net.ServicePointManager.Expect100Continue = False
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
Dim origUrl As String = String.Empty
If Request.QueryString(QS_ORIGURL) Is Nothing OrElse _
Request.QueryString(QS_ORIGURL).Length.Equals(0) Then
origUrl = realUrl & "/default.aspx"
Else
origUrl = Request.QueryString(QS_ORIGURL)
End If
Dim targetUrl As String = GetTargetUrl(origUrl)
If Not AuthorizeUser(targetUrl) Then
Context.Response.Write(ERR_ACCESS_DENIED)
Context.Response.End()
End If
Dim proxyRequest As HttpWebRequest = _
WebRequest.Create(targetUrl)
proxyRequest.Headers.Add(HTTP_FORMSID, _
Context.User.Identity.Name)
Dim authCookie As HttpCookie = _
Request.Cookies(FormsAuthentication.FormsCookieName)
Dim authTicket As FormsAuthenticationTicket = _
FormsAuthentication.Decrypt(authCookie.Value)
Dim userRoles As String = authTicket.UserData
proxyRequest.Headers.Add(HTTP_ROLES, userRoles)
proxyRequest.Credentials = CredentialCache.DefaultCredentials
proxyRequest.Referer = realHost & proxyUrl
proxyRequest.Accept = Request.Headers.Get("Accept")
proxyRequest.Headers.Add("Cookie", _
Context.Request.Headers.Get("Cookie"))
Select Case Request.RequestType
Case "GET"
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
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
Dim receiveBuffer(1024) As Byte
Dim byteFlag As Integer = 0
Dim ms As New MemoryStream
Do
byteFlag = getStream.Read(receiveBuffer, _
0, receiveBuffer.Length)
ms.Write(receiveBuffer, 0, byteFlag)
Loop Until byteFlag.Equals(0)
Response.BinaryWrite(ms.ToArray)
Else
Context.Response.ClearHeaders()
For i As Integer = 0 To getResponse.Headers.Count - 1
Context.Response.AddHeader(getResponse.Headers.Keys(i), _
getResponse.Headers(i))
Next
Dim readStream As New StreamReader(getStream)
Dim content As String = readStream.ReadToEnd
Context.Response.Write(content)
readStream.Close()
End If
getResponse.Close()
getStream.Close()
Context.Response.End()
Case "POST"
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()
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
Context.Response.ClearHeaders()
For i As Integer = 0 To getResponse.Headers.Count - 1
Context.Response.AddHeader(getResponse.Headers.Keys(i), _
getResponse.Headers(i))
Next
Dim readStream As New StreamReader(getStream)
Dim content As String = readStream.ReadToEnd
Context.Response.Write(content)
getResponse.Close()
getStream.Close()
readStream.Close()
Context.Response.End()
End Select
End Sub
Private Function GetTargetUrl(ByVal originalUrl As String) As String
originalUrl = originalUrl.Replace(clientUrl, realUrl)
originalUrl = originalUrl.Replace(clientHost, realHost)
If Not originalUrl.StartsWith(PRE_HTTP) Then
originalUrl = realHost & originalUrl
End If
If Not originalUrl.StartsWith(realHost) Then
originalUrl = originalUrl.Replace(PRE_HTTP, realHost)
End If
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:
Private Function AuthorizeUser(ByRef TargetUrl As String) As Boolean
Dim Allow As Boolean = False
Dim xmlAuth As XmlDocument = Application("authorization")
Dim defaultRule As String = _
xmlAuth.SelectSingleNode("authorization" & _
"/permissions").Attributes(AUTH_ALLOW).Value
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
Dim protectedPages As XmlNodeList = _
xmlAuth.SelectNodes("authorization/permissions" & _
"/location[@url='" & TargetUrl.ToLower & "']")
If protectedPages Is Nothing OrElse _
protectedPages.Count.Equals(0) Then
Return Allow
End If
Allow = False
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
If urlAllow.Length > 0 Then
If urlAllow.Equals("*") Then
Allow = True
Else
For Each urlPermission As String In urlAllow.Split(",")
If User.IsInRole(urlPermission) Then
Allow = True
Exit For
End If
Next
End If
End If
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
- 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.
- 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).
- 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.
- Copy the contents of the \ProxyWeb and \MyRealSite folders into these virtual folders.
To test the application:
- Type http://localhost/proxyweb/test.asp. If you haven’t been authenticated, you should be redirected to the logon page.
- Enter the ID “Toby” and the Password “Toto” to get your authentication cookie.
- 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.
- 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:
- 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.
- 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:
- 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.
- Modify the ISAPI filter and proxy.aspx code so that multiple secure websites can be supported by a single proxy instance.
- 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.
- 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.