using System; using System.Collections.Generic; using System.Linq; using HttpServer.HttpModules; using System.Security.Cryptography.X509Certificates; using Duplicati.Library.Common.IO; namespace Duplicati.Server.WebServer { public class Server { /// /// The tag used for logging /// private static readonly string LOGTAG = Duplicati.Library.Logging.Log.LogTagFromType(); /// /// Option for changing the webroot folder /// public const string OPTION_WEBROOT = "webservice-webroot"; /// /// Option for changing the webservice listen port /// public const string OPTION_PORT = "webservice-port"; /// /// Option for changing the webservice listen interface /// public const string OPTION_INTERFACE = "webservice-interface"; /// /// The default path to the web root /// public const string DEFAULT_OPTION_WEBROOT = "webroot"; /// /// The default listening port /// public const int DEFAULT_OPTION_PORT = 8200; /// /// Option for setting the webservice SSL certificate /// public const string OPTION_SSLCERTIFICATEFILE = "webservice-sslcertificatefile"; /// /// Option for setting the webservice SSL certificate key /// public const string OPTION_SSLCERTIFICATEFILEPASSWORD = "webservice-sslcertificatepassword"; /// /// The default listening interface /// public const string DEFAULT_OPTION_INTERFACE = "loopback"; /// /// The single webserver instance /// private readonly HttpServer.HttpServer m_server; /// /// The webserver listening port /// public readonly int Port; /// /// A string that is sent out instead of password values /// public const string PASSWORD_PLACEHOLDER = "**********"; /// /// Sets up the webserver and starts it /// /// A set of options public Server(IDictionary options) { int port; string portstring; IEnumerable ports = null; options.TryGetValue(OPTION_PORT, out portstring); if (!string.IsNullOrEmpty(portstring)) ports = from n in portstring.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries) where int.TryParse(n, out port) select int.Parse(n); if (ports == null || !ports.Any()) ports = new int[] { DEFAULT_OPTION_PORT }; string interfacestring; System.Net.IPAddress listenInterface; options.TryGetValue(OPTION_INTERFACE, out interfacestring); if (string.IsNullOrWhiteSpace(interfacestring)) interfacestring = Program.DataConnection.ApplicationSettings.ServerListenInterface; if (string.IsNullOrWhiteSpace(interfacestring)) interfacestring = DEFAULT_OPTION_INTERFACE; if (interfacestring.Trim() == "*" || interfacestring.Trim().Equals("any", StringComparison.OrdinalIgnoreCase) || interfacestring.Trim().Equals("all", StringComparison.OrdinalIgnoreCase)) listenInterface = System.Net.IPAddress.Any; else if (interfacestring.Trim() == "loopback") listenInterface = System.Net.IPAddress.Loopback; else listenInterface = System.Net.IPAddress.Parse(interfacestring); string certificateFile; options.TryGetValue(OPTION_SSLCERTIFICATEFILE, out certificateFile); string certificateFilePassword; options.TryGetValue(OPTION_SSLCERTIFICATEFILEPASSWORD, out certificateFilePassword); X509Certificate2 cert = null; bool certValid = false; if (certificateFile == null) { try { cert = Program.DataConnection.ApplicationSettings.ServerSSLCertificate; if (cert != null) certValid = cert.HasPrivateKey; } catch (Exception ex) { Duplicati.Library.Logging.Log.WriteWarningMessage(LOGTAG, "DefectStoredSSLCert", ex, Strings.Server.DefectSSLCertInDatabase); } } else if (certificateFile.Length == 0) { Program.DataConnection.ApplicationSettings.ServerSSLCertificate = null; } else { try { if (string.IsNullOrWhiteSpace(certificateFilePassword)) cert = new X509Certificate2(certificateFile, "", X509KeyStorageFlags.Exportable); else cert = new X509Certificate2(certificateFile, certificateFilePassword, X509KeyStorageFlags.Exportable); certValid = cert.HasPrivateKey; } catch (Exception ex) { throw new Exception(Strings.Server.SSLCertificateFailure(ex.Message), ex); } } // If we are in hosted mode with no specified port, // then try different ports foreach (var p in ports) try { // Due to the way the server is initialized, // we cannot try to start it again on another port, // so we create a new server for each attempt var server = CreateServer(options); if (!certValid) server.Start(listenInterface, p); else server.Start(listenInterface, p, cert, System.Security.Authentication.SslProtocols.Tls11 | System.Security.Authentication.SslProtocols.Tls12, null, false); m_server = server; m_server.ServerName = string.Format("{0} v{1}", Library.AutoUpdater.AutoUpdateSettings.AppName, System.Reflection.Assembly.GetExecutingAssembly().GetName().Version); this.Port = p; if (interfacestring != Program.DataConnection.ApplicationSettings.ServerListenInterface) Program.DataConnection.ApplicationSettings.ServerListenInterface = interfacestring; if (certValid && !cert.Equals(Program.DataConnection.ApplicationSettings.ServerSSLCertificate)) Program.DataConnection.ApplicationSettings.ServerSSLCertificate = cert; Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "ServerListening", Strings.Server.StartedServer(listenInterface.ToString(), p)); return; } catch (System.Net.Sockets.SocketException) { } throw new Exception(Strings.Server.ServerStartFailure(ports)); } private static void AddMimeTypes(FileModule fm) { fm.AddDefaultMimeTypes(); fm.MimeTypes["htc"] = "text/x-component"; fm.MimeTypes["json"] = "application/json"; fm.MimeTypes["map"] = "application/json"; fm.MimeTypes["htm"] = "text/html; charset=utf-8"; fm.MimeTypes["html"] = "text/html; charset=utf-8"; fm.MimeTypes["hbs"] = "application/x-handlebars-template"; fm.MimeTypes["woff"] = "application/font-woff"; fm.MimeTypes["woff2"] = "application/font-woff"; } private static HttpServer.HttpServer CreateServer(IDictionary options) { HttpServer.HttpServer server = new HttpServer.HttpServer(); server.Add(new HostHeaderChecker()); if (string.Equals(Environment.GetEnvironmentVariable("SYNO_DSM_AUTH") ?? string.Empty, "1")) server.Add(new SynologyAuthenticationHandler()); server.Add(new AuthenticationHandler()); server.Add(new RESTHandler()); string webroot = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); string install_webroot = System.IO.Path.Combine(Library.AutoUpdater.UpdaterManager.InstalledBaseDir, "webroot"); #if DEBUG // Easy test for extensions while debugging install_webroot = Library.AutoUpdater.UpdaterManager.InstalledBaseDir; if (!System.IO.Directory.Exists(System.IO.Path.Combine(webroot, "webroot"))) { //For debug we go "../../../.." to get out of "GUI/Duplicati.GUI.TrayIcon/bin/debug" string tmpwebroot = System.IO.Path.GetFullPath(System.IO.Path.Combine(webroot, "..", "..", "..", "..")); tmpwebroot = System.IO.Path.Combine(tmpwebroot, "Server"); if (System.IO.Directory.Exists(System.IO.Path.Combine(tmpwebroot, "webroot"))) webroot = tmpwebroot; else { //If we are running the server standalone, we only need to exit "bin/Debug" tmpwebroot = System.IO.Path.GetFullPath(System.IO.Path.Combine(webroot, "..", "..")); if (System.IO.Directory.Exists(System.IO.Path.Combine(tmpwebroot, "webroot"))) webroot = tmpwebroot; } } #endif webroot = System.IO.Path.Combine(webroot, "webroot"); if (options.ContainsKey(OPTION_WEBROOT)) { string userroot = options[OPTION_WEBROOT]; #if DEBUG //In debug mode we do not care where the path points #else //In release mode we check that the user supplied path is located // in the same folders as the running application, to avoid users // that inadvertently expose top level folders if (!string.IsNullOrWhiteSpace(userroot) && ( userroot.StartsWith(Util.AppendDirSeparator(System.Reflection.Assembly.GetExecutingAssembly().Location), Library.Utility.Utility.ClientFilenameStringComparison) || userroot.StartsWith(Util.AppendDirSeparator(Program.StartupPath), Library.Utility.Utility.ClientFilenameStringComparison) ) ) #endif { webroot = userroot; install_webroot = webroot; } } if (install_webroot != webroot && System.IO.Directory.Exists(System.IO.Path.Combine(install_webroot, "customized"))) { var customized_files = new CacheControlFileHandler("/customized/", System.IO.Path.Combine(install_webroot, "customized")); AddMimeTypes(customized_files); server.Add(customized_files); } if (install_webroot != webroot && System.IO.Directory.Exists(System.IO.Path.Combine(install_webroot, "oem"))) { var oem_files = new CacheControlFileHandler("/oem/", System.IO.Path.Combine(install_webroot, "oem")); AddMimeTypes(oem_files); server.Add(oem_files); } if (install_webroot != webroot && System.IO.Directory.Exists(System.IO.Path.Combine(install_webroot, "package"))) { var proxy_files = new CacheControlFileHandler("/proxy/", System.IO.Path.Combine(install_webroot, "package")); AddMimeTypes(proxy_files); server.Add(proxy_files); } var fh = new CacheControlFileHandler("/", webroot, true); AddMimeTypes(fh); server.Add(fh); server.Add(new IndexHtmlHandler(webroot)); #if DEBUG //For debugging, it is nice to know when we get a 404 server.Add(new DebugReportHandler()); #endif return server; } private class DebugReportHandler : HttpModule { public override bool Process(HttpServer.IHttpRequest request, HttpServer.IHttpResponse response, HttpServer.Sessions.IHttpSession session) { System.Diagnostics.Trace.WriteLine(string.Format("Rejecting request for {0}", request.Uri)); return false; } } private class CacheControlFileHandler : FileModule { public CacheControlFileHandler(string baseUri, string basePath, bool useLastModifiedHeader = false) : base(baseUri, basePath, useLastModifiedHeader) { } public override bool Process(HttpServer.IHttpRequest request, HttpServer.IHttpResponse response, HttpServer.Sessions.IHttpSession session) { if (!this.CanHandle(request.Uri)) return false; if (request.Uri.AbsolutePath.EndsWith("index.html", StringComparison.Ordinal) || request.Uri.AbsolutePath.EndsWith("index.htm", StringComparison.Ordinal)) response.AddHeader("Cache-Control", "no-cache, no-store, must-revalidate, max-age=0"); else response.AddHeader("Cache-Control", "max-age=" + (60 * 60 * 24)); return base.Process(request, response, session); } } /// /// Module for injecting host header verification /// private class HostHeaderChecker : HttpModule { /// /// The hostnames that we allow /// private string[] m_lastSplitNames; /// /// The string used to generate m_lastSplitNames; /// private string m_lastAllowed; /// /// A regex to detect potential IPv4 addresses. /// Note that this also detects things that are not valid IPv4. /// private static readonly System.Text.RegularExpressions.Regex IPV4 = new System.Text.RegularExpressions.Regex(@"((\d){1,3}\.){3}(\d){1,3}"); /// /// A regex to detect potential IPv6 addresses. /// Note that this also detects things that are not valid IPv6. /// private static readonly System.Text.RegularExpressions.Regex IPV6 = new System.Text.RegularExpressions.Regex(@"(\:)?(\:?[A-Fa-f0-9]{1,4}\:?){1,8}(\:)?"); /// /// The hostnames that are always allowed /// private static string[] DEFAULT_ALLOWED = new string[] { "localhost", "127.0.0.1", "::1", "localhost.localdomain" }; /// /// Process the received request /// /// A flag indicating if the request is handled. /// The received request. /// The response object. /// The session state. public override bool Process(HttpServer.IHttpRequest request, HttpServer.IHttpResponse response, HttpServer.Sessions.IHttpSession session) { string[] h = null; var hstring = Program.DataConnection.ApplicationSettings.AllowedHostnames; if (!string.IsNullOrWhiteSpace(hstring)) { h = m_lastSplitNames; if (hstring != m_lastAllowed) { m_lastAllowed = hstring; h = m_lastSplitNames = (hstring ?? string.Empty).Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries); } if (h == null || h.Length == 0) h = null; } // For some reason, the web server strips out the host header var host = request.Headers["Host"]; if (string.IsNullOrWhiteSpace(host)) host = request.Uri.Host; // This should not happen if (string.IsNullOrWhiteSpace(host)) { response.Reason = "Invalid request, missing host header"; response.Status = System.Net.HttpStatusCode.Forbidden; var msg = System.Text.Encoding.ASCII.GetBytes(response.Reason); response.ContentType = "text/plain"; response.ContentLength = msg.Length; response.Body.Write(msg, 0, msg.Length); response.Send(); return true; } // Check the hostnames we always allow if (Array.IndexOf(DEFAULT_ALLOWED, host) >= 0) return false; // Then the user specified ones if (h != null && Array.IndexOf(h, host) >= 0) return false; // Disable checks if we have an asterisk if (h != null && Array.IndexOf(h, "*") >= 0) return false; // Finally, check if we have a potential IP address var v4 = IPV4.Match(host); var v6 = IPV6.Match(host); if ((v4.Success && v4.Length == host.Length) || (v6.Success && v6.Length == host.Length)) { try { // Verify that the hostname is indeed a valid IP address System.Net.IPAddress.Parse(host); return false; } catch { } } // Failed to find a valid header response.Reason = $"The host header sent by the client is not allowed"; response.Status = System.Net.HttpStatusCode.Forbidden; var txt = System.Text.Encoding.ASCII.GetBytes(response.Reason); response.ContentType = "text/plain"; response.ContentLength = txt.Length; response.Body.Write(txt, 0, txt.Length); response.Send(); return true; } } } }