Fix path security bug and added features
This commit is contained in:
@@ -14,6 +14,7 @@ using System.Net.Security;
|
||||
using System.Security.Authentication;
|
||||
using System.Web;
|
||||
using Tesses.VirtualFilesystem;
|
||||
using System.Net.Mime;
|
||||
|
||||
namespace Tesses.WebServer
|
||||
{
|
||||
@@ -42,6 +43,58 @@ namespace Tesses.WebServer
|
||||
|
||||
public static class Extensions
|
||||
{
|
||||
public static ServerContext WithStatusCode(this ServerContext ctx, HttpStatusCode statusCode)
|
||||
{
|
||||
ctx.StatusCode = (int)statusCode;
|
||||
return ctx;
|
||||
}
|
||||
public static void SendNotFound(this ServerContext ctx)
|
||||
{
|
||||
ctx.StatusCode=404;
|
||||
string url=WebUtility.HtmlEncode(ctx.OriginalUrlPath);
|
||||
ctx.SendText($"<html><head><title>File {url} not found</title></head><body><h1>404 Not Found</h1><h4>{url}</h4></body></html>");
|
||||
}
|
||||
public static async Task SendNotFoundAsync(this ServerContext ctx)
|
||||
{
|
||||
ctx.StatusCode=404;
|
||||
string url=WebUtility.HtmlEncode(ctx.OriginalUrlPath);
|
||||
await ctx.SendTextAsync($"<html><head><title>File {url} not found</title></head><body><h1>404 Not Found</h1><h4>{url}</h4></body></html>");
|
||||
}
|
||||
public static ServerContext WithDate(this ServerContext ctx, DateTime dateTime)
|
||||
{
|
||||
ctx.ResponseHeaders.Add("Date",dateTime.ToString("R"));
|
||||
return ctx;
|
||||
}
|
||||
public static ServerContext WithLastModified(this ServerContext ctx, DateTime dateTime)
|
||||
{
|
||||
ctx.ResponseHeaders.Add("Last-Modified",dateTime.ToString("R"));
|
||||
return ctx;
|
||||
}
|
||||
public static ServerContext WithMimeType(this ServerContext ctx, string mimeType)
|
||||
{
|
||||
if(ctx.ResponseHeaders.ContainsKey("Content-Type"))
|
||||
{
|
||||
ctx.ResponseHeaders["Content-Type"].Clear();
|
||||
ctx.ResponseHeaders["Content-Type"].Add(mimeType);
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx.ResponseHeaders.Add("Content-Type",mimeType);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
public static ServerContext WithMimeTypeFromFileName(this ServerContext ctx, string filename)
|
||||
{
|
||||
return ctx.WithMimeType(HeyRed.Mime.MimeTypesMap.GetMimeType(filename));
|
||||
}
|
||||
public static ServerContext WithFileName(this ServerContext ctx, string filename, bool inline)
|
||||
{
|
||||
ContentDisposition disposition=new ContentDisposition();
|
||||
disposition.FileName = filename;
|
||||
disposition.Inline = inline;
|
||||
ctx.ResponseHeaders.Add("Content-Disposition",disposition.ToString());
|
||||
return ctx;
|
||||
}
|
||||
public static async Task WriteAsync(this Stream strm,string text)
|
||||
{
|
||||
var data=Encoding.UTF8.GetBytes(text);
|
||||
@@ -82,13 +135,36 @@ namespace Tesses.WebServer
|
||||
public static async Task<string> ReadStringAsync(this ServerContext ctx)
|
||||
{
|
||||
string str = null;
|
||||
using (var reader = new StreamReader(ctx.GetRequestStream()))
|
||||
using (var reader = ctx.GetRequestStreamReader())
|
||||
{
|
||||
str = await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
public static StreamReader GetRequestStreamReader(this ServerContext ctx)
|
||||
{
|
||||
return new StreamReader(ctx.GetRequestStream(),Encoding.UTF8,false,4096,false);
|
||||
}
|
||||
public static StreamWriter GetResponseStreamWriter(this ServerContext ctx,string mimetype)
|
||||
{
|
||||
return ctx.WithMimeType(mimetype).GetResponseStreamWriter();
|
||||
}
|
||||
public static StreamWriter GetResponseStreamWriter(this ServerContext ctx)
|
||||
{
|
||||
return new StreamWriter(ctx.GetResponseStream(),Encoding.UTF8,4096,false);
|
||||
}
|
||||
public static Stream GetResponseStream(this ServerContext ctx)
|
||||
{
|
||||
ctx.ResponseHeaders.Add("Transfer-Encoding","chunked");
|
||||
ctx.WriteHeaders();
|
||||
return new ChunkedStream(ctx.NetworkStream,false);
|
||||
}
|
||||
public static Stream GetResponseStream(this ServerContext ctx, string mimetype)
|
||||
{
|
||||
return ctx.WithMimeType(mimetype).GetResponseStream();
|
||||
|
||||
}
|
||||
/// <summary>
|
||||
/// Read string from request body
|
||||
/// </summary>
|
||||
@@ -97,7 +173,7 @@ namespace Tesses.WebServer
|
||||
public static string ReadString(this ServerContext ctx)
|
||||
{
|
||||
string str = null;
|
||||
using (var reader = new StreamReader(ctx.GetRequestStream()))
|
||||
using (var reader = ctx.GetRequestStreamReader())
|
||||
{
|
||||
str = reader.ReadToEnd();
|
||||
}
|
||||
@@ -214,7 +290,7 @@ namespace Tesses.WebServer
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Write headers to stream
|
||||
/// </summary>
|
||||
@@ -255,32 +331,7 @@ namespace Tesses.WebServer
|
||||
var data = Encoding.UTF8.GetBytes(b.ToString());
|
||||
ctx.NetworkStream.Write(data, 0, data.Length);
|
||||
}
|
||||
/// <summary>
|
||||
/// Send file to client (supports range partial content)
|
||||
/// </summary>
|
||||
/// <param name="ctx">ServerContext</param>
|
||||
/// <param name="file">the file to serve</param>
|
||||
|
||||
public static async Task SendFileAsync(this ServerContext ctx, string file)
|
||||
{
|
||||
using (var strm = File.OpenRead(file))
|
||||
{
|
||||
await ctx.SendStreamAsync( strm, MimeTypesMap.GetMimeType(file));
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Send file to client (supports range partial content)
|
||||
/// </summary>
|
||||
/// <param name="ctx">ServerContext</param>
|
||||
/// <param name="file">the file to serve</param>
|
||||
|
||||
public static void SendFile(this ServerContext ctx, string file)
|
||||
{
|
||||
using (var strm = File.OpenRead(file))
|
||||
{
|
||||
ctx.SendStream( strm, MimeTypesMap.GetMimeType(file));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send exception to client
|
||||
/// </summary>
|
||||
@@ -290,7 +341,7 @@ namespace Tesses.WebServer
|
||||
public static async Task SendExceptionAsync(this ServerContext ctx, Exception ex)
|
||||
{
|
||||
string name = ex.GetType().FullName;
|
||||
string j = $"<html><head><title>{WebUtility.HtmlEncode(name)} thrown</title></head><body><h1>{WebUtility.HtmlEncode(name)} thrown</h1><h3>Description: {WebUtility.HtmlEncode(ex.Message)}</h3></body></html>";
|
||||
string j = $"<html><head><title>{WebUtility.HtmlEncode(name)} thrown</title></head><body><h1>{WebUtility.HtmlEncode(name)} thrown</h1><h3>Description: {WebUtility.HtmlEncode(ex.ToString())}</h3></body></html>";
|
||||
ctx.StatusCode = 500;
|
||||
await ctx.SendTextAsync(j);
|
||||
}
|
||||
@@ -303,7 +354,7 @@ namespace Tesses.WebServer
|
||||
public static void SendException(this ServerContext ctx, Exception ex)
|
||||
{
|
||||
string name = ex.GetType().FullName;
|
||||
string j = $"<html><head><title>{WebUtility.HtmlEncode(name)} thrown</title></head><body><h1>{WebUtility.HtmlEncode(name)} thrown</h1><h3>Description: {WebUtility.HtmlEncode(ex.Message)}</h3></body></html>";
|
||||
string j = $"<html><head><title>{WebUtility.HtmlEncode(name)} thrown</title></head><body><h1>{WebUtility.HtmlEncode(name)} thrown</h1><h3>Description: {WebUtility.HtmlEncode(ex.ToString())}</h3></body></html>";
|
||||
ctx.StatusCode = 500;
|
||||
ctx.SendText(j);
|
||||
}
|
||||
@@ -512,6 +563,8 @@ namespace Tesses.WebServer
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
public class SameServer : Server
|
||||
@@ -534,6 +587,7 @@ namespace Tesses.WebServer
|
||||
ctx.StatusCode = _statusCode;
|
||||
await ctx.SendTextAsync( _html.Replace("{url}", WebUtility.HtmlEncode(ctx.OriginalUrlPath)));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public enum WebServerPathType
|
||||
@@ -693,9 +747,23 @@ namespace Tesses.WebServer
|
||||
name = "";
|
||||
return false;
|
||||
}
|
||||
//fix a nasty security risk, that allows people to access parent files in filesystem
|
||||
private string FixDotPath(string url)
|
||||
{
|
||||
List<string> strs=new List<string>();
|
||||
foreach(var item in url.Split(new char[]{'/'}, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if(item != ".." && item != ".")
|
||||
{
|
||||
strs.Add(item);
|
||||
}
|
||||
}
|
||||
return string.Join("/",strs);
|
||||
}
|
||||
public WebServerPathEntry GetPath(string url)
|
||||
{
|
||||
string someUrl = Path.Combine(_path,WebUtility.UrlDecode(url.Substring(1)).Replace('/', Path.DirectorySeparatorChar));
|
||||
|
||||
string someUrl = Path.Combine(_path,WebUtility.UrlDecode(FixDotPath(url)).Replace('/', Path.DirectorySeparatorChar));
|
||||
//Console.WriteLine(someUrl);
|
||||
if (Directory.Exists(someUrl))
|
||||
{
|
||||
@@ -967,12 +1035,13 @@ namespace Tesses.WebServer
|
||||
_forbidden = new SameServer("<html><head><title>File {url} not accessable</title></head><body><h1>403 Forbidden</h1><h4>{url}</h4></body></html>",403);
|
||||
|
||||
}
|
||||
public bool RedirectToRootInsteadOfNotFound {get;set;}=false;
|
||||
/// <summary>
|
||||
/// construct with path and with option to allow/deny listing directories
|
||||
/// </summary>
|
||||
/// <param name="path">directory for server</param>
|
||||
/// <param name="allowListing">whether to allow listing directory or not (overridable by environment variable with TESSES_WEBSERVER_ALLOW_LISTING=true|false)
|
||||
public bool RedirectToRootInsteadOfNotFound {get;set;}=false;
|
||||
|
||||
public StaticServer(string path,bool allowListing)
|
||||
{
|
||||
_server = new NotFoundServer();
|
||||
@@ -1240,6 +1309,124 @@ namespace Tesses.WebServer
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// use values inside path like this /hello/YOUR_VALUE/somepage
|
||||
/// </summary>
|
||||
public class PathValueServer : Server
|
||||
{
|
||||
public IServer Server {get;set;}
|
||||
int paths;
|
||||
/// <summary>
|
||||
/// Construct a PathValueServer with NotFoundServer
|
||||
/// </summary>
|
||||
/// <param name="paths">How many path parts do you want, eg if your path is /jim/john/joel, 1: jim 2: jim/john 3: jim/john/joel</param>
|
||||
public PathValueServer(int paths=1)
|
||||
{
|
||||
Server = new NotFoundServer();
|
||||
this.paths=paths;
|
||||
}
|
||||
/// <summary>
|
||||
/// Construct a PathValueServer with your server
|
||||
/// </summary>
|
||||
/// <param name="inner">Your server</param>
|
||||
/// <param name="paths">How many path parts do you want, eg if your path is /jim/john/joel, 1: jim 2: jim/john 3: jim/john/joel</param>
|
||||
public PathValueServer(IServer inner,int paths=1)
|
||||
{
|
||||
|
||||
Server = inner;
|
||||
this.paths = paths;
|
||||
}
|
||||
/// <summary>
|
||||
/// Use this inside your inner server to get string value
|
||||
/// </summary>
|
||||
/// <param name="ctx">the ServerContext</param>
|
||||
/// <returns>the string value from the url part</returns>
|
||||
public string GetValue(ServerContext ctx)
|
||||
{
|
||||
lock(kvps)
|
||||
{
|
||||
if(kvps.ContainsKey(ctx))
|
||||
{
|
||||
return kvps[ctx];
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
private void SetValue(ServerContext ctx,string val)
|
||||
{
|
||||
lock(kvps)
|
||||
{
|
||||
if(kvps.ContainsKey(ctx))
|
||||
{
|
||||
kvps[ctx] = val;
|
||||
}
|
||||
else
|
||||
{
|
||||
kvps.Add(ctx,val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveValue(ServerContext ctx)
|
||||
{
|
||||
lock(kvps)
|
||||
{
|
||||
kvps.Remove(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary<ServerContext,string> kvps=new Dictionary<ServerContext, string>();
|
||||
public override async Task GetAsync(ServerContext ctx)
|
||||
{
|
||||
string[] path=ctx.UrlPath.Split(new char[]{'/'},System.StringSplitOptions.RemoveEmptyEntries);
|
||||
SetValue(ctx,string.Join("/",path.Take(paths)));
|
||||
ctx.UrlPath = $"/{string.Join("/",path.Skip(paths))}";
|
||||
|
||||
await Server.GetAsync(ctx);
|
||||
RemoveValue(ctx);
|
||||
}
|
||||
public override async Task OptionsAsync(ServerContext ctx)
|
||||
{
|
||||
string[] path=ctx.UrlPath.Split(new char[]{'/'},System.StringSplitOptions.RemoveEmptyEntries);
|
||||
SetValue(ctx,string.Join("/",path.Take(paths)));
|
||||
ctx.UrlPath = $"/{string.Join("/",path.Skip(paths))}";
|
||||
|
||||
await Server.OptionsAsync(ctx);
|
||||
RemoveValue(ctx);
|
||||
|
||||
}
|
||||
public override async Task OtherAsync(ServerContext ctx)
|
||||
{
|
||||
string[] path=ctx.UrlPath.Split(new char[]{'/'},System.StringSplitOptions.RemoveEmptyEntries);
|
||||
SetValue(ctx,string.Join("/",path.Take(paths)));
|
||||
ctx.UrlPath = $"/{string.Join("/",path.Skip(paths))}";
|
||||
|
||||
await Server.OtherAsync(ctx);
|
||||
RemoveValue(ctx);
|
||||
|
||||
}
|
||||
public override async Task PostAsync(ServerContext ctx)
|
||||
{
|
||||
string[] path=ctx.UrlPath.Split(new char[]{'/'},System.StringSplitOptions.RemoveEmptyEntries);
|
||||
SetValue(ctx,string.Join("/",path.Take(paths)));
|
||||
ctx.UrlPath = $"/{string.Join("/",path.Skip(paths))}";
|
||||
|
||||
await Server.PostAsync(ctx);
|
||||
RemoveValue(ctx);
|
||||
|
||||
}
|
||||
public override async Task<bool> BeforeAsync(ServerContext ctx)
|
||||
{
|
||||
var urlPath = ctx.UrlPath;
|
||||
string[] path=ctx.UrlPath.Split(new char[]{'/'},System.StringSplitOptions.RemoveEmptyEntries);
|
||||
SetValue(ctx,string.Join("/",path.Take(paths)));
|
||||
ctx.UrlPath = $"/{string.Join("/",path.Skip(paths))}";
|
||||
|
||||
var r= await Server.BeforeAsync(ctx);
|
||||
ctx.UrlPath = urlPath;
|
||||
return r;
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Abstract class for server
|
||||
/// </summary>
|
||||
public abstract class Server : IServer
|
||||
@@ -1327,6 +1514,36 @@ namespace Tesses.WebServer
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public static Server FromCallback(HttpActionAsync cb)
|
||||
{
|
||||
return new CallbackServer(cb);
|
||||
}
|
||||
private class CallbackServer : Server
|
||||
{
|
||||
HttpActionAsync cb;
|
||||
public CallbackServer(HttpActionAsync cb)
|
||||
{
|
||||
this.cb = cb;
|
||||
}
|
||||
|
||||
public override async Task GetAsync(ServerContext ctx)
|
||||
{
|
||||
await cb(ctx);
|
||||
}
|
||||
public override async Task OptionsAsync(ServerContext ctx)
|
||||
{
|
||||
await cb(ctx);
|
||||
}
|
||||
public override async Task OtherAsync(ServerContext ctx)
|
||||
{
|
||||
await cb(ctx);
|
||||
}
|
||||
public override async Task PostAsync(ServerContext ctx)
|
||||
{
|
||||
await cb(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// mount multiple servers at different url paths
|
||||
@@ -1652,6 +1869,140 @@ namespace Tesses.WebServer
|
||||
Task OtherAsync(ServerContext ctx);
|
||||
}
|
||||
|
||||
public sealed class ChunkedStream : Stream
|
||||
{
|
||||
int offset=0;
|
||||
byte[] buffer=new byte[0];
|
||||
Stream strm;
|
||||
bool receive;
|
||||
public ChunkedStream(Stream strm, bool receive)
|
||||
{
|
||||
this.receive = receive;
|
||||
this.strm=strm;
|
||||
}
|
||||
public override bool CanRead => receive;
|
||||
|
||||
public override bool CanSeek => false;
|
||||
|
||||
public override bool CanWrite => !receive;
|
||||
|
||||
public override long Length => throw new NotImplementedException();
|
||||
|
||||
public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
strm.Flush();
|
||||
}
|
||||
|
||||
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if(!receive) throw new IOException("Cannot read from a writeonly stream");
|
||||
if(EOF) throw new EndOfStreamException();
|
||||
if(this.offset>=this.buffer.Length)
|
||||
{
|
||||
|
||||
//we need to read a new one
|
||||
StringBuilder b=new StringBuilder();
|
||||
int read = 0;
|
||||
while((read=strm.ReadByte()) != '\r')
|
||||
{
|
||||
b.Append((char)read);
|
||||
}
|
||||
if(read == '\r')
|
||||
{
|
||||
read=strm.ReadByte();
|
||||
if(read != '\n')
|
||||
{
|
||||
throw new IOException("Must end with \r\n");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new IOException("Must end with \r\n");
|
||||
}
|
||||
|
||||
if(int.TryParse(b.ToString(),System.Globalization.NumberStyles.HexNumber,null,out int val))
|
||||
{
|
||||
if(val == 0)
|
||||
{
|
||||
EOF=true;
|
||||
if(strm.ReadByte()!='\r' || strm.ReadByte() != '\n')
|
||||
{
|
||||
throw new IOException("Must end with \r\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
this.offset = 0;
|
||||
this.buffer=new byte[val];
|
||||
strm.Read(this.buffer,0,this.buffer.Length);
|
||||
|
||||
if(strm.ReadByte()!='\r' || strm.ReadByte() != '\n')
|
||||
{
|
||||
throw new IOException("Must end with \r\n");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
count = Math.Min(count,this.buffer.Length-this.offset);
|
||||
Array.Copy(this.buffer,this.offset,buffer,offset,count);
|
||||
|
||||
this.offset+=count;
|
||||
return count;
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override void SetLength(long value)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if(receive) throw new IOException("Cannot write to readonly stream");
|
||||
if(count == 0 && EOF) return;
|
||||
var len = System.Text.Encoding.ASCII.GetBytes($"{count.ToString("X")}\r\n");
|
||||
|
||||
strm.Write(len,0,len.Length);
|
||||
strm.Write(buffer,offset,count);
|
||||
strm.Write(crlf,0,crlf.Length);
|
||||
|
||||
if(count==0) EOF=true;
|
||||
}
|
||||
private static readonly byte[] crlf = new byte[]{(byte)'\r',(byte)'\n'};
|
||||
public bool EOF {get;private set;}=false;
|
||||
public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{
|
||||
if(receive) throw new IOException("Cannot write to readonly stream");
|
||||
if(count == 0 && EOF) return;
|
||||
var len = System.Text.Encoding.ASCII.GetBytes($"{count.ToString("X")}\r\n");
|
||||
|
||||
await strm.WriteAsync(len,0,len.Length,cancellationToken);
|
||||
await strm.WriteAsync(buffer,offset,count,cancellationToken);
|
||||
await strm.WriteAsync(crlf,0,crlf.Length,cancellationToken);
|
||||
|
||||
if(count==0) EOF=true;
|
||||
}
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if(disposing)
|
||||
{
|
||||
if(!EOF)
|
||||
{
|
||||
if(!receive)
|
||||
Write(crlf,0,0);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class HttpServerListener
|
||||
{
|
||||
/// <summary>
|
||||
@@ -1861,8 +2212,7 @@ namespace Tesses.WebServer
|
||||
{
|
||||
StringBuilder s = new StringBuilder();
|
||||
|
||||
var decoder = Encoding.UTF8.GetDecoder();
|
||||
var nextChar = new char[1];
|
||||
|
||||
while (!s.EndsWith("\r\n\r\n",StringComparison.Ordinal))
|
||||
{
|
||||
int data = strm.ReadByte();
|
||||
@@ -1870,9 +2220,8 @@ namespace Tesses.WebServer
|
||||
{
|
||||
break;
|
||||
}
|
||||
int charCount=decoder.GetChars(new byte[] { (byte)data }, 0, 1, nextChar, 0);
|
||||
if (charCount == 0) continue;
|
||||
s.Append(nextChar);
|
||||
if(data > 127) continue;
|
||||
s.Append((char)data); //its ascii now
|
||||
}
|
||||
|
||||
return s.ToString();
|
||||
|
||||
Reference in New Issue
Block a user