Securing Web Services with ISA Server

 

Scott Seely
Microsoft Developer Network

February 2002

Summary: Create a Web Service whose security is handled by Microsoft ISA Server: look at the Web Service and what it allows callers to do, create a client application that will call Web Methods, and create an ISA Server extension to secure the Web Service. (26 printed pages)

Note The samples shown in this article are for demonstration purposes and as such may not be production-ready. Developers wishing to use the capabilities discussed below in their systems should ensure that they implement them in such a way as to ensure the desired level of security.

Download Securing Web Services with ISA Server Sample.msi.

Contents

Introduction
A Web Service in Need of Protection
A Simple Client
ISA Server to the Rescue
Summary

Introduction

An issue facing those adopting Web Services is security. For the most part, things that you do to secure a Web site can be used to secure a Web Service. If you need to encrypt the data exchange, you use Secure Sockets Layer (SSL) or a Virtual Private Network to keep the bits secure. For authentication, use HTTP Basic or Digest authentication with Microsoft® Windows® integration to figure out who the caller is. These two items do a great job of hiding information from eavesdroppers and identifying the caller. The bad news is that these two items cannot do other items required for secure Web Services. For example, these items cannot:

  • Parse a SOAP request for valid values
  • Authenticate access at the Web Method level (they can authenticate at the Web Service level)
  • Stop reading a request as soon as it is recognized as invalid

Routers and other network hardware can provide protection for the boxes hosting the Web Service. These can filter IP addresses, perform load balancing, and a whole host of other items. Still, a hardware router is not a good place to store custom code for inspecting SOAP messages to authenticate at the Web Method level. In SOAP v1.1, an HTTP header named SOAPAction was created to declare the intent of a SOAP request. Such a mechanism would work great in a hardware router if not for the fact that this header and the SOAP request must agree. If the caller tries to break in by claiming to execute one thing while actually executing something completely different, the hardware solution fails you. So, what do you do?

