Skip to content

Instantly share code, notes, and snippets.

@rdev5
Last active August 29, 2015 14:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rdev5/6da1a2f46460a3507dde to your computer and use it in GitHub Desktop.
Save rdev5/6da1a2f46460a3507dde to your computer and use it in GitHub Desktop.
A utility class for establishing SSL connection with remote web service and returning response in SecureString object. Unit tests included.
using System;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Net.Security;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Security;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Web;
namespace SecureClient
{
/// <summary>
/// A utility class for establishing SSL connection with remote web service and returning response in SecureString object.
/// </summary>
/// <author>Matt Borja</author>
public sealed class SecureClient
{
private const string _httpVersion = "HTTP/1.1";
private const string _expectedUriScheme = "https";
private const int _maxSecureStringLength = 65536;
/// <summary>
/// Exposes helper methods for encoding HTTP headers
///
/// Source: https://github.com/mono/mono/blob/master/mcs/class/System.Web/System.Web.Util/HttpEncoder.cs#L94-L130
/// </summary>
private static void StringBuilderAppend(string s, ref StringBuilder sb)
{
if (sb == null)
sb = new StringBuilder(s);
else
sb.Append(s);
}
public static string EncodeHeaderString(string input)
{
StringBuilder sb = null;
foreach (char ch in input)
{
if ((ch < 32 && ch != 9) || ch == 127)
StringBuilderAppend(String.Format("%{0:x2}", (int)ch), ref sb);
}
if (sb != null)
return sb.ToString();
return input;
}
public static NameValueCollection EncodeHeaders(NameValueCollection headers)
{
var encodedHeaders = new NameValueCollection();
foreach (var k in headers.AllKeys)
{
var encodedHeaderName = String.IsNullOrEmpty(k) ? k : EncodeHeaderString(k);
encodedHeaders[encodedHeaderName] = String.IsNullOrEmpty(headers[k]) ? headers[k] : EncodeHeaderString(headers[k]);
}
return encodedHeaders;
}
/// <summary>
/// Remote certificate validation callback
/// </summary>
/// <param name="sender"></param>
/// <param name="certificate"></param>
/// <param name="chain"></param>
/// <param name="sslPolicyErrors"></param>
/// <returns></returns>
private static bool ValidateServerCertificate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
{
return sslPolicyErrors == SslPolicyErrors.None;
}
/// <summary>
/// Helper function for creating rawRequest from NameValueCollections
/// </summary>
/// <param name="uri"></param>
/// <param name="body"></param>
/// <param name="headers"></param>
/// <param name="method"></param>
/// <returns></returns>
public static string CreateRawRequest(Uri uri, NameValueCollection body = null, NameValueCollection headers = null, string method = null)
{
if (uri == null) throw new ArgumentNullException("uri");
var rawRequest = String.Empty;
// Set default request method
method = String.IsNullOrEmpty(method) ? "GET" : method;
// Construct HTTP verb with version (i.e. GET /page?params=... HTTP/1.1)
rawRequest = String.Format("{0} {1} {2}", method, uri.PathAndQuery, _httpVersion) + Environment.NewLine + rawRequest;
// Construct headers
if (headers != null)
{
// Set default Host header
if (!headers.AllKeys.Contains("Host"))
{
headers["Host"] = uri.Authority;
}
// Encode headers
headers = EncodeHeaders(headers);
rawRequest += String.Join(Environment.NewLine, headers.AllKeys.Select(k => k + ": " + headers[k]));
rawRequest += Environment.NewLine;
}
// Construct parameters
if (body != null)
{
rawRequest += String.Join("&", body.AllKeys.Select(k => k + "=" + HttpUtility.UrlEncode(body[k])));
rawRequest += Environment.NewLine;
}
return rawRequest.Trim();
}
/// <summary>
/// Uses SslStream to marshal TcpClient response buffer into SecureString object one byte at a time.
///
/// Considerations when implementing:
/// - Request should contain "Connection: close" HTTP header or connection will remain opened (i.e. keep-alive) and block.
/// - ReadByte will block the application if no bytes are received so a default Read/WriteTimeout of 1000ms is provided.
/// </summary>
/// <param name="uri">Request Uri</param>
/// <param name="rawRequest" rel="POST">
/// --- For POST requests ---
///
/// POST /bin/login HTTP/1.1
/// Host: www.example.com
/// Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
/// Pragma: no-cache
/// Content-Type: application/x-www-form-urlencoded
/// User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36
/// Content-Length: 37
/// Connection: close
/// Cache-Control: no-cache
/// User=Peter+Lee&pw=123456&action=login
///
/// --- For GET requests ---
///
/// GET /index.html?param1=value&param2=value HTTP/1.1
/// Host: www.example.com
/// Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
/// Pragma: no-cache
/// User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36
/// Cache-Control: no-cache
/// Connection: close
///
/// </param>
/// <param name="timeout">Maximum amount of time in milliseconds a single ReadByte() or Write() operation can block the caller (default: 1000ms)</param>
/// <param name="readBytesAtOnce">
/// This is an optional performance optimization mode (specified as total number of bytes to read from SslStream) for
/// constructing the entire SecureString at once at the expense of momentarily storing more plaintext in insecure memory.
///
/// If set to 1, all bytes in SslStream will be marshalled into SecureString object one byte at a time.
/// If set to any number greater than 1, this will become the total number of bytes read from SslStream at once.
///
/// Example benchmarks using Uri("https://www.google.com/")
/// - Time to initialize SecureString with 65536 chars using unsafe context: 00:00:00.20
/// - Time to individually marshal 65536 chars into SecureString one at a time: 00:00:52.51
///
/// Caution:
/// - The larger readBytesAtOnce is, the more plaintext in insecure memory will be stored until SecureString initialization has been completed.
/// - All remaining bytes in stream will be truncated after the specified number of bytes have been read.
/// - Maximum size of readBytesAtOnce is the maximum size of SecureString (65536)
/// </param>
/// <returns>SecureString containing HTTP response</returns>
public static SecureString SecureHttpRequest(Uri uri, string rawRequest = null, uint timeout = 1000, uint readBytesAtOnce = 1)
{
if (uri == null) throw new ArgumentNullException("uri");
if (uri.Scheme != _expectedUriScheme) throw new WarningException("URI does not match expected scheme and may fail to establish SSL connection.", new UriFormatException(String.Format("Expected URI scheme was <{0}> but got <{1}> instead.", _expectedUriScheme, uri.Scheme)));
if (readBytesAtOnce < 1) throw new ArgumentOutOfRangeException("readBytesAtOnce");
if (readBytesAtOnce > _maxSecureStringLength) throw new ArgumentOutOfRangeException("readBytesAtOnce");
// Construct default GET request if none provided
if (String.IsNullOrEmpty(rawRequest))
{
var headers = new NameValueCollection();
headers["Host"] = uri.Authority;
headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8";
headers["Pragma"] = "no-cache";
headers["User-Agent"] = "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36";
headers["Cache-Control"] = "no-cache";
headers["Connection"] = "close";
rawRequest = CreateRawRequest(uri, null, headers);
}
var secureResponse = new SecureString();
using (var client = new TcpClient(uri.Host, uri.Port))
{
using (var sslStream = new SslStream(client.GetStream(), false, ValidateServerCertificate, null))
{
try
{
// Set timeouts
sslStream.WriteTimeout = (int) timeout;
sslStream.ReadTimeout = (int) timeout;
// Authenticate
sslStream.AuthenticateAsClient(uri.Host);
// Send (request must be terminated with at least two newline characters)
sslStream.Write(Encoding.UTF8.GetBytes(rawRequest + Environment.NewLine + Environment.NewLine));
sslStream.Flush();
// Receive bytes at once (if set)
// Time to append 256 chars to SecureString: 00:00:00.19
if (readBytesAtOnce != 1)
{
// Pin chunk in memory
var chunk = new byte[readBytesAtOnce];
var chunkHandler = GCHandle.Alloc(chunk, GCHandleType.Pinned);
var chars = new char[chunk.Length];
var charHandler = GCHandle.Alloc(chars, GCHandleType.Pinned);
try
{
// Read all bytes at once
sslStream.Read(chunk, 0, (int) readBytesAtOnce);
// Uses decrement optimization for converting byte[] to char[]
for (var i = chunk.Length - 1; i >= 0; --i)
{
chars[i] = Convert.ToChar(chunk[i]);
// Zero byte in source
chunk[i] = 0;
}
unsafe
{
// Create byte pointer to chunk and re-cast as char pointer
fixed (char* charPtr = chars)
{
secureResponse = new SecureString(charPtr, chunk.Length);
}
}
}
finally
{
// Scrub and free handler
Array.Clear(chunk, 0, chunk.Length);
chunkHandler.Free();
Array.Clear(chars, 0, chunk.Length);
charHandler.Free();
}
}
// Otherwise marshal bytes one at a time
// Time to append 65536 chars to SecureString: 00:00:52.51
else
{
// Pin byte in memory
var b = -1;
var byteHandler = GCHandle.Alloc(b, GCHandleType.Pinned);
try
{
do
{
b = sslStream.ReadByte();
if (b == -1) break;
secureResponse.AppendChar(Convert.ToChar(b));
} while (sslStream.CanRead && b != -1);
}
catch (ArgumentOutOfRangeException)
{
// SecureString has a maximum length of 65535 characters (unable to determine length from SslStream)
}
finally
{
// Scrub byte and free handler
b = -1;
byteHandler.Free();
}
}
}
// Ensure cleanup
finally
{
// Make secureMessage read-only
secureResponse.MakeReadOnly();
// Explicitly close streams
sslStream.Close();
client.Close();
}
return secureResponse;
}
}
}
}
}
using System;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace SecureClient.Tests
{
[TestClass]
public class SecureClientTest
{
#region CreateRawRequest
[TestMethod]
[ExpectedException(typeof(ArgumentNullException))]
public void CreateRawRequestThrowsExceptionForNullUri()
{
SecureClient.CreateRawRequest(null);
}
[TestMethod]
public void CreateRawRequestUsesDefaultUriAuthority()
{
// Pre-calculated expectation
var expectedResult =
Encoding.UTF8.GetString(
Convert.FromBase64String("R0VUIC8gSFRUUC8xLjENCkFjY2VwdDogdGV4dC9odG1sLGFwcGxpY2F0aW9uL3hodG1sK3htbCxhcHBsaWNhdGlvbi94bWw7cT0wLjksaW1hZ2Uvd2VicCwqLyo7cT0wLjgNClByYWdtYTogbm8tY2FjaGUNClVzZXItQWdlbnQ6IE1vemlsbGEvNS4wIChXaW5kb3dzIE5UIDYuMTsgV09XNjQpIEFwcGxlV2ViS2l0LzUzNy4zNiAoS0hUTUwsIGxpa2UgR2Vja28pIENocm9tZS80MS4wLjIyNzIuMTE4IFNhZmFyaS81MzcuMzYNCkNhY2hlLUNvbnRyb2w6IG5vLWNhY2hlDQpDb25uZWN0aW9uOiBjbG9zZQ0KSG9zdDogd3d3LmV4YW1wbGUuY29t"));
// Arrange
var uri = new Uri("https://www.example.com/");
var headers = new NameValueCollection();
// headers["Host"] = uri.Authority
headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8";
headers["Pragma"] = "no-cache";
headers["User-Agent"] = "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36";
headers["Cache-Control"] = "no-cache";
headers["Connection"] = "close";
// Act
var rawRequest = SecureClient.CreateRawRequest(uri, null, headers);
// Assert
Assert.AreEqual(expectedResult, rawRequest);
}
[TestMethod]
public void CreateRawRequestReturnsExpectedResult()
{
// Pre-calculated expectation
var expectedResult =
Encoding.UTF8.GetString(
Convert.FromBase64String("R0VUIC9zZWFyY2g/cT1lbGxpcHRpYytjdXJ2ZStjcnlwdG9ncmFwaHkgSFRUUC8xLjENCkhvc3Q6IHd3dy5nb29nbGUuY29tDQpBY2NlcHQ6IHRleHQvaHRtbCxhcHBsaWNhdGlvbi94aHRtbCt4bWwsYXBwbGljYXRpb24veG1sO3E9MC45LGltYWdlL3dlYnAsKi8qO3E9MC44DQpQcmFnbWE6IG5vLWNhY2hlDQpVc2VyLUFnZW50OiBNb3ppbGxhLzUuMCAoV2luZG93cyBOVCA2LjE7IFdPVzY0KSBBcHBsZVdlYktpdC81MzcuMzYgKEtIVE1MLCBsaWtlIEdlY2tvKSBDaHJvbWUvNDEuMC4yMjcyLjExOCBTYWZhcmkvNTM3LjM2DQpDYWNoZS1Db250cm9sOiBuby1jYWNoZQ0KQ29ubmVjdGlvbjogY2xvc2UNCnBhcmFtQT1IZWxsbyZwYXJhbUI9V29ybGQmcGFyYW1DPTE="));
// Arrange
var uri = new Uri("https://www.google.com/search?q=elliptic+curve+cryptography");
var body = new NameValueCollection();
body["paramA"] = "Hello";
body["paramB"] = "World";
body["paramC"] = "1";
var headers = new NameValueCollection();
headers["Host"] = "www.google.com";
headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8";
headers["Pragma"] = "no-cache";
headers["User-Agent"] = "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36";
headers["Cache-Control"] = "no-cache";
headers["Connection"] = "close";
// Act
var rawRequest = SecureClient.CreateRawRequest(uri, body, headers);
// Assert
Assert.AreEqual(expectedResult, rawRequest);
}
#endregion
#region SecureHttpRequest
[TestMethod]
[ExpectedException(typeof(ArgumentNullException))]
public void SecureHttpRequestThrowsExceptionForNullUri()
{
SecureClient.SecureHttpRequest(null);
}
[TestMethod]
[ExpectedException(typeof(WarningException))]
public void SecureHttpRequestThrowsWarningForInvalidUriScheme()
{
var uri = new Uri("http://www.example.com/");
SecureClient.SecureHttpRequest(uri);
}
[TestMethod]
public void SecureHttpRequestUsesDefaultUri()
{
var uri = new Uri("https://www.example.com/");
var secureResponse = SecureClient.SecureHttpRequest(uri);
Assert.AreNotEqual(0, secureResponse.Length);
}
[TestMethod]
public void SecureHttpRequestReadsBytesAtOnce()
{
var uri = new Uri("https://www.example.com/");
var secureResponse = SecureClient.SecureHttpRequest(uri, null, 1000, 65536);
Assert.AreEqual(secureResponse.Length, 65536);
}
[TestMethod]
[ExpectedException(typeof(ArgumentOutOfRangeException))]
public void SecureHttpRequestThrowsExceptionForZeroSize()
{
var uri = new Uri("https://www.example.com/");
SecureClient.SecureHttpRequest(uri, null, 1000, 0);
}
[TestMethod]
[ExpectedException(typeof(ArgumentOutOfRangeException))]
public void SecureHttpRequestThrowsExceptionForUnsupportedSize()
{
var uri = new Uri("https://www.example.com/");
SecureClient.SecureHttpRequest(uri, null, 1000, 65537);
}
[TestMethod]
public void SecureHttpRequestReturnsReadableSecureString()
{
var expectedResult = "HTTP/1.1 200";
// Arrange
var insecureStringPtr = IntPtr.Zero;
var insecureString = "";
var gcHandler = GCHandle.Alloc(insecureString, GCHandleType.Pinned);
var uri = new Uri("https://www.example.com/");
// Act
var secureResponse = SecureClient.SecureHttpRequest(uri, null, 1000, (uint)expectedResult.Length);
// Assert
try
{
insecureStringPtr = Marshal.SecureStringToGlobalAllocUnicode(secureResponse);
insecureString = Marshal.PtrToStringUni(insecureStringPtr);
Assert.IsNotNull(insecureString);
Assert.AreEqual(expectedResult, insecureString);
}
finally
{
insecureString = null;
gcHandler.Free();
Marshal.ZeroFreeGlobalAllocUnicode(insecureStringPtr);
}
}
#endregion
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment