Modern Security on IIS

About a year ago I started work at a company with a large web presence (around 50 distinct websites). These sites had little in the way of security, and though they handled no sensitive user data, the lack of security definitely looked bad.

I slowly addressed security concerns during my off-time, until I got the green light a couple months in to systematically patch all major security issues. Nowadays all the sites adhere to modern security standards, but making them do so involved a lot of filtering through documentation, and even more trial and error.

There are a lot of resources on IIS website security out there, and they generally tend to fall in three categories:

  1. The Outdated
    Hacks for IIS 6 aren't (usually) relevant when better solutions exist for IIS 10, but still appear higher in search rankings.

  2. The Overly General
    Yes, keeping your antivirus definitions up-to-date is key, but you should be doing that regardless of if you're using IIS or something else. Many tips about IIS security are really about OS security, so not very useful.

  3. The Good
    There are nonetheless many good resources on IIS security that are still relevant nowadays, and where appropriate I've linked them in this post.

I've written this resource in an attempt to distill large chunks of relevant information into a single document, with enough info to configure IIS, and links towards further reading on other sites.

Automated Scanners

There's a number of useful scanners that can offer a general idea of where a site stands in terms of security, and a few particularly relevant ones are listed below.

Scoring high on these scans doesn't guarantee that your codebase isn't riddled with horrifying application-level security flaws, but they offer a good starting point.

HTTPS

HTTPS is practically a must-have nowadays. It's worth starting off with this even if it's not IIS-specific, as by not having HTTPS, every other security measure in this post is practically voided. Not having a certificate installed will have web browsers mark your site as insecure in their address bar, which — even if security is of absolutely no concern — simply looks bad.

Obtaining a Certificate

There are many places to obtain an SSL certificate in case you don't already have one. LetsEncrypt is a good choice if you don't need wildcard hostnames on a certificate, and happens to be free.

Though the majority of LetsEncrypt support is for Linux-based systems, there exist Windows APIs for interfacing with the certification process. letsencrypt-win-simple is a good starting choice since it can manage IIS settings itself, and performs all the heavy lifting transparently: just point it at an IIS site, and get HTTPS enabled within a few seconds. If you're looking for a more programmatic solution, ACMESharp (the library letsencrypt-win-simple is based on) may be of use, but there are others as well.

Redirecting to HTTPS

Great, so you have an SSL certificate installed and you can visit your site via https://. You'll likely want to redirect HTTP traffic to HTTPS at some point, and this can be accomplished with a simple URL rewrite.

<system.webServer>
    <!-- This section needs the URL Rewrite module to be installed, otherwise you
         get nice 500 errors on any page load.
         
         You can install it from the Web Platform Installer in IIS, or from
         https://www.iis.net/downloads/microsoft/url-rewrite -->
    <rewrite>
         <rules>
            <rule name="HTTP to HTTPS redirect" stopProcessing="true">
                <match url="(.*)" />
                <conditions logicalGrouping="MatchAll">
                    <!-- Must not redirect the .well-known directory if using LetsEncrypt, since LetsEncrypt
                         needs it for the certification / renewal process -->
                    <add input="{REQUEST_URI}" negate="true" pattern="^.*\.well-known.*$" ignoreCase="true" />
                    <add input="{HTTPS}" pattern="off" ignoreCase="true" />
                </conditions>
                <!-- Switch redirectType to Permanent once you're confident your site works well under HTTPS -->
                <action type="Redirect" url="https://{HTTP_HOST}/{R:1}" redirectType="Temporary" />
            </rule>
        </rules>
    </rewrite>
</system.webServer>

Bad Ciphers and Insecure Protocols

If at this point in time if you were to take your shiny new HTTPS-enabled site for a spin over at the Qualys SSL Labs Server Test, you'll find that our setup so far is far from perfect — it scores a dusty C.