Looking at the title of this article, you have probably guessed that Microsoft Internet Security and Acceleration (ISA) Server can help out. ISA filters are ISAPI filters that are registered with ISA. If you have written ISAPI filters in the past, you already know how to extend ISA Server. The only real difference is in how they are registered (we'll get to how later). In this article, we are going to create a Web Service whose security is handled by ISA Server. We will first look at the Web Service and what it allows callers to do. We will then create a client application that will call the Web Methods. This client will be able to break the Web Service due to some fabricated bugs. Finally, an ISA Server extension will be created to secure the Web Service. The extension will allow us to authenticate users at the Web Method level as well as inspect the XML to assure that the requests themselves are valid. If you need some pointers in getting ISA Server installed on the same machine as Microsoft Internet Information Services (IIS) and Microsoft Visual Studio®, refer to the links at the end of this article.

A Web Service in Need of Protection

This article's main purpose is to show how to use ISA Server to help prevent unauthorized access and malicious XML from accessing any given Web Method. To show how to do this, we need a simple Web Service so that we do not get tangled up in the particulars of accessing a database or any other code that might get complex. Two methods will be exposed: Query and Purchase. Query allows the caller to request the details of a purchase order given an order ID. Our implementation will return the same order for any order ID passed in. The security aspect is more important to this example than the underlying system. Purchase will allow the caller to create a new purchase order. Like Query, Purchase is not hooked up to any back end, so the method will be written to always succeed.

The code for the Web Service is pretty straightforward. The two methods make use of a class named PurchaseOrder. PurchaseOrder contains a Long representing the order ID and an array of OrderItems. An OrderItem is a simple class containing a Long representing the product ID. OrderItem has two other member variables to store the product name and description as strings. You can view the code for these items from the associated download.

The Order class implements the Web Service itself. As discussed, the Web Methods stay away from anything even remotely fancy. Here is the listing for the Order class:

<WebService(Namespace:="https://msdn.microsoft.com/ISADemoService")> _
Public Class Order
    Inherits System.Web.Services.WebService

    <WebMethod()> _
    Public Function Query(ByVal orderID As Long) As PurchaseOrder
        Dim retval As New PurchaseOrder()
        Dim orderItems(4) As OrderItem

        ' Create some different item names.
        Dim itemNames() As String = {"Stapler", _
            "Pencil", "Computer Workstation", _
            "Porsche 911", "MSDN Universal Subscription"}
        Dim index As Long
        Dim arrayLen As Long

        ' Fill in the return data
        retval.OrderID = orderID
        arrayLen = orderItems.Length

        For index = 0 To arrayLen - 1
            orderItems(index) = New OrderItem()
            orderItems(index).ProductDescription = _
                "Generic Description " & index
            orderItems(index).ProductID = _
                CInt(Int((6000 * Rnd()) + 1000))
            orderItems(index).ProductName = itemNames(index)
        Next
        retval.OrderItems = orderItems

        ' Return the result to the caller.
        Return retval
    End Function

    <WebMethod()> _
    Public Sub Purchase(ByVal po As PurchaseOrder)
        ' No need to do anything since this Web Method
        ' doesn't return a value. In the actual application,
        ' the order ID would be sent via e-mail to the person 
        ' placing the order when once it was processed. 
    End Sub
End Class

As you can see, the code is written without any concerns for who can access the Web Methods. This mimics the same attitude that SQL Server developers can use when writing stored procedures and COM+ developers use when exposing objects. What is that attitude? The responsibility of controlling access gets handled by code outside of the bits being protected. This model has proven useful time and again. With Microsoft ASP.NET Web Services, you can get similar protection at the .ASMX level, but not at the Web Method level. Before looking at how to protect access at that finer-grained level, we will build a simple client application that uses the Web Service.

A Simple Client

The preceding Web Service does not have any security in place. If the Web Service were further developed to actually report the contents of purchases or place purchase orders, bad things might happen. By knowing the contents of purchase orders, people might be able to figure out what types of unannounced projects are going on. If anyone could place orders, it would be difficult to make sure that people were keeping departmental budgets in line. With the state of the application as it stands, no security is in place. Furthermore, individuals could feed bad data to the Web Service to try and make it break. The ISADemoClient application demonstrates accessing the Web Service. The client stores the address of the client server in its app.config file, ISADemoClient.exe.config, under the ISADemoClient.localhost.Order key. Both methods are available to the caller at this point and the caller can feed in a bad XML stream.

The application has a simple, three-button interface shown in Figure 1.

Figure 1. The ISA Demo Client

When clicking the Query Purchase Order button, a message box will pop up showing the contents of an order. The code always asks for the same order number: 10.

Private Sub btnQuery_Click(ByVal sender As System.Object, _
    ByVal e As System.EventArgs) Handles btnQuery.Click

    Dim svc As New localhost.Order()
    ' Uniquely identify the current user. This will not be used
    ' until the Web Service starts asking for credentials.
    svc.Credentials = CredentialCache.DefaultCredentials
    Try
        Dim po As localhost.PurchaseOrder = svc.Query(10)
        Dim msg As String = "The order items are: " & vbCrLf
        Dim orderItem As localhost.OrderItem
        If Not (po.OrderItems Is Nothing) Then
        For Each orderItem In po.OrderItems
            msg = msg & "ID: " & orderItem.ProductID & vbCrLf & _
                "Name: " & orderItem.ProductName & vbCrLf & _
                "Description: " & orderItem.ProductDescription & _
                vbCrLf & vbCrLf
        Next
        MsgBox(msg)
        End If
    Catch soapEx As SoapException
        MsgBox("SOAP Exception was captured." & vbCrLf & _
            "URL: " & svc.Url & vbCrLf & _
            "Code: " & soapEx.Code.ToString() & vbCrLf & _
            "Actor: " & soapEx.Actor.ToString() & vbCrLf & _
            "Message: " & soapEx.Message)
    Catch ex As Exception
        MsgBox("An unexpected exception was captured" & vbCrLf & _
            "Details: " & ex.ToString())
    End Try
End Sub

Figure 2 shows the results of executing this code.

Figure 2. Result of pressing the Query Purchase Order button

The code underlying Submit Purchase Order is pretty simplistic: it executes the Query Web Method and then submits that purchase order to the Purchase method. The thing we have not examined yet is the transmission of an intentionally bad request. What would happen if the Query method contained a string of gibberish data? Would ASP.NET handle it well? To test this, the code for the Send Malformed Request button sends a really long string of characters to the Web Server. The really long string is contained in a request to the Query method using a 20 thousand character, URL-encoded string. By URL-encoding the string, the parser has to convert the value back to a regular string with all the odd characters embedded and then parse that huge value to figure out if the string can be converted to a numeric value. This uses up large amounts of memory on the server and processor time on the server. If enough machines tried this tactic simultaneously, the server might see a denial of service attack coming its way. The following listing shows what the code is doing to create a large, seemingly valid request and then hit the server.

Private Sub btnMalformed_Click( _
    ByVal sender As System.Object, _
    ByVal e As System.EventArgs) Handles btnMalformed.Click
    Dim svc As New localhost.Order()
    Dim theUri As New Uri(svc.Url)
    Dim i As Long

    svc.Dispose()
    Dim client As TcpClient
    Dim netStream As NetworkStream
    Try
        Dim theBytes() As Byte
        ' Open up the salvo using a valid header
        Dim openingMessage As String = _
            "POST /ISADemoService/Order.asmx HTTP/1.1" & vbCrLf & _
            "User-Agent: Mozilla/4.0 (compatible; MSIE 6.0; " & _
            "MS Web Services Client Protocol 1.0.3705.0)" & _
            vbCrLf & "Content-Type: text/xml; charset=utf-8" & _
            vbCrLf & "SOAPAction: " & _
            """https://msdn.microsoft.com/ISADemoService/Query"" " & _
            vbCrLf & "Connection: Keep(-Alive)" & vbCrLf & _
            "Host: " & theUri.Host & vbCrLf & _
            "Content-Length: "

        ' Set the opening SOAP message
        Dim soapMsg As StringBuilder = New StringBuilder( _
            "<?xml version=""1.0"" encoding=""utf-8""?>" & _
            vbCrLf & "<soap:Envelope " & _
            "xmlns:soap=" & _
            """https://schemas.xmlsoap.org/soap/envelope/"" " & _
            "xmlns:xsi=""http://www.w3.org/2001/" & _
            "XMLSchema-instance"" " & _
            "xmlns:xsd=""http://www.w3.org/2001/XMLSchema"">" & _
            "<soap:Body><Query " & _
            "https://msdn.microsoft.com/ISADemoService"">" & _
            vbCrLf & "<orderID>")

        theBytes = Encoding.UTF8.GetBytes(openingMessage)

        ' Now, write random gibberish to the output stream for 
        ' 20,000 (bytes)
        Dim aByte As Byte
        Dim byteArray() As Byte = {0}
        Dim aValue As String
        Dim capacity As Integer = 20000
        Dim soapValue As New StringBuilder(capacity)
        For i = 0 To capacity - 1
            Randomize()
            aByte = CByte(Int(Rnd() * 255))
            byteArray(0) = aByte
            aValue = Encoding.ASCII.GetString(byteArray)
            soapValue.Append(aValue)
        Next
        soapMsg.Append(HttpUtility.UrlEncode( _
            soapValue.ToString()))
        soapMsg.Append( _
            "</orderID>" & vbCrLf & "</Query></soap:Body>" & _
            "</soap:Envelope>")
        openingMessage = openingMessage & _
            soapMsg.Length.ToString() & vbCrLf & _
            vbCrLf & soapMsg.ToString()
        theBytes = Encoding.UTF8.GetBytes(openingMessage)
        client = New TcpClient(theUri.Host, theUri.Port)
        netStream = client.GetStream()
        netStream.Write(theBytes, 0, theBytes.Length)
        ReDim theBytes(20000)
        netStream.Read(theBytes, 0, theBytes.Length)
        MsgBox(Encoding.UTF8.GetString(theBytes))
    Catch ex As Exception
        ' We just want to catch it, not do anything with it.
        ' Why? This code is meant to be evil.
        MsgBox("Number of iterations: " & i.ToString() & vbCrLf & _
            "An exception occured while hitting the Web Service" _
            & vbCrLf & "Details: " & ex.ToString())
    Finally
        netStream.Close()
        MsgBox("All done")
    End Try
