//-----------------------------------------------------------------------
//
// Copyright (c) Outercurve Foundation. All rights reserved.
//
//-----------------------------------------------------------------------
namespace DotNetOpenAuth.Messaging {
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Cache;
using System.Text.RegularExpressions;
using DotNetOpenAuth.Configuration;
using DotNetOpenAuth.Messaging;
///
/// A paranoid HTTP get/post request engine. It helps to protect against attacks from remote
/// server leaving dangling connections, sending too much data, causing requests against
/// internal servers, etc.
///
///
/// Protections include:
/// * Conservative maximum time to receive the complete response.
/// * Only HTTP and HTTPS schemes are permitted.
/// * Internal IP address ranges are not permitted: 127.*.*.*, 1::*
/// * Internal host names are not permitted (periods must be found in the host name)
/// If a particular host would be permitted but is in the blacklist, it is not allowed.
/// If a particular host would not be permitted but is in the whitelist, it is allowed.
///
public class UntrustedWebRequestHandler : IDirectWebRequestHandler {
///
/// The set of URI schemes allowed in untrusted web requests.
///
private ICollection allowableSchemes = new List { "http", "https" };
///
/// The collection of blacklisted hosts.
///
private ICollection blacklistHosts = new List(Configuration.BlacklistHosts.KeysAsStrings);
///
/// The collection of regular expressions used to identify additional blacklisted hosts.
///
private ICollection blacklistHostsRegex = new List(Configuration.BlacklistHostsRegex.KeysAsRegexs);
///
/// The collection of whitelisted hosts.
///
private ICollection whitelistHosts = new List(Configuration.WhitelistHosts.KeysAsStrings);
///
/// The collection of regular expressions used to identify additional whitelisted hosts.
///
private ICollection whitelistHostsRegex = new List(Configuration.WhitelistHostsRegex.KeysAsRegexs);
///
/// The maximum redirections to follow in the course of a single request.
///
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private int maximumRedirections = Configuration.MaximumRedirections;
///
/// The maximum number of bytes to read from the response of an untrusted server.
///
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private int maximumBytesToRead = Configuration.MaximumBytesToRead;
///
/// The handler that will actually send the HTTP request and collect
/// the response once the untrusted server gates have been satisfied.
///
private IDirectWebRequestHandler chainedWebRequestHandler;
///
/// Initializes a new instance of the class.
///
public UntrustedWebRequestHandler()
: this(new StandardWebRequestHandler()) {
}
///
/// Initializes a new instance of the class.
///
/// The chained web request handler.
public UntrustedWebRequestHandler(IDirectWebRequestHandler chainedWebRequestHandler) {
Requires.NotNull(chainedWebRequestHandler, "chainedWebRequestHandler");
this.chainedWebRequestHandler = chainedWebRequestHandler;
if (Debugger.IsAttached) {
// Since a debugger is attached, requests may be MUCH slower,
// so give ourselves huge timeouts.
this.ReadWriteTimeout = TimeSpan.FromHours(1);
this.Timeout = TimeSpan.FromHours(1);
} else {
this.ReadWriteTimeout = Configuration.ReadWriteTimeout;
this.Timeout = Configuration.Timeout;
}
}
///
/// Gets or sets the default maximum bytes to read in any given HTTP request.
///
/// Default is 1MB. Cannot be less than 2KB.
public int MaximumBytesToRead {
get {
return this.maximumBytesToRead;
}
set {
Requires.InRange(value >= 2048, "value");
this.maximumBytesToRead = value;
}
}
///
/// Gets or sets the total number of redirections to allow on any one request.
/// Default is 10.
///
public int MaximumRedirections {
get {
return this.maximumRedirections;
}
set {
Requires.InRange(value >= 0, "value");
this.maximumRedirections = value;
}
}
///
/// Gets or sets the time allowed to wait for single read or write operation to complete.
/// Default is 500 milliseconds.
///
public TimeSpan ReadWriteTimeout { get; set; }
///
/// Gets or sets the time allowed for an entire HTTP request.
/// Default is 5 seconds.
///
public TimeSpan Timeout { get; set; }
///
/// Gets a collection of host name literals that should be allowed even if they don't
/// pass standard security checks.
///
[SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Whitelist", Justification = "Spelling as intended.")]
public ICollection WhitelistHosts { get { return this.whitelistHosts; } }
///
/// Gets a collection of host name regular expressions that indicate hosts that should
/// be allowed even though they don't pass standard security checks.
///
[SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Whitelist", Justification = "Spelling as intended.")]
public ICollection WhitelistHostsRegex { get { return this.whitelistHostsRegex; } }
///
/// Gets a collection of host name literals that should be rejected even if they
/// pass standard security checks.
///
public ICollection BlacklistHosts { get { return this.blacklistHosts; } }
///
/// Gets a collection of host name regular expressions that indicate hosts that should
/// be rejected even if they pass standard security checks.
///
public ICollection BlacklistHostsRegex { get { return this.blacklistHostsRegex; } }
///
/// Gets the configuration for this class that is specified in the host's .config file.
///
private static UntrustedWebRequestElement Configuration {
get { return DotNetOpenAuthSection.Messaging.UntrustedWebRequest; }
}
#region IDirectWebRequestHandler Members
///
/// Determines whether this instance can support the specified options.
///
/// The set of options that might be given in a subsequent web request.
///
/// true if this instance can support the specified options; otherwise, false.
///
[Pure]
public bool CanSupport(DirectWebRequestOptions options) {
// We support whatever our chained handler supports, plus RequireSsl.
return this.chainedWebRequestHandler.CanSupport(options & ~DirectWebRequestOptions.RequireSsl);
}
///
/// Prepares an that contains an POST entity for sending the entity.
///
/// The that should contain the entity.
/// The options to apply to this web request.
///
/// The writer the caller should write out the entity data to.
///
/// Thrown for any network error.
///
/// The caller should have set the
/// and any other appropriate properties before calling this method.
/// Implementations should catch and wrap it in a
/// to abstract away the transport and provide
/// a single exception type for hosts to catch.
///
public Stream GetRequestStream(HttpWebRequest request, DirectWebRequestOptions options) {
this.EnsureAllowableRequestUri(request.RequestUri, (options & DirectWebRequestOptions.RequireSsl) != 0);
this.PrepareRequest(request, true);
// Submit the request and get the request stream back.
return this.chainedWebRequestHandler.GetRequestStream(request, options & ~DirectWebRequestOptions.RequireSsl);
}
///
/// Processes an and converts the
/// to a instance.
///
/// The to handle.
/// The options to apply to this web request.
///
/// An instance of describing the response.
///
/// Thrown for any network error.
///
/// Implementations should catch and wrap it in a
/// to abstract away the transport and provide
/// a single exception type for hosts to catch. The
/// value, if set, should be Closed before throwing.
///
[SuppressMessage("Microsoft.Usage", "CA2234:PassSystemUriObjectsInsteadOfStrings", Justification = "Uri(Uri, string) accepts second arguments that Uri(Uri, new Uri(string)) does not that we must support.")]
public IncomingWebResponse GetResponse(HttpWebRequest request, DirectWebRequestOptions options) {
// This request MAY have already been prepared by GetRequestStream, but
// we have no guarantee, so do it just to be safe.
this.PrepareRequest(request, false);
// Since we may require SSL for every redirect, we handle each redirect manually
// in order to detect and fail if any redirect sends us to an HTTP url.
// We COULD allow automatic redirect in the cases where HTTPS is not required,
// but our mock request infrastructure can't do redirects on its own either.
Uri originalRequestUri = request.RequestUri;
int i;
for (i = 0; i < this.MaximumRedirections; i++) {
this.EnsureAllowableRequestUri(request.RequestUri, (options & DirectWebRequestOptions.RequireSsl) != 0);
CachedDirectWebResponse response = this.chainedWebRequestHandler.GetResponse(request, options & ~DirectWebRequestOptions.RequireSsl).GetSnapshot(this.MaximumBytesToRead);
if (response.Status == HttpStatusCode.MovedPermanently ||
response.Status == HttpStatusCode.Redirect ||
response.Status == HttpStatusCode.RedirectMethod ||
response.Status == HttpStatusCode.RedirectKeepVerb) {
// We have no copy of the post entity stream to repeat on our manually
// cloned HttpWebRequest, so we have to bail.
ErrorUtilities.VerifyProtocol(request.Method != "POST", MessagingStrings.UntrustedRedirectsOnPOSTNotSupported);
Uri redirectUri = new Uri(response.FinalUri, response.Headers[HttpResponseHeader.Location]);
request = request.Clone(redirectUri);
} else {
if (response.FinalUri != request.RequestUri) {
// Since we don't automatically follow redirects, there's only one scenario where this
// can happen: when the server sends a (non-redirecting) Content-Location header in the response.
// It's imperative that we do not trust that header though, so coerce the FinalUri to be
// what we just requested.
Logger.Http.WarnFormat("The response from {0} included an HTTP header indicating it's the same as {1}, but it's not a redirect so we won't trust that.", request.RequestUri, response.FinalUri);
response.FinalUri = request.RequestUri;
}
return response;
}
}
throw ErrorUtilities.ThrowProtocol(MessagingStrings.TooManyRedirects, originalRequestUri);
}
///
/// Prepares an that contains an POST entity for sending the entity.
///
/// The that should contain the entity.
///
/// The writer the caller should write out the entity data to.
///
Stream IDirectWebRequestHandler.GetRequestStream(HttpWebRequest request) {
return this.GetRequestStream(request, DirectWebRequestOptions.None);
}
///
/// Processes an and converts the
/// to a instance.
///
/// The to handle.
///
/// An instance of describing the response.
///
/// Thrown for any network error.
///
/// Implementations should catch and wrap it in a
/// to abstract away the transport and provide
/// a single exception type for hosts to catch. The
/// value, if set, should be Closed before throwing.
///
IncomingWebResponse IDirectWebRequestHandler.GetResponse(HttpWebRequest request) {
return this.GetResponse(request, DirectWebRequestOptions.None);
}
#endregion
///
/// Determines whether an IP address is the IPv6 equivalent of "localhost/127.0.0.1".
///
/// The ip address to check.
///
/// true if this is a loopback IP address; false otherwise.
///
private static bool IsIPv6Loopback(IPAddress ip) {
Requires.NotNull(ip, "ip");
byte[] addressBytes = ip.GetAddressBytes();
for (int i = 0; i < addressBytes.Length - 1; i++) {
if (addressBytes[i] != 0) {
return false;
}
}
if (addressBytes[addressBytes.Length - 1] != 1) {
return false;
}
return true;
}
///
/// Determines whether the given host name is in a host list or host name regex list.
///
/// The host name.
/// The list of host names.
/// The list of regex patterns of host names.
///
/// true if the specified host falls within at least one of the given lists; otherwise, false.
///
private static bool IsHostInList(string host, ICollection stringList, ICollection regexList) {
Requires.NotNullOrEmpty(host, "host");
Requires.NotNull(stringList, "stringList");
Requires.NotNull(regexList, "regexList");
foreach (string testHost in stringList) {
if (string.Equals(host, testHost, StringComparison.OrdinalIgnoreCase)) {
return true;
}
}
foreach (Regex regex in regexList) {
if (regex.IsMatch(host)) {
return true;
}
}
return false;
}
///
/// Determines whether a given host is whitelisted.
///
/// The host name to test.
///
/// true if the host is whitelisted; otherwise, false.
///
private bool IsHostWhitelisted(string host) {
return IsHostInList(host, this.WhitelistHosts, this.WhitelistHostsRegex);
}
///
/// Determines whether a given host is blacklisted.
///
/// The host name to test.
///
/// true if the host is blacklisted; otherwise, false.
///
private bool IsHostBlacklisted(string host) {
return IsHostInList(host, this.BlacklistHosts, this.BlacklistHostsRegex);
}
///
/// Verify that the request qualifies under our security policies
///
/// The request URI.
/// If set to true, only web requests that can be made entirely over SSL will succeed.
/// Thrown when the URI is disallowed for security reasons.
private void EnsureAllowableRequestUri(Uri requestUri, bool requireSsl) {
ErrorUtilities.VerifyProtocol(this.IsUriAllowable(requestUri), MessagingStrings.UnsafeWebRequestDetected, requestUri);
ErrorUtilities.VerifyProtocol(!requireSsl || string.Equals(requestUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase), MessagingStrings.InsecureWebRequestWithSslRequired, requestUri);
}
///
/// Determines whether a URI is allowed based on scheme and host name.
/// No requireSSL check is done here
///
/// The URI to test for whether it should be allowed.
///
/// true if [is URI allowable] [the specified URI]; otherwise, false.
///
private bool IsUriAllowable(Uri uri) {
Requires.NotNull(uri, "uri");
if (!this.allowableSchemes.Contains(uri.Scheme)) {
Logger.Http.WarnFormat("Rejecting URL {0} because it uses a disallowed scheme.", uri);
return false;
}
// Allow for whitelist or blacklist to override our detection.
Func failsUnlessWhitelisted = (string reason) => {
if (IsHostWhitelisted(uri.DnsSafeHost)) {
return true;
}
Logger.Http.WarnFormat("Rejecting URL {0} because {1}.", uri, reason);
return false;
};
// Try to interpret the hostname as an IP address so we can test for internal
// IP address ranges. Note that IP addresses can appear in many forms
// (e.g. http://127.0.0.1, http://2130706433, http://0x0100007f, http://::1
// So we convert them to a canonical IPAddress instance, and test for all
// non-routable IP ranges: 10.*.*.*, 127.*.*.*, ::1
// Note that Uri.IsLoopback is very unreliable, not catching many of these variants.
IPAddress hostIPAddress;
if (IPAddress.TryParse(uri.DnsSafeHost, out hostIPAddress)) {
byte[] addressBytes = hostIPAddress.GetAddressBytes();
// The host is actually an IP address.
switch (hostIPAddress.AddressFamily) {
case System.Net.Sockets.AddressFamily.InterNetwork:
if (addressBytes[0] == 127 || addressBytes[0] == 10) {
return failsUnlessWhitelisted("it is a loopback address.");
}
break;
case System.Net.Sockets.AddressFamily.InterNetworkV6:
if (IsIPv6Loopback(hostIPAddress)) {
return failsUnlessWhitelisted("it is a loopback address.");
}
break;
default:
return failsUnlessWhitelisted("it does not use an IPv4 or IPv6 address.");
}
} else {
// The host is given by name. We require names to contain periods to
// help make sure it's not an internal address.
if (!uri.Host.Contains(".")) {
return failsUnlessWhitelisted("it does not contain a period in the host name.");
}
}
if (this.IsHostBlacklisted(uri.DnsSafeHost)) {
Logger.Http.WarnFormat("Rejected URL {0} because it is blacklisted.", uri);
return false;
}
return true;
}
///
/// Prepares the request by setting timeout and redirect policies.
///
/// The request to prepare.
/// true if this is a POST request whose headers have not yet been sent out; false otherwise.
private void PrepareRequest(HttpWebRequest request, bool preparingPost) {
Requires.NotNull(request, "request");
// Be careful to not try to change the HTTP headers that have already gone out.
if (preparingPost || request.Method == "GET") {
// Set/override a few properties of the request to apply our policies for untrusted requests.
request.ReadWriteTimeout = (int)this.ReadWriteTimeout.TotalMilliseconds;
request.Timeout = (int)this.Timeout.TotalMilliseconds;
request.KeepAlive = false;
}
// If SSL is required throughout, we cannot allow auto redirects because
// it may include a pass through an unprotected HTTP request.
// We have to follow redirects manually.
// It also allows us to ignore HttpWebResponse.FinalUri since that can be affected by
// the Content-Location header and open security holes.
request.AllowAutoRedirect = false;
}
}
}