This is because on a fresh install, IIS is set up for maximum compatibility with old ancient clients. By necessity, this means laxer security in the form of very weak ciphers and protocols enabled. Since we likely don't need to support straggling users using IE 7 on Windows XP, we can safely drop a lot of compatibility in exchange for greater security. Presently, IIS doesn't offer any user interface for managing ciphers and protocols, so you'll have to dig in to the registry, or use a graphical interface like Nartac Software's IIS Crypto.

With IIS Crypto, all the work is done for you if you hit the Best Practices button.

After a quick reboot, if we head back on over to Qualys and retest, we'll be greeted with a nice green A grade:

HTTP Strict Transport Security (HSTS)

If we want to take HTTPS even further, we can enable HSTS. HSTS is a server header that instructs browsers to not attempt accessing a site over HTTP (instead connecting directly via HTTPS) for a specified duration after receiving the header.

Without HSTS: an attacker can tamper with the initial HTTP connection (before our redirect to HTTPS) on every page load, potentially loading their own spoof in place
With HSTS: the window of attack is shrunk to only the first page load for the duration of the HSTS header (browsers will store this offline for the next time they access the site)

HSTS is useful to have, but it should be enabled with caution: if you ever need to disable HTTPS, you'll have to wait for the HSTS header to expire, since clients who have received the HSTS header will not be able to connect to plain HTTP. You can set a length of 0 to "bust" HSTS out, but this doesn't guarantee that every user who has visited your site while it was HSTS-enabled will receive your bust header in time. This is likewise the case if your certificate expires without it being renewed.

With those warnings in mind, enabling HSTS is just another simple URL rewrite rule.

<system.webServer>
    <rewrite>
         <rules>
            ...
        </rules>
        <outboundRules>
            <rule name="Add Strict-Transport-Security when HTTPS">
                <match serverVariable="RESPONSE_Strict_Transport_Security" pattern=".*" />
                <conditions>
                    <add input="{HTTPS}" pattern="on" ignoreCase="true" />
                </conditions>
                <!-- max-age should be at least 10886400 (18 weeks) -->
                <action type="Rewrite" value="max-age=10886400" />
            </rule>
        </outboundRules>
    </rewrite>
</system.webServer>

Further Reading

HTTP/2.0

As a nice extra, if your site runs IIS 10 or newer and has HTTPS enabled, IIS will by default use the HTTP/2.0 protocol when communicating with supporting clients. HTTP/2.0 is a lot faster than HTTP/1.0 by virtue of reusing connections for sending data, so it's a welcome addition to IIS.

Further Reading

Response Headers

Following enabling HTTPS, sending security headers is the next most important thing you can do to secure your site. If properly configured, even if your codebase is exploitable, proper headers can help ensure your clients remain safe.

There's a couple of important ones which I've gone over systematically below. You can check how your site ranks with regards to these on securityheaders.io.

Starting off, a site will score an E grade if HSTS was enabled, or F if not.

X-Frame-Options

A non-standard header that instructs supporting browsers on when your website should be rendered inside iframe tags and friends. Unless your website needs to be framed anywhere, setting this to DENY prevents a slew of potential clickjacking attacks. With appcmd enabling globally,

$ appcmd set config /section:httpProtocol /+"customHeaders.[name='X-Frame-Options', value='DENY']"

Further Reading

X-Content-Type-Options

A non-standard header, the only valid option for it is nosniff; it instructs supporting browers not to guess content types when they seem to be wrong. For example, given a CSS (text/css mimetype) resource being set as the src of a script tag, browsers will not execute under the assumption that the original text/css mimetype was wrong, and force an executable text/javascript one instead.

With appcmd enabling globally,

$ appcmd set config /section:httpProtocol /+"customHeaders.[name='X-Content-Type-Options', value='nosniff']"

Further Reading

X-XSS-Protection

