Skip to content

Instantly share code, notes, and snippets.

@elliotwoods
Created May 29, 2018 11:44
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 elliotwoods/4822f9c512b4f464534d9e01eb6d21b1 to your computer and use it in GitHub Desktop.
Save elliotwoods/4822f9c512b4f464534d9e01eb6d21b1 to your computer and use it in GitHub Desktop.
Allow unsafe file requests
namespace Nancy.Responses
{
using System;
using System.IO;
using System.Linq;
using Nancy.Configuration;
using Nancy.Helpers;
/// <summary>
/// A response representing a file.
/// </summary>
/// <remarks>If the response contains an invalid file (not found, empty name, missing extension and so on) the status code of the response will be set to <see cref="HttpStatusCode.NotFound"/>.</remarks>
public class UnsafeGenericFileResponse : Response
{
private readonly StaticContentConfiguration configuration;
/// <summary>
/// Size of buffer for transmitting file. Default size 4 Mb
/// </summary>
public static int BufferSize = 4 * 1024 * 1024;
/// <summary>
/// Initializes a new instance of the <see cref="GenericFileResponse"/> for the file specified
/// by the <paramref name="filePath"/> parameter and <paramref name="context"/>.
/// </summary>
/// <param name="filePath">The name of the file, including path relative to the root of the application, that should be returned.</param>
/// <remarks>The <see cref="MimeTypes.GetMimeType"/> method will be used to determine the mimetype of the file and will be used as the content-type of the response. If no match if found the content-type will be set to application/octet-stream.</remarks>
/// <param name="context">Current context</param>
public UnsafeGenericFileResponse(string filePath, NancyContext context)
: this(filePath, MimeTypes.GetMimeType(filePath), context)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="GenericFileResponse"/> for the file specified
/// by the <paramref name="filePath"/> parameter, the content-type specified by the <paramref name="contentType"/> parameter
/// and <paramref name="context"/>.
/// </summary>
/// <param name="filePath">The name of the file, including path relative to the root of the application, that should be returned.</param>
/// <param name="contentType">The content-type of the response.</param>
/// <param name="context">Current context</param>
public UnsafeGenericFileResponse(string filePath, string contentType, NancyContext context)
{
var environment = context.Environment;
this.configuration = environment.GetValue<StaticContentConfiguration>();
this.InitializeGenericFileResponse(filePath, contentType, context);
}
/// <summary>
/// Gets the filename of the file response
/// </summary>
/// <value>A string containing the name of the file.</value>
public string Filename { get; protected set; }
private static Action<Stream> GetFileContent(string filePath, long length)
{
return stream =>
{
using (var file = File.OpenRead(filePath))
{
file.CopyTo(stream, (int)(length < BufferSize ? length : BufferSize));
}
};
}
/*
* REMOVE FOR UNSAFE
static bool IsSafeFilePath(string rootPath, string filePath)
{
if (!File.Exists(filePath))
{
return false;
}
var fullPath = Path.GetFullPath(filePath);
return fullPath.StartsWith(Path.GetFullPath(rootPath), StringComparison.OrdinalIgnoreCase);
}
*/
private void InitializeGenericFileResponse(string filePath, string contentType, NancyContext context)
{
if (string.IsNullOrEmpty(filePath))
{
StatusCode = HttpStatusCode.NotFound;
return;
}
if (this.configuration.SafePaths == null || !this.configuration.SafePaths.Any())
{
throw new InvalidOperationException("No SafePaths defined.");
}
foreach (var rootPath in this.configuration.SafePaths)
{
string fullPath;
if (Path.IsPathRooted(filePath))
{
fullPath = filePath;
}
else
{
fullPath = Path.Combine(rootPath, filePath);
}
/*
* REMOVE FOR UNSAFE
if (IsSafeFilePath(rootPath, fullPath))
{
*/
if(true)
{
this.Filename = Path.GetFileName(fullPath);
this.SetResponseValues(contentType, fullPath, context);
return;
}
}
StatusCode = HttpStatusCode.NotFound;
}
private void SetResponseValues(string contentType, string fullPath, NancyContext context)
{
// TODO - set a standard caching time and/or public?
var fi = new FileInfo(fullPath);
var lastWriteTimeUtc = fi.LastWriteTimeUtc;
var etag = string.Concat("\"", lastWriteTimeUtc.Ticks.ToString("x"), "\"");
var lastModified = lastWriteTimeUtc.ToString("R");
var length = fi.Length;
if (CacheHelpers.ReturnNotModified(etag, lastWriteTimeUtc, context))
{
this.StatusCode = HttpStatusCode.NotModified;
this.ContentType = null;
this.Contents = NoBody;
return;
}
this.Headers["ETag"] = etag;
this.Headers["Last-Modified"] = lastModified;
this.Headers["Content-Length"] = length.ToString();
if (length > 0)
{
this.Contents = GetFileContent(fullPath, length);
}
this.ContentType = contentType;
this.StatusCode = HttpStatusCode.OK;
}
}
}
using Nancy.Diagnostics;
namespace Nancy.Conventions
{
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Nancy.Helpers;
using Nancy.Responses;
/// <summary>
/// Helper class for defining directory-based conventions for static contents.
/// </summary>
public class UnsafeStaticContentConventionBuilder
{
private static readonly ConcurrentDictionary<ResponseFactoryCacheKey, Func<NancyContext, Response>> ResponseFactoryCache;
private static readonly Regex PathReplaceRegex = new Regex(@"[/\\]", RegexOptions.Compiled);
static UnsafeStaticContentConventionBuilder()
{
ResponseFactoryCache = new ConcurrentDictionary<ResponseFactoryCacheKey, Func<NancyContext, Response>>();
}
/// <summary>
/// Adds a directory-based convention for static convention.
/// </summary>
/// <param name="requestedPath">The path that should be matched with the request.</param>
/// <param name="contentPath">The path to where the content is stored in your application, relative to the root. If this is <see langword="null" /> then it will be the same as <paramref name="requestedPath"/>.</param>
/// <param name="allowedExtensions">A list of extensions that is valid for the conventions. If not supplied, all extensions are valid.</param>
/// <returns>A <see cref="UnsafeGenericFileResponse"/> 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)
{
if (!requestedPath.StartsWith("/"))
{
requestedPath = string.Concat("/", requestedPath);
}
return (ctx, root) =>
{
var path =
HttpUtility.UrlDecode(ctx.Request.Path);
var fileName = GetSafeFileName(path);
if (string.IsNullOrEmpty(fileName))
{
return null;
}
var pathWithoutFilename =
GetPathWithoutFilename(fileName, path);
if (!pathWithoutFilename.StartsWith(requestedPath, StringComparison.OrdinalIgnoreCase))
{
(ctx.Trace.TraceLog ?? new NullLog()).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(new ResponseFactoryCacheKey(path, root), BuildContentDelegate(ctx, root, requestedPath, contentPath, allowedExtensions));
return responseFactory.Invoke(ctx);
};
}
/// <summary>
/// Adds a file-based convention for static convention.
/// </summary>
/// <param name="requestedFile">The file that should be matched with the request.</param>
/// <param name="contentFile">The file that should be served when the requested path is matched.</param>
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))
{
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(new ResponseFactoryCacheKey(path, root), BuildContentDelegate(ctx, root, requestedFile, contentFile, ArrayCache.Empty<string>()));
return responseFactory.Invoke(ctx);
};
}
private static string GetSafeFileName(string path)
{
try
{
return Path.GetFileName(path);
}
catch (Exception)
{
}
return null;
}
private static string GetSafeFullPath(string path)
{
try
{
return Path.GetFullPath(path);
}
catch (Exception)
{
}
return null;
}
private static string GetContentPath(string requestedPath, string contentPath)
{
contentPath =
contentPath ?? requestedPath;
if (!contentPath.StartsWith("/"))
{
contentPath = string.Concat("/", contentPath);
}
return contentPath;
}
private static Func<ResponseFactoryCacheKey, Func<NancyContext, Response>> BuildContentDelegate(NancyContext context, string applicationRootPath, string requestedPath, string contentPath, string[] allowedExtensions)
{
return pathAndRootPair =>
{
context.Trace.TraceLog.WriteLog(x => x.AppendLine(string.Concat("[StaticContentConventionBuilder] Attempting to resolve static content '", pathAndRootPair, "'")));
var extension =
Path.GetExtension(pathAndRootPair.Path);
if (!string.IsNullOrEmpty(extension))
{
extension = extension.Substring(1);
}
if (allowedExtensions.Length != 0 && !allowedExtensions.Any(e => string.Equals(e.TrimStart(new[] { '.' }), 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 ctx => null;
}
var transformedRequestPath =
GetSafeRequestPath(pathAndRootPair.Path, requestedPath, contentPath);
transformedRequestPath =
GetEncodedPath(transformedRequestPath);
var relativeFileName =
Path.Combine(applicationRootPath, transformedRequestPath);
var fileName =
GetSafeFullPath(relativeFileName);
if (fileName == null)
{
context.Trace.TraceLog.WriteLog(x => x.AppendLine(string.Concat("[StaticContentConventionBuilder] The request '", relativeFileName, "' contains an invalid path character")));
return ctx => null;
}
var relatveContentRootPath =
Path.Combine(applicationRootPath, GetEncodedPath(contentPath));
var contentRootPath =
GetSafeFullPath(relatveContentRootPath);
if (contentRootPath == null)
{
context.Trace.TraceLog.WriteLog(x => x.AppendLine(string.Concat("[StaticContentConventionBuilder] The request '", fileName, "' is trying to access a path inside the content folder, which contains an invalid path character '", relatveContentRootPath, "'")));
return ctx => null;
}
/*
* REMOVE FOR UNSAFE
if (!IsWithinContentFolder(contentRootPath, fileName))
{
context.Trace.TraceLog.WriteLog(x => x.AppendLine(string.Concat("[StaticContentConventionBuilder] The request '", fileName, "' is trying to access a path outside the content folder '", contentPath, "'")));
return ctx => null;
}
*/
if (!File.Exists(fileName))
{
context.Trace.TraceLog.WriteLog(x => x.AppendLine(string.Concat("[StaticContentConventionBuilder] The requested file '", fileName, "' does not exist")));
return ctx => null;
}
context.Trace.TraceLog.WriteLog(x => x.AppendLine(string.Concat("[StaticContentConventionBuilder] Returning file '", fileName, "'")));
return ctx => new UnsafeGenericFileResponse(fileName, ctx);
};
}
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);
}
/*
* REMOVE FOR UNSAFE
/// <summary>
/// Returns whether the given filename is contained within the content folder
/// </summary>
/// <param name="contentRootPath">Content root path</param>
/// <param name="fileName">Filename requested</param>
/// <returns>True if contained within the content root, false otherwise</returns>
private static bool IsWithinContentFolder(string contentRootPath, string fileName)
{
return fileName.StartsWith(contentRootPath, StringComparison.Ordinal);
}
*/
/// <summary>
/// Used to uniquely identify a request. Needed for when two Nancy applications want to serve up static content of the same
/// name from within the same AppDomain.
/// </summary>
private class ResponseFactoryCacheKey : IEquatable<ResponseFactoryCacheKey>
{
private readonly string path;
private readonly string rootPath;
public ResponseFactoryCacheKey(string path, string rootPath)
{
this.path = path;
this.rootPath = rootPath;
}
/// <summary>
/// The path of the static content for which this response is being issued
/// </summary>
public string Path
{
get { return this.path; }
}
/// <summary>
/// The root folder path of the Nancy application for which this response will be issued
/// </summary>
public string RootPath
{
get { return this.rootPath; }
}
public bool Equals(ResponseFactoryCacheKey other)
{
if (ReferenceEquals(null, other))
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
return string.Equals(this.path, other.path) && string.Equals(this.rootPath, other.rootPath);
}
public override bool Equals(object obj)
{
if (ReferenceEquals(null, obj))
{
return false;
}
if (ReferenceEquals(this, obj))
{
return true;
}
if (obj.GetType() != this.GetType())
{
return false;
}
return Equals((ResponseFactoryCacheKey)obj);
}
public override int GetHashCode()
{
unchecked
{
return ((this.path != null ? this.path.GetHashCode() : 0) * 397) ^ (this.rootPath != null ? this.rootPath.GetHashCode() : 0);
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment