• blogs
  • elite
  • jobs
  • market

RSSASP.NET IIS 6 and ASP.NET MVC extensionless URLs that work, even in postbacks

While there are a number of solutions (here, here and here) for making ASP.NET MVC extensionless URLs run under IIS 6, I’d like to introduce my own approach. It’s not a silver bullet, but other solutions have their pitfalls as well.

My solution is based on a well-known approach that uses a custom URL for the 404 error code in IIS. I found this approach practical when I had to do URL rewriting in IIS 6 about 2 years ago. I didn’t want to use custom ISAPI modules or ASP.NET wildcard execution. Hence, I combined the 404 error code approach with the free UrlRewritingNet.UrlRewrite library to get the perfect answer to my needs. I’ve successfully used this approach in various projects for last 2 years when I was limited to IIS 6. Although there were a few attempts to use this solution for MVC (like here), they didn’t support postbacks, which made them useless in real life. However, my approach does support postback! And I will tell you how.

In IIS 6, it is possible to execute a custom URL when the 404 error occurs. This means that when IIS is unable to find the user requested URL on the server, it will pass the execution to the specified URL. This URL could be something ending with .aspx or .ashx or any other extension that is processed by the ASP.NET engine. Therefore, when IIS executes our custom URL for the 404 error code, it will be passed to ASP.NET. This is exactly what we need!

To configure this custom URL execution, you need to perform following steps:

  1. Open the Properties dialog box for your Virtual Directory or Website in IIS Manager;
  2. Locate the Custom Errors tab;
  3. Scroll the list of HTTP errors to 404;Image
  4. Press the Edit button;
  5. Select URL from Message type drop-down list;Image
  6. Enter the full relative path to your application + a file name that is processed by ASP.NET. It could be a name of non-existent file. In my case I used this path: “/mvc/elt-urlrewriting.ashx”, where “mvc” is my Virtual Application name and “elt-urlrewriting.ashx” some file name that will be run by ASP.NET because of the .ashx extension. If you use this approach for Website, you could write something like this: “/elt-urlrewriting.ashx”. Just make sure that you write the full path before the file name!

Now let’s see how it works:

  1. A user requests a URL on our server: http://mvc/Account/LogOn;
  2. IIS is unable to find “Account/LogOn” because such file doesn’t exist;
  3. It will raise 404 error and pass the execution to our configured URL;
  4. Since we used an extension that is processed by ASP.NET, ASP.NET will be activated and it will execute the request;
  5. We can use our own HttpModule to do all required URL rewriting to map virtual URLs to real pages.

Since we are using ASP.NET MVC, which already has its own URL rewriting module, we only need to help it process our URLs that come through our custom 404 error page.

And we really do need to provide this help, because when IIS passes execution to the custom page, it changes the URL to something like this:
/elt-urlrewriting.ashx?404;http://mvc:80/Home/About

To make URL rewriting run smoothly for our solution, we need to use a custom HttpModule to prepare URLs for ASP.NET MVC routing. Make sure that caching and URL resolving in Themes work properly, and prepare correct action URLs in forms for correct postback.

The following is the source code for our URL rewriting module.

/*
 * Sources are licensed under the Creative Commons license (http://creativecommons.org/licenses/by-sa/3.0/) 
 * with attribution (http://EliteBrains.com/info/help#attribution) required.
 * http://EliteBrains.com
 */
/// <summary>
/// ASP.NET MVC URL rewriting helper module for IIS 6. 
/// </summary>
public class UrlRewritingModule : IHttpModule
{
    /// <summary>
    /// Lookup string for 404-error URLs from IIS.
    /// </summary>
    private const string Lookup404 = "?404;";
    
    /// <summary>
    /// Lookup key for original URL that is stored in HttpContext.
    /// </summary>
    private const string OriginalUrlKey = "Elt_UrlRewritingModule_Url";
    
    /// <summary>
    /// Reference to HttpRequest._httpMethod field.
    /// </summary>
    private static readonly FieldInfo _httpRequestHttpMethodField;

    /// <summary>
    /// Reference to HttpRequest._httpVerb field.
    /// </summary>
    private static readonly FieldInfo _httpRequestHttpVerbField;

    /// <summary>
    /// Reference to HttpRequest._wr field.
    /// </summary>
    private static readonly FieldInfo _httpRequestWrField;
    
    /// <summary>
    /// Holder for HttpVerb.Unparsed value.
    /// </summary>
    private static readonly object _httpVerbUnparsedValue;

