Created
May 29, 2018 11:44
-
-
Save elliotwoods/4822f9c512b4f464534d9e01eb6d21b1 to your computer and use it in GitHub Desktop.
Allow unsafe file requests
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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