Files
duplicati/Duplicati/WebserverCore/Middlewares/SynologyAuthMiddleware.cs
T

477 lines
19 KiB
C#

// Copyright (C) 2026, 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.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Caching.Memory;
namespace Duplicati.WebserverCore.Middlewares;
public sealed class SynologyDsmAuthOptions
{
/// <summary>
/// Path to DSM authenticate.cgi
/// </summary>
public string AuthenticateCgi { get; set; } = GetEnvArg("SYNO_AUTHENTICATE_CGI", "/usr/syno/synoman/webman/authenticate.cgi");
/// <summary>
/// Path to DSM login.cgi
/// </summary>
public string LoginCgi { get; set; } = GetEnvArg("SYNO_LOGIN_CGI", "/usr/syno/synoman/webman/login.cgi");
/// <summary>
/// If set, use this username instead of invoking authenticate.cgi (mostly for testing).
/// </summary>
public string? ForcedUsername { get; set; } = GetEnvArg("SYNO_USERNAME");
/// <summary>
/// If set, use these group ids (space separated) instead of invoking 'id -G' (mostly for testing).
/// </summary>
public string? ForcedGroupIds { get; set; } = GetEnvArg("SYNO_GROUP_IDS");
/// <summary>
/// Allow all DSM users (not only admins) if SYNO_ALL_USERS=1; otherwise admin-only.
/// </summary>
public bool AdminOnly { get; set; } = !(GetEnvArg("SYNO_ALL_USERS", "0") == "1");
/// <summary>
/// A flag indicating if the XSRF token should be fetched automatically
/// </summary>
/// <remarks>Enabling this disables XSRF protection, so use with caution.</remarks>
public readonly bool AutoXsrf = GetEnvArg("SYNO_AUTO_XSRF", "0") == "1";
/// <summary>
/// If true, enable the middleware. Controlled by SYNO_DSM_AUTH_ENABLED=1/0.
/// </summary>
public bool Enabled { get; set; } = GetEnvArg("SYNO_DSM_AUTH_ENABLED", "0") == "1";
/// <summary>
/// Cache validity per (remote ip/port, cookie, token) to avoid hitting DSM on every request.
/// </summary>
public TimeSpan LoginCacheTimeout { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Cache validity per login id cookie to avoid hitting DSM on every request.
/// </summary>
public TimeSpan AuthCacheTimeout { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Prefixes to protect. Defaults to /api, /login, /logout.
/// Set SYNO_PROTECT_PREFIXES to override (comma-separated).
/// </summary>
public string[] ProtectedPathPrefixes { get; set; } =
(GetEnvArg("SYNO_PROTECT_PREFIXES") ?? "/api,/notfications")
.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
.Select(x => x.Trim())
.Where(x => x.Length > 0)
.ToArray();
/// <summary>
/// If true, trust X-Real-IP / X-Real-Port headers (set by reverse proxy).
/// Controlled by SYNO_TRUST_XREAL=1/0.
/// </summary>
public bool TrustXRealHeaders { get; set; } = GetEnvArg("SYNO_TRUST_XREAL", "1") == "1";
/// <summary>
/// Treat gid 101 as administrators (DSM default). If you want to avoid hardcoding,
/// set SYNO_ADMIN_GID to a specific value or set SYNO_ADMIN_GROUP_NAME.
/// </summary>
public string AdminGid { get; set; } = GetEnvArg("SYNO_ADMIN_GID", "101");
/// <summary>
/// If set (e.g. "administrators"), checks membership by group name using 'id -Gn'.
/// Preferred over hardcoding gid when available.
/// </summary>
public string AdminGroupName { get; set; } = GetEnvArg("SYNO_ADMIN_GROUP_NAME", "administrators");
/// <summary>
/// Get environment variable or default value.
/// </summary>
/// <param name="key">The environment variable key</param>
/// <param name="default">The default value if the environment variable is not set or empty.</param>
/// <returns>The environment variable value or the default value</returns>
[return: NotNullIfNotNull(nameof(@default))]
internal static string? GetEnvArg(string key, string? @default = null)
{
var res = Environment.GetEnvironmentVariable(key);
return string.IsNullOrWhiteSpace(res) ? @default : res.Trim();
}
}
/// <summary>
/// Middleware for Synology DSM integrated authentication.
/// </summary>
/// <remarks>
/// The DSM on Synology NAS devices uses CGI scripts to handle authentication and authorization.
/// This middleware integrates with those scripts to authenticate users based on DSM's user management.
/// It checks for a valid login session using the "id" cookie and optionally an XSRF token.
/// The middleware works by forwarding requests to the DSM's authenticate.cgi script to verify user credentials.
/// If the user is authenticated, the request proceeds; otherwise, a 401 Unauthorized response is returned.
/// For endpoints that involve non-static content (i.e., API calls), the middleware ensures the user is authenticated, including the XSRF token.
/// For endpoints that involve static content (i.e., html, js, css), the middleware checks that the user has a valid cookie, but does not require the XSRF token.
/// To avoid slowdowns, the middleware caches authentication results for a short period of time.
/// </remarks>
public sealed class SynologyDsmAuthMiddleware
{
private static readonly string LOGTAG = Library.Logging.Log.LogTagFromType<SynologyDsmAuthMiddleware>();
private readonly RequestDelegate _next;
private readonly IMemoryCache _cache;
private readonly SynologyDsmAuthOptions _opt;
private readonly Regex _synoTokenRegex = new Regex(@"""SynoToken""\s?\:\s?""(?<token>[^""]+)""", RegexOptions.Compiled);
private readonly bool _fullyDisabled;
public SynologyDsmAuthMiddleware(RequestDelegate next, IMemoryCache cache, SynologyDsmAuthOptions options)
{
// WARNING: This module is written for Duplicati which does not have a concept of "users" or "roles".
// If the code is adapted for other uses, care must be taken to ensure that user identities and roles
// are handled securely and appropriately. Specifically, the caching mechanism needs to be revised to
// support multiple users and roles.
_next = next ?? throw new ArgumentNullException(nameof(next));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_opt = options ?? throw new ArgumentNullException(nameof(options));
// Validate scripts exist; if not, disable with 503.
if (!File.Exists(_opt.AuthenticateCgi) || !File.Exists(_opt.LoginCgi))
_fullyDisabled = true;
}
public async Task Invoke(HttpContext context)
{
// If scripts are missing, return 503, the system is not looking as expected
if (_fullyDisabled)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsync("The system is incorrectly configured");
return;
}
// Check if this is an API call, or a static content call
var isApiCall = IsProtectedPath(context.Request.Path);
// Static content calls are GET or HEAD requests to non-API paths
var isStaticContentCall = !isApiCall && new[] { "GET", "HEAD" }.Contains(context.Request.Method.ToUpperInvariant());
// Cookie "id" is required for authenticate.cgi
var loginId = context.Request.Cookies.TryGetValue("id", out var idVal) ? idVal : null;
// Static content calls require a valid cookie, but does not (usually) have an XSRF token,
// so we cannot call authenticate.cgi, but instead check if the token itself has been authenticated recently
// giving "authenticated but no XSRF protection" for static content
if (isStaticContentCall)
{
if (!string.IsNullOrWhiteSpace(loginId) && _cache.TryGetValue(BuildAuthCacheKey(loginId), out _))
{
// Authenticated recently, allow
await _next(context);
return;
}
}
// Build env for DSM scripts, prepare for invocation of CGI scripts
var env = BuildEnv(context);
if (!string.IsNullOrWhiteSpace(loginId))
env["HTTP_COOKIE"] = "id=" + loginId;
// Get XSRF token from header or query
// API calls supply a header, but some calls (like static content or websockets) use query
string? xsrftoken = context.Request.Headers["X-Syno-Token"];
if (string.IsNullOrWhiteSpace(xsrftoken) && context.Request.Query.TryGetValue("SynoToken", out var qt))
xsrftoken = qt.ToString();
// Build cache key for the login from this specific client
var loginCacheKey = BuildLoginCacheKey(env, xsrftoken);
// Check cache so we do not need to call authenticate.cgi for every request
if (!string.IsNullOrWhiteSpace(loginCacheKey) && _cache.TryGetValue(loginCacheKey, out _))
{
await _next(context);
return;
}
// Auto-fetch happens when AutoXsrf is enabled or when serving static content
// It is possible to enable this for API requests as well by environment variable, but that is not recommended for security reasons
// If we get a request for static content, we auto-fetch the token to allow static content to load (still protected by cookie)
if (string.IsNullOrWhiteSpace(xsrftoken) && (_opt.AutoXsrf || isStaticContentCall))
{
try
{
// Call login.cgi to get XSRF token
var resp = ShellExec(_opt.LoginCgi, env: env).Result;
var m = _synoTokenRegex.Match(resp);
if (m.Success)
xsrftoken = m.Groups["token"].Value;
else
throw new Exception("Unable to get XSRF token");
}
catch (Exception)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("Request not authorized");
return;
}
}
// Include the XSRF token if available
if (!string.IsNullOrWhiteSpace(xsrftoken))
env["HTTP_X_SYNO_TOKEN"] = xsrftoken;
// Authenticate
var username = _opt.ForcedUsername;
if (string.IsNullOrWhiteSpace(username))
{
try
{
// Call authenticate.cgi to get username
username = await ShellExec(_opt.AuthenticateCgi, shell: false, exitcode: 0, env: env, ct: context.RequestAborted);
}
catch
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsync("Request not authorized");
return;
}
}
if (string.IsNullOrWhiteSpace(username))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Permission denied, not logged in");
return;
}
username = username.Trim();
// Admin-only check, look up group membership
if (_opt.AdminOnly)
{
var isAdmin = await IsAdminUser(username, context.RequestAborted);
if (!isAdmin)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Administrator login required");
return;
}
}
// Auth OK: cache (only if cacheKey available)
if (!string.IsNullOrWhiteSpace(loginCacheKey))
_cache.Set(loginCacheKey, true, _opt.LoginCacheTimeout);
// Auth OK: cache loginId too
if (!string.IsNullOrWhiteSpace(loginId))
_cache.Set(BuildAuthCacheKey(loginId), true, _opt.AuthCacheTimeout);
await _next(context);
}
private bool IsProtectedPath(PathString path)
{
var p = path.Value ?? string.Empty;
return _opt.ProtectedPathPrefixes.Any(prefix =>
p.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
}
private Dictionary<string, string> BuildEnv(HttpContext ctx)
{
var env = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// Defaults from actual connection
var remoteAddr = ctx.Connection.RemoteIpAddress?.ToString();
var remotePort = ctx.Connection.RemotePort.ToString();
// Optionally trust reverse-proxy headers
if (_opt.TrustXRealHeaders)
{
if (ctx.Request.Headers.TryGetValue("X-Real-IP", out var xrip) && !string.IsNullOrWhiteSpace(xrip))
remoteAddr = xrip.ToString();
if (ctx.Request.Headers.TryGetValue("X-Real-Port", out var xrport) && !string.IsNullOrWhiteSpace(xrport))
remotePort = xrport.ToString();
}
if (!string.IsNullOrWhiteSpace(remoteAddr))
env["REMOTE_ADDR"] = remoteAddr;
if (!string.IsNullOrWhiteSpace(remotePort))
env["REMOTE_PORT"] = remotePort;
else
env["REMOTE_PORT"] = "443"; // Default to 443 if missing
// Setup other required env vars, not really used, but needed by DSM scripts
env["SERVER_ADDR"] = "127.0.0.1";
env["REQUEST_METHOD"] = "GET";
env["SERVER_PROTOCOL"] = "HTTP/1.1";
env["HTTP_HOST"] = ctx.Request.Host.Host;
return env;
}
private static string? BuildLoginCacheKey(Dictionary<string, string> env, string? xsrfToken)
{
if (env == null)
return null;
if (!env.TryGetValue("REMOTE_ADDR", out var addr) ||
!env.TryGetValue("REMOTE_PORT", out var port) ||
!env.TryGetValue("HTTP_COOKIE", out var cookie) ||
string.IsNullOrWhiteSpace(addr) ||
string.IsNullOrWhiteSpace(port) ||
string.IsNullOrWhiteSpace(cookie))
return null;
return $"{nameof(SynologyDsmAuthMiddleware)}:login:{addr}:{port}/{cookie}?{xsrfToken}";
}
private static string BuildAuthCacheKey(string loginId)
=> $"{nameof(SynologyDsmAuthMiddleware)}:auth:{loginId}";
private async Task<bool> IsAdminUser(string username, CancellationToken ct)
{
// 1) Prefer group-name membership (more robust).
if (!string.IsNullOrWhiteSpace(_opt.AdminGroupName))
{
try
{
// id -Gn prints group names
var groupsByName = await ShellExec("id", $"-Gn {EscapeArg(username)}", shell: false, exitcode: 0, env: null, ct: ct);
var names = (groupsByName ?? string.Empty)
.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
if (names.Any(n => string.Equals(n, _opt.AdminGroupName, StringComparison.OrdinalIgnoreCase)))
return true;
}
catch
{
// Fall back to gid checks below
}
}
// 2) If forced gids are provided, use them.
var gids = _opt.ForcedGroupIds;
if (string.IsNullOrWhiteSpace(gids))
{
try
{
// id -G prints numeric group IDs
gids = await ShellExec("id", $"-G {EscapeArg(username)}", shell: false, exitcode: 0, env: null, ct: ct) ?? string.Empty;
gids = gids.Replace(Environment.NewLine, string.Empty);
}
catch
{
return false;
}
}
var gidList = gids.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
return gidList.Contains(_opt.AdminGid);
}
private static string EscapeArg(string value)
{
if (string.IsNullOrEmpty(value))
return "\"\"";
// Minimal safe quoting for ProcessStartInfo.Arguments parsing.
if (value.IndexOfAny(new[] { ' ', '\t', '"', '\\' }) >= 0)
return "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
return value;
}
private static async Task<string> ShellExec(
string command,
string? args = null,
bool shell = false,
int exitcode = -1,
Dictionary<string, string>? env = null,
CancellationToken ct = default)
{
try
{
var psi = new ProcessStartInfo
{
FileName = command,
Arguments = shell ? null : (args ?? string.Empty),
UseShellExecute = false,
RedirectStandardInput = shell,
RedirectStandardOutput = true,
RedirectStandardError = true
};
if (env != null)
foreach (var kv in env)
psi.EnvironmentVariables[kv.Key] = kv.Value;
using var p = Process.Start(psi);
if (p == null)
throw new Exception($"Failed to start process: {command}");
if (shell && args != null)
await p.StandardInput.WriteLineAsync(args);
// Read output concurrently; avoid deadlocks.
var stdoutTask = p.StandardOutput.ReadToEndAsync();
var stderrTask = p.StandardError.ReadToEndAsync();
// Wait with cancellation.
while (!p.HasExited)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(100, ct);
}
var stdout = await stdoutTask;
var stderr = await stderrTask;
if (exitcode != -1 && p.ExitCode != exitcode)
throw new Exception($"Exit code was: {p.ExitCode}, stdout: {stdout}, stderr: {stderr}");
return stdout;
}
catch (Exception ex)
{
Duplicati.Library.Logging.Log.WriteErrorMessage(LOGTAG, "ShellExecError", ex, $"Error executing command '{command} {args}'");
throw;
}
}
}
public static class SynologyDsmAuthMiddlewareExtensions
{
/// <summary>
/// Add DSM-integrated auth. Protects /api,/login,/logout by default.
/// Env vars:
/// SYNO_LOGIN_CGI, SYNO_AUTHENTICATE_CGI, SYNO_ALL_USERS, SYNO_DSM_AUTH_ENABLED,
/// SYNO_TRUST_XREAL, SYNO_PROTECT_PREFIXES,
/// SYNO_USERNAME, SYNO_GROUP_IDS, SYNO_ADMIN_GID, SYNO_ADMIN_GROUP_NAME
/// </summary>
public static IApplicationBuilder UseSynologyDsmAuthIfEnabled(this IApplicationBuilder app)
{
var opt = new SynologyDsmAuthOptions();
if (!opt.Enabled)
return app;
Console.WriteLine("Enabling Synology DSM authentication middleware");
return app.UseMiddleware<SynologyDsmAuthMiddleware>(opt);
}
}