commit 33d2c287129ad7f2835b08558cea9c5dd53752df Author: uwaa Date: Fri Oct 25 05:00:22 2024 +0100 initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..65dad72 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.cs eol=crlf +*.txt eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44e1bbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.vs +.vscode +bin +obj \ No newline at end of file diff --git a/MiniHTTP/HttpContent.cs b/MiniHTTP/HttpContent.cs new file mode 100644 index 0000000..bd4ddc3 --- /dev/null +++ b/MiniHTTP/HttpContent.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace MiniHTTP; + +/// +/// Content served as the body of a HTTP request or response. +/// +public struct HttpContent +{ + public static implicit operator HttpContent?([NotNullWhen(false)] string? value) + { + return value == null ? null : new HttpContent("text/plain", value); + } + + /// + /// The MIME type of the HTTP content. + /// + public string Type; + + /// + /// The raw HTTP content body, as bytes. + /// + public byte[] Content; + + public HttpContent(string type, string body) + { + Type = type; + Content = Encoding.UTF8.GetBytes(body); + } + + public HttpContent(string type, byte[] body) + { + Type = type; + Content = body; + } +} diff --git a/MiniHTTP/HttpMethod.cs b/MiniHTTP/HttpMethod.cs new file mode 100644 index 0000000..faedeae --- /dev/null +++ b/MiniHTTP/HttpMethod.cs @@ -0,0 +1,18 @@ +namespace MiniHTTP; + +/// +/// HTTP method from a HTTP request. +/// +public enum HttpMethod +{ + Any, + GET, + HEAD, + POST, + PUT, + DELETE, + CONNECT, + OPTIONS, + TRACE, + PATCH, +} \ No newline at end of file diff --git a/MiniHTTP/HttpRequest.cs b/MiniHTTP/HttpRequest.cs new file mode 100644 index 0000000..155f2c6 --- /dev/null +++ b/MiniHTTP/HttpRequest.cs @@ -0,0 +1,264 @@ +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Web; +using System.Collections.Specialized; +using MiniHTTP.Websockets; +using MiniHTTP.Responses; + +namespace MiniHTTP; + +/// +/// A HTTP request currently being made to a . +/// +public sealed class HttpRequest +{ + /// + /// The server the request is being made to. + /// + public readonly HttpServer Server; + + /// + /// The TCP client serving this request. + /// + public readonly TcpClient Client; + + /// + /// The underlying TCP stream. + /// + public readonly Stream Stream; + + /// + /// The HTTP method of the request. + /// + public HttpMethod Method { get; private set; } = HttpMethod.Any; + + /// + /// The HTTP path being requested. + /// + public string Path { get; private set; } = string.Empty; + + /// + /// Additional paramters in the path of the request. + /// + public NameValueCollection? Query { get; private set; } + + /// + /// HTTP headers included in the request. + /// + public readonly Dictionary Headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + + internal readonly BufferedStream Buffer; + + readonly Decoder Decoder; + + readonly StreamWriter Writer; + + /// + /// If true, the request is connected and waiting for a reply. + /// + public bool Connected => Client.Connected; + + internal HttpRequest(HttpServer server, TcpClient client, Stream stream) + { + Server = server; + Client = client; + Stream = stream; + Buffer = new BufferedStream(stream); + Decoder = Encoding.UTF8.GetDecoder(); + + Writer = new StreamWriter(Buffer, Encoding.ASCII); + Writer.AutoFlush = true; + } + + public async Task ReadAll() + { + //Read initial header + string? header = await ReadLine(); + if (header == null) + throw new RequestParseException("Connection closed unexpectedly"); + + if (header.Length > 1000) + throw new RequestParseException("Initial header is too long"); + + { + string[] parts = header.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 3) + throw new RequestParseException("Invalid initial header"); + + HttpMethod method; + if (!Enum.TryParse(parts[0], true, out method)) + throw new RequestParseException("Unknown HTTP method"); + + Method = method; + + string[] pathParts = parts[1].Replace("\\", "/").Split('?', 2, StringSplitOptions.RemoveEmptyEntries); + Path = pathParts[0]; + Query = HttpUtility.ParseQueryString(pathParts.Length > 1 ? pathParts[1] : string.Empty); + } + + //Read headers + while (true) + { + string? headerStr = await ReadLine(); + if (headerStr == null) + throw new RequestParseException("Connection closed unexpectedly"); + + if (string.IsNullOrWhiteSpace(headerStr)) + break; //End of headers + + if (Headers.Count >= 20) + throw new RequestParseException("Too many headers"); + + if (headerStr.Length > 500) + throw new RequestParseException("A request header is too long"); + + int splitPoint = headerStr.IndexOf(':'); + if (splitPoint == -1) + throw new RequestParseException("A header is invalid"); + + string name = headerStr.Remove(splitPoint).Trim(); + string value = headerStr.Substring(splitPoint + 1).Trim(); + + if (Headers.ContainsKey(name)) + throw new RequestParseException("Duplicate header"); + + Headers.Add(name, value); + } + } + + public async Task ReadLine() + { + byte[] dataBuffer = new byte[1]; + char[] charBuffer = new char[4096]; + int charBufferIndex = 0; + while (true) + { + if (await Buffer.ReadAsync(dataBuffer) == 0) + break; + + if (charBufferIndex >= charBuffer.Length) + throw new RequestParseException("Header is too large"); + + charBufferIndex += Decoder.GetChars(dataBuffer, 0, 1, charBuffer, charBufferIndex, false); + + if (charBufferIndex >= 2 && charBuffer[charBufferIndex - 1] == '\n' && charBuffer[charBufferIndex - 2] == '\r') + { + charBufferIndex -= 2; + break; + } + } + Decoder.Reset(); + return new string(charBuffer, 0, charBufferIndex); + } + + public async Task WriteStatus(int code, string message) + { + await Writer.WriteAsync("HTTP/1.1 "); + await Writer.WriteAsync(code.ToString()); + await Writer.WriteAsync(' '); + await Writer.WriteAsync(message); + await WriteLine(); + } + + public async Task WriteHeader(string name, string value) + { + await Writer.WriteAsync(name); + await Writer.WriteAsync(": "); + await Writer.WriteAsync(value); + await WriteLine(); + } + + public async Task WriteLine() + { + await Writer.WriteAsync("\r\n"); + } + + public async Task WriteBytes(byte[] bytes) + { + await Buffer.WriteAsync(bytes); + } + + public async Task Flush() + { + await Buffer.FlushAsync(); + } + + /// + /// Writes a response without closing the socket. + /// + public async Task Write(HttpResponse response) + { + if (response.StatusCode == 0) + return; + + await WriteStatus(response.StatusCode, response.StatusMessage); + foreach (var header in response.GetHeaders()) + await WriteHeader(header.Item1, header.Item2); + await WriteHeader("Access-Control-Allow-Origin", "*"); + await WriteLine(); + + if (response.Body.HasValue) + await WriteBytes(response.Body.Value.Content); + + await Flush(); + } + + /// + /// Attempts to destroy the connection, ignoring all errors. + /// + public async void Close(HttpResponse? response) + { + if (response != null && Client.Connected) + { + try + { + await Write(response); + } + catch + { + //Who cares if it does not arrive. Fuck off remote endpoint. + } + } + Client.Close(); + Client.Dispose(); + } + + /// + /// Sends a "switching protocol" header for a websocket. + /// + public async Task UpgradeToWebsocket() + { + if (!Headers.TryGetValue("Sec-WebSocket-Key", out string? wsKey)) + { + await Write(new BadRequest("Missing Sec-WebSocket-Key header")); + return null; + } + + //Increase timeouts + Client.SendTimeout = 120_000; + Client.ReceiveTimeout = 120_000; + + string acceptKey = Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(wsKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))); + + await WriteStatus(101, "Switching Protocols"); + await WriteHeader("Upgrade", "websocket"); + await WriteHeader("Connection", "Upgrade"); + await WriteHeader("Sec-WebSocket-Accept", acceptKey); + await WriteHeader("Access-Control-Allow-Origin", "*"); + await WriteLine(); + await Flush(); + + return new Websocket(this); + } +} + +/// +/// An exception caused during HTTP reading and parsing. +/// +class RequestParseException : IOException +{ + public RequestParseException(string? message) : base(message) + { + } +} \ No newline at end of file diff --git a/MiniHTTP/HttpResponse.cs b/MiniHTTP/HttpResponse.cs new file mode 100644 index 0000000..b035753 --- /dev/null +++ b/MiniHTTP/HttpResponse.cs @@ -0,0 +1,37 @@ +namespace MiniHTTP.Responses; + +public class HttpResponse +{ + public static implicit operator HttpResponse(HttpContent value) + { + return new OK(value); + } + + public static implicit operator HttpResponse(HttpContent? value) + { + return value == null ? new NotFound() : new OK(value); + } + + public readonly int StatusCode; + + public readonly string StatusMessage; + + public readonly HttpContent? Body; + + public HttpResponse(int statusCode, string statusMessage, HttpContent? body = null) + { + StatusCode = statusCode; + StatusMessage = statusMessage; + Body = body; + } + + public virtual IEnumerable<(string, string)> GetHeaders() + { + if (Body.HasValue) + { + int contentLength = Body.Value.Content.Length; + yield return ("Content-Length", contentLength.ToString()); + yield return ("Content-Type", Body.Value.Type); + } + } +} diff --git a/MiniHTTP/HttpServer.cs b/MiniHTTP/HttpServer.cs new file mode 100644 index 0000000..6df40d3 --- /dev/null +++ b/MiniHTTP/HttpServer.cs @@ -0,0 +1,187 @@ +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using MiniHTTP.Responses; +using MiniHTTP.Routing; + +namespace MiniHTTP; + +/// +/// A hypertext transfer protocol server which can listen on a port and deliver content over HTTP, optionally over SSL (HTTPS). +/// +public sealed class HttpServer +{ + /// + /// The router to be used to find and serve content to clients. + /// + public readonly IRouter Router; + + /// + /// If non-null, all connections will be secured via SSL using this certificate. + /// + public readonly X509Certificate? Certificate; + + /// + /// Maximum number of connections per IP before new connections are rejected. + /// + /// + /// Prevents Slow Loris attacks. + /// + public int MaxConnections = 5; + + /// + /// The port the server will listen on. + /// + public int Port => ((IPEndPoint)listener.LocalEndpoint).Port; + + readonly Dictionary IPCounts = new Dictionary(); + + readonly SemaphoreSlim IPCountsLock = new SemaphoreSlim(1, 1); + + readonly TcpListener listener; + + public HttpServer(int port, X509Certificate? certificate, IRouter router) + { + listener = new TcpListener(IPAddress.Any, port); + Certificate = certificate; + Router = router; + } + + /// + /// Begins listening for new connections. + /// + public async Task Start() + { + listener.Start(); + + while (true) + HandleClient(await listener.AcceptTcpClientAsync()); + } + + async void HandleClient(TcpClient client) + { + if (client.Client.RemoteEndPoint is not IPEndPoint endpoint) + return; + + //Count IPs to prevent Slow Loris attacks + try + { + await IPCountsLock.WaitAsync(); + if (IPCounts.TryGetValue(endpoint.Address, out int count)) + { + if (count >= MaxConnections) + { + //Too many connections + client.Close(); + return; + } + + IPCounts[endpoint.Address] = count + 1; + } + else + { + IPCounts[endpoint.Address] = 1; + } + } + finally + { + IPCountsLock.Release(); + } + + try + { + //Setup client + client.Client.LingerState = new LingerOption(true, 5); + client.Client.SendTimeout = 20_000; + client.Client.ReceiveTimeout = 20_000; + + Stream stream; + try + { + stream = client.GetStream(); + + if (Certificate != null) + { + //Pass through SSL stream + SslStream ssl = new SslStream(stream); + await ssl.AuthenticateAsServerAsync(Certificate); + stream = ssl; + } + + while (client.Connected) + { + HttpRequest req = new HttpRequest(this, client, stream); + try + { + await req.ReadAll(); + + //Parse path + ArraySegment pathSpl = req.Path.Split('/'); + + for (int i = 0; i < pathSpl.Count; i++) + pathSpl[i] = WebUtility.UrlDecode(pathSpl[i]); + + if (pathSpl.Count > 0 && pathSpl[0].Length == 0) + pathSpl = pathSpl.Slice(1); + + //Execute + HttpResponse? response = await Router.Handle(req, pathSpl, null); + if (response != null) + await req.Write(response); + else + await req.Write(new HttpResponse(500, "Server produced no response")); + } + catch (RequestParseException e) + { + req.Close(new HttpResponse(400, e.Message)); + break; + } + catch (IOException) + { + throw; + } + catch (Exception) + { + await req.Write(new HttpResponse(500, "Internal Server Error")); + throw; + } + + if (req.Headers.TryGetValue("connection", out string? connectionValue) && connectionValue == "close") + { + client.Close(); + break; + } + } + } + catch (IOException) + { + //Client likely disconnected unexpectedly + } + catch (Exception) + { + //Error + } + } + finally + { + client.Close(); + try + { + await IPCountsLock.WaitAsync(); + if (IPCounts[endpoint.Address] <= 1) + { + IPCounts.Remove(endpoint.Address); + } + else + { + IPCounts[endpoint.Address]--; + } + } + finally + { + IPCountsLock.Release(); + } + } + } +} diff --git a/MiniHTTP/MiniHTTP.csproj b/MiniHTTP/MiniHTTP.csproj new file mode 100644 index 0000000..a0b5ef0 --- /dev/null +++ b/MiniHTTP/MiniHTTP.csproj @@ -0,0 +1,8 @@ + + + net8.0 + enable + enable + true + + diff --git a/MiniHTTP/Responses/BadRequest.cs b/MiniHTTP/Responses/BadRequest.cs new file mode 100644 index 0000000..fd0b9a8 --- /dev/null +++ b/MiniHTTP/Responses/BadRequest.cs @@ -0,0 +1,11 @@ +namespace MiniHTTP.Responses; + +/// +/// An error caused by an invalid request. +/// +public class BadRequest : HttpResponse +{ + public BadRequest(string? body = null) : base(400, "Bad Request", body) + { + } +} diff --git a/MiniHTTP/Responses/Empty.cs b/MiniHTTP/Responses/Empty.cs new file mode 100644 index 0000000..80cd898 --- /dev/null +++ b/MiniHTTP/Responses/Empty.cs @@ -0,0 +1,11 @@ +namespace MiniHTTP.Responses; + +/// +/// Special response which closes the request without sending a response. Used when an endpoint handles its own response, such as websocket upgrades. +/// +public class Empty : HttpResponse +{ + public Empty() : base(0, string.Empty) + { + } +} diff --git a/MiniHTTP/Responses/NotFound.cs b/MiniHTTP/Responses/NotFound.cs new file mode 100644 index 0000000..6771b09 --- /dev/null +++ b/MiniHTTP/Responses/NotFound.cs @@ -0,0 +1,11 @@ +namespace MiniHTTP.Responses; + +/// +/// An error caused by a request to non-existent data. +/// +public class NotFound : HttpResponse +{ + public NotFound(HttpContent? body = null) : base(404, "Not Found", body) + { + } +} diff --git a/MiniHTTP/Responses/OK.cs b/MiniHTTP/Responses/OK.cs new file mode 100644 index 0000000..bae0391 --- /dev/null +++ b/MiniHTTP/Responses/OK.cs @@ -0,0 +1,11 @@ +namespace MiniHTTP.Responses; + +/// +/// An simple reply to a request. +/// +public class OK : HttpResponse +{ + public OK(HttpContent? body = null) : base(body == null ? 204 : 200, body == null ? "No Content" : "OK", body) + { + } +} diff --git a/MiniHTTP/Responses/Redirect.cs b/MiniHTTP/Responses/Redirect.cs new file mode 100644 index 0000000..db1b7be --- /dev/null +++ b/MiniHTTP/Responses/Redirect.cs @@ -0,0 +1,19 @@ +namespace MiniHTTP.Responses; + +/// +/// A response redirecting the request to another location. +/// +public class Redirect : HttpResponse +{ + public string Location; + + public Redirect(string location) : base(301, "Redirect") + { + Location = location; + } + + public override IEnumerable<(string, string)> GetHeaders() + { + yield return ("Location", Location); + } +} diff --git a/MiniHTTP/Routing/CORS.cs b/MiniHTTP/Routing/CORS.cs new file mode 100644 index 0000000..109ee02 --- /dev/null +++ b/MiniHTTP/Routing/CORS.cs @@ -0,0 +1,35 @@ +using MiniHTTP.Responses; + +namespace MiniHTTP.Routing; + +/// +/// Handles CORS preflight requests. +/// +public class CORS : IRouter +{ + public CORS() + { + } + + public Task Handle(HttpRequest client, ArraySegment path, ParameterCollection? baseParameters) + { + if (client.Method != HttpMethod.OPTIONS) + return Task.FromResult(null); + + return Task.FromResult(new PreflightResponse()); + } +} + +class PreflightResponse : OK +{ + public PreflightResponse() : base() + { + } + + public override IEnumerable<(string, string)> GetHeaders() + { + yield return ("Access-Control-Allow-Origin", "*"); + yield return ("Methods", "GET,HEAD,POST,PUT,DELETE,CONNECT,OPTIONS,TRACE,PATCH"); + yield return ("Vary", "Origin"); + } +} \ No newline at end of file diff --git a/MiniHTTP/Routing/EndpointHandler.cs b/MiniHTTP/Routing/EndpointHandler.cs new file mode 100644 index 0000000..514c983 --- /dev/null +++ b/MiniHTTP/Routing/EndpointHandler.cs @@ -0,0 +1,19 @@ +using MiniHTTP.Responses; + +namespace MiniHTTP.Routing; + +/// +/// An asynchronous endpoint handler. +/// +/// The request being served. +/// Parameters in the path. +/// Returns a HTTP response if the request successfully hit. Returns null if the request misses the handler. +public delegate Task EndpointHandlerAsync(HttpRequest request, ParameterCollection parameters); + +/// +/// An synchronous endpoint handler. +/// +/// The request being served. +/// Parameters in the path. +/// Returns a HTTP response if the request successfully hit. Returns null if the request misses the handler. +public delegate HttpResponse? EndpointHandler(HttpRequest request, ParameterCollection parameters); \ No newline at end of file diff --git a/MiniHTTP/Routing/IRouter.cs b/MiniHTTP/Routing/IRouter.cs new file mode 100644 index 0000000..d72e9f8 --- /dev/null +++ b/MiniHTTP/Routing/IRouter.cs @@ -0,0 +1,11 @@ +using MiniHTTP.Responses; + +namespace MiniHTTP.Routing; + +/// +/// Parses a path and serves content to clients. +/// +public interface IRouter +{ + Task Handle(HttpRequest client, ArraySegment path, ParameterCollection? baseParameters); +} diff --git a/MiniHTTP/Routing/ParameterCollection.cs b/MiniHTTP/Routing/ParameterCollection.cs new file mode 100644 index 0000000..b441419 --- /dev/null +++ b/MiniHTTP/Routing/ParameterCollection.cs @@ -0,0 +1,58 @@ +namespace MiniHTTP.Routing; + +/// +/// A map of arguments passed as parameters in a HTTP path. +/// +/// +/// +/// Routes may contain parameters, denoted by a colon at the beginning. When a request is made, whatever is provided in that space becomes available in the parameter collection as an argument. +/// If you have a route such as /far/:example/bar, and a client requests the path /foo/quux/bar, then the argument passed to the "example" parameter will be "quux". +/// +/// +/// Arguments can be obtained from the collection like a . For example: +/// +/// string id = parameters["id"]; +/// +/// +/// +public class ParameterCollection +{ + public static readonly ParameterCollection Empty = new ParameterCollection(); + + /// + /// A parameter collection to read from if this instance fails to fulfill a lookup. + /// + public readonly ParameterCollection? Fallback; + + readonly Dictionary Parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public string? this[string key] + { + get + { + if (Parameters.TryGetValue(key, out var value)) + return value; + else if (Fallback == null) + return null; + else + return Fallback[key]; + } + set + { + if (value == null) + Parameters.Remove(key); + else + Parameters[key] = value; + } + } + + public ParameterCollection(ParameterCollection? fallback = null) + { + Fallback = fallback; + } + + public void Clear() + { + Parameters.Clear(); + } +} diff --git a/MiniHTTP/Routing/Router.cs b/MiniHTTP/Routing/Router.cs new file mode 100644 index 0000000..5c69f58 --- /dev/null +++ b/MiniHTTP/Routing/Router.cs @@ -0,0 +1,126 @@ +using MiniHTTP.Responses; + +namespace MiniHTTP.Routing; + +/// +/// Standard implementation for . +/// +public class Router : IRouter +{ + struct Route + { + public string[] Paths; + + public IRouter Router; + + public Route(string[] paths, IRouter router) + { + Paths = paths; + Router = router; + } + } + + /// + /// The default handler to use if no other handlers are hit. + /// + public EndpointHandlerAsync? Endpoint; + + /// + /// Which method this router serves. + /// + public HttpMethod Method = HttpMethod.Any; + + readonly List Routes = new List(); + + public Router(EndpointHandlerAsync endpoint, HttpMethod method = HttpMethod.Any) + { + Endpoint = endpoint; + } + + public Router(EndpointHandler endpoint, HttpMethod method = HttpMethod.Any) + { + Endpoint = (client, parameters) => Task.FromResult(endpoint(client, parameters)); + } + + internal async Task Handle(HttpRequest client, ArraySegment path, ParameterCollection? baseParameters) + { + if (Method != HttpMethod.Any && client.Method != Method) + return null; + + ParameterCollection parameters = new ParameterCollection(baseParameters); + foreach (var route in Routes) + { + if (route.Paths.Length != 1 || route.Paths[0] != "*") + { + if (route.Paths.Length > path.Count) + continue; + + for (int i = 0; i < route.Paths.Length; i++) + { + string key = route.Paths[i]; + if (key.Length == 0) + { + if (path[i].Length == 0) + continue; + else + goto skip; + } + + if (key[0] == ':') + { + parameters[route.Paths[i][1..]] = path[i]; + continue; + } + + if (!key.Equals(path[i], StringComparison.OrdinalIgnoreCase)) + goto skip; + } + } + + HttpResponse? resp = await route.Router.Handle(client, path.Slice(route.Paths.Length), parameters); + if (resp != null) + return resp; + + skip: + parameters.Clear(); + } + + if (Endpoint == null) + return null; + else + return await Endpoint(client, baseParameters ?? ParameterCollection.Empty); + } + + Task IRouter.Handle(HttpRequest client, ArraySegment path, ParameterCollection? baseParameters) + => Handle(client, path, baseParameters); + + /// + /// Adds a new route to the router. + /// + /// The subpath to the route. + /// The router to use. + public void Use(string path, IRouter route) + { + string[] pathParams = path.Split('/', StringSplitOptions.TrimEntries); + Routes.Add(new Route(pathParams, route)); + } + + /// + /// Adds an endpoint to the router. + /// + /// The HTTP method to respond to. + /// The subpath to the endpoint. + /// The endpoint handler which serves the request. + public void Add(HttpMethod method, string path, EndpointHandlerAsync endpoint) => Use(path, new Router(endpoint, method)); + + /// + public void Add(HttpMethod method, string path, EndpointHandler endpoint) => Use(path, new Router(endpoint, method)); + + /// + /// Adds a static file route to the router. + /// + public void Static(string directory) => Routes.Add(new Route([":asset"], new Static(directory, "asset"))); + + /// + public void Static(string directory, string path, string assetParam) => Use(path, new Static(directory, assetParam)); +} \ No newline at end of file diff --git a/MiniHTTP/Routing/Static.cs b/MiniHTTP/Routing/Static.cs new file mode 100644 index 0000000..dfd0d76 --- /dev/null +++ b/MiniHTTP/Routing/Static.cs @@ -0,0 +1,109 @@ +using MiniHTTP.Responses; +using System.Text.RegularExpressions; + +namespace MiniHTTP.Routing; + +/// +/// Special router which serves static files from the filesystem. +/// +public partial class Static : IRouter +{ + static string GuessMIMEType(string extension) + { + return extension switch + { + ".txt" => "text/plain", + ".htm" or "html" => "text/html", + ".js" => "text/javascript", + ".css" => "text/css", + ".csv" => "text/csv", + + ".bin" => "application/octet-stream", + ".zip" => "application/zip", + ".7z" => "application/x-7z-compressed", + ".gz" => "application/gzip", + ".xml" => "application/xml", + ".pdf" => "application/pdf", + ".json" => "application/json", + + ".bmp" => "image/bmp", + ".png" => "image/png", + ".jpg" or "jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".svg" => "image/svg+xml", + ".ico" => "image/vnd.microsoft.icon", + + ".mid" or ".midi" => "audio/midi", + ".mp3" => "audio/mpeg", + ".ogg" or ".oga" or ".opus" => "audio/ogg", + ".wav" => "audio/wav", + ".weba" => "audio/webm", + + ".webm" => "video/webm", + ".mp4" => "video/mp4", + ".mpeg" => "video/mpeg", + ".ogv" => "video/ogg", + + ".otf" => "font/otf", + ".ttf" => "font/ttf", + ".woff" => "font/woff", + ".woff2" => "font/woff2", + + _ => "application/octet-stream", //Unknown + }; + } + + /// + /// The source directory from which assets should be served. + /// + public string Directory; + + /// + /// The name of the asset parameter in the route. + /// + public string AssetParameter; + + public Static(string directory, string assetParameter) + { + Directory = directory; + AssetParameter = assetParameter; + } + + async Task IRouter.Handle(HttpRequest client, ArraySegment path, ParameterCollection? baseParameters) + { + if (baseParameters == null) + return null; + + string? asset = baseParameters[AssetParameter]; + if (string.IsNullOrWhiteSpace(asset)) + return null; + + if (FilenameChecker().IsMatch(asset)) + return new BadRequest("Illegal chars in asset path"); + + string assetPath = $"{Directory}/{asset}"; + FileInfo fileInfo = new FileInfo(assetPath); + string mime; + if (string.IsNullOrEmpty(fileInfo.Extension)) + { + mime = "text/html"; + assetPath += ".htm"; + } + else + { + mime = GuessMIMEType(fileInfo.Extension); + } + return !File.Exists(assetPath) ? null : (HttpResponse)new HttpContent(mime, await File.ReadAllBytesAsync(assetPath)); + } + + /// + /// Ensures filenames are legal. + /// + /// + /// Enforcing a set of legal characters in filenames reduces the potential attack surface against the server. + /// + /// Returns a regular expression which checks for invalid characters. + [GeneratedRegex(@"[^a-zA-Z0-9_\-.()\[\] ]")] + private static partial Regex FilenameChecker(); +} diff --git a/MiniHTTP/Websockets/CloseStatus.cs b/MiniHTTP/Websockets/CloseStatus.cs new file mode 100644 index 0000000..c25255a --- /dev/null +++ b/MiniHTTP/Websockets/CloseStatus.cs @@ -0,0 +1,16 @@ +namespace MiniHTTP.Websockets; + +public enum CloseStatus : ushort +{ + NormalClosure = 1000, + EndpointUnavailable = 1001, + ProtocolError = 1002, + InvalidMessageType = 1003, + Empty = 1005, + AbnormalClosure = 1006, + InvalidPayloadData = 1007, + PolicyViolation = 1008, + MessageTooBig = 1009, + MandatoryExtension = 1010, + InternalServerError = 1011 +} \ No newline at end of file diff --git a/MiniHTTP/Websockets/DataFrame.cs b/MiniHTTP/Websockets/DataFrame.cs new file mode 100644 index 0000000..5a9ac4e --- /dev/null +++ b/MiniHTTP/Websockets/DataFrame.cs @@ -0,0 +1,24 @@ +using System.Text; + +namespace MiniHTTP.Websockets; + +public readonly struct DataFrame +{ + public readonly WSOpcode Opcode; + + public readonly bool EndOfMessage; + + public readonly ArraySegment Payload; + + public DataFrame(WSOpcode opcode, bool endOfMessage, ArraySegment payload) + { + Opcode = opcode; + EndOfMessage = endOfMessage; + Payload = payload; + } + + public string AsString() + { + return Encoding.UTF8.GetString(Payload); + } +} diff --git a/MiniHTTP/Websockets/WSOpcode.cs b/MiniHTTP/Websockets/WSOpcode.cs new file mode 100644 index 0000000..9da1b22 --- /dev/null +++ b/MiniHTTP/Websockets/WSOpcode.cs @@ -0,0 +1,12 @@ +namespace MiniHTTP.Websockets; + +public enum WSOpcode : byte +{ + Continuation = 0x0, + Text = 0x1, + Binary = 0x2, + + Close = 0x8, + Ping = 0x9, + Pong = 0xA, +} diff --git a/MiniHTTP/Websockets/Websocket.cs b/MiniHTTP/Websockets/Websocket.cs new file mode 100644 index 0000000..dfb5703 --- /dev/null +++ b/MiniHTTP/Websockets/Websocket.cs @@ -0,0 +1,257 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.Text; + +namespace MiniHTTP.Websockets; + +/// +/// A websocket client currently connected to a HTTP server. +/// +public sealed class Websocket +{ + /// + /// The HTTP request encompassing the websocket. + /// + public readonly HttpRequest Request; + + readonly List finalPayload = new List(); + + internal Websocket(HttpRequest client) + { + Request = client; + } + + /// + /// Reads a data frame from the websocket. + /// + /// + /// Thrown when the stream stops unexpectedly. + /// Thrown when the client declares a payload which is too large. + public async Task Read() + { + var pool = ArrayPool.Shared; + byte[] recvBuffer = pool.Rent(10); + try + { + while (true) + { + //First byte + if (await Request.Buffer.ReadAsync(recvBuffer.AsMemory(0, 2)) < 2) + throw new EndOfStreamException(); + + byte firstByte = recvBuffer[0]; + bool fin = (firstByte & 1) != 0; + //bool rsv1 = (firstByte & 2) != 0; + //bool rsv2 = (firstByte & 4) != 0; + //bool rsv3 = (firstByte & 8) != 0; + WSOpcode opcode = (WSOpcode)(firstByte & 0b00001111); + + + //Second byte + byte secondByte = recvBuffer[1]; + + bool maskEnabled = (secondByte & 0b10000000) != 0; + if (maskEnabled) + secondByte &= 0b01111111; + + //Payload length + uint payloadLength; + if (secondByte < 126) + { + payloadLength = secondByte; + } + else + { + if (secondByte == 126) + { + if (await Request.Buffer.ReadAsync(recvBuffer.AsMemory(0, 2)) < 2) + throw new EndOfStreamException(); + + payloadLength = BinaryPrimitives.ReadUInt16BigEndian(recvBuffer); + } + else if (secondByte == 127) + { + if (await Request.Buffer.ReadAsync(recvBuffer.AsMemory(0, 8)) < 8) + throw new EndOfStreamException(); + + payloadLength = BinaryPrimitives.ReadUInt32BigEndian(recvBuffer); + } + else + { + throw new Exception("This shouldn't happen"); + } + } + + if (finalPayload.Count + payloadLength > 100_000) + throw new IOException("Payload too large"); + + //Mask + byte maskKey1, maskKey2, maskKey3, maskKey4; + if (maskEnabled) + { + if (await Request.Buffer.ReadAsync(recvBuffer.AsMemory(0, 4)) < 4) + throw new EndOfStreamException(); + + maskKey1 = recvBuffer[0]; + maskKey2 = recvBuffer[1]; + maskKey3 = recvBuffer[2]; + maskKey4 = recvBuffer[3]; + } + else + { + maskKey1 = 0; + maskKey2 = 0; + maskKey3 = 0; + maskKey4 = 0; + } + + //Payload + byte[] payloadBuffer = pool.Rent((int)payloadLength); + try + { + ArraySegment payload = new ArraySegment(payloadBuffer, 0, (int)payloadLength); + if (await Request.Buffer.ReadAsync(payload) < payloadLength) + throw new EndOfStreamException(); + + //Unmask payload + //TODO: Optimize using unsafe + if (maskEnabled) + { + int index = 0; + while (true) + { + if (index >= payloadLength) + break; + + payload[index] = (byte)(payload[index] ^ maskKey1); + index++; + + if (index >= payloadLength) + break; + + payload[index] = (byte)(payload[index] ^ maskKey2); + index++; + + if (index >= payloadLength) + break; + + payload[index] = (byte)(payload[index] ^ maskKey3); + index++; + + if (index >= payloadLength) + break; + + payload[index] = (byte)(payload[index] ^ maskKey4); + index++; + } + } + + if (opcode is WSOpcode.Close) + { + await Write(new DataFrame(WSOpcode.Close, true, Array.Empty())); + Request.Client.Close(); + + return new DataFrame(WSOpcode.Close, true, FlushPayload()); + } + + if (opcode is WSOpcode.Text or WSOpcode.Binary) + { + finalPayload.AddRange(payload); + + if (fin) + { + return new DataFrame(opcode, fin, FlushPayload()); + } + } + + if (opcode is WSOpcode.Ping) + { + await Write(new DataFrame(WSOpcode.Pong, true, payload)); + } + } + finally + { + pool.Return(payloadBuffer); + } + } + } + finally + { + pool.Return(recvBuffer); + } + } + + byte[] FlushPayload() + { + byte[] final = finalPayload.ToArray(); + finalPayload.Clear(); + return final; + } + + public Task Write(bool endOfMessage, byte[] payload) + { + return Write(new DataFrame(WSOpcode.Binary, endOfMessage, payload)); + } + + public Task Write(bool endOfMessage, string payload) + { + return Write(new DataFrame(WSOpcode.Text, endOfMessage, Encoding.UTF8.GetBytes(payload))); + } + + public async Task Write(DataFrame frame) + { + if (!Request.Connected) + return; + + var pool = ArrayPool.Shared; + byte[] writeBuf = pool.Rent(10); + try + { + byte firstByte = 0; + if (frame.EndOfMessage) + firstByte |= 0b10000000; + + firstByte |= (byte)((int)frame.Opcode & 0b00001111); + + writeBuf[0] = firstByte; + await Request.Buffer.WriteAsync(writeBuf.AsMemory(0, 1)); + + if (frame.Payload.Count < 126) + { + writeBuf[0] = (byte)frame.Payload.Count; + await Request.Buffer.WriteAsync(writeBuf.AsMemory(0, 1)); + } + else + { + if (frame.Payload.Count < ushort.MaxValue) + { + writeBuf[0] = 126; + BinaryPrimitives.WriteUInt16BigEndian(writeBuf.AsSpan(1), (ushort)frame.Payload.Count); + await Request.Buffer.WriteAsync(writeBuf.AsMemory(0, 3)); + } + else + { + writeBuf[0] = 127; + BinaryPrimitives.WriteUInt64BigEndian(writeBuf.AsSpan(1), (ulong)frame.Payload.Count); + await Request.Buffer.WriteAsync(writeBuf.AsMemory(0, 9)); + } + } + + await Request.Buffer.WriteAsync(frame.Payload); + await Request.Flush(); + } + finally + { + pool.Return(writeBuf); + } + } + + public async void Close(CloseStatus status = CloseStatus.NormalClosure) + { + byte[] closeBuf = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(closeBuf, (ushort)status); + + await Write(new DataFrame(WSOpcode.Close, true, closeBuf)); + Request.Client.Close(); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b82a687 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +Minimal HTTPS 1.1 server library for C#, featuring routing. Does not depend on IIS. \ No newline at end of file