// Copyright (C) 2024, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using HttpServer.HttpModules; namespace Duplicati.Server.WebServer { /// /// Helper class for enforcing the built-in authentication on Synology DSM /// public class SynologyAuthenticationHandler : HttpModule { /// /// The path to the login.cgi script /// private readonly string LOGIN_CGI = GetEnvArg("SYNO_LOGIN_CGI", "/usr/syno/synoman/webman/login.cgi"); /// /// The path to the authenticate.cgi script /// private readonly string AUTH_CGI = GetEnvArg("SYNO_AUTHENTICATE_CGI", "/usr/syno/synoman/webman/modules/authenticate.cgi"); /// /// A flag indicating if only admins are allowed /// private readonly bool ADMIN_ONLY = !(GetEnvArg("SYNO_ALL_USERS", "0") == "1"); /// /// A flag indicating if the XSRF token should be fetched automatically /// private readonly bool AUTO_XSRF = GetEnvArg("SYNO_AUTO_XSRF", "1") == "1"; /// /// A flag indicating that the auth-module is fully disabled /// private readonly bool FULLY_DISABLED; /// /// Re-evaluate the logins periodically to ensure it is still valid /// private readonly TimeSpan CACHE_TIMEOUT = TimeSpan.FromMinutes(3); /// /// A cache of previously authenticated logins /// private readonly ConcurrentDictionary m_logincache = new ConcurrentDictionary(); /// /// Initializes a new instance of the class. /// public SynologyAuthenticationHandler() { Console.WriteLine("Enabling Synology integrated authentication handler"); var disable = false; if (!File.Exists(LOGIN_CGI)) { Console.WriteLine("Disabling webserver as the login script is not found: {0}", LOGIN_CGI); disable = true; } if (!File.Exists(AUTH_CGI)) { Console.WriteLine("Disabling webserver as the auth script is not found: {0}", AUTH_CGI); disable = true; } FULLY_DISABLED = disable; } /// /// Processes the request /// /// true if the request is handled false otherwise. /// The request. /// The response. /// The session. public override bool Process(HttpServer.IHttpRequest request, HttpServer.IHttpResponse response, HttpServer.Sessions.IHttpSession session) { if (FULLY_DISABLED) { response.Status = System.Net.HttpStatusCode.ServiceUnavailable; response.Reason = "The system is incorrectly configured"; return true; } var limitedAccess = request.Uri.AbsolutePath.StartsWith(RESTHandler.API_URI_PATH, StringComparison.OrdinalIgnoreCase) || request.Uri.AbsolutePath.StartsWith(AuthenticationHandler.LOGIN_SCRIPT_URI, StringComparison.OrdinalIgnoreCase) || request.Uri.AbsolutePath.StartsWith(AuthenticationHandler.LOGOUT_SCRIPT_URI, StringComparison.OrdinalIgnoreCase); if (!limitedAccess) return false; var tmpenv = new Dictionary(); tmpenv["REMOTE_ADDR"] = request.RemoteEndPoint.Address.ToString(); tmpenv["REMOTE_PORT"] = request.RemoteEndPoint.Port.ToString(); if (!string.IsNullOrWhiteSpace(request.Headers["X-Real-IP"])) tmpenv["REMOTE_ADDR"] = request.Headers["X-Real-IP"]; if (!string.IsNullOrWhiteSpace(request.Headers["X-Real-IP"])) tmpenv["REMOTE_PORT"] = request.Headers["X-Real-Port"]; var loginid = request.Cookies["id"]?.Value; if (!string.IsNullOrWhiteSpace(loginid)) tmpenv["HTTP_COOKIE"] = "id=" + loginid; var xsrftoken = request.Headers["X-Syno-Token"]; if (string.IsNullOrWhiteSpace(xsrftoken)) xsrftoken = request.QueryString["SynoToken"]?.Value; var cachestring = BuildCacheKey(tmpenv, xsrftoken); DateTime cacheExpires; if (m_logincache.TryGetValue(cachestring, out cacheExpires) && cacheExpires > DateTime.Now) { // We do not refresh the cache, as we need to ask the synology auth system periodically return false; } if (string.IsNullOrWhiteSpace(xsrftoken) && AUTO_XSRF) { var authre = new Regex(@"""SynoToken""\s?\:\s?""(?[^""]+)"""); try { var resp = ShellExec(LOGIN_CGI, env: tmpenv).Result; var m = authre.Match(resp); if (m.Success) xsrftoken = m.Groups["token"].Value; else throw new Exception("Unable to get XSRF token"); } catch (Exception) { response.Status = System.Net.HttpStatusCode.InternalServerError; response.Reason = "The system is incorrectly configured"; return true; } } if (!string.IsNullOrWhiteSpace(xsrftoken)) tmpenv["HTTP_X_SYNO_TOKEN"] = xsrftoken; cachestring = BuildCacheKey(tmpenv, xsrftoken); var username = GetEnvArg("SYNO_USERNAME"); if (string.IsNullOrWhiteSpace(username)) { try { username = ShellExec(AUTH_CGI, shell: false, exitcode: 0, env: tmpenv).Result; } catch (Exception) { response.Status = System.Net.HttpStatusCode.InternalServerError; response.Reason = "The system is incorrectly configured"; return true; } } if (string.IsNullOrWhiteSpace(username)) { response.Status = System.Net.HttpStatusCode.Forbidden; response.Reason = "Permission denied, not logged in"; return true; } username = username.Trim(); if (ADMIN_ONLY) { var groups = GetEnvArg("SYNO_GROUP_IDS"); if (string.IsNullOrWhiteSpace(groups)) { groups = ShellExec("id", "-G '" + username.Trim().Replace("'", "\\'") + "'", exitcode: 0).Result ?? string.Empty; groups = groups.Replace(Environment.NewLine, String.Empty); } if (!groups.Split(new char[] { ' ' }).Contains("101")) { response.Status = System.Net.HttpStatusCode.Forbidden; response.Reason = "Administrator login required"; return true; } } // We are now authenticated, add to cache m_logincache[cachestring] = DateTime.Now + CACHE_TIMEOUT; return false; } /// /// Builds a cache key from the environment data /// /// The cache key. /// The environment. /// The XSRF token. private static string BuildCacheKey(Dictionary values, string xsrftoken) { if (!values.ContainsKey("REMOTE_ADDR") || !values.ContainsKey("REMOTE_PORT") || !values.ContainsKey("HTTP_COOKIE")) return null; return string.Format("{0}:{1}/{2}?{3}", values["REMOTE_ADDR"], values["REMOTE_PORT"], values["HTTP_COOKIE"], xsrftoken); } /// /// Runs an external command /// /// The stdout data. /// The executable /// The executable and the arguments. /// If set to true use the shell context for execution. /// Set the value to check for a particular exitcode. private static async Task ShellExec(string command, string args = null, bool shell = false, int exitcode = -1, Dictionary env = null) { var psi = new ProcessStartInfo() { FileName = command, Arguments = shell ? null : args, UseShellExecute = false, RedirectStandardInput = shell, RedirectStandardOutput = true, RedirectStandardError = false }; if (env != null) foreach (var pk in env) psi.EnvironmentVariables[pk.Key] = pk.Value; using (var p = System.Diagnostics.Process.Start(psi)) { if (shell && args != null) await p.StandardInput.WriteLineAsync(args); var res = p.StandardOutput.ReadToEndAsync(); var tries = 10; var ms = (int)TimeSpan.FromSeconds(0.5).TotalMilliseconds; while (tries > 0 && !p.HasExited) { tries--; p.WaitForExit(ms); } if (!p.HasExited) try { p.Kill(); } catch { } if (!p.HasExited || (p.ExitCode != exitcode && exitcode != -1)) throw new Exception(string.Format("Exit code was: {0}, stdout: {1}", p.ExitCode, res)); return await res; } } /// /// Gets the environment variable argument. /// /// The environment variable. /// The name of the environment variable. /// The default value. private static string GetEnvArg(string key, string @default = null) { var res = Environment.GetEnvironmentVariable(key); return string.IsNullOrWhiteSpace(res) ? @default : res.Trim(); } } }