…you get the idea, a non-standard header that configures XSS auditing in browsers that support it. A browser will check to see if it believes an XSS attack is being performed on the current page load (for example, if a script tag exists as a querystring and appears identically in the DOM), and act based on the value of this header. 1 is default, which instructs the browser to remove the XSS but continue loading the page. I typically go with 1; mode=block for some extra security; the mode=block tells the browser to refuse loading the page at all (don't attempt sanitizing the XSS), and display a message to the client instead.

With appcmd enabling globally,

$ appcmd set config /section:httpProtocol /+"customHeaders.[name='X-XSS-Protection', value='1; mode=block']"

Further Reading

Referrer-Policy

Referrer-Policy controls how much information browsers send to a host when a client navigates off your page and onto another one (potentially on the same domain) via the Referer request header.

There's a number of referrer modes, and which one you go with depends on the needs of your site. Typically, I go with no-referrer-when-downgrade, which means that Referer info will be sent from my HTTPS site to another HTTPS site, but not insecurely to an HTTP site. Going with no-referrer doesn't sit well with user tracking solutions like Google Analytics (browsers won't send Referer data even to the same site), while same-origin will send data to the same site, but not to other sites. Consult the MDN reference before deciding on a Referrer-Policy.

To set one globally with appcmd,

$ appcmd set config /section:httpProtocol /+"customHeaders.[name='Referrer-Policy', value='no-referrer-when-downgrade']"

Further Reading

Content-Security-Policy

This is by far the most important header you can send. If configured, it supplants X-Frame-Options, X-XSS-Protection, and Referrer-Policy (though they should still be sent, for browsers which support them but not CSP). It provides a fine-grained control over what resources your site is allowed to access, and supports reporting to a callback for notification of violations. A full explanation of the dozens of parameters CSP offers is outside the scope of this article, but the MDN documents them thoroughly.

If you don't want to mess with long CSP strings manually, Scott Helme has a web CSP builder that you may find useful.

Anecdotally, an hour after we rolled out a CSP across our sites (-Report-Only), we started getting suspect scripts from being loaded in our internal administration sites. As it turns out, an employee's computer had malware that injected itself into pages and tracked actions. In our case, CSP provided instant gratification, as not only were we able to stop malware from tracking internal info: thanks to the reporting functionality, we were also able to determine which employee was affected.

Stripping Server Headers

IIS likes to advertise what it is, and sends out an X-Powered-By header specifying the exact version of .NET the server is running. Not great, but it can be easily deleted from the computer node's HTTP Headers view. It also likes saying that it is, in fact, IIS and not some other server. As of IIS 10, this behaviour can be disabled with appcmd.

$ appcmd set config /section:system.webServer/security/requestFiltering /removeServerHeader:true

Further Reading

After implementing these individually small header changes, client security will be much improved, and securityheaders.io will agree with an A grade:

Miscellaneous IIS Settings

Microsoft maintains a list of security best-practices for IIS 8. For the most part a clean install of IIS adheres to these, and a lot of it extends to general OS-level things like keeping permissions minimal, not installing things you don't need, etc. It's still worth a read.

Below I've outlined a few things which burned me the first time around, and which weren't immediately clear from reading documentation. These are likely basic items for anyone with experience managing IIS servers, but I've included them since coming from managing nginx servers they weren't exactly what I had expected.

.NET CLR Version for ASP Classic

If you're stuck maintaining ancient ASP Classic websites, you're likely not making use of the .NET framework. Since ASP Classic is on shaky grounds design-wise (querystrings unescaped by default, clunky SQL sanitization, the list goes on), switching the application pool .NET CLR Version (in Advanced Settings) to No Managed Code at least prevents ASP.NET-related exploits. Then you only need to worry about all the ASP Classic ones!

ASP Classic vs. ASP.NET Error Pages

ASP Classic and ASP.NET use very different error page settings. It's essential that stacktraces aren't sent to the browser on errors, but this must be done twice, once for ASP Classic and again — differently — for ASP.NET.

To disable remote reporting for ASP Classic, under the Error Pages feature view, Custom Errors should be selected from Edit feature settings… Furthermore from the ASP feature view, Send errors to browser should be set to false under the Debugging Properties node.

For ASP.NET, the idea is the same from the .NET Error Pages view. Remote Only should be selected with ResponseRedirect, with the error page handler under the Absolute URL field. If you don't have one, / for the website root works fine.