Chris Idzerda
Vertigo Software, Inc.
November 1999
Summary: This article describes the design and implementation of the Fitch & Mather Stocks 2000 (FMStocks 2000) ISAPI Security filter. (17 printed pages)
Overview
Implementation
The Filter
The Request
The Response
Summary
About the Author
For More Information
Fitch & Mather Stocks (FMStocks) funnels all Web requests through a single Internet Server Application Programming Interface (ISAPI) filter called fms2ksec.dll. This filter offers several benefits:
We chose Microsoft® Visual C++® as our implementation language for the following reasons:
The source for our filter is in a Microsoft Visual C++ ISAPI filter project called fms2ksec. Two classes are implemented in four files together with six support files, as shown in Figure 1.
Figure 1. FMS2ksec files
Note To compile the source code, you will need the current Platform SDK.
The following table describes the files used to implement the ISAPI filter:
Filenames | Purpose |
fms2ksec.cpp, fms2ksec.h | Contains the interface and implementation of the CFms2ksecFilter class derived from the MFC class CHttpFilter. This class is responsible for filtering the Web requests and responses. |
Crypto.cpp, Crypto.h | Contains the interface and implementation of the CCrypto class. This class is responsible for encoding a string. In this case, the string is part of the URL received from and passed back to the client. |
StdAfx.cpp, StdAfx.h | Standard includes files created by the MFC ISAPI project wizard to hold commonly included files. |
Fms2ksec.def | Contains the exports necessary for all ISAPI filters. In this file, there are two such required exports—HttpFilterProc and GetFilterVersion. |
fms2ksec.rc, fms2ksec.rc2, Resource.h | Contains resources used by this filter. There are only two: the string describing the filter and the filter version. |
Separating the cryptographic part from the rest of the code is not necessary, but we put it in its own class to simplify replacing it with a different cryptographic method (or none at all).
We will not study the mechanics of ISAPI filters in detail, because several references in "Developing ISAPI Filters" within the Platform SDK/Web Services/Internet Information Services SDK node in the MSDN Library provide more detail.
You can find a helpful example in the article, "Writing Interactive Web Apps is a Piece of Cake with the New ISAPI Classes in MFC 4.1," located in the Microsoft Systems Journal, May 1996, Vol. 11, No.5. For this discussion, it is enough to say that an ISAPI filter sits between the client and the server and can see all traffic between them in the form of notifications from the Internet Information Server (IIS), as illustrated in Figure 2.
Figure 2. Filter block diagram
The following table shows how this filter makes use of three such notifications:
Notification | Purpose |
SF_NOTIFY_PREPROC_HEADERS | Indicates that the server has completed pre-processing of the headers associated with the request, but has not yet begun to process the information contained within the headers. |
SF_NOTIFY_SEND_RESPONSE | Occurs after the request is processed and before headers are sent back to the client. |
SF_NOTIFY_END_OF_REQUEST | Occurs at the end of each request. |
The GetFilterVersion() function is one of the required DLL entry points and is the first function called when a filter is loaded by IIS when the Web server starts. This function is responsible for informing IIS which notifications the filter wants to receive, the priority of the filter, and the ports for whose traffic it wishes to receive notifications.
BOOL CFms2ksecFilter::GetFilterVersion(PHTTP_FILTER_VERSION pVer)
{
LoadFromRegistry();
// Call the default implementation for initialization.
CHttpFilter::GetFilterVersion(pVer);
// Clear the flags set by base class.
pVer->dwFlags & = ~SF_NOTIFY_ORDER_MASK;
// Set the flags we want.
pVer->dwFlags |= SF_NOTIFY_ORDER_MEDIUM | SF_NOTIFY_NONSECURE_PORT
| SF_NOTIFY_PREPROC_HEADERS | SF_NOTIFY_SEND_RESPONSE
| SF_NOTIFY_END_OF_REQUEST;
// Load the description string.
TCHAR sz[SF_MAX_FILTER_DESC_LEN+1];
ISAPIVERIFY(::LoadString(AfxGetResourceHandle(),
IDS_FILTER, sz, SF_MAX_FILTER_DESC_LEN));
_tcscpy(pVer->lpszFilterDesc, sz);
return TRUE;
}
The first statement calls a helper method to initialize some static member variables used by this filter. These variables, shown in the table below, initialized from the registry (HKEY_LOCAL_MACHINE\SOFTWARE\fms2k), control certain aspects of the filter's behavior.
Member Variable | Registry Value | Purpose |
sm_flag | (default) | Used to signal the presence of a decorated URL that must be checked for a valid key. |
sm_app_name | AppName | Holds the name of the Web application, in this case, FMStocks. |
Sm_login | LoginPage | Holds the file name of the page to which a client is redirected when first visiting the site or when the session expires. |
sm_first_page | FirstPage | Holds the file name of the page to which a client is redirected when login is successful. |
sm_cookie | CookieName | Holds the name of the cookie that is used to hold an arbitrary ID. For FMStocks, it is the AccountID. |
Note that since ISAPI filters may receive notifications from multiple IIS threads they must be re-entrant. Any static or global variables accessed by the notification methods must either be read-only (as they are in this filter) or protected with a synchronization object, such as a critical section. Since the GetFilterVersion() method is called only once at the beginning of the filter's lifetime before any notifications occur, it is safe to set these variables from within its scope without synchronization protection.
Once this function returns control to IIS, the filter simply waits for notifications. Although the exact order of all of the possible ISAPI filter notifications varies, we are safe knowing that the SF_NOTIFY_PREPROC_HEADERS notification will occur before the SF_NOTIFY_SEND_RESPONSE notification—because the former occurs during the HTTP request from the client, and the latter occurs during the HTTP response to the client. Note that the MFC CHttpFilter class provides the implementation for HttpFilterProc() and calls handlers for notifications in much the same way as the MFC CWnd class calls handlers for window messages.
When a request comes in, our filter is called for the SF_NOTIFY_PREPROC_HEADERS notification.
DWORD CFms2ksecFilter::OnPreprocHeaders(CHttpFilterContext* pfc,
PHTTP_FILTER_PREPROC_HEADERS pHeaderInfo)
{
char* sz;
// Determine if this is a POST request.
get_header(pHeaderInfo, sz, "method", 0);
BOOL bIsPost= sz != NULL && 0 == _stricmp(sz, "post");
// Get the requested URL.
get_header(pHeaderInfo, sz, "URL", 0);
if(sz == NULL)
return SF_STATUS_REQ_NEXT_NOTIFICATION; // No URL; continue on.
// Remove any query string.
char* p= strchr(sz, '?');
if(p != NULL)
*p= '\0';
// Is this a login attempt?
if(bIsPost && 0 == _stricmp(sz, sm_login_path))
{
// Yes, it is. Set the context to any non-null value to check the
// response for a valid login from the ASP.
pfc->m_pFC->pFilterContext= pfc;
}
else
{
// No, it isn't. Check the path to determine what action to take.
pfc->m_pFC->pFilterContext= NULL;
switch(CheckPath(sz))
{
case let_it_go:
break;
case verify_key:
CCrypto::EState state;
state= CheckFlag(pfc, pHeaderInfo, bIsPost);
if(state == CCrypto::good)
break; // The key checks out and CheckFlag has removed it.
else if(state == CCrypto::refresh)
return SF_STATUS_REQ_FINISHED;
// CheckFlag already sent a redirect.
// Otherwise, the key is bad.
case redirect_to_login:
Redirect(pfc, sm_login, sm_len_login);
return SF_STATUS_REQ_FINISHED;
}
}
return SF_STATUS_REQ_NEXT_NOTIFICATION;
}
This notification occurs after the entire HTTP header has been received from the client but before IIS performs any processing on it. For FMStocks 2000, our notification handler determines if the request is a POST and what resource is being requested. If the client is posting to the login page, we know the user is attempting to log in to FMStocks. In this case, we wait until IIS has processed the request, is ready to send a response, and notifies us of it. For all other cases, we call the CheckPath() helper method to determine what to do with the request. CheckPath() returns one of the values described below:
CheckPath Result | Action | |
let_it_go | Allow the request to proceed unchanged. This value is returned if the request is for any file that is not an ASP page or if the ASP page's file name begins with an underscore. | |
verify_key | Since the CheckPath helper method has detected an encrypted key in the URL, call the CheckFlag helper method to determine the state of the key | |
Key State | Action | |
Good | The key is acceptable. Allow the request to proceed. | |
Refresh | The key is acceptable but is about to expire. Redirect the client to the same page with an updated key. | |
Bad | The key has expired. Redirect the client to the login page. | |
redirect_to_login | Send a "302" redirect response to the client, which directs it to the login page. |
Between this notification and the SF_NOTIFY_SEND_RESPONSE notification, IIS processes the request. If it is for an ASP page, all ASP processing (login attempts, ticker look-ups, buy and sell orders, etc.) occurs during this time, including all calls to the FMStocks BLL and DAL objects and all database accesses.
After IIS has processed the request but just before it sends a response, our filter is called for the SF_NOTIFY_SEND_RESPONSE notification:
DWORD CFms2ksecFilter::OnSendResponse(CHttpFilterContext* pfc,
PHTTP_FILTER_SEND_RESPONSE pSendResponse)
{
// Did OnPreprocHeaders() detect a login attempt?
if(pfc->m_pFC->pFilterContext != NULL)
{
// Is this the redirect header I'm expecting on successful login?
if(pSendResponse->HttpStatus == 302)
{
// Get all cookies.
char* sz;
get_header(pSendResponse, sz, "Set-Cookie:", 0);
if(sz != NULL)
{
// Look for the "Account" cookie
// ("Account=####; path=...").
char* cookie= strstr(sz, sm_cookie);
if(cookie != NULL)
{
// Extract the cookie's value.
char* cookie_value= cookie;
cookie_value += sm_len_cookie + 1; // also skip '='
char* cookie_value_end= strchr(cookie_value, ';');
if(cookie_value_end != NULL)
{
// Put the cookie into the URL and remove it
// from the HTTP response header.
DecorateURL(pfc, pSendResponse,
cookie_value, cookie_value_end);
smash_cookie(sz, cookie, cookie_value_end);
pSendResponse->SetHeader(pfc->m_pFC,
"Set-Cookie:", sz);
pfc->m_pFC->pFilterContext= NULL;
}
}
}
}
}
return SF_STATUS_REQ_NEXT_NOTIFICATION;
}
If the ASP successfully validates the login request, it sends a redirect to the account's home page. This redirect is detected in the second if statement above. If an "Account" cookie is found, its value is encoded in the response URL and removed from the cookie response header. Thus, if it is the only cookie ever used (as is the case in FMStocks), no cookie is ever sent to the client.
Rather than use hard-coded strings such as "Account" and "FMStocks," we chose to use static variables. This allows us to rename the site, change the cookie name, or vary any of several other parameters without having to rebuild the code. Because all these strings are stored in the Microsoft Windows® Registry, they may be changed at any time and the server restarted. We considered allowing these parameters to be changed without restarting the server, but doing so with active sessions is likely to confuse the filter, browser, server, or all three—even if we protect the parameters with synchronization objects.
The two places the CFms2ksecFilter object calls the CCrypto object are: in DecorateURL() to generate the key to be placed in the URL, and in CheckFlag() to verify the key in the URL. The method to generate the key is GetKey():
char* CCrypto::GetKey(char const* begin, char const* end,
char* rv, int age)
{
// Catenate the parameter, the secret seed,
// and the current time quantum into a temporary buffer.
char sz[255];
char* p= std::copy(begin, end, sz);
p= copy_z(m_szSecretSeed, p);
int i= time(NULL) / 60 / m_TimeQuantum - age;
sprintf(p, "%x", i);
// Create a hash of this string.
CalcHash(sz, rv);
return rv + m_HashLength;
}
Before describing the code, note that FMStocks makes heavy use of the C++ Standard Template Library (STL). The STL likes to work with pointers to the beginning and ending of a buffer instead of a pointer to the buffer and its size, as seen in the use of std::copy() above, and we have maintained this usage throughout where appropriate. This is a minor performance improvement when dealing with pointers to null-terminated strings, since we would otherwise need to call strlen() or strcat() or other such functions that require determining how long a string is before appending to it. Our own helper function, copy_z(), is a template function similar to strcpy(), except it returns the end of the destination string rather than its beginning. This is much more useful when cancatenating strings.
The begin and end parameters refer to the buffer holding the AccountID passed in from the CheckFlag() method. The age parameter determines which time quantum for which we want to generate a key. This parameter is zero when called by DecorateURL(), since it wants to put the client in the current time quantum upon login. The rv parameter points at a buffer into which GetKey() copies the generated key. The return value points to the end of the generated key.
The CCrypto member variables m_szSecretSeed, m_TimeQuantum, and m_HashLength are initialized from the registry (HKEY_LOCAL_MACHINE\SOFTWARE\fms2k\Crypto) when the filter first starts. These three values, described below, control certain behaviors of the CCrypto class:
Member Variable | Registry Value | Purpose |
m_szSecretSeed | Seed | Holds a string used to improve security. |
m_TimeQuantum | Timeout | Holds the number of minutes a session lasts before it must be updated. |
m_HashLength | HashLength | Holds the length of the hash generated. In the current implementation, it must be between 1 and 32 bytes. |
The method to verify the key is CheckKey():
CCrypto::EState CCrypto::CheckKey(char const* begin, char const* end,
char const* key, char const* key_end, char* rv, char*& rv_end)
{
// In web farms, the servers' clocks may not be in sync
// Check the next time quantum and allow it if it's good.
rv_end= GetKey(begin, end, rv, -1);
if(rv_end - rv != key_end - key)
return bad;
if(0 == memcmp(key, rv, key_end - key))
return good;
// Check the current time quantum.
rv_end= GetKey(begin, end, rv, 0);
if(rv_end - rv != key_end - key)
return bad;
if(0 == memcmp(key, rv, key_end - key))
return good;
// Check the previous time quantum. If it's good, tell the caller to
// refresh its copy with the new one provided in rv/rv_end.
char prev_key[255];
char* prev_key_end= GetKey(begin, end, prev_key, 1);
if(prev_key_end - prev_key != key_end - key)
return bad;
return 0 == memcmp(key, prev_key, key_end - key) ? refresh : bad;
}
This method makes use of GetKey() with different time quanta to determine whether the key is bad, good, or needs updating.
Note The first part of this method checks the time quantum that will be served next (i.e., the time quantum that corresponds to the future). When we were first testing the timeout mechanism against our load-balanced Web farm, all but one of the servers always rejected the key and sent us back to login. It turned out that that one server's clock was ahead of the others causing it to update the key too early. When any other server handled a request after that one, it determined that the key was invalid because it corresponded to neither the current time quantum nor the last one. Adding a check for the next time quantum fixed the problem.
For a clearer understanding of the code above and how the ISAPI security filter fits into the big picture, let's look at an end-to-end example. We'll take a closer look at the Web pages that allow a client to log in to or out of the site.
The code shown in Figure 3 is a simplified version of the full default.asp page. The ASP code uses a form to get the e-mail address and password from the user. When the user presses the Login button, this page posts to itself to validate the login.
Figure 3. Simplified default.asp login code
If you don't use an ISAPI filter, you can put the encryption logic in a component that you instantiated from this page and store the encrypted AccountID in a cookie. Unfortunately, you have to instantiate a component in every other page to decrypt the cookie back into the AccountID. All this happens with an ISAPI filter—the ASP code doesn't even know about it. In fact, FMStocks can be run with or without the ISAPI filter in place. Without the filter, the AccountID will be sent in a cookie to the client's browser.
Figures 4 and 5 show a successful login. Pressing the Logout link at any time after logging in redirects the user back to default.asp.
Figure 4. Login page with normal URL
If the ISAPI security filter is not installed, and you have your browser configured to prompt you before accepting cookies, you will get the dialog shown in Figure 5. This is a good way to verify the contents of the cookie.
Figure 5. IE configured to prompt for all cookies
If the ISAPI filter is installed, and you successfully log in, you will be directed to home.asp. The URL will be decorated and look similar to the one shown in the address bar in Figure 6.
Figure 6. A decorated URL after logging in
Note that the code in default.asp redirects to home.asp, not the URL shown above. Without the ISAPI filter, that is what will happen. With the ISAPI filter, the client is redirected to a URL that has been modified to include the encrypted AccountID. As time passes and the filter updates the encrypted key, the path will change. The ASP has no knowledge of this. It never sees the modified path because the ISAPI filter removes the encoded key part of the path when it detects it, validates it, and changes it into a cookie for the ASP.
You may be asking, "Why did you modify the path instead of adding a query string?" After all, changing the path means that the client will think that the same page from twenty minutes ago is completely different and will store it in the Internet cache as a different file. For instance, home.asp always delivers the same content. After using FMStocks for a few days, take a look at your Internet cache. You will notice that the same page is in there many times because it has a different path. We also ran into strange behavior due to this mechanism during testing. When the ISAPI filter decided to update an expiring key, our testing software was unable to handle the change in the URL and started reporting errors. We worked around this problem by changing the expiration time during testing to 24 hours. During normal operations, we recommend using either the default (15 minutes) or a similar time to maintain the security level it affords.
The bottom line is performance. If the ISAPI filter is to create or modify the query string, then it must do so for all HREFs (used in A, AREA, and LINK elements) and SRCs (used in FRAME, IFRAME, and SCRIPT elements) throughout the HTML text sent to the client. (To get access to the HTML, it requests the SF_NOTIFY_SEND_RAW_DATA notification.) The ASP cannot perform this task for the ISAPI filter because this introduces unnecessary coupling between the ASP and the ISAPI filter and defeats the purpose of introducing the ISAPI filter in the first place. Searching through the data looking for HREFs and SRCs and modifying them is a great deal of work and requires extra memory movement. By modifying the path, we obviate the need for such complexity. Since all HREFs and SRCs in FMStocks use relative paths, the client's browser will send requests with the correctly modified path.
FMStocks uses ASP almost exclusively, but any site that makes significant use of HTML files will definitely want to steer clear of filtering HTML data, because IIS optimizes requests for HTML files by never copying their contents from kernel space to user space. The file system opens the file, maps it into an address space, and gives the network subsystem a handle that allows it to directly squirt the bits onto the wire. This is why HTML files are served so much faster than ASP files, even if the ASP files contain no script. (IIS 5 does optimize access to "scriptless" ASP files.) Since ISAPI filters run in user space, IIS must copy all data to user mode buffers in order for the filter to have access to the data and then copy its output data back to kernel mode buffers in order for the network subsystem to put it on the wire. This is an even worse performance impact than modifying data already in user mode.
FMStocks does not use the MFC runtime. The CHttpFilter class from which CFms2ksecFilter is derived does not require linking with the MFC DLL. It is an easy matter to turn it back on if needed, but we use basic C types instead of C++ classes, such as std::string or CString, to maintain top performance by not requiring any dynamic allocation. In fact, FMStocks does not use any dynamic memory allocation after initialization, although MFC and the other runtime modules probably do so. All of our allocations are off of the stack in the form of local variables or a call to the _alloca() function.
Note the following important items when designing and developing with the ISAPI filter. A filter can affect the performance of your entire web site, not just one or two web pages. We learned these lessons relating to filters:
Chris Idzerda is a Software Engineer at Vertigo Software, Inc. Vertigo is a San Francisco Bay Area-based consulting firm that specializes in the design and development of Windows DNA applications and components. He can be reached at chris@vertigosoftware.com or on the Web at http://www.vertigosoftware.com/.