End Sub

We need a way to protect against this type of attack by not letting the caller get to the Web Service with this type of request.

ISA Server to the Rescue

At this point, we have a Web Service that is open to the world. Even if we imposed security on the Order.asmx file, we could not protect the Web Service from the following items:

  • Excessively large requests
  • Web Method level access

To do this, we need to filter the request and parse the XML message itself. To do this, we have a couple of choices and the coding effort will be the same. One choice is to apply an ISAPI filter on the IIS server that validates the request before passing it on to the Web Service. The downside to doing this is that IIS must provide security across the board. Also, you need to lock down the IIS box, and make sure that nothing else is accessible. If the Web Service is deployed on a Web Farm, you have to do this again for each and every IIS box in the farm. Not only that, but IIS is not a software firewall. To do this, you need to use a security-focused product. This is where ISA Server comes into play.

ISA Server can be set up to publish Web servers sitting on an internal network. ISA can receive the request and, depending on the rules set up for the requests, direct the request to the correct server. Redirection works using Web publishing rules. It is possible to create rules so restrictive that only the Web Service pages you wish to expose are visible. This way, if an application gets installed on the IIS box and happens to expose some functionality via HTTP, the ISA server will not know about this and will automatically block the requests because ISA will not know how to handle the request. Another advantage of using ISA is that it does have a fairly complete interface for managing the publication of internal network Web servers. This interface allows you to handle security from a single machine.

