summaryrefslogtreecommitdiffstats
path: root/src/DotNetOpenAuth.Messaging/Messaging/UntrustedWebRequestHandler.cs
blob: a1a34a563fb7ca94935c26d60b780957f6091da4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
//-----------------------------------------------------------------------
// <copyright file="UntrustedWebRequestHandler.cs" company="Andrew Arnott">
//     Copyright (c) Andrew Arnott. All rights reserved.
// </copyright>
//-----------------------------------------------------------------------

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;

	/// <summary>
	/// 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.
	/// </summary>
	/// <remarks>
	/// 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.
	/// </remarks>
	public class UntrustedWebRequestHandler : IDirectWebRequestHandler {
		/// <summary>
		/// The set of URI schemes allowed in untrusted web requests.
		/// </summary>
		private ICollection<string> allowableSchemes = new List<string> { "http", "https" };

		/// <summary>
		/// The collection of blacklisted hosts.
		/// </summary>
		private ICollection<string> blacklistHosts = new List<string>(Configuration.BlacklistHosts.KeysAsStrings);

		/// <summary>
		/// The collection of regular expressions used to identify additional blacklisted hosts.
		/// </summary>
		private ICollection<Regex> blacklistHostsRegex = new List<Regex>(Configuration.BlacklistHostsRegex.KeysAsRegexs);

		/// <summary>
		/// The collection of whitelisted hosts.
		/// </summary>
		private ICollection<string> whitelistHosts = new List<string>(Configuration.WhitelistHosts.KeysAsStrings);

		/// <summary>
		/// The collection of regular expressions used to identify additional whitelisted hosts.
		/// </summary>
		private ICollection<Regex> whitelistHostsRegex = new List<Regex>(Configuration.WhitelistHostsRegex.KeysAsRegexs);

		/// <summary>
		/// The maximum redirections to follow in the course of a single request.
		/// </summary>
		[DebuggerBrowsable(DebuggerBrowsableState.Never)]
		private int maximumRedirections = Configuration.MaximumRedirections;

		/// <summary>
		/// The maximum number of bytes to read from the response of an untrusted server.
		/// </summary>
		[DebuggerBrowsable(DebuggerBrowsableState.Never)]
		private int maximumBytesToRead = Configuration.MaximumBytesToRead;

		/// <summary>
		/// The handler that will actually send the HTTP request and collect
		/// the response once the untrusted server gates have been satisfied.
		/// </summary>
		private IDirectWebRequestHandler chainedWebRequestHandler;

		/// <summary>
		/// Initializes a new instance of the <see cref="UntrustedWebRequestHandler"/> class.
		/// </summary>
		public UntrustedWebRequestHandler()
			: this(new StandardWebRequestHandler()) {
		}

		/// <summary>
		/// Initializes a new instance of the <see cref="UntrustedWebRequestHandler"/> class.
		/// </summary>
		/// <param name="chainedWebRequestHandler">The chained web request handler.</param>
		public UntrustedWebRequestHandler(IDirectWebRequestHandler chainedWebRequestHandler) {
			Contract.Requires<ArgumentNullException>(chainedWebRequestHandler != null);

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

		/// <summary>
		/// Gets or sets the default maximum bytes to read in any given HTTP request.
		/// </summary>
		/// <value>Default is 1MB.  Cannot be less than 2KB.</value>
		public int MaximumBytesToRead {
			get {
				return this.maximumBytesToRead;
			}

			set {
				Contract.Requires<ArgumentOutOfRangeException>(value >= 2048);
				this.maximumBytesToRead = value;
			}
		}

		/// <summary>
		/// Gets or sets the total number of redirections to allow on any one request.
		/// Default is 10.
		/// </summary>
		public int MaximumRedirections {
			get {
				return this.maximumRedirections;
			}

			set {
				Contract.Requires<ArgumentOutOfRangeException>(value >= 0);
				this.maximumRedirections = value;
			}
		}

		/// <summary>
		/// Gets or sets the time allowed to wait for single read or write operation to complete.
		/// Default is 500 milliseconds.
		/// </summary>
		public TimeSpan ReadWriteTimeout { get; set; }

		/// <summary>
		/// Gets or sets the time allowed for an entire HTTP request.  
		/// Default is 5 seconds.
		/// </summary>
		public TimeSpan Timeout { get; set; }

		/// <summary>
		/// Gets a collection of host name literals that should be allowed even if they don't
		/// pass standard security checks.
		/// </summary>
		[SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Whitelist", Justification = "Spelling as intended.")]
		public ICollection<string> WhitelistHosts { get { return this.whitelistHosts; } }

		/// <summary>
		/// Gets a collection of host name regular expressions that indicate hosts that should
		/// be allowed even though they don't pass standard security checks.
		/// </summary>
		[SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Whitelist", Justification = "Spelling as intended.")]
		public ICollection<Regex> WhitelistHostsRegex { get { return this.whitelistHostsRegex; } }

		/// <summary>
		/// Gets a collection of host name literals that should be rejected even if they 
		/// pass standard security checks.
		/// </summary>
		public ICollection<string> BlacklistHosts { get { return this.blacklistHosts; } }

		/// <summary>
		/// Gets a collection of host name regular expressions that indicate hosts that should
		/// be rejected even if they pass standard security checks.
		/// </summary>
		public ICollection<Regex> BlacklistHostsRegex { get { return this.blacklistHostsRegex; } }

		/// <summary>
		/// Gets the configuration for this class that is specified in the host's .config file.
		/// </summary>
		private static UntrustedWebRequestElement Configuration {
			get { return DotNetOpenAuthSection.Messaging.UntrustedWebRequest; }
		}

		#region IDirectWebRequestHandler Members

		/// <summary>
		/// Determines whether this instance can support the specified options.
		/// </summary>
		/// <param name="options">The set of options that might be given in a subsequent web request.</param>
		/// <returns>
		/// 	<c>true</c> if this instance can support the specified options; otherwise, <c>false</c>.
		/// </returns>
		[Pure]
		public bool CanSupport(DirectWebRequestOptions options) {
			// We support whatever our chained handler supports, plus RequireSsl.
			return this.chainedWebRequestHandler.CanSupport(options & ~DirectWebRequestOptions.RequireSsl);
		}

		/// <summary>
		/// Prepares an <see cref="HttpWebRequest"/> that contains an POST entity for sending the entity.
		/// </summary>
		/// <param name="request">The <see cref="HttpWebRequest"/> that should contain the entity.</param>
		/// <param name="options">The options to apply to this web request.</param>
		/// <returns>
		/// The writer the caller should write out the entity data to.
		/// </returns>
		/// <exception cref="ProtocolException">Thrown for any network error.</exception>
		/// <remarks>
		/// 	<para>The caller should have set the <see cref="HttpWebRequest.ContentLength"/>
		/// and any other appropriate properties <i>before</i> calling this method.</para>
		/// 	<para>Implementations should catch <see cref="WebException"/> and wrap it in a
		/// <see cref="ProtocolException"/> to abstract away the transport and provide
		/// a single exception type for hosts to catch.</para>
		/// </remarks>
		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);
		}

		/// <summary>
		/// Processes an <see cref="HttpWebRequest"/> and converts the
		/// <see cref="HttpWebResponse"/> to a <see cref="IncomingWebResponse"/> instance.
		/// </summary>
		/// <param name="request">The <see cref="HttpWebRequest"/> to handle.</param>
		/// <param name="options">The options to apply to this web request.</param>
		/// <returns>
		/// An instance of <see cref="CachedDirectWebResponse"/> describing the response.
		/// </returns>
		/// <exception cref="ProtocolException">Thrown for any network error.</exception>
		/// <remarks>
		/// 	<para>Implementations should catch <see cref="WebException"/> and wrap it in a
		/// <see cref="ProtocolException"/> to abstract away the transport and provide
		/// a single exception type for hosts to catch.  The <see cref="WebException.Response"/>
		/// value, if set, should be Closed before throwing.</para>
		/// </remarks>
		[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);
		}

		/// <summary>
		/// Prepares an <see cref="HttpWebRequest"/> that contains an POST entity for sending the entity.
		/// </summary>
		/// <param name="request">The <see cref="HttpWebRequest"/> that should contain the entity.</param>
		/// <returns>
		/// The writer the caller should write out the entity data to.
		/// </returns>
		Stream IDirectWebRequestHandler.GetRequestStream(HttpWebRequest request) {
			return this.GetRequestStream(request, DirectWebRequestOptions.None);
		}

		/// <summary>
		/// Processes an <see cref="HttpWebRequest"/> and converts the
		/// <see cref="HttpWebResponse"/> to a <see cref="IncomingWebResponse"/> instance.
		/// </summary>
		/// <param name="request">The <see cref="HttpWebRequest"/> to handle.</param>
		/// <returns>
		/// An instance of <see cref="IncomingWebResponse"/> describing the response.
		/// </returns>
		/// <exception cref="ProtocolException">Thrown for any network error.</exception>
		/// <remarks>
		/// 	<para>Implementations should catch <see cref="WebException"/> and wrap it in a
		/// <see cref="ProtocolException"/> to abstract away the transport and provide
		/// a single exception type for hosts to catch.  The <see cref="WebException.Response"/>
		/// value, if set, should be Closed before throwing.</para>
		/// </remarks>
		IncomingWebResponse IDirectWebRequestHandler.GetResponse(HttpWebRequest request) {
			return this.GetResponse(request, DirectWebRequestOptions.None);
		}

		#endregion

		/// <summary>
		/// Determines whether an IP address is the IPv6 equivalent of "localhost/127.0.0.1".
		/// </summary>
		/// <param name="ip">The ip address to check.</param>
		/// <returns>
		/// 	<c>true</c> if this is a loopback IP address; <c>false</c> otherwise.
		/// </returns>
		private static bool IsIPv6Loopback(IPAddress ip) {
			Contract.Requires<ArgumentNullException>(ip != null);
			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;
		}

		/// <summary>
		/// Determines whether the given host name is in a host list or host name regex list.
		/// </summary>
		/// <param name="host">The host name.</param>
		/// <param name="stringList">The list of host names.</param>
		/// <param name="regexList">The list of regex patterns of host names.</param>
		/// <returns>
		/// 	<c>true</c> if the specified host falls within at least one of the given lists; otherwise, <c>false</c>.
		/// </returns>
		private static bool IsHostInList(string host, ICollection<string> stringList, ICollection<Regex> regexList) {
			Contract.Requires<ArgumentException>(!String.IsNullOrEmpty(host));
			Contract.Requires<ArgumentNullException>(stringList != null);
			Contract.Requires<ArgumentNullException>(regexList != null);
			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;
		}

		/// <summary>
		/// Determines whether a given host is whitelisted.
		/// </summary>
		/// <param name="host">The host name to test.</param>
		/// <returns>
		/// 	<c>true</c> if the host is whitelisted; otherwise, <c>false</c>.
		/// </returns>
		private bool IsHostWhitelisted(string host) {
			return IsHostInList(host, this.WhitelistHosts, this.WhitelistHostsRegex);
		}

		/// <summary>
		/// Determines whether a given host is blacklisted.
		/// </summary>
		/// <param name="host">The host name to test.</param>
		/// <returns>
		/// 	<c>true</c> if the host is blacklisted; otherwise, <c>false</c>.
		/// </returns>
		private bool IsHostBlacklisted(string host) {
			return IsHostInList(host, this.BlacklistHosts, this.BlacklistHostsRegex);
		}

		/// <summary>
		/// Verify that the request qualifies under our security policies
		/// </summary>
		/// <param name="requestUri">The request URI.</param>
		/// <param name="requireSsl">If set to <c>true</c>, only web requests that can be made entirely over SSL will succeed.</param>
		/// <exception cref="ProtocolException">Thrown when the URI is disallowed for security reasons.</exception>
		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);
		}

		/// <summary>
		/// Determines whether a URI is allowed based on scheme and host name.
		/// No requireSSL check is done here
		/// </summary>
		/// <param name="uri">The URI to test for whether it should be allowed.</param>
		/// <returns>
		/// 	<c>true</c> if [is URI allowable] [the specified URI]; otherwise, <c>false</c>.
		/// </returns>
		private bool IsUriAllowable(Uri uri) {
			Contract.Requires<ArgumentNullException>(uri != null);
			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<string, bool> 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;
		}

		/// <summary>
		/// Prepares the request by setting timeout and redirect policies.
		/// </summary>
		/// <param name="request">The request to prepare.</param>
		/// <param name="preparingPost"><c>true</c> if this is a POST request whose headers have not yet been sent out; <c>false</c> otherwise.</param>
		private void PrepareRequest(HttpWebRequest request, bool preparingPost) {
			Contract.Requires<ArgumentNullException>(request != null);

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