Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Merge pull request #591 from thecodejunkie/staticcontentinroot-587

Static content in root
  • Loading branch information...
commit 23d00cf1b11af2b03cb9109c35ff9fa04e509b1d 2 parents 87dcda5 + 12cbd9f
@grumpydev grumpydev authored
View
10 src/Nancy.Tests/Nancy.Tests.csproj
@@ -196,8 +196,8 @@
<Compile Include="Unit\Sessions\DefaultSessionObjectFormatterFixture.cs" />
<Compile Include="Unit\Sessions\NullSessionProviderFixture.cs" />
<Compile Include="Unit\Sessions\SessionFixture.cs" />
- <Compile Include="Unit\StaticConventBuilderFixture.cs" />
<Compile Include="Unit\TextFormatterFixture.cs" />
+ <Compile Include="Unit\StaticContentConventionBuilderFixture.cs" />
<Compile Include="Unit\UrlFixture.cs" />
<Compile Include="Unit\Validation\CompositeValidatorFixture.cs" />
<Compile Include="Unit\Validation\ModuleExtensionsFixture.cs" />
@@ -234,6 +234,9 @@
<EmbeddedResource Include="Resources\Views\staticviewresource.html" />
</ItemGroup>
<ItemGroup>
+ <Content Include="Resources\Assets\Styles\dotted.filename.css">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </Content>
<Content Include="Resources\Assets\Styles\css\styles.css">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
@@ -243,6 +246,9 @@
<Content Include="Resources\Assets\Styles\styles.css">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
+ <Content Include="Resources\Assets\Styles\Sub.folder\styles.css">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </Content>
<Content Include="Resources\Assets\Styles\Sub\styles.css">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
@@ -262,4 +268,4 @@
<Target Name="AfterBuild">
</Target>
-->
-</Project>
+</Project>
View
3  src/Nancy.Tests/Resources/Assets/Styles/Sub.folder/styles.css
@@ -0,0 +1,3 @@
+body {
+ background-color: white;
+}
View
3  src/Nancy.Tests/Resources/Assets/Styles/dotted.filename.css
@@ -0,0 +1,3 @@
+body {
+ background-color: white;
+}
View
137 src/Nancy.Tests/Unit/StaticContentConventionBuilderFixture.cs
@@ -0,0 +1,137 @@
+namespace Nancy.Tests.Unit
+{
+ using System;
+ using System.IO;
+ using System.Security;
+ using System.Text;
+ using Nancy.Conventions;
+ using Nancy.Responses;
+ using Xunit;
+ using Xunit.Extensions;
+
+ public class StaticContentConventionBuilderFixture
+ {
+ private const string StylesheetContents = @"body {
+ background-color: white;
+}";
+
+ [Fact]
+ public void Should_retrieve_static_content_when_path_has_same_name_as_extension()
+ {
+ // Given
+ // When
+ var result = GetStaticContent("css", "styles.css");
+
+ // Then
+ result.ShouldEqual(StylesheetContents);
+ }
+
+ [Fact]
+ public void Should_retrieve_static_content_when_virtual_directory_name_exists_in_static_route()
+ {
+ // Given
+ // When
+ var result = GetStaticContent("css", "strange-css-filename.css");
+
+ // Then
+ result.ShouldEqual(StylesheetContents);
+ }
+
+ [Fact]
+ public void Should_retrieve_static_content_when_path_is_nested()
+ {
+ // Given
+ // When
+ var result = GetStaticContent("css/sub", "styles.css");
+
+ // Then
+ result.ShouldEqual(StylesheetContents);
+ }
+
+ [Fact]
+ public void Should_retrieve_static_content_when_path_contains_nested_folders_with_duplicate_name()
+ {
+ // Given
+ // When
+ var result = GetStaticContent("css/css", "styles.css");
+
+ // Then
+ result.ShouldEqual(StylesheetContents);
+ }
+
+ [Fact]
+ public void Should_retrieve_static_content_when_filename_contains_dot()
+ {
+ // Given
+ // When
+ var result = GetStaticContent("css", "dotted.filename.css");
+
+ // Then
+ result.ShouldEqual(StylesheetContents);
+ }
+
+ [Fact]
+ public void Should_retrieve_static_content_when_path_contains_dot()
+ {
+ // Given
+ // When
+ var result = GetStaticContent("css/Sub.folder", "styles.css");
+
+ // Then
+ result.ShouldEqual(StylesheetContents);
+ }
+
+ [Fact]
+ public void Should_throw_security_exception_when_content_path_points_to_root()
+ {
+ // Given
+ var convention = StaticContentConventionBuilder.AddDirectory("/", "/");
+ var request = new Request("GET", "/face.png", "http");
+ var context = new NancyContext { Request = request };
+
+ // When
+ var exception = Record.Exception(() => convention.Invoke(context, Environment.CurrentDirectory));
+
+ // Then
+ exception.ShouldBeOfType<SecurityException>();
+ }
+
+ [Fact]
+ public void Should_throw_security_exception_when_content_path_is_null_and_requested_path_points_to_root()
+ {
+ // Given
+ var convention = StaticContentConventionBuilder.AddDirectory("/");
+ var request = new Request("GET", "/face.png", "http");
+ var context = new NancyContext { Request = request };
+
+ // When
+ var exception = Record.Exception(() => convention.Invoke(context, Environment.CurrentDirectory));
+
+ // Then
+ exception.ShouldBeOfType<SecurityException>();
+ }
+
+ private static string GetStaticContent(string virtualDirectory, string requestedFilename)
+ {
+ var resource =
+ string.Format("/{0}/{1}", virtualDirectory, requestedFilename);
+
+ var context =
+ new NancyContext { Request = new Request("GET", resource, "http") };
+
+ var resolver =
+ StaticContentConventionBuilder.AddDirectory(virtualDirectory, "Resources/Assets/Styles");
+
+ GenericFileResponse.SafePaths.Add(Environment.CurrentDirectory);
+
+ var response =
+ resolver.Invoke(context, Environment.CurrentDirectory) as GenericFileResponse;
+
+ using (var stream = new MemoryStream())
+ {
+ response.Contents(stream);
+ return Encoding.UTF8.GetString(stream.GetBuffer(), 0, (int)stream.Length);
+ }
+ }
+ }
+}
View
62 src/Nancy.Tests/Unit/StaticConventBuilderFixture.cs
@@ -1,62 +0,0 @@
-using System;
-using System.IO;
-using System.Text;
-using FakeItEasy;
-using Nancy.Conventions;
-using Nancy.Responses;
-using Xunit;
-
-namespace Nancy.Tests.Unit
-{
- public class StaticConventBuilderFixture
- {
- private const string StylesheetContents = @"body {
- background-color: white;
-}";
-
- [Fact]
- public void Static_routes_can_have_same_name_as_extension()
- {
- getStaticContent("css", "styles.css");
- }
-
- [Fact]
- public void Virtual_directory_name_can_exist_in_static_route()
- {
- getStaticContent("css", "strange-css-filename.css");
- }
-
- [Fact]
- public void Static_content_can_be_nested()
- {
- getStaticContent("css/sub", "styles.css");
- }
-
- [Fact]
- public void Static_content_can_be_nested_with_duplicate_name()
- {
- getStaticContent("css/css", "styles.css");
- }
-
- private void getStaticContent(string virtualDirectory, string requestedFilename)
- {
- var resource = string.Format("{0}/{1}", virtualDirectory, requestedFilename);
- var nancyCtx = new NancyContext() { Request = new Request("GET", resource, "http") };
-
- var resolver = StaticContentConventionBuilder.AddDirectory("css", @"Resources\Assets\Styles");
-
- GenericFileResponse.SafePaths.Add(Environment.CurrentDirectory);
- var response = resolver.Invoke(nancyCtx, Environment.CurrentDirectory) as GenericFileResponse;
-
- Assert.NotNull(response);
- Assert.True(requestedFilename.Equals(response.Filename, StringComparison.CurrentCultureIgnoreCase));
-
- using (var ms = new MemoryStream())
- {
- response.Contents(ms);
- var css = Encoding.UTF8.GetString(ms.GetBuffer(), 0, (int)ms.Length);
- Assert.Equal(StylesheetContents, css);
- }
- }
- }
-}
View
119 src/Nancy/Conventions/StaticContentConventionBuilder.cs
@@ -4,6 +4,7 @@ namespace Nancy.Conventions
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
+ using System.Security;
using System.Text.RegularExpressions;
using Responses;
@@ -13,7 +14,7 @@ namespace Nancy.Conventions
public class StaticContentConventionBuilder
{
private static readonly ConcurrentDictionary<string, Func<Response>> ResponseFactoryCache;
- private static readonly Regex pathReplaceRegex = new Regex(@"/\\", RegexOptions.Compiled);
+ private static readonly Regex PathReplaceRegex = new Regex(@"[/\\]", RegexOptions.Compiled);
static StaticContentConventionBuilder()
{
@@ -29,23 +30,76 @@ static StaticContentConventionBuilder()
/// <returns>A <see cref="GenericFileResponse"/> instance for the requested static contents if it was found, otherwise <see langword="null"/>.</returns>
public static Func<NancyContext, string, Response> AddDirectory(string requestedPath, string contentPath = null, params string[] allowedExtensions)
{
- return (ctx, root) =>
- {
+ return (ctx, root) =>{
+
var path =
- ctx.Request.Path.TrimStart(new[] { '/' });
+ ctx.Request.Path;
+
+ var fileName =
+ Path.GetFileName(path);
- if (!path.StartsWith(requestedPath, StringComparison.OrdinalIgnoreCase))
+ if (string.IsNullOrEmpty(fileName))
{
return null;
}
- if(contentPath != null)
+ var pathWithoutFilename =
+ GetPathWithoutFilename(fileName, path);
+
+ if (!requestedPath.StartsWith("/"))
+ {
+ requestedPath = string.Concat("/", requestedPath);
+ }
+
+ if (!pathWithoutFilename.StartsWith(requestedPath, StringComparison.OrdinalIgnoreCase))
+ {
+ ctx.Trace.TraceLog.WriteLog(x => x.AppendLine(string.Concat("[StaticContentConventionBuilder] The requested resource '", path, "' does not match convention mapped to '", requestedPath, "'" )));
+ return null;
+ }
+
+ contentPath =
+ GetContentPath(requestedPath, contentPath);
+
+ if (contentPath.Equals("/"))
+ {
+ throw new ArgumentException("This is not the security vulnerability you are looking for. Mapping static content to the root of your application is not a good idea.");
+ }
+
+ var responseFactory =
+ ResponseFactoryCache.GetOrAdd(path, BuildContentDelegate(ctx, root, requestedPath, contentPath, allowedExtensions));
+
+ return responseFactory.Invoke();
+ };
+ }
+
+ private static string GetContentPath(string requestedPath, string contentPath)
+ {
+ contentPath =
+ contentPath ?? requestedPath;
+
+ if (!contentPath.StartsWith("/"))
+ {
+ contentPath = string.Concat("/", contentPath);
+ }
+
+ return contentPath;
+ }
+
+ public static Func<NancyContext, string, Response> AddFile(string requestedFile, string contentFile)
+ {
+ return (ctx, root) => {
+
+ var path =
+ ctx.Request.Path;
+
+ if (!path.Equals(requestedFile, StringComparison.OrdinalIgnoreCase))
{
- contentPath = pathReplaceRegex.Replace(contentPath, Path.PathSeparator.ToString());
+ ctx.Trace.TraceLog.WriteLog(x => x.AppendLine(string.Concat("[StaticContentConventionBuilder] The requested resource '", path, "' does not match convention mapped to '", requestedFile, "'")));
+ return null;
}
var responseFactory =
- ResponseFactoryCache.GetOrAdd(path, BuildContentDelegate(ctx, root, requestedPath, contentPath ?? requestedPath, allowedExtensions));
+ ResponseFactoryCache.GetOrAdd(path, BuildContentDelegate(ctx, root, requestedFile, contentFile, new string[] {}));
return responseFactory.Invoke();
};
@@ -58,27 +112,23 @@ static StaticContentConventionBuilder()
context.Trace.TraceLog.WriteLog(x => x.AppendLine(string.Concat("[StaticContentConventionBuilder] Attempting to resolve static content '", requestPath, "'")));
var extension = Path.GetExtension(requestPath);
- if (string.IsNullOrEmpty(extension))
- {
- context.Trace.TraceLog.WriteLog(x => x.AppendLine("[StaticContentConventionBuilder] The requested file did not contain a file extension."));
- return () => null;
- }
-
if (allowedExtensions.Length != 0 && !allowedExtensions.Any(e => string.Equals(e, extension, StringComparison.OrdinalIgnoreCase)))
{
context.Trace.TraceLog.WriteLog(x => x.AppendLine(string.Concat("[StaticContentConventionBuilder] The requested extension '", extension, "' does not match any of the valid extensions for the convention '", string.Join(",", allowedExtensions), "'")));
return () => null;
}
- var rgx = new Regex(requestedPath, RegexOptions.IgnoreCase);
+ var transformedRequestPath =
+ GetSafeRequestPath(requestPath, requestedPath, contentPath);
- requestPath = rgx.Replace(requestPath, Regex.Escape(contentPath), 1);
+ transformedRequestPath =
+ GetEncodedPath(transformedRequestPath);
- var fileName =
- Path.GetFullPath(Path.Combine(applicationRootPath, requestPath));
+ var fileName =
+ Path.GetFullPath(Path.Combine(applicationRootPath, transformedRequestPath));
var contentRootPath =
- Path.Combine(applicationRootPath, contentPath);
+ Path.Combine(applicationRootPath, GetEncodedPath(contentPath));
if (!IsWithinContentFolder(contentRootPath, fileName))
{
@@ -97,6 +147,37 @@ static StaticContentConventionBuilder()
};
}
+ private static string GetEncodedPath(string path)
+ {
+ return PathReplaceRegex.Replace(path.TrimStart(new[] { '/' }), Path.DirectorySeparatorChar.ToString());
+ }
+
+ private static string GetPathWithoutFilename(string fileName, string path)
+ {
+ var pathWithoutFileName =
+ path.Replace(fileName, string.Empty);
+
+ return (pathWithoutFileName.Equals("/")) ?
+ pathWithoutFileName :
+ pathWithoutFileName.TrimEnd(new[] {'/'});
+ }
+
+ private static string GetSafeRequestPath(string requestPath, string requestedPath, string contentPath)
+ {
+ var actualContentPath =
+ (contentPath.Equals("/") ? string.Empty : contentPath);
+
+ if (requestedPath.Equals("/"))
+ {
+ return string.Concat(actualContentPath, requestPath);
+ }
+
+ var expression =
+ new Regex(Regex.Escape(requestedPath), RegexOptions.IgnoreCase);
+
+ return expression.Replace(requestPath, actualContentPath, 1);
+ }
+
/// <summary>
/// Returns whether the given filename is contained within the content folder
/// </summary>
Please sign in to comment.
Something went wrong with that request. Please try again.