Because this is a somewhat involved setup, we will go through the details of how to set up IIS if you are deploying ISA and IIS on the same machine, ISA, and then cover the ISAPI extension deployed to ISA.

Configuring IIS

If the IIS machine is different from the ISA machine, you do not need to do anything special with IIS. If they are on the same machine, ISA will need to listen to port 80 (the default HTTP port) and you want to make sure that a port-scanning tool does not find any alternative ports open. To do this, you have to do more than just change the port that IIS listens to. To make IIS unable to respond to external requests, you need to configure it to listen to a port other than 80 on the localhost address: 127.0.0.1. To do this, follow these steps:

  1. Run inetmgr.exe (Click Start, click Run, and in the text box, type inetmgr. Click OK).

  2. Navigate to (local computer)/Web Sites/Default Web Site and click the Default Web Site node.

  3. On the Action menu, click Properties.

  4. On the Web Site tab, change the IP Address value to 127.0.0.1 (localhost) and the TCP port to 8000. Figure 3 shows how the Web Site tab should look after you make the changes.

  5. Repeat the above steps for any other sites running on the same IIS installation.

    Figure 3. Setting up ISA to listen to 127.0.0.1:8000

You can't listen on port 80 on 127.0.0.1 because ISA will be listening on that port, too. IIS listens for requests originating on 127.0.0.1 because the only application that can talk to that address must also live on the same machine. Now, if you try to connect to any page on that machine from an external box, IIS will not be able to connect. To test this, find another computer on the same network as your IIS computer and try to connect to this URL: http://[machine name]:8000/ISADemoSerivce/Order.asmx. (The test assumes that you installed the sample on that machine.) IIS should not respond to the request. So, what do you need to do with ISA to make it able to respond to the request?

Configuring ISA

If you are like most other technical industry professionals, ISA is something you have heard of but haven't used much. Because of this, we will walk through the complete configuration of ISA so that you can set it up to filter out all requests except for those authenticated individuals trying to access the Web Site. These steps will work if you set up ISA on the same machine as IIS or on a separate machine. If you are experimenting with ISA, I recommend installing the ISA Server 2000 Standard Edition. If you do choose to install the Enterprise Edition, make sure to install it in stand-alone mode. Any other mode will cause the Enterprise Edition to make modifications to the Active Directory schema on your network. The remainder of the article assumes you installed ISA Server Standard Edition.

For initial setup, make sure that when choosing the installation type in the Microsoft ISA Server (Standard Edition) Setup dialog box, you select Full Installation as shown in Figure 4.

Figure 4. Choosing Installation Type in ISA

The next screen will ask you what mode to use when installing the server. Select Firewall Mode. This example does not take advantage of the caching functionality present in ISA. As a result, the other two modes are of no use. Figure 5 shows how this dialog box should look before you click Continue.

Figure 5. Installing ISA Server 2000 in Firewall Mode

When ISA asks you to enter the IP address ranges that span the internal network space, have ISA construct the table for you. Once the table is constructed, make sure that any back-end servers are included in the Local Address Table (LAT). Clients should not be in the LAT, though they can be if ISA is only being used to protect internal data and is acting as a Web Server to the outside world. Once ISA is completely installed, we have just a few tasks to take care of.

When ISA is done installing, it will offer to open the ISA Management interface. If you need to open it again, click Start, click Programs, click Microsoft ISA Server, and then click ISA Management. This brings up the ISA Management console. To configure ISA, we must perform the following tasks:

  • Configure ISA to listen on port 80.
  • Configure ISA to allow HTTP traffic.
  • Configure ISA to redirect requests for the /ISADemoService/Order.asmx page to IIS.
  • Require client authentication for requests to /ISADemoService/Order.asmx.

The following instructions all assume that you are using the ISA Management console.

Out of the box, ISA Server does not listen on any ports. To give it the ability to handle HTTP traffic, you first have to open up port 80 on the ISA server. The following steps show you how to do this:

  1. In the tree view, select the Internet Security and Acceleration Server/Servers and Arrays/[Computer Name]/Computer node.

  2. Right-click the node and select Properties.

  3. Click the Incoming Web Requests tab.

  4. In the Identification group box, select the Configure listeners individually per IP address radio button.

  5. Click Add.

  6. In the Server combo box, select the ISA machine.

  7. In the IP Address combo box, select the external IP address for the ISA machine. If you have one network card in the server, choose the address that is not the localhost address (127.0.0.1).

  8. For the Display Name, type ISA HTTP Listener.

  9. Leave all other defaults alone. When you are done, the Add/Edit Listeners dialog box should look similar to what you see in Figure 6. Figure 7 shows what the Incoming Web Requests tab should look like as well.

    Figure 6. Configuring the Add/Edit Listeners for the ISA HTTP Listener

    Figure 7. Configuring the Incoming Web Requests setup

  10. In the Add/Edit Listeners dialog box, click OK.
    In the Properties dialog box, click OK.

  11. When the warning comes up, Save changes and restart the service(s).