    /// <summary>
    /// Initializes fields' references using Reflection.
    /// </summary>
    static UrlRewritingModule()
    {
        var requestType = typeof (HttpRequest);
        _httpRequestHttpMethodField = requestType.GetField("_httpMethod", BindingFlags.NonPublic | BindingFlags.Instance);
        _httpRequestHttpVerbField = requestType.GetField("_httpVerb", BindingFlags.NonPublic | BindingFlags.Instance);
        _httpRequestWrField = requestType.GetField("_wr", BindingFlags.NonPublic | BindingFlags.Instance);

        // Getting HttpVerb.Unparsed enum value.
        var httpVerbType = _httpRequestHttpVerbField.FieldType;
        var httpVerbUnparsed = httpVerbType.GetField("Unparsed");
        _httpVerbUnparsedValue = Enum.ToObject(httpVerbType, (int) httpVerbUnparsed.GetValue(httpVerbType));
    }

    #region IHttpModule Members

    public void Init(HttpApplication context)
    {
        context.BeginRequest += ContextBeginRequest;
        context.PreRequestHandlerExecute += ContextPostMapRequestHandler;
    }

    public void Dispose()
    {
    }

    #endregion

    /// <summary>
    /// Handler for BeginRequest event.
    /// </summary>
    private static void ContextBeginRequest(object sender, EventArgs e)
    {
        var context = HttpContext.Current;

        string rawUrl = context.Request.RawUrl;
        if (string.IsNullOrEmpty(rawUrl))
        {
            throw new InvalidOperationException("Cannot read RawUrl property.");
        }

        int pos = rawUrl.IndexOf(Lookup404, StringComparison.Ordinal);
        if (pos < 1)
        {
            // Not a 404-error.
            return;
        }

        Uri requestUri;
        if (!Uri.TryCreate(rawUrl.Substring(pos + Lookup404.Length), UriKind.Absolute, out requestUri))
        {
            throw new InvalidOperationException("Cannot create URI from string \"" +
                                                rawUrl.Substring(pos + Lookup404.Length) +
                                                "\".");
        }

        // Store request URL to be used in PreRequestHandlerExecute.
        context.Items.Add(OriginalUrlKey, requestUri.AbsoluteUri);

        string contentType = context.Request.ContentType;

        if (!string.IsNullOrEmpty(contentType)
            && String.CompareOrdinal(context.Request.HttpMethod, "GET") == 0
            && (contentType.IndexOf("x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) > -1
                || contentType.IndexOf("multipart/form-data", StringComparison.OrdinalIgnoreCase) > -1)
            && _httpRequestHttpMethodField != null)
        {
            // HACK.
            // Change request method to POST. 
            // When we have 404 request from IIS it comes with GET method even if there was POST.
            _httpRequestHttpMethodField.SetValue(context.Request, "POST");
            _httpRequestHttpVerbField.SetValue(context.Request, _httpVerbUnparsedValue);
        }
        
        // Get query string parameters.
        string requestQueryString = string.Empty;
        if (!string.IsNullOrEmpty(requestUri.Query))
        {
            requestQueryString = requestUri.Query.Substring(1);
        }

        //Identify the correct base folder.
        pos = requestUri.AbsoluteUri.IndexOf("/", 8, StringComparison.Ordinal);
        string clientBase = "/";
        string urlPath = requestUri.AbsoluteUri.Substring(pos);
        pos = urlPath.LastIndexOf("/", StringComparison.Ordinal);
        if (pos == urlPath.Length - 1)
        {
            clientBase = urlPath;
        }
        else if (pos > 1)
        {
            clientBase = urlPath.Substring(0, pos + 1);
        }

        // Here we need to rewrite path twice for correct work of Themes and URL resolving.
        // Client path should be changed with accordance to directory hierarchy in URL.
        context.RewritePath("~" + clientBase, clientBase, requestQueryString, true);

        // This is for correct current URL (Request.Url) handling.
        context.RewritePath(requestUri.AbsolutePath, string.Empty, requestQueryString, false);
    }

    /// <summary>
    /// Handler for PostMapRequestHandler event.
    /// </summary>
    private static void ContextPostMapRequestHandler(object sender, EventArgs e)
    {
        HttpContext context = HttpContext.Current;
        var url = (string)context.Items[OriginalUrlKey];

        // Check if we got an URL so rewriting had occurred and we need to process.
        if (url != null)
        {
            // Now we have to change worker request to be able to change RawUrl field.
            _httpRequestWrField.SetValue(context.Request,
                                         new HttpWorkerRequestWrapper(
                                             (HttpWorkerRequest)_httpRequestWrField.GetValue(context.Request),
                                             url));
        }
    }

    /// <summary>
    /// Wraps internal HttpWorkerRequest to return custom RawUrl.
    /// </summary>
    private class HttpWorkerRequestWrapper : HttpWorkerRequest
    {
        private readonly HttpWorkerRequest _originalRequest;
        private readonly string _rawUrl;

        public HttpWorkerRequestWrapper(HttpWorkerRequest originalRequest, string rawUrl)
        {
            _originalRequest = originalRequest;
            _rawUrl = rawUrl;
        }

        public override string GetUriPath()
        {
            return _originalRequest.GetUriPath();
        }

        public override string GetQueryString()
        {
            return _originalRequest.GetQueryString();
        }

        public override string GetRawUrl()
        {
            return _rawUrl;
        }

        public override string GetHttpVerbName()
        {
            return _originalRequest.GetHttpVerbName();
        }

        public override string GetHttpVersion()
        {
            return _originalRequest.GetHttpVersion();
        }

        public override string GetRemoteAddress()
        {
            return _originalRequest.GetRemoteAddress();
        }

        public override int GetRemotePort()
        {
            return _originalRequest.GetRemotePort();
        }

        public override string GetLocalAddress()
        {
            return _originalRequest.GetLocalAddress();
        }

        public override int GetLocalPort()
        {
            return _originalRequest.GetLocalPort();
        }

        public override void SendStatus(int statusCode, string statusDescription)
        {
            _originalRequest.SendStatus(statusCode, statusDescription);
        }

        public override void SendKnownResponseHeader(int index, string value)
        {
            _originalRequest.SendKnownResponseHeader(index, value);
        }

        public override void SendUnknownResponseHeader(string name, string value)
        {
            _originalRequest.SendUnknownResponseHeader(name, value);
        }

        public override void SendResponseFromMemory(byte[] data, int length)
        {
            _originalRequest.SendResponseFromMemory(data, length);
        }

        public override void SendResponseFromFile(string filename, long offset, long length)
        {
            _originalRequest.SendResponseFromFile(filename, offset, length);
        }

        public override void SendResponseFromFile(IntPtr handle, long offset, long length)
        {
            _originalRequest.SendResponseFromFile(handle, offset, length);
        }

        public override void FlushResponse(bool finalFlush)
        {
            _originalRequest.FlushResponse(finalFlush);
        }

        public override void EndOfRequest()
        {
            _originalRequest.EndOfRequest();
        }
    }
}

To enable our module, we need to add the following line to web.config in the httpModules section of system.web:

<add name="MvcRewritingHelperModule" type="EliteBrains.MvcUrlRewriting.UrlRewritingModule, EliteBrains.MvcUrlRewriting"/>

So, it might look like this:

<httpModules>
      <add name="MvcRewritingHelperModule" type="EliteBrains.MvcUrlRewriting.UrlRewritingModule, EliteBrains.MvcUrlRewriting"/>
      <add name="ScriptModule" type="System.Web.Handlers.ScriptModule, System.Web.Extensions, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35"/>
      <add name="UrlRoutingModule" type="System.Web.Routing.UrlRoutingModule, System.Web.Routing, Version=3.5.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35"/>			
</httpModules>

Now let’s go into the internals of our HttpModule to understand how it works.

In this module, we will process two Application events:

