using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Sockets; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.Configuration; using Emby.Server.Implementations.Net; using Emby.Server.Implementations.Services; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Events; using MediaBrowser.Model.Extensions; using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Internal; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using ServiceStack.Text.Jsv; namespace Emby.Server.Implementations.HttpServer { public class HttpListenerHost : IHttpServer, IDisposable { private readonly ILogger _logger; private readonly IServerConfigurationManager _config; private readonly INetworkManager _networkManager; private readonly IServerApplicationHost _appHost; private readonly IJsonSerializer _jsonSerializer; private readonly IXmlSerializer _xmlSerializer; private readonly IHttpListener _socketListener; private readonly Func> _funcParseFn; private readonly string _defaultRedirectPath; private readonly string _baseUrlPrefix; private readonly Dictionary ServiceOperationsMap = new Dictionary(); private IWebSocketListener[] _webSocketListeners = Array.Empty(); private readonly List _webSocketConnections = new List(); private bool _disposed = false; public HttpListenerHost( IServerApplicationHost applicationHost, ILogger logger, IServerConfigurationManager config, IConfiguration configuration, INetworkManager networkManager, IJsonSerializer jsonSerializer, IXmlSerializer xmlSerializer, IHttpListener socketListener) { _appHost = applicationHost; _logger = logger; _config = config; _defaultRedirectPath = configuration["HttpListenerHost:DefaultRedirectPath"]; _baseUrlPrefix = _config.Configuration.BaseUrl; _networkManager = networkManager; _jsonSerializer = jsonSerializer; _xmlSerializer = xmlSerializer; _socketListener = socketListener; _socketListener.WebSocketConnected = OnWebSocketConnected; _funcParseFn = t => s => JsvReader.GetParseFn(t)(s); Instance = this; ResponseFilters = Array.Empty>(); } public Action[] ResponseFilters { get; set; } public static HttpListenerHost Instance { get; protected set; } public string[] UrlPrefixes { get; private set; } public string GlobalResponse { get; set; } public ServiceController ServiceController { get; private set; } public event EventHandler> WebSocketConnected; public object CreateInstance(Type type) { return _appHost.CreateInstance(type); } private static string NormalizeUrlPath(string path) { if (path.StartsWith("/")) { // If the path begins with a leading slash, just return it as-is return path; } else { // If the path does not begin with a leading slash, append one for consistency return "/" + path; } } /// /// Applies the request filters. Returns whether or not the request has been handled /// and no more processing should be done. /// /// public void ApplyRequestFilters(IRequest req, HttpResponse res, object requestDto) { // Exec all RequestFilter attributes with Priority < 0 var attributes = GetRequestFilterAttributes(requestDto.GetType()); int count = attributes.Count; int i = 0; for (; i < count && attributes[i].Priority < 0; i++) { var attribute = attributes[i]; attribute.RequestFilter(req, res, requestDto); } // Exec remaining RequestFilter attributes with Priority >= 0 for (; i < count && attributes[i].Priority >= 0; i++) { var attribute = attributes[i]; attribute.RequestFilter(req, res, requestDto); } } public Type GetServiceTypeByRequest(Type requestType) { ServiceOperationsMap.TryGetValue(requestType, out var serviceType); return serviceType; } public void AddServiceInfo(Type serviceType, Type requestType) { ServiceOperationsMap[requestType] = serviceType; } private List GetRequestFilterAttributes(Type requestDtoType) { var attributes = requestDtoType.GetCustomAttributes(true).OfType().ToList(); var serviceType = GetServiceTypeByRequest(requestDtoType); if (serviceType != null) { attributes.AddRange(serviceType.GetCustomAttributes(true).OfType()); } attributes.Sort((x, y) => x.Priority - y.Priority); return attributes; } private void OnWebSocketConnected(WebSocketConnectEventArgs e) { if (_disposed) { return; } var connection = new WebSocketConnection(e.WebSocket, e.Endpoint, _jsonSerializer, _logger) { OnReceive = ProcessWebSocketMessageReceived, Url = e.Url, QueryString = e.QueryString ?? new QueryCollection() }; connection.Closed += OnConnectionClosed; lock (_webSocketConnections) { _webSocketConnections.Add(connection); } WebSocketConnected?.Invoke(this, new GenericEventArgs(connection)); } private void OnConnectionClosed(object sender, EventArgs e) { lock (_webSocketConnections) { _webSocketConnections.Remove((IWebSocketConnection)sender); } } private static Exception GetActualException(Exception ex) { if (ex is AggregateException agg) { var inner = agg.InnerException; if (inner != null) { return GetActualException(inner); } else { var inners = agg.InnerExceptions; if (inners != null && inners.Count > 0) { return GetActualException(inners[0]); } } } return ex; } private int GetStatusCode(Exception ex) { switch (ex) { case ArgumentException _: return 400; case SecurityException _: return 401; case DirectoryNotFoundException _: case FileNotFoundException _: case ResourceNotFoundException _: return 404; case MethodNotAllowedException _: return 405; case RemoteServiceUnavailableException _: return 502; default: return 500; } } private async Task ErrorHandler(Exception ex, IRequest httpReq, bool logExceptionStackTrace) { try { ex = GetActualException(ex); if (logExceptionStackTrace) { _logger.LogError(ex, "Error processing request"); } else { _logger.LogError("Error processing request: {Message}", ex.Message); } var httpRes = httpReq.Response; if (httpRes.HasStarted) { return; } var statusCode = GetStatusCode(ex); httpRes.StatusCode = statusCode; var errContent = NormalizeExceptionMessage(ex.Message); httpRes.ContentType = "text/plain"; httpRes.ContentLength = errContent.Length; await httpRes.WriteAsync(errContent).ConfigureAwait(false); } catch (Exception errorEx) { _logger.LogError(errorEx, "Error this.ProcessRequest(context)(Exception while writing error to the response)"); } } private string NormalizeExceptionMessage(string msg) { if (msg == null) { return string.Empty; } // Strip any information we don't want to reveal msg = msg.Replace(_config.ApplicationPaths.ProgramSystemPath, string.Empty, StringComparison.OrdinalIgnoreCase); msg = msg.Replace(_config.ApplicationPaths.ProgramDataPath, string.Empty, StringComparison.OrdinalIgnoreCase); return msg; } /// /// Shut down the Web Service /// public void Stop() { List connections; lock (_webSocketConnections) { connections = _webSocketConnections.ToList(); _webSocketConnections.Clear(); } foreach (var connection in connections) { try { connection.Dispose(); } catch (Exception ex) { _logger.LogError(ex, "Error disposing connection"); } } } public static string RemoveQueryStringByKey(string url, string key) { var uri = new Uri(url); // this gets all the query string key value pairs as a collection var newQueryString = QueryHelpers.ParseQuery(uri.Query); var originalCount = newQueryString.Count; if (originalCount == 0) { return url; } // this removes the key if exists newQueryString.Remove(key); if (originalCount == newQueryString.Count) { return url; } // this gets the page path from root without QueryString string pagePathWithoutQueryString = url.Split(new[] { '?' }, StringSplitOptions.RemoveEmptyEntries)[0]; return newQueryString.Count > 0 ? QueryHelpers.AddQueryString(pagePathWithoutQueryString, newQueryString.ToDictionary(kv => kv.Key, kv => kv.Value.ToString())) : pagePathWithoutQueryString; } private static string GetUrlToLog(string url) { url = RemoveQueryStringByKey(url, "api_key"); return url; } private static string NormalizeConfiguredLocalAddress(string address) { var add = address.AsSpan().Trim('/'); int index = add.IndexOf('/'); if (index != -1) { add = add.Slice(index + 1); } return add.TrimStart('/').ToString(); } private bool ValidateHost(string host) { var hosts = _config .Configuration .LocalNetworkAddresses .Select(NormalizeConfiguredLocalAddress) .ToList(); if (hosts.Count == 0) { return true; } host = host ?? string.Empty; if (_networkManager.IsInPrivateAddressSpace(host)) { hosts.Add("localhost"); hosts.Add("127.0.0.1"); return hosts.Any(i => host.IndexOf(i, StringComparison.OrdinalIgnoreCase) != -1); } return true; } private bool ValidateRequest(string remoteIp, bool isLocal) { if (isLocal) { return true; } if (_config.Configuration.EnableRemoteAccess) { var addressFilter = _config.Configuration.RemoteIPFilter.Where(i => !string.IsNullOrWhiteSpace(i)).ToArray(); if (addressFilter.Length > 0 && !_networkManager.IsInLocalNetwork(remoteIp)) { if (_config.Configuration.IsRemoteIPFilterBlacklist) { return !_networkManager.IsAddressInSubnets(remoteIp, addressFilter); } else { return _networkManager.IsAddressInSubnets(remoteIp, addressFilter); } } } else { if (!_networkManager.IsInLocalNetwork(remoteIp)) { return false; } } return true; } private bool ValidateSsl(string remoteIp, string urlString) { if (_config.Configuration.RequireHttps && _appHost.EnableHttps && !_config.Configuration.IsBehindProxy) { if (urlString.IndexOf("https://", StringComparison.OrdinalIgnoreCase) == -1) { // These are hacks, but if these ever occur on ipv6 in the local network they could be incorrectly redirected if (urlString.IndexOf("system/ping", StringComparison.OrdinalIgnoreCase) != -1 || urlString.IndexOf("dlna/", StringComparison.OrdinalIgnoreCase) != -1) { return true; } if (!_networkManager.IsInLocalNetwork(remoteIp)) { return false; } } } return true; } /// /// Overridable method that can be used to implement a custom hnandler /// public async Task RequestHandler(IHttpRequest httpReq, string urlString, string host, string localPath, CancellationToken cancellationToken) { var stopWatch = new Stopwatch(); stopWatch.Start(); var httpRes = httpReq.Response; string urlToLog = null; string remoteIp = httpReq.RemoteIp; try { if (_disposed) { httpRes.StatusCode = 503; httpRes.ContentType = "text/plain"; await httpRes.WriteAsync("Server shutting down", cancellationToken).ConfigureAwait(false); return; } if (!ValidateHost(host)) { httpRes.StatusCode = 400; httpRes.ContentType = "text/plain"; await httpRes.WriteAsync("Invalid host", cancellationToken).ConfigureAwait(false); return; } if (!ValidateRequest(remoteIp, httpReq.IsLocal)) { httpRes.StatusCode = 403; httpRes.ContentType = "text/plain"; await httpRes.WriteAsync("Forbidden", cancellationToken).ConfigureAwait(false); return; } if (!ValidateSsl(httpReq.RemoteIp, urlString)) { RedirectToSecureUrl(httpReq, httpRes, urlString); return; } if (string.Equals(httpReq.Verb, "OPTIONS", StringComparison.OrdinalIgnoreCase)) { httpRes.StatusCode = 200; httpRes.Headers.Add("Access-Control-Allow-Origin", "*"); httpRes.Headers.Add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS"); httpRes.Headers.Add("Access-Control-Allow-Headers", "Content-Type, Authorization, Range, X-MediaBrowser-Token, X-Emby-Authorization"); httpRes.ContentType = "text/plain"; await httpRes.WriteAsync(string.Empty, cancellationToken).ConfigureAwait(false); return; } urlToLog = GetUrlToLog(urlString); if (string.Equals(localPath, _baseUrlPrefix + "/", StringComparison.OrdinalIgnoreCase) || string.Equals(localPath, _baseUrlPrefix, StringComparison.OrdinalIgnoreCase) || string.Equals(localPath, "/", StringComparison.OrdinalIgnoreCase) || string.IsNullOrEmpty(localPath) || !localPath.StartsWith(_baseUrlPrefix)) { // Always redirect back to the default path if the base prefix is invalid or missing _logger.LogDebug("Normalizing a URL at {0}", localPath); httpRes.Redirect(_baseUrlPrefix + "/" + _defaultRedirectPath); return; } if (!string.IsNullOrEmpty(GlobalResponse)) { // We don't want the address pings in ApplicationHost to fail if (localPath.IndexOf("system/ping", StringComparison.OrdinalIgnoreCase) == -1) { httpRes.StatusCode = 503; httpRes.ContentType = "text/html"; await httpRes.WriteAsync(GlobalResponse, cancellationToken).ConfigureAwait(false); return; } } var handler = GetServiceHandler(httpReq); if (handler != null) { await handler.ProcessRequestAsync(this, httpReq, httpRes, _logger, cancellationToken).ConfigureAwait(false); } else { await ErrorHandler(new FileNotFoundException(), httpReq, false).ConfigureAwait(false); } } catch (Exception ex) when (ex is SocketException || ex is IOException || ex is OperationCanceledException) { await ErrorHandler(ex, httpReq, false).ConfigureAwait(false); } catch (SecurityException ex) { await ErrorHandler(ex, httpReq, false).ConfigureAwait(false); } catch (Exception ex) { var logException = !string.Equals(ex.GetType().Name, "SocketException", StringComparison.OrdinalIgnoreCase); await ErrorHandler(ex, httpReq, logException).ConfigureAwait(false); } finally { if (httpRes.StatusCode >= 500) { _logger.LogDebug("Sending HTTP Response 500 in response to {Url}", urlToLog); } stopWatch.Stop(); var elapsed = stopWatch.Elapsed; if (elapsed.TotalMilliseconds > 500) { _logger.LogWarning("HTTP Response {StatusCode} to {RemoteIp}. Time (slow): {Elapsed:g}. {Url}", httpRes.StatusCode, remoteIp, elapsed, urlToLog); } } } // Entry point for HttpListener public ServiceHandler GetServiceHandler(IHttpRequest httpReq) { var pathInfo = httpReq.PathInfo; pathInfo = ServiceHandler.GetSanitizedPathInfo(pathInfo, out string contentType); var restPath = ServiceController.GetRestPathForRequest(httpReq.HttpMethod, pathInfo); if (restPath != null) { return new ServiceHandler(restPath, contentType); } _logger.LogError("Could not find handler for {PathInfo}", pathInfo); return null; } private void RedirectToSecureUrl(IHttpRequest httpReq, HttpResponse httpRes, string url) { if (Uri.TryCreate(url, UriKind.Absolute, out Uri uri)) { var builder = new UriBuilder(uri) { Port = _config.Configuration.PublicHttpsPort, Scheme = "https" }; url = builder.Uri.ToString(); } httpRes.Redirect(url); } /// /// Adds the rest handlers. /// /// The services. /// /// public void Init(IEnumerable services, IEnumerable listeners, IEnumerable urlPrefixes) { _webSocketListeners = listeners.ToArray(); UrlPrefixes = urlPrefixes.ToArray(); ServiceController = new ServiceController(); var types = services.Select(r => r.GetType()); ServiceController.Init(this, types); ResponseFilters = new Action[] { new ResponseFilter(_logger).FilterResponse }; } public RouteAttribute[] GetRouteAttributes(Type requestType) { var routes = requestType.GetTypeInfo().GetCustomAttributes(true).ToList(); var clone = routes.ToList(); foreach (var route in clone) { routes.Add(new RouteAttribute(NormalizeCustomRoutePath(route.Path), route.Verbs) { Notes = route.Notes, Priority = route.Priority, Summary = route.Summary }); routes.Add(new RouteAttribute(NormalizeEmbyRoutePath(route.Path), route.Verbs) { Notes = route.Notes, Priority = route.Priority, Summary = route.Summary }); routes.Add(new RouteAttribute(NormalizeMediaBrowserRoutePath(route.Path), route.Verbs) { Notes = route.Notes, Priority = route.Priority, Summary = route.Summary }); } return routes.ToArray(); } public Func GetParseFn(Type propertyType) { return _funcParseFn(propertyType); } public void SerializeToJson(object o, Stream stream) { _jsonSerializer.SerializeToStream(o, stream); } public void SerializeToXml(object o, Stream stream) { _xmlSerializer.SerializeToStream(o, stream); } public Task DeserializeXml(Type type, Stream stream) { return Task.FromResult(_xmlSerializer.DeserializeFromStream(type, stream)); } public Task DeserializeJson(Type type, Stream stream) { return _jsonSerializer.DeserializeFromStreamAsync(stream, type); } public Task ProcessWebSocketRequest(HttpContext context) { return _socketListener.ProcessWebSocketRequest(context); } private string NormalizeEmbyRoutePath(string path) { _logger.LogDebug("Normalizing /emby route"); return _baseUrlPrefix + "/emby" + NormalizeUrlPath(path); } private string NormalizeMediaBrowserRoutePath(string path) { _logger.LogDebug("Normalizing /mediabrowser route"); return _baseUrlPrefix + "/mediabrowser" + NormalizeUrlPath(path); } private string NormalizeCustomRoutePath(string path) { _logger.LogDebug("Normalizing custom route {0}", path); return _baseUrlPrefix + NormalizeUrlPath(path); } /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (_disposed) return; if (disposing) { Stop(); } _disposed = true; } /// /// Processes the web socket message received. /// /// The result. private Task ProcessWebSocketMessageReceived(WebSocketMessageInfo result) { if (_disposed) { return Task.CompletedTask; } _logger.LogDebug("Websocket message received: {0}", result.MessageType); IEnumerable GetTasks() { foreach (var x in _webSocketListeners) { yield return x.ProcessMessageAsync(result); } } return Task.WhenAll(GetTasks()); } } }