Now that ISA is ready to listen to port 80, we need to create a destination set. A destination set defines a set of directories, files, or computers. These sets are then used to set up other pieces of ISA. This sample uses a destination set as a part of a Web Publishing Rule. To create the destination set for the Web Service, follow these steps:

  1. In the tree view, select the Internet Security and Acceleration Server/Servers and Arrays/[Computer Name]/Policy Elements/Destination Sets node.

  2. Click the Create a Destination Set icon. The New Destination Set dialog box opens.

  3. Name the destination set ISADemoService DS.

  4. Click Add... to open the Add/Edit Destination dialog box.

  5. Click Browse... to open the Select Computer dialog box.

  6. Select the machine that has IIS and the sample installed (or type in the name) and click OK.

  7. In the Path edit box, type /ISADemoService/Order.asmx. The dialog should almost match what is shown in Figure 8. You could open up the path to allow /ISADemoService/* as well to allow access to all files in the directory. This example is showing how to minimize what you expose to the outside.

    Figure 8. Editing the Add/Edit Destination dialog to handle only the Order.asmx file

  8. Click OK. The New Destination Set dialog box should appear as shown in Figure 9. Click OK again and the destination set will be saved.

    Figure 9. Adding the ISADemoService DS destination set

Almost everything is in place from the ISA end of things. We still need to tell ISA how to find the Web Service. To do this, we need to add a Web Publishing Rule. By default, all requests are denied. If a particular request comes in and a rule does not cover it, ISA denies the client. This is where ISA works great for publishing Web content and making it difficult to open up access to other services. Unless you make a rule too general, ISA is going to help you keep things secure. To create the rule to publish just the Web Service, follow these steps:

  1. In the tree view, select the Internet Security and Acceleration Server/Servers and Arrays/[Computer Name]/Publishing/Web Publishing Rules node.

  2. In the Web Publishing Rules view, click Create a Web Publishing Rule. This brings up the New Web Publishing Rule wizard.

  3. Name the rule ISADemoService and click Next.

  4. For the Destination Sets page of the wizard, we want to apply this rule to a Specified destination set. In the Apply this rule to combo box, select Specified destination set. (Doing this shows some fields on the dialog that were previously hidden.)

  5. In the Name combo box, select the ISADemoService DS destination set we just created. Figure 10 shows what the dialog should look like.

    Figure 10. Selecting the ISADemoService DS destination set for the Web Publishing Rule

  6. Click Next.

  7. For Client Type, select the Any request radio button. We want to apply this rule to all requests going to the Web Service.

  8. In the Rule Action dialog box, you want to respond to client requests by redirecting the request to an internal Web server.

    1. If the Web server is the same as the ISA box, redirect the request to 127.0.0.1. Send the HTTP request to port 8000.

    2. If the Web server is on another computer, redirect the request to that machine. Send the HTTP request to port 80 (unless the other machine is listening on a different port).

      Figure 11 shows what this dialog should look like when redirecting to IIS setup on the same machine.

      Figure 11. Setting the Web Publishing Rule to send requests to IIS listening on localhost, port 8000

  9. Click Next and then click Finish. The rule is now published.

If you want to make access to the Web Service more restrictive, in the Web Publishing Rules view, double-click ISADemoService Rule. Click the Applies To tab and either specify the addresses or users that have access to the Web Service. Figure 12 shows the rule restricting access to individuals that the machine can authenticate.

Figure 12. Restricting access to only real users

Because so much stuff has been done, you need to make sure the correct services restart. To apply the changes, cycle the services in ISA Server. You can do this from the Select the Internet Security and Acceleration Server/Servers and Arrays/[Computer Name]/Monitoring/Services node. At this point, the Web Service can be accessed by anyone other than anonymous users. This handles one requirement. We still need to block excessively large requests and need to manage access to the Purchase Web Method. For this, we need an ISAPI extension.

The ISAPI Extension for ISA

This filter has two jobs: make sure any requests for /ISADemoService/Order.asmx are under 20000 bytes and make sure that only members of the Administrators group have access to the Purchase Web Method. I chose the Administrators group because every version of Windows that can run ISA Server 2000 has this group installed by default. In your own situations, a different group peculiar to your organization would be more appropriate. Because ISA uses ISAPI extensions, you can use Visual Studio to create the filter. To do this, I went into Visual Studio .NET and created a new Microsoft Visual C++® Project that is an MFC ISAPI Extension DLL. ISA requires that the DLL gets registered with ISA on installation. To make this work with an existing DLL registration tool, regsvr32.exe, the ISAPI extension needs to export two functions: DllRegisterServer and DllUnregisterServer. The code is boilerplate that I lifted from an example supplied to me by a member of the ISA Server team. You can see that code in SOAPFilter/Register.cpp. The settings that drive this file are located in SOAPFilter/Configure.h. You can copy and reuse these two files in your own projects. Make sure to update the s_szGuid value for your extensions using the Create GUID tool. To access it in Visual Studio .NET, click Tools, and then click Create GUID. Use the Registry Format and click Copy to get the GUID onto the clipboard. The sample uses the following code:

static char const s_szGuid[]= "{99b253fd-128c-4b2f-8176-e5fb5889e4cd}";

For our filter, we need to decide what events we need to monitor in order to process the request correctly. Since the filter needs to know who the caller is, how big the message is, and what method the caller is executing, the filter needs to listen for the following notifications:

  • SF_NOTIFY_AUTH_COMPLETE—Lets us know when the caller has been authenticated and tells us who it is. If the user does not pass this minimum bar of being authenticated, the filter does not need to do anything.
  • SF_NOTIFY_PREPROC_HEADERS—Using this notification, we can filter out requests larger than 20000 bytes. ISA will make sure that the Content-Length HTTP header is obeyed and won't read in extra bytes. The filter will make sure that the value does not exceed the 20000-byte threshold.
  • SF_NOTIFY_READ_RAW_DATA—We do not want to do anything with this notification until after the client has been authenticated. Once the client is authenticated, the filter will use this notification to read the SOAP message and see what the user wants to do. We inspect the SOAP itself instead of the HTTP SOAPAction header because the client can lie about what the call is doing. This also gives the example an excuse to show you how to crack open the SOAP message and inspect the content.

The ISAPI filter that does all of this is called SOAPFilter.dll. The project has already been installed on your computer if you ran the installation for this sample. The installation does not install the filter—you have to do this by building the project. The filter contains two classes: CSOAPFilter and CSessionContext. CSOAPFilter handles any notifications and makes sure that the message is correctly sized. CSessionContext verifies that the message is being handled by the correct filter and verifies that the message can be executed by the caller.

The ISAPI filter registers the notifications it is interested in by implementing GetFilterVersion. A constant, s_nNotifications, contains the notifications that the filter cares about.

static const DWORD s_nNotifications = 
    SF_NOTIFY_READ_RAW_DATA | 
    SF_NOTIFY_PREPROC_HEADERS | 
    SF_NOTIFY_AUTH_COMPLETE;

GetFilterVersion initializes the relationship between the filter and ISA.

BOOL CSOAPFilter::GetFilterVersion(PHTTP_FILTER_VERSION pVer) {
   // Call default implementation for initialization
   CHttpFilter::GetFilterVersion(pVer);

   // Clear the flags set by base class
   pVer->dwFlags &= ~SF_NOTIFY_ORDER_MASK;

   // Set the flags we are interested in
   pVer->dwFlags |= SF_NOTIFY_ORDER_LOW | SF_NOTIFY_SECURE_PORT | 
            SF_NOTIFY_NONSECURE_PORT | s_nNotifications;

   // Load 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;
}

Next, we need to write the code that handles the client authorization notification. To do this, we implement the virtual method OnAuthComplete in the ISAPI filter. When the client has been authorized, this function allows us to get a token representing the user. Using this token, we can discover what groups the user belongs to. Two ATL classes make this very easy: CAccessToken and CTokenGroups. CAccessToken encapsulates the functions one might use on an access token. CTokenGroups helps a programmer manage the security IDs (SIDs) associated with a token. A SID can map to a user, group, or device. In the implementation of OnAuthComplete for the SOAP filter, the code checks to see if the client is a member of the built-in Administrators group.

DWORD CSOAPFilter::OnAuthComplete(
    CHttpFilterContext* pCtxt, 
    PHTTP_FILTER_AUTH_COMPLETE_INFO pAuthComplInfo){
    DWORD retval = SF_STATUS_REQ_NEXT_NOTIFICATION;
    HANDLE hUserToken = (HANDLE)1;

    if ( pAuthComplInfo->GetUserToken( pCtxt->m_pFC, &hUserToken ) ) {
        CAccessToken tok;
        bool bIsAdmin = false;
        tok.Attach(hUserToken);

        // Get the token groups.
        CTokenGroups tokGroups;
        if (tok.GetGroups(&tokGroups)) {
            // Determine whether the Administrators group
            // is one of the user's groups.
            if (tokGroups.LookupSid(Sids::Admins())) {
                 // The user is an administrator.
                 bIsAdmin = true;
            }
        }

        // We're done with the token. 
        tok.Detach();
        CSessionContext::Get( pCtxt )->SetAdmin( bIsAdmin );
    }
    return retval;
}

When this function finally gets called, we store the fact that the client is or is not an administrator in the CSessionContext associated with this particular exchange. Until CSessionContext::SetAdmin gets called, we know that the user has not been authenticated. As a result, the call to SetAdmin remembers if the client is an Administrator and if the client has been mapped to a Windows user identity.

void CSessionContext::SetAdmin(bool bAdmin){
    m_bAdmin = bAdmin;
    m_bUserHasAuthenticated = true;
}

One item the filter can identify at any time is the overall size of the message. To do this, an override also exists for OnPreprocHeaders. When the time comes, this method takes a look at the headers and verifies that the message total message size is under 20000 bytes. To do this, the method simply looks at the Content-length HTTP header and reads it in. If the value is greater than 20000, the filter denies the connection.

DWORD CSOAPFilter::OnPreprocHeaders(CHttpFilterContext* pCtxt, 
    PHTTP_FILTER_PREPROC_HEADERS pHeaders)  {
    DWORD retval = SF_STATUS_REQ_NEXT_NOTIFICATION;

    if ( IsWatchedService( pCtxt ) ) {
        TCHAR szHeader[512];
        DWORD n= sizeof(szHeader);
        if(pHeaders->GetHeader(pCtxt->m_pFC, _T("Content-Length:"),
            szHeader, &n)) {
            DWORD nContentLength= atoi(szHeader);
            if ( nContentLength < CSOAPFilter::MAX_MSG_SIZE ) {
                n= sizeof(szHeader);

                if(pHeaders->GetHeader(pCtxt->m_pFC,
                    _T("Content-Type:"), szHeader, &n)) {
                    CSessionContext* psc= CSessionContext::Get(pCtxt);
                    // Allocate the space in the CSessionContext
                    // for any more data that comes in.
                    if(psc == NULL || !psc->SetupBuffer(pCtxt, 
                        szHeader, nContentLength)) {
                        IgnoreFurtherRequests(pCtxt);
                    }
                }
            } else {
                // Reject the client's request.
                pCtxt->m_pFC->ServerSupportFunction(pCtxt->m_pFC,
                    SF_REQ_SEND_RESPONSE_HEADER,
                    _T("416"),
                    reinterpret_cast<DWORD>(_T("None Acceptable")),
                    0);
                retval = SF_STATUS_REQ_FINISHED;
            }
        }
    }

    return retval;
}

    return retval;
}

OnReadRawData, the handler for notifications that data has arrived, gets called before any of the other notifications this filter listens for. If the request is for a page other than /ISADemoService/Order.asmx, this handler will tell ISA to skip the filter for any further notifications on this session. How does this happen? When the filter realizes that the message does not apply to this protected file, it tells ISA that it wants to stop listening for the current session. The way the filter makes this decision is by passing the parsing on to the CSessionContext::Read method. Every HTTP session that we will be interested in will start by sending in the following string:

static const TCHAR s_szPostRequest[]= 
    _T("POST /ISADEMOSERVICE/ORDER.ASMX");

If the session starts out with this string, the filter should keep listening. If not, it should ignore any further requests. CSessionContext::Read also reads in the complete request into a buffer to be analyzed once the complete request has been received.

bool CSessionContext::Read(CHttpFilterContext* pCtxt, 
    LPCTSTR lpszData, DWORD cData, LPTSTR& lpszContent) {
    bool retval = true;
    lpszContent= NULL;
    const CString szPostRequest( s_szPostRequest );
    TCHAR* buff = NULL;
    const long buffLen = szPostRequest.GetLength() + 1;
   switch(m_state) {
    case LOOKING_FOR_POST_REQUEST: {
        buff = new TCHAR[buffLen];
        memset( buff, _T('\0'), buffLen );
        if ( NULL != _tcsncpy(buff, lpszData, buffLen - 1 ) ) {
            CString value( buff );
            value.MakeUpper();
            if( value != szPostRequest) {
                // I didn't find the one I wanted.
                retval = false;
            }
        }
        delete[] buff;
        m_state = ON_CORRECT_REQUEST;
        }
        break;

   case COLLECT_DATA:
        // Make sure that the new data will not 
        // overflow the buffer.
        if ( ( m_iReadIndex + cData ) <= m_nContentLength ) {
            memcpy(m_lpszContent + m_iReadIndex, lpszData, cData);
            m_iReadIndex += cData;
            if(m_iReadIndex == m_nContentLength) {
                LPTSTR begin= m_lpszContent;
                begin += lstrlen(m_lpszBoundary);
                lpszContent= begin;
                m_state = LOOKING_FOR_POST_REQUEST;
            }
        } else {
            retval = false;
        }
        break;
    default:
        retval = false;
    }

    return retval;
}

Once the filter knows that the user has been authenticated, that the message is for /ISADemoService/Order.asmx, and that the complete message has been received, the filter needs to check to see what Web Method the user wants to execute. Depending on that, the user may or may not be denied access to the Web Service. This check happens in CSessionContext::IsAcceptable.

bool CSessionContext::IsAcceptable(
    CHttpFilterContext* pCtxt, LPCSTR lpszText) {
    bool retval = false;
    HRESULT hr= CoInitialize(NULL);

    if(SUCCEEDED(hr)) {
        try {
            XML::IXMLDOMDocument2Ptr x(__uuidof(XML::DOMDocument));
            if(x->loadXML(lpszText)) {
                x->setProperty( _T("SelectionNamespaces"), 
                _T("xmlns:soap="
                "'https://schemas.xmlsoap.org/soap/envelope/'"));
                XML::IXMLDOMNodePtr bodyNode = 
                x->documentElement->selectSingleNode(
                _T("/soap:Envelope/soap:Body") );
                if ( bodyNode->firstChild->baseName == 
                    _bstr_t( _T("Query") ) ) {
                    retval = true;
                } else if( bodyNode->firstChild->baseName == 
                    _bstr_t( _T("Purchase") ) ) {
                    retval = m_bAdmin;
                } 
            }
        }
        // If either catch gets called, something is likely wrong 
        // with the XML. As a result, let's bounce the request.
        catch(_com_error& err){
            TRACE( _T("%s\n"), err.Description() );
            retval = false;
        } catch (...) {
            retval = false;
        }

        CoUninitialize();
    }
    return retval;
}

In IsAcceptable, the message is read into an XML document. If the message contains invalid XML or requests a method that is not recognized, the entire message is rejected and the function returns false. Otherwise, the code makes sure that the caller has access to the requested method and allows execution to continue.

To build the project, make sure that the ISA SDK include directory is a part of your include path. To add this in Visual Studio .NET, on the Tools menu, click Options. In the Options dialog box, click Projects, and then click VC++ Directories. In the Show directories for: combo box, select Include Files. Assuming that you installed the demonstration version of ISA Server 2000 and extracted the download to the default directory, add the C:\ISA Server Standard CD\sdk\inc to the list. If you have the ISA Server 2000 installation disk, copy the sdk\inc directory to your local machine and add that path to the list. Then, to install the extension on your machine, just build the project. Assuming that you installed ISA Server 2000 to the default directory on the C: drive, everything will be installed for you. This is done through a step that runs at the end of the build. If ISA is in a different location than the default, open up the file SOAPFilter\copyproj.bat and run search-and-replace on the string "C:\Program Files\Microsoft ISA Server\", replacing that text with the location of the ISA Server install point. Then, rebuild the project. The filter DLL will be registered in ISA Server 2000. If it was registered correctly, ISA Manager should display the Web Filter as shown in Figure 13.

Figure 13. A successful installation of the SOAP filter

To make sure that the filter is working correctly, run the ISADemoClient application again. The Send Malformed Request button will not even allow the message to go through to ASP.NET. If you click Submit Purchase Order and you are not a member of the Administrator group on the ISA server box, the request will be denied as well.

Summary

In this article, we took a look at how to add security to a Web Service at the method level by creating an ISA Extension. This extension also allowed us to block invalid incoming requests before they even access the Web Service. By using ISA, we were able to reduce the likelihood that a denial of service attack would be successful against the Web Service.

For a more complex Web Service, the rules might be stored in a database or other location that allows for easier changes and addition of Web Services to the rules. The good news is that the capability exists to add Web Method level security today. The bad news: you have to build it yourself if you run on ASP.NET. The same is true if you want to filter requests at the ISA level and your Web Service uses Microsoft .NET Remoting and COM+.

ISA Server 2000 home page: This page includes a link to a 120-day trial version of ISA Server 2000.