  • BeginRequest – this will do the actual URL rewriting.
  • PreRequestHandlerExecute – here we use some helper logic to make correct postback action URLs in forms.

ContextBeginRequest – this is the method used to perform the core logic of our module.

First of all, we check whether the current request is a 404 error and extract the original URL that was requested by the user. Remember, we have something like this as the URL from IIS:
/elt-urlrewriting.ashx?404;http://mvc:80/Home/About

Then comes the tricky part that cost me a few hours of work with Reflector. As I told you earlier, even though there are solutions that try to use the same 404 error approach, they don’t support postbacks. This is because when IIS initially executes a custom URL on a 404 error, it changes POST to GET, even if the client does a POST request. However, with some Reflection magic we can correct this and make POST be really POST.

This is how we do this:

string contentType = context.Request.ContentType;

if (!string.IsNullOrEmpty(contentType)
    && String.CompareOrdinal(context.Request.HttpMethod, "GET") == 0
    && (contentType.IndexOf("x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) > -1
        || contentType.IndexOf("multipart/form-data", StringComparison.OrdinalIgnoreCase) > -1)
    && _httpRequestHttpMethodField != null)
{
    // HACK.
    // Change request method to POST. 
    // When we have 404 request from IIS it comes with GET method even if there was POST.
    _httpRequestHttpMethodField.SetValue(context.Request, "POST");
    _httpRequestHttpVerbField.SetValue(context.Request, _httpVerbUnparsedValue);
}

Finally, we do our rewriting, again using some tricks to make everything run smoothly.

// Here we need to rewrite path twice for correct work of Themes and URL resolving.
// Client path should be changed with accordance to directory hierarchy in URL.
context.RewritePath("~" + clientBase, clientBase, requestQueryString, true);

// This is for correct current URL (Request.Url) handling.
context.RewritePath(requestUri.AbsolutePath, string.Empty, requestQueryString, false);

Here is another important thing – action URLs for forms. When we run the MVC application, the form tag is rendered by the MVC engine, and to find out the postback URL for a form, MVC uses the RawUrl property of HttpRequest. Remember, however, that IIS sets this property to:
/elt-urlrewriting.ashx?404;http://mvc:80/Home/About

This makes MVC form action URLs look trashy and postbacks are broken. Being a member of EliteBrains means being creative and fighting all obstacles. We will fix it! Using Reflector we can see that RawUrl is returned from HttpWorkerRequest:

public string get_RawUrl()
{
    string rawUrl;
    if (this._wr != null)
    {
        rawUrl = this._wr.GetRawUrl();
    }
    else
    {
        string path = this.Path;
        string queryStringText = this.QueryStringText;
        if (!string.IsNullOrEmpty(queryStringText))
        {
            rawUrl = path + "?" + queryStringText;
        }
        else
        {
            rawUrl = path;
        }
    }
    if (this._flags[0x80])
    {
        this._flags.Clear(0x80);
        ValidateString(rawUrl, null, "Request.RawUrl");
    }
    return rawUrl;
}

 

So why not to have our own?! To this end, we create a dummy wrapper called HttpWorkerRequestWrapper that will return the correct RawUrl. We will substitute the original HttpWorkerRequest in the PreRequestHandlerExecute event just before the execution of MVC handler.

/// <summary>
/// Handler for PostMapRequestHandler event.
/// </summary>
private static void ContextPostMapRequestHandler(object sender, EventArgs e)
{
    HttpContext context = HttpContext.Current;
    var url = (string)context.Items[OriginalUrlKey];

    // Check if we got an URL so rewriting had occurred and we need to process.
    if (url != null)
    {
        // Now we have to change worker request to be able to change RawUrl field.
        _httpRequestWrField.SetValue(context.Request,
                                     new HttpWorkerRequestWrapper(
                                         (HttpWorkerRequest)_httpRequestWrField.GetValue(context.Request),
                                         url));
    }
}

And to make all our Reflection magic work, we need to get some internals from ASP.NET runtime. This we are doing in the static constructor of our HttpModule:

/// <summary>
/// Initializes fields' references using Reflection.
/// </summary>
static UrlRewritingModule()
{
    var requestType = typeof (HttpRequest);
    _httpRequestHttpMethodField = requestType.GetField("_httpMethod", BindingFlags.NonPublic | BindingFlags.Instance);
    _httpRequestHttpVerbField = requestType.GetField("_httpVerb", BindingFlags.NonPublic | BindingFlags.Instance);
    _httpRequestWrField = requestType.GetField("_wr", BindingFlags.NonPublic | BindingFlags.Instance);

    // Getting HttpVerb.Unparsed enum value.
    var httpVerbType = _httpRequestHttpVerbField.FieldType;
    var httpVerbUnparsed = httpVerbType.GetField("Unparsed");
    _httpVerbUnparsedValue = Enum.ToObject(httpVerbType, (int) httpVerbUnparsed.GetValue(httpVerbType));
}

Conclusion

This solution works perfectly in 99.99% of cases, even with ASP.NET cache. But since this is the real world, in which perfection is impossible, the solution has minor drawbacks:

  1. It doesn’t support the If-Modified-Since HTTP header. I can advise you, however, to use absolute cache expiration – this will reduce the number of requests. In that case, the browser will not check if the content is modified on every request.
  2. I found out that postback to a virtual URL does not work if IIS was just restarted and this request is the first request to the application. The second request, even if postback, works fine. Application Pool restarts handled just fine – no worries there. So the probability of this situation occurring is quite low and the user can try to resubmit the request, which will be processed.
  3. Your application needs permission to use Reflection, and you have to be able to set the custom URL for 404 error in IIS and add custom HttpModule to web.config. This might be an issue with some hosting providers.
  4. All 404 errors will be executed by ASP.NET and MVC engines. This might have a minor impact on the server’s performance.

Note 1: IIS logs are perfectly OK with this solution. You will see a 404 error only when it’s a true 404  error.

Note 2: This approach also works under IIS 7 with Classic Application Pool.

Note 3: I assume that it can work under IIS 5.1.


The sources are licensed under the Creative Commons license with attribution required.

Enjoy!

+12
Dec 20, 2009 10:45 PM
by dmitry

Comments (0)

You have to sign in to post new comments.