diff --git a/OpenSim/Framework/MultipartForm.cs b/OpenSim/Framework/MultipartForm.cs new file mode 100644 index 0000000000..8ba6d22683 --- /dev/null +++ b/OpenSim/Framework/MultipartForm.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.IO; +using System.Text; + +namespace OpenSim.Framework +{ + public static class MultipartForm + { + #region Helper Classes + + public abstract class Element + { + public string Name; + } + + public class File : Element + { + public string Filename; + public string ContentType; + public byte[] Data; + + public File(string name, string filename, string contentType, byte[] data) + { + Name = name; + Filename = filename; + ContentType = contentType; + Data = data; + } + } + + public class Parameter : Element + { + public string Value; + + public Parameter(string name, string value) + { + Name = name; + Value = value; + } + } + + #endregion Helper Classes + + public static HttpWebResponse Post(HttpWebRequest request, List postParameters) + { + string boundary = Boundary(); + + // Set up the request properties + request.Method = "POST"; + request.ContentType = "multipart/form-data; boundary=" + boundary; + + #region Stream Writing + + using (MemoryStream formDataStream = new MemoryStream()) + { + foreach (var param in postParameters) + { + if (param is File) + { + File file = (File)param; + + // Add just the first part of this param, since we will write the file data directly to the Stream + string header = string.Format("--{0}\r\nContent-Disposition: form-data; name=\"{1}\"; filename=\"{2}\";\r\nContent-Type: {3}\r\n\r\n", + boundary, + file.Name, + !String.IsNullOrEmpty(file.Filename) ? file.Filename : "tempfile", + file.ContentType); + + formDataStream.Write(Encoding.UTF8.GetBytes(header), 0, header.Length); + formDataStream.Write(file.Data, 0, file.Data.Length); + } + else + { + Parameter parameter = (Parameter)param; + + string postData = string.Format("--{0}\r\nContent-Disposition: form-data; name=\"{1}\"\r\n\r\n{2}\r\n", + boundary, + parameter.Name, + parameter.Value); + formDataStream.Write(Encoding.UTF8.GetBytes(postData), 0, postData.Length); + } + } + + // Add the end of the request + byte[] footer = Encoding.UTF8.GetBytes("\r\n--" + boundary + "--\r\n"); + formDataStream.Write(footer, 0, footer.Length); + + request.ContentLength = formDataStream.Length; + + // Copy the temporary stream to the network stream + formDataStream.Seek(0, SeekOrigin.Begin); + using (Stream requestStream = request.GetRequestStream()) + formDataStream.CopyTo(requestStream, (int)formDataStream.Length); + } + + #endregion Stream Writing + + return request.GetResponse() as HttpWebResponse; + } + + private static string Boundary() + { + Random rnd = new Random(); + string formDataBoundary = String.Empty; + + while (formDataBoundary.Length < 15) + formDataBoundary = formDataBoundary + rnd.Next(); + + formDataBoundary = formDataBoundary.Substring(0, 15); + formDataBoundary = "-----------------------------" + formDataBoundary; + + return formDataBoundary; + } + } +} diff --git a/OpenSim/Framework/UntrustedWebRequest.cs b/OpenSim/Framework/UntrustedWebRequest.cs new file mode 100644 index 0000000000..1af7c41558 --- /dev/null +++ b/OpenSim/Framework/UntrustedWebRequest.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Security; +using System.Text; +using log4net; + +namespace OpenSim.Framework +{ + /// + /// Used for requests to untrusted endpoints that may potentially be + /// malicious + /// + public static class UntrustedHttpWebRequest + { + /// Setting this to true will allow HTTP connections to localhost + private const bool DEBUG = true; + + private static readonly ILog m_log = + LogManager.GetLogger( + System.Reflection.MethodBase.GetCurrentMethod().DeclaringType); + + private static readonly ICollection allowableSchemes = new List { "http", "https" }; + + /// + /// Creates an HttpWebRequest that is hardened against malicious + /// endpoints after ensuring the given Uri is safe to retrieve + /// + /// Web location to request + /// A hardened HttpWebRequest if the uri was determined to be safe + /// If uri is null + /// If uri is unsafe + public static HttpWebRequest Create(Uri uri) + { + return Create(uri, DEBUG, 1000 * 5, 1000 * 20, 10); + } + + /// + /// Creates an HttpWebRequest that is hardened against malicious + /// endpoints after ensuring the given Uri is safe to retrieve + /// + /// Web location to request + /// True to allow connections to localhost, otherwise false + /// Read write timeout, in milliseconds + /// Connection timeout, in milliseconds + /// Maximum number of allowed redirects + /// A hardened HttpWebRequest if the uri was determined to be safe + /// If uri is null + /// If uri is unsafe + public static HttpWebRequest Create(Uri uri, bool allowLoopback, int readWriteTimeoutMS, int timeoutMS, int maximumRedirects) + { + if (uri == null) + throw new ArgumentNullException("uri"); + + if (!IsUriAllowable(uri, allowLoopback)) + throw new ArgumentException("Uri " + uri + " was rejected"); + + HttpWebRequest httpWebRequest = (HttpWebRequest)HttpWebRequest.Create(uri); + httpWebRequest.MaximumAutomaticRedirections = maximumRedirects; + httpWebRequest.ReadWriteTimeout = readWriteTimeoutMS; + httpWebRequest.Timeout = timeoutMS; + httpWebRequest.KeepAlive = false; + + return httpWebRequest; + } + + public static string PostToUntrustedUrl(Uri url, string data) + { + try + { + byte[] requestData = System.Text.Encoding.UTF8.GetBytes(data); + + HttpWebRequest request = Create(url); + request.Method = "POST"; + request.ContentLength = requestData.Length; + request.ContentType = "application/x-www-form-urlencoded"; + + using (Stream requestStream = request.GetRequestStream()) + requestStream.Write(requestData, 0, requestData.Length); + + using (WebResponse response = request.GetResponse()) + { + using (Stream responseStream = response.GetResponseStream()) + return responseStream.GetStreamString(); + } + } + catch (Exception ex) + { + m_log.Warn("POST to untrusted URL " + url + " failed: " + ex.Message); + return null; + } + } + + public static string GetUntrustedUrl(Uri url) + { + try + { + HttpWebRequest request = Create(url); + + using (WebResponse response = request.GetResponse()) + { + using (Stream responseStream = response.GetResponseStream()) + return responseStream.GetStreamString(); + } + } + catch (Exception ex) + { + m_log.Warn("GET from untrusted URL " + url + " failed: " + ex.Message); + return null; + } + } + + /// + /// Determines whether a URI is allowed based on scheme and host name. + /// No requireSSL check is done here + /// + /// True to allow loopback addresses to be used + /// The URI to test for whether it should be allowed. + /// + /// true if [is URI allowable] [the specified URI]; otherwise, false. + /// + private static bool IsUriAllowable(Uri uri, bool allowLoopback) + { + if (!allowableSchemes.Contains(uri.Scheme)) + { + m_log.WarnFormat("Rejecting URL {0} because it uses a disallowed scheme.", uri); + 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 (!allowLoopback && (addressBytes[0] == 127 || addressBytes[0] == 10)) + { + m_log.WarnFormat("Rejecting URL {0} because it is a loopback address.", uri); + return false; + } + break; + case System.Net.Sockets.AddressFamily.InterNetworkV6: + if (!allowLoopback && IsIPv6Loopback(hostIPAddress)) + { + m_log.WarnFormat("Rejecting URL {0} because it is a loopback address.", uri); + return false; + } + break; + default: + m_log.WarnFormat("Rejecting URL {0} because it does not use an IPv4 or IPv6 address.", uri); + return false; + } + } + else + { + // The host is given by name. We require names to contain periods to + // help make sure it's not an internal address. + if (!allowLoopback && !uri.Host.Contains(".")) + { + m_log.WarnFormat("Rejecting URL {0} because it does not contain a period in the host name.", uri); + return false; + } + } + + return true; + } + + /// + /// 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) + { + if (ip == null) + throw new ArgumentNullException("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; + } + } +} diff --git a/OpenSim/Framework/WebUtil.cs b/OpenSim/Framework/WebUtil.cs new file mode 100644 index 0000000000..d9782ff9ac --- /dev/null +++ b/OpenSim/Framework/WebUtil.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.IO; +using System.Net; +using System.Net.Security; +using System.Reflection; +using System.Text; +using System.Web; +using log4net; +using OpenSim.Framework.Servers.HttpServer; +using OpenMetaverse.StructuredData; + +namespace OpenSim.Framework +{ + /// + /// Miscellaneous static methods and extension methods related to the web + /// + public static class WebUtil + { + private static readonly ILog m_log = + LogManager.GetLogger( + MethodBase.GetCurrentMethod().DeclaringType); + + /// + /// Send LLSD to an HTTP client in application/llsd+json form + /// + /// HTTP response to send the data in + /// LLSD to send to the client + public static void SendJSONResponse(OSHttpResponse response, OSDMap body) + { + byte[] responseData = Encoding.UTF8.GetBytes(OSDParser.SerializeJsonString(body)); + + response.ContentEncoding = Encoding.UTF8; + response.ContentLength = responseData.Length; + response.ContentType = "application/llsd+json"; + response.Body.Write(responseData, 0, responseData.Length); + } + + /// + /// Send LLSD to an HTTP client in application/llsd+xml form + /// + /// HTTP response to send the data in + /// LLSD to send to the client + public static void SendXMLResponse(OSHttpResponse response, OSDMap body) + { + byte[] responseData = OSDParser.SerializeLLSDXmlBytes(body); + + response.ContentEncoding = Encoding.UTF8; + response.ContentLength = responseData.Length; + response.ContentType = "application/llsd+xml"; + response.Body.Write(responseData, 0, responseData.Length); + } + + /// + /// Make a GET or GET-like request to a web service that returns LLSD + /// or JSON data + /// + public static OSDMap ServiceRequest(string url, string httpVerb) + { + string errorMessage; + + try + { + HttpWebRequest request = (HttpWebRequest)HttpWebRequest.Create(url); + request.Method = httpVerb; + + using (WebResponse response = request.GetResponse()) + { + using (Stream responseStream = response.GetResponseStream()) + { + try + { + string responseStr = responseStream.GetStreamString(); + OSD responseOSD = OSDParser.Deserialize(responseStr); + if (responseOSD.Type == OSDType.Map) + return (OSDMap)responseOSD; + else + errorMessage = "Response format was invalid."; + } + catch + { + errorMessage = "Failed to parse the response."; + } + } + } + } + catch (Exception ex) + { + m_log.Warn("GET from URL " + url + " failed: " + ex.Message); + errorMessage = ex.Message; + } + + return new OSDMap { { "Message", OSD.FromString("Service request failed. " + errorMessage) } }; + } + + /// + /// POST URL-encoded form data to a web service that returns LLSD or + /// JSON data + /// + public static OSDMap PostToService(string url, NameValueCollection data) + { + string errorMessage; + + try + { + string queryString = BuildQueryString(data); + byte[] requestData = System.Text.Encoding.UTF8.GetBytes(queryString); + + HttpWebRequest request = (HttpWebRequest)HttpWebRequest.Create(url); + request.Method = "POST"; + request.ContentLength = requestData.Length; + request.ContentType = "application/x-www-form-urlencoded"; + + using (Stream requestStream = request.GetRequestStream()) + requestStream.Write(requestData, 0, requestData.Length); + + using (WebResponse response = request.GetResponse()) + { + using (Stream responseStream = response.GetResponseStream()) + { + try + { + string responseStr = responseStream.GetStreamString(); + OSD responseOSD = OSDParser.Deserialize(responseStr); + if (responseOSD.Type == OSDType.Map) + return (OSDMap)responseOSD; + else + errorMessage = "Response format was invalid."; + } + catch + { + errorMessage = "Failed to parse the response."; + } + } + } + } + catch (Exception ex) + { + m_log.Warn("POST to URL " + url + " failed: " + ex.Message); + errorMessage = ex.Message; + } + + return new OSDMap { { "Message", OSD.FromString("Service request failed. " + errorMessage) } }; + } + + #region Uri + + /// + /// Combines a Uri that can contain both a base Uri and relative path + /// with a second relative path fragment + /// + /// Starting (base) Uri + /// Relative path fragment to append to the end + /// of the Uri + /// The combined Uri + /// This is similar to the Uri constructor that takes a base + /// Uri and the relative path, except this method can append a relative + /// path fragment on to an existing relative path + public static Uri Combine(this Uri uri, string fragment) + { + string fragment1 = uri.Fragment; + string fragment2 = fragment; + + if (!fragment1.EndsWith("/")) + fragment1 = fragment1 + '/'; + if (fragment2.StartsWith("/")) + fragment2 = fragment2.Substring(1); + + return new Uri(uri, fragment1 + fragment2); + } + + /// + /// Combines a Uri that can contain both a base Uri and relative path + /// with a second relative path fragment. If the fragment is absolute, + /// it will be returned without modification + /// + /// Starting (base) Uri + /// Relative path fragment to append to the end + /// of the Uri, or an absolute Uri to return unmodified + /// The combined Uri + public static Uri Combine(this Uri uri, Uri fragment) + { + if (fragment.IsAbsoluteUri) + return fragment; + + string fragment1 = uri.Fragment; + string fragment2 = fragment.ToString(); + + if (!fragment1.EndsWith("/")) + fragment1 = fragment1 + '/'; + if (fragment2.StartsWith("/")) + fragment2 = fragment2.Substring(1); + + return new Uri(uri, fragment1 + fragment2); + } + + /// + /// Appends a query string to a Uri that may or may not have existing + /// query parameters + /// + /// Uri to append the query to + /// Query string to append. Can either start with ? + /// or just containg key/value pairs + /// String representation of the Uri with the query string + /// appended + public static string AppendQuery(this Uri uri, string query) + { + if (String.IsNullOrEmpty(query)) + return uri.ToString(); + + if (query[0] == '?' || query[0] == '&') + query = query.Substring(1); + + string uriStr = uri.ToString(); + + if (uriStr.Contains("?")) + return uriStr + '&' + query; + else + return uriStr + '?' + query; + } + + #endregion Uri + + #region NameValueCollection + + /// + /// Convert a NameValueCollection into a query string. This is the + /// inverse of HttpUtility.ParseQueryString() + /// + /// Collection of key/value pairs to convert + /// A query string with URL-escaped values + public static string BuildQueryString(NameValueCollection parameters) + { + List items = new List(parameters.Count); + + foreach (string key in parameters.Keys) + { + foreach (string value in parameters.GetValues(key)) + items.Add(String.Concat(key, "=", HttpUtility.UrlEncode(value ?? String.Empty))); + } + + return String.Join("&", items.ToArray()); + } + + /// + /// + /// + /// + /// + /// + public static string GetOne(this NameValueCollection collection, string key) + { + string[] values = collection.GetValues(key); + if (values != null && values.Length > 0) + return values[0]; + + return null; + } + + #endregion NameValueCollection + + #region Stream + + /// + /// Copies the contents of one stream to another, starting at the + /// current position of each stream + /// + /// The stream to copy from, at the position + /// where copying should begin + /// The stream to copy to, at the position where + /// bytes should be written + /// The maximum bytes to copy + /// The total number of bytes copied + /// + /// Copying begins at the streams' current positions. The positions are + /// NOT reset after copying is complete. + /// + public static int CopyTo(this Stream copyFrom, Stream copyTo, int maximumBytesToCopy) + { + byte[] buffer = new byte[4096]; + int readBytes; + int totalCopiedBytes = 0; + + while ((readBytes = copyFrom.Read(buffer, 0, Math.Min(4096, maximumBytesToCopy))) > 0) + { + int writeBytes = Math.Min(maximumBytesToCopy, readBytes); + copyTo.Write(buffer, 0, writeBytes); + totalCopiedBytes += writeBytes; + maximumBytesToCopy -= writeBytes; + } + + return totalCopiedBytes; + } + + /// + /// Converts an entire stream to a string, regardless of current stream + /// position + /// + /// The stream to convert to a string + /// + /// When this method is done, the stream position will be + /// reset to its previous position before this method was called + public static string GetStreamString(this Stream stream) + { + string value = null; + + if (stream != null && stream.CanRead) + { + long rewindPos = -1; + + if (stream.CanSeek) + { + rewindPos = stream.Position; + stream.Seek(0, SeekOrigin.Begin); + } + + StreamReader reader = new StreamReader(stream); + value = reader.ReadToEnd(); + + if (rewindPos >= 0) + stream.Seek(rewindPos, SeekOrigin.Begin); + } + + return value; + } + + #endregion Stream + } +} diff --git a/prebuild.xml b/prebuild.xml index 09d78e9e22..98a471bb9f 100644 --- a/prebuild.xml +++ b/prebuild.xml @@ -157,6 +157,7 @@ +