From 97d9004355f7f0c6a0246f1910b0fa0ef5b684fd Mon Sep 17 00:00:00 2001 From: Geo Perez Date: Mon, 18 May 2015 11:44:24 -0500 Subject: [PATCH] First CORS release --- README.md | 6 +- Unosquare.Labs.EmbedIO.Command/Program.cs | 2 +- Unosquare.Labs.EmbedIO.Samples/Program.cs | 12 +++- .../CorsModuleTest.cs | 52 ++++++++++++++ .../Unosquare.Labs.EmbedIO.Tests.csproj | 1 + .../WebApiModuleTest.cs | 9 +++ Unosquare.Labs.EmbedIO/Constants.cs | 59 ++++++++++++--- Unosquare.Labs.EmbedIO/FluentExtensions.cs | 20 +++++- Unosquare.Labs.EmbedIO/Modules/CorsModule.cs | 71 ++++++++++++++----- 9 files changed, 198 insertions(+), 34 deletions(-) create mode 100644 Unosquare.Labs.EmbedIO.Tests/CorsModuleTest.cs diff --git a/README.md b/README.md index b99a34bab..6c230df4d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![EmbedIO](http://unosquare.github.io/embedio/images/embedio.png) -A tiny, cross-platform, module based, MIT-licensed web server +A tiny, cross-platform, module based, MIT-licensed web server. * New Version: Network operations make heavy use of the relatively recent async/await pattern * Cross-platform (tested in Mono 3.10.x on Windows and on a custom Yocto image for the Raspberry Pi) @@ -14,6 +14,7 @@ A tiny, cross-platform, module based, MIT-licensed web server * Serve static files with 1 line of code (built-in module) * Handle sessions with the built-in LocalSessionWebModule * Web Sockets support (Not available on Mono though) +* CORS support. Origin, Headers and Methods validation with OPTIONS preflight *For detailed usage and REST API implementation, download the code and take a look at the Samples project* @@ -115,9 +116,10 @@ namespace Company.Project url = args[0]; // Create Webserver with console logger and attach LocalSession and Static - // files module + // files module and CORS enabled var server = WebServer .CreateWithConsole(url) + .EnableCors() .WithLocalSession() .WithStaticFolderAt("c:/web"); diff --git a/Unosquare.Labs.EmbedIO.Command/Program.cs b/Unosquare.Labs.EmbedIO.Command/Program.cs index 168cb86a2..1e7df40b5 100644 --- a/Unosquare.Labs.EmbedIO.Command/Program.cs +++ b/Unosquare.Labs.EmbedIO.Command/Program.cs @@ -29,7 +29,7 @@ private static void Main(string[] args) if (Properties.Settings.Default.UseLocalSessionModule) server.WithLocalSession(); - server.WithStaticFolderAt(options.RootPath, + server.EnableCors().WithStaticFolderAt(options.RootPath, defaultDocument: Properties.Settings.Default.HtmlDefaultDocument); server.Module().DefaultExtension = Properties.Settings.Default.HtmlDefaultExtension; diff --git a/Unosquare.Labs.EmbedIO.Samples/Program.cs b/Unosquare.Labs.EmbedIO.Samples/Program.cs index 6b0213248..e91d6530f 100644 --- a/Unosquare.Labs.EmbedIO.Samples/Program.cs +++ b/Unosquare.Labs.EmbedIO.Samples/Program.cs @@ -20,7 +20,7 @@ private static void Main(string[] args) // Our web server is disposable. Note that if you don't want to use logging, // there are alternate constructors that allow you to skip specifying an ILog object. - using (var server = new WebServer(url, new SimpleConsoleLog())) + using (var server = new WebServer(url, Log)) { // First, we will configure our web server by adding Modules. // Please note that order DOES matter. @@ -30,8 +30,14 @@ private static void Main(string[] args) // You can use the server.GetSession() method to get the SessionInfo object and manupulate it. server.RegisterModule(new Modules.LocalSessionModule()); - // Set the CORS Rules, Origins separated by comma without last slash - server.RegisterModule(new Modules.CorsModule("http://client.cors-api.appspot.com,http://unosquare.github.io,http://run.plnkr.co")); + // Set the CORS Rules + server.RegisterModule(new Modules.CorsModule( + // Origins, separated by comma without last slash + "http://client.cors-api.appspot.com,http://unosquare.github.io,http://run.plnkr.co", + // Allowed headers + "content-type, accept", + // Allowed methods + "post")); // Register the static files server. See the html folder of this project. Also notice that // the files under the html folder have Copy To Output Folder = Copy if Newer diff --git a/Unosquare.Labs.EmbedIO.Tests/CorsModuleTest.cs b/Unosquare.Labs.EmbedIO.Tests/CorsModuleTest.cs new file mode 100644 index 000000000..6b71af934 --- /dev/null +++ b/Unosquare.Labs.EmbedIO.Tests/CorsModuleTest.cs @@ -0,0 +1,52 @@ +namespace Unosquare.Labs.EmbedIO.Tests +{ + using NUnit.Framework; + using System; + using System.Net; + using System.Threading; + using Unosquare.Labs.EmbedIO.Modules; + using Unosquare.Labs.EmbedIO.Tests.Properties; + + [TestFixture] + public class CorsModuleTest + { + protected WebServer WebServer; + protected TestConsoleLog Logger = new TestConsoleLog(); + + [SetUp] + public void Init() + { + WebServer = new WebServer(Resources.ServerAddress, Logger) + .EnableCors( + "http://client.cors-api.appspot.com,http://unosquare.github.io,http://run.plnkr.co", + "content-type", + "post,get"); + + WebServer.RegisterModule(new WebApiModule()); + WebServer.Module().RegisterController(); + WebServer.RunAsync(); + } + + [Test] + public void PreFlight() + { + var request = (HttpWebRequest) WebRequest.Create(Resources.ServerAddress + TestController.GetPath); + request.Headers.Add(Constants.HeaderOrigin, "http://unosquare.github.io"); + request.Headers.Add(Constants.HeaderAccessControlRequestMethod, "post"); + request.Headers.Add(Constants.HeaderAccessControlRequestHeaders, "content-type"); + request.Method = "OPTIONS"; + + using (var response = (HttpWebResponse) request.GetResponse()) + { + Assert.AreEqual(response.StatusCode, HttpStatusCode.OK, "Status Code OK"); + } + } + + [TearDown] + public void Kill() + { + Thread.Sleep(TimeSpan.FromSeconds(1)); + WebServer.Dispose(); + } + } +} \ No newline at end of file diff --git a/Unosquare.Labs.EmbedIO.Tests/Unosquare.Labs.EmbedIO.Tests.csproj b/Unosquare.Labs.EmbedIO.Tests/Unosquare.Labs.EmbedIO.Tests.csproj index 9dd5c1752..c30ee7ab8 100644 --- a/Unosquare.Labs.EmbedIO.Tests/Unosquare.Labs.EmbedIO.Tests.csproj +++ b/Unosquare.Labs.EmbedIO.Tests/Unosquare.Labs.EmbedIO.Tests.csproj @@ -47,6 +47,7 @@ + diff --git a/Unosquare.Labs.EmbedIO.Tests/WebApiModuleTest.cs b/Unosquare.Labs.EmbedIO.Tests/WebApiModuleTest.cs index 0677f894e..07c0a4721 100644 --- a/Unosquare.Labs.EmbedIO.Tests/WebApiModuleTest.cs +++ b/Unosquare.Labs.EmbedIO.Tests/WebApiModuleTest.cs @@ -2,10 +2,12 @@ { using Newtonsoft.Json; using NUnit.Framework; + using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; + using System.Threading; using Unosquare.Labs.EmbedIO.Modules; using Unosquare.Labs.EmbedIO.Tests.Properties; @@ -64,5 +66,12 @@ public void GetJsonData() } // TODO: Test POST + + [TearDown] + public void Kill() + { + Thread.Sleep(TimeSpan.FromSeconds(1)); + WebServer.Dispose(); + } } } diff --git a/Unosquare.Labs.EmbedIO/Constants.cs b/Unosquare.Labs.EmbedIO/Constants.cs index 6aa867afb..392c3da64 100644 --- a/Unosquare.Labs.EmbedIO/Constants.cs +++ b/Unosquare.Labs.EmbedIO/Constants.cs @@ -1,64 +1,73 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Unosquare.Labs.EmbedIO +namespace Unosquare.Labs.EmbedIO { + using System; + using System.Collections.Generic; + /// /// Defines assembly-wide constants /// - static public class Constants + public static class Constants { /// /// Accept-Encoding HTTP Header /// public const string HeaderAcceptEncoding = "Accept-Encoding"; + /// /// Content-Encoding HTTP Header /// public const string HeaderContentEncoding = "Content-Encoding"; + /// /// If-Modified-Since HTTP Header /// public const string HeaderIfModifiedSince = "If-Modified-Since"; + /// /// Cache-Control HTTP Header /// public const string HeaderCacheControl = "Cache-Control"; + /// /// Pragma HTTP Header /// public const string HeaderPragma = "Pragma"; + /// /// Expires HTTP Header /// public const string HeaderExpires = "Expires"; + /// /// Last-Modified HTTP Header /// public const string HeaderLastModified = "Last-Modified"; + /// /// If-None-Match HTTP Header /// public const string HeaderIfNotMatch = "If-None-Match"; + /// /// ETag HTTP Header /// public const string HeaderETag = "ETag"; + /// /// Accept-Ranges HTTP Header /// public const string HeaderAcceptRanges = "Accept-Ranges"; + /// /// Range HTTP Header /// public const string HeaderRange = "Range"; + /// /// Content-Range HTTP Header /// public const string HeaderContentRanges = "Content-Range"; + /// /// Default Browser time format /// @@ -69,6 +78,40 @@ static public class Constants /// public const string Response404Html = "

404 - Not Found

"; + /// + /// Default CORS rule + /// + public const string CorsWildcard = "*"; + + /// + /// Access-Control-Allow-Origin HTTP Header + /// + public const string HeaderAccessControlAllowOrigin = "Access-Control-Allow-Origin: *"; + + /// + /// Access-Control-Allow-Headers HTTP Header + /// + public const string HeaderAccessControlAllowHeaders = "Access-Control-Allow-Headers: "; + + /// + /// Access-Control-Allow-Methods HTTP Header + /// + public const string HeaderAccessControlAllowMethods = "Access-Control-Allow-Methods: "; + + /// + /// Origin HTTP Header + /// + public const string HeaderOrigin = "Origin"; + + /// + /// Access-Control-Request-Headers HTTP Header + /// + public const string HeaderAccessControlRequestHeaders = "Access-Control-Request-Headers"; + /// + /// Access-Control-Request-Headers HTTP Method + /// + public const string HeaderAccessControlRequestMethod = "Access-Control-Request-Method"; + /// /// Default Http Status 500 response output /// The first format argument takes the error message. diff --git a/Unosquare.Labs.EmbedIO/FluentExtensions.cs b/Unosquare.Labs.EmbedIO/FluentExtensions.cs index 07a386cef..b4e0bcb6c 100644 --- a/Unosquare.Labs.EmbedIO/FluentExtensions.cs +++ b/Unosquare.Labs.EmbedIO/FluentExtensions.cs @@ -100,7 +100,6 @@ public static WebServer LoadApiControllers(this WebServer webserver, Assembly as /// /// The webserver instance. /// The assembly to load WebSocketsServer types from. Leave null to load from the currently executing assembly. - /// Set verbose /// The webserver instance. public static WebServer LoadWebSockets(this WebServer webserver, Assembly assembly = null) { @@ -123,5 +122,24 @@ public static WebServer LoadWebSockets(this WebServer webserver, Assembly assemb return webserver; } + + /// + /// Enables CORS in the WebServer + /// + /// The webserver instance. + /// The valid origins, default all + /// The valid headers, default all + /// The valid method, default all + /// + public static WebServer EnableCors(this WebServer webserver, string origins = Constants.CorsWildcard, + string headers = Constants.CorsWildcard, + string methods = Constants.CorsWildcard) + { + if (webserver == null) throw new ArgumentException("Argument cannot be null.", "webserver"); + + webserver.RegisterModule(new CorsModule(origins, headers, methods)); + + return webserver; + } } } \ No newline at end of file diff --git a/Unosquare.Labs.EmbedIO/Modules/CorsModule.cs b/Unosquare.Labs.EmbedIO/Modules/CorsModule.cs index 4aaa03826..6a6de1cb7 100644 --- a/Unosquare.Labs.EmbedIO/Modules/CorsModule.cs +++ b/Unosquare.Labs.EmbedIO/Modules/CorsModule.cs @@ -8,49 +8,81 @@ /// public class CorsModule : WebModuleBase { - private const string CorsWildcard = "*"; - private const string AccessControlAllowOrigin = "Access-Control-Allow-Origin: *"; - private const string AccessControlAllowHeaders = "Access-Control-Allow-Headers: "; - private const string AccessControlAllowMethods = "Access-Control-Allow-Methods: "; - /// /// Generates the rules for CORS + /// + /// TODO: Add Whitelist origins with Regex + /// TODO: Add Path Regex, just apply CORS in some paths + /// TODO: Handle valid headers in other modules + /// /// /// The valid origins, default all /// The valid headers, default all /// The valid method, default all - public CorsModule(string origins = CorsWildcard, string headers = CorsWildcard, string methods = CorsWildcard) + public CorsModule(string origins = Constants.CorsWildcard, string headers = Constants.CorsWildcard, + string methods = Constants.CorsWildcard) { - var validOrigins = origins.Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries); - this.AddHandler(ModuleMap.AnyPath, HttpVerbs.Any, (server, context) => + if (origins == null) throw new ArgumentException("Argument cannot be null.", "origins"); + if (headers == null) throw new ArgumentException("Argument cannot be null.", "headers"); + if (methods == null) throw new ArgumentException("Argument cannot be null.", "methods"); + + var validOrigins = origins.ToLower().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()); + var validHeaders = headers.ToLower().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()); + var validMethods = methods.ToLower().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()); + + AddHandler(ModuleMap.AnyPath, HttpVerbs.Any, (server, context) => { // If we allow all we don't need to filter - if (origins == CorsWildcard && headers == CorsWildcard && methods == CorsWildcard) + if (origins == Constants.CorsWildcard && headers == Constants.CorsWildcard && methods == Constants.CorsWildcard) { - context.Response.Headers.Add(AccessControlAllowOrigin); + context.Response.Headers.Add(Constants.HeaderAccessControlAllowOrigin); return false; } - var currentOrigin = context.RequestHeader("Origin"); - var currentHeader = context.RequestHeader("Access-Control-Request-Headers"); - var currentMethod = context.RequestHeader("Access-Control-Request-Method"); + var currentOrigin = context.RequestHeader(Constants.HeaderOrigin); + var currentHeader = context.RequestHeader(Constants.HeaderAccessControlRequestHeaders); + var currentMethod = context.RequestHeader(Constants.HeaderAccessControlRequestMethod); if (String.IsNullOrWhiteSpace(currentOrigin) && context.Request.IsLocal) return false; - if (origins != CorsWildcard) + if (origins != Constants.CorsWildcard) { if (validOrigins.Contains(currentOrigin)) { - context.Response.Headers.Add(AccessControlAllowOrigin.Replace("*", currentOrigin)); + context.Response.Headers.Add(Constants.HeaderAccessControlAllowOrigin.Replace("*", currentOrigin)); if (context.RequestVerb() == HttpVerbs.Options) { if (String.IsNullOrWhiteSpace(currentHeader) == false) - context.Response.Headers.Add(AccessControlAllowHeaders + currentHeader); + { + var currentHeaders = currentHeader.ToLower() + .Split(new[] {','}, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()); + + if (headers == Constants.CorsWildcard || currentHeaders.All(validHeaders.Contains)) + { + context.Response.Headers.Add(Constants.HeaderAccessControlAllowHeaders + currentHeader); + } + else + { + return false; + } + } if (String.IsNullOrWhiteSpace(currentMethod) == false) - context.Response.Headers.Add(AccessControlAllowMethods + currentMethod); + { + var currentMethods = currentMethod.ToLower() + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()); + + if (methods == Constants.CorsWildcard || currentMethods.All(validMethods.Contains)) + { + context.Response.Headers.Add(Constants.HeaderAccessControlAllowMethods + currentMethod); + } + else + { + return false; + } + } return true; } @@ -59,12 +91,13 @@ public CorsModule(string origins = CorsWildcard, string headers = CorsWildcard, } } - // TODO: Implement Methods and Header - return false; }); } + /// + /// Module's name + /// public override string Name { get { return "CORS Module"; }