This PR is a security hardning change and will break some setups. The hardning is done to ensure the datafolder is not compromised and particularly that it is not maliciously seeded. The changes now **requires** that the datafolder is locked down with exact permissions, or Duplicati will refuse to use it. This is a change to prior functionality where the folder would simply be locked down if it was not already locked. Previously, a file named `insecure-permissions.txt` could be placed in the folder to instruct Duplicati not to set permissions. With this change that file is no longer supported. The only way to opt-out of the permission check is to supply `--allow-insecure-datafolder` or the environment variable `DUPLICATI__ALLOW_INSECURE_DATAFOLDER`. Once either of those are set, Duplicati will no longer check permissions and accept folders with other permissions. The setting can be applied to `preload.json`, but the file needs to be in a trusted location, which is either the directory with the binaries or the file pointed to by `DUPLICATI_PRELOAD_SETTINGS`. The setting cannot be placed in a `preload.json` file that is in an insecure folder as the folder is not read if the permissions are not correct. The previous paths `/usr/local/share/Duplicati/preload.json` and `C:\ProgramData\Duplicati\preload.json` are no longer supported as they cannot be guaranteed to be locked down. A `preload.json` in the datafolder is still supported, provided the folder passes the permission check (or the `--allow-insecure-datafolder` is passed to the executable). For most users, this should not cause any problems as Duplicati has been locking down the folders, so they should be correctly locked down already. The configuretool has been updated with a `secure-datafolder` command that can be used to force the correct permissions on a folder, in case the folder did not have the correct permissions. For some Docker setups it may not be possible to set the permissions and the check will fail. These setup will need to apply `DUPLICATI__ALLOW_INSECURE_DATAFOLDER=true` in the image to run without the protections.
444 lines
19 KiB
C#
444 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.
|
|
|
|
#nullable enable
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using Duplicati.Library.Common.IO;
|
|
using Duplicati.Library.Interface;
|
|
|
|
namespace Duplicati.Library.AutoUpdater;
|
|
|
|
/// <summary>
|
|
/// Utility class for loading the preload settings
|
|
/// </summary>
|
|
public static class PreloadSettingsLoader
|
|
{
|
|
/// <summary>
|
|
/// The environment variable to specify the preload settings file
|
|
/// </summary>
|
|
private const string PreloadSettingsEnvVar = "DUPLICATI_PRELOAD_SETTINGS";
|
|
/// <summary>
|
|
/// The environment variable to enable debug output for preload settings
|
|
/// </summary>
|
|
private const string PreloadSettingsDebugEnvVar = "DUPLICATI_PRELOAD_SETTINGS_DEBUG";
|
|
/// <summary>
|
|
/// The marker for any executable
|
|
/// </summary>
|
|
private const string AnyExecutableMarker = "*";
|
|
|
|
/// <summary>
|
|
/// Cached value for toggling debug code
|
|
/// </summary>
|
|
private static readonly bool PreloadDebug = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(PreloadSettingsDebugEnvVar));
|
|
|
|
/// <summary>
|
|
/// The file name for the preload settings
|
|
/// </summary>
|
|
private const string FILE_NAME = "preload.json";
|
|
|
|
/// <summary>
|
|
/// The path for preload settings specified with an environment variable
|
|
/// </summary>
|
|
private static readonly string PreloadSettingsFile = Environment.GetEnvironmentVariable(PreloadSettingsEnvVar) ?? "";
|
|
|
|
/// <summary>
|
|
/// Paths that are suited for portable installations
|
|
/// </summary>
|
|
private static readonly string[] PortablePreloadPaths = new string[] {
|
|
|
|
// The path for preload settings with the install directory
|
|
Path.Combine(
|
|
Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) ?? "",
|
|
FILE_NAME
|
|
),
|
|
|
|
// The path for preload settings specified with an environment variable
|
|
PreloadSettingsFile,
|
|
}
|
|
.Where(x => !string.IsNullOrEmpty(x))
|
|
.Distinct()
|
|
.ToArray();
|
|
|
|
/// <summary>
|
|
/// The preload paths to search for settings in.
|
|
/// Each path is checked and applied to obtain the final settings.
|
|
/// Later paths take precedence over earlier ones, so the env variable is most specific.
|
|
/// These are statically loaded so the preload cannot change the settings after startup.
|
|
/// </summary>
|
|
private static readonly string[] PreloadPaths = new string[]
|
|
{
|
|
// User-context path for preload settings,
|
|
// the same default path as where other data is stored.
|
|
// This is the data folder, which is verified and locked down to the current user,
|
|
// so a preload file placed here cannot be tampered with by an untrusted user.
|
|
Path.Combine(DataFolderManager.GetDataFolder(DataFolderManager.AccessMode.ProbeOnly), FILE_NAME),
|
|
}
|
|
.Concat(PortablePreloadPaths)
|
|
.Where(x => !string.IsNullOrEmpty(x))
|
|
.Distinct()
|
|
.ToArray();
|
|
|
|
/// <summary>
|
|
/// Configures the preload settings for the given executable
|
|
/// </summary>
|
|
/// <param name="arguments">The source commandline arguments</param>
|
|
/// <param name="executable">The executable to match</param>
|
|
public static void ConfigurePreloadSettings(ref string[] arguments, PackageHelper.NamedExecutable executable)
|
|
=> ConfigurePreloadSettings(ref arguments, executable, out _);
|
|
|
|
/// <summary>
|
|
/// Configures the preload settings for the given executable
|
|
/// </summary>
|
|
/// <param name="arguments">The source commandline arguments</param>
|
|
/// <param name="executable">The executable to match</param>
|
|
/// <param name="dbsettings">The database settings</param>
|
|
public static void ConfigurePreloadSettings(ref string[] arguments, PackageHelper.NamedExecutable executable, out Dictionary<string, string?> dbsettings)
|
|
{
|
|
var (env, args, db) = GetExecutableMergedSettings(executable, DataFolderManager.PORTABLE_MODE);
|
|
|
|
dbsettings = db;
|
|
ApplyEnvironmentVariables(env);
|
|
ApplyCommandLineArguments(ref arguments, args);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the argument name from the given argument
|
|
/// </summary>
|
|
/// <param name="arg">The argument to get the name from</param>
|
|
/// <returns>The argument name</returns>
|
|
private static string GetArgumentName(string arg)
|
|
=> arg.Split('=', 2)[0];
|
|
|
|
|
|
/// <summary>
|
|
/// Gets the merged settings for the given executable
|
|
/// </summary>
|
|
/// <param name="executable">The executable to get settings for</param>
|
|
/// <param name="sourceargs">The source commandline arguments</param>
|
|
/// <param name="portableMode">The portable mode flag</param>
|
|
/// <returns>The merged settings</returns>
|
|
private static (Dictionary<string, string> env, List<string> args, Dictionary<string, string?> db) GetExecutableMergedSettings(PackageHelper.NamedExecutable executable, bool portableMode)
|
|
{
|
|
// Collect settings in generic and specific dictionaries
|
|
// The executable-specific settings take precedence over the generic ones,
|
|
// but the loading order is used so that the most specific file is loaded last
|
|
|
|
var env_generic = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
|
var env_specific = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
|
var args_generic = new List<string>();
|
|
var args_specific = new List<string>();
|
|
|
|
var db_generic = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
|
var db_specific = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
var exename = MapExecutableName(executable);
|
|
|
|
void MergeDicts(Dictionary<string, string?> target, Dictionary<string, string?>? source)
|
|
{
|
|
if (source != null)
|
|
foreach (var kvp in source)
|
|
target[kvp.Key] = kvp.Value;
|
|
}
|
|
|
|
var paths = PreloadPaths;
|
|
if (portableMode)
|
|
{
|
|
if (PreloadDebug)
|
|
Console.WriteLine("Portable mode detected, using portable preload paths only");
|
|
paths = PortablePreloadPaths;
|
|
}
|
|
|
|
// Portable preload paths are user-controlled (install directory or env var),
|
|
// so the security check is skipped for files in those locations.
|
|
var portablePathSet = new HashSet<string>(PortablePreloadPaths, StringComparer.OrdinalIgnoreCase);
|
|
|
|
var isExplicitOverride = false;
|
|
if (!string.IsNullOrWhiteSpace(PreloadSettingsFile) && PreloadSettingsFile.StartsWith("!"))
|
|
{
|
|
if (PreloadDebug)
|
|
Console.WriteLine($"Preload settings file specified with environment variable and exclamation mark, ignoring others");
|
|
paths = new[] { PreloadSettingsFile.Substring(1) };
|
|
isExplicitOverride = true;
|
|
}
|
|
|
|
foreach (var path in paths)
|
|
{
|
|
if (!Path.IsPathRooted(path))
|
|
{
|
|
if (PreloadDebug)
|
|
Console.WriteLine($"Preload settings path is not rooted, ignoring: {path}");
|
|
continue;
|
|
}
|
|
|
|
if (!File.Exists(path))
|
|
{
|
|
if (PreloadDebug)
|
|
Console.WriteLine($"Preload settings file does not exist, ignoring: {path}");
|
|
continue;
|
|
}
|
|
|
|
// Verify that preload files in shared locations have restricted permissions.
|
|
// The security check is skipped for:
|
|
// - Portable mode (all paths are user-controlled)
|
|
// - The "!" prefix explicit env var override
|
|
// - Files located alongside the binaries (install directory) or specified via env var,
|
|
// since those are user-controlled locations even when not in portable mode.
|
|
if (!portableMode && !isExplicitOverride && !portablePathSet.Contains(path))
|
|
VerifyPreloadFileSecurity(path);
|
|
|
|
var settings = LoadSettings(path);
|
|
|
|
if (settings == null)
|
|
continue;
|
|
|
|
if (settings.db != null)
|
|
{
|
|
if (settings.db.TryGetValue(AnyExecutableMarker, out var entry))
|
|
MergeDicts(db_generic, entry);
|
|
|
|
if (exename != AnyExecutableMarker && settings.db.TryGetValue(exename, out entry))
|
|
MergeDicts(db_specific, entry);
|
|
}
|
|
|
|
if (settings.env != null)
|
|
{
|
|
if (settings.env.TryGetValue(AnyExecutableMarker, out var entry))
|
|
MergeDicts(env_generic, entry);
|
|
|
|
if (exename != AnyExecutableMarker && settings.env.TryGetValue(exename, out entry))
|
|
MergeDicts(env_specific, entry);
|
|
}
|
|
|
|
if (settings.args != null)
|
|
{
|
|
if (settings.args.TryGetValue(AnyExecutableMarker, out var entry))
|
|
args_generic.AddRange(entry ?? []);
|
|
|
|
if (exename != AnyExecutableMarker && settings.args.TryGetValue(exename, out entry))
|
|
args_specific.AddRange(entry ?? []);
|
|
}
|
|
}
|
|
|
|
// Merge specific settings into generic ones
|
|
foreach (var kvp in db_specific)
|
|
db_generic[kvp.Key] = kvp.Value;
|
|
foreach (var kvp in env_specific)
|
|
env_generic[kvp.Key] = kvp.Value;
|
|
|
|
args_generic.AddRange(args_specific);
|
|
|
|
// Remove duplicates from the arguments, preserve order
|
|
var mapped = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
var args = new List<string>();
|
|
|
|
foreach (var value in args_generic)
|
|
{
|
|
var argname = GetArgumentName(value);
|
|
if (mapped.TryGetValue(argname, out var index))
|
|
args.RemoveAt(index);
|
|
|
|
mapped[argname] = args.Count;
|
|
args.Add(value);
|
|
}
|
|
|
|
var env = env_generic.ToDictionary(x => x.Key, x => x.Value ?? "");
|
|
return (env, args, db_generic);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies loaded environment variables, but does not overwrite existing ones
|
|
/// </summary>
|
|
/// <param name="env">The environment variables to apply</param>
|
|
private static void ApplyEnvironmentVariables(Dictionary<string, string> env)
|
|
{
|
|
var current = Environment.GetEnvironmentVariables();
|
|
foreach (var kvp in env)
|
|
if (!current.Contains(kvp.Key))
|
|
Environment.SetEnvironmentVariable(kvp.Key, kvp.Value ?? "");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies loaded commandline arguments, but does not overwrite existing ones
|
|
/// </summary>
|
|
/// <param name="arguments">The source commandline arguments</param>
|
|
/// <param name="args">The arguments to apply</param>
|
|
private static void ApplyCommandLineArguments(ref string[] arguments, List<string> args)
|
|
{
|
|
arguments ??= [];
|
|
var existing = new HashSet<string>(arguments.Select(GetArgumentName), StringComparer.OrdinalIgnoreCase);
|
|
var result = arguments.ToList();
|
|
|
|
foreach (var value in args)
|
|
if (!existing.Contains(GetArgumentName(value)))
|
|
result.Add(value);
|
|
|
|
arguments = result.ToArray();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads the settings from the given path
|
|
/// </summary>
|
|
/// <param name="path">The path to load settings from</param>
|
|
/// <returns>The loaded settings, or null if an error occurred</returns>
|
|
private static PreloadSettingsRoot? LoadSettings(string path)
|
|
{
|
|
try
|
|
{
|
|
var result = JsonSerializer.Deserialize<PreloadSettingsRoot>(File.ReadAllText(path));
|
|
if (PreloadDebug)
|
|
{
|
|
if (result == null)
|
|
{
|
|
if (PreloadDebug)
|
|
Console.WriteLine($"Loaded empty preload settings from {path}");
|
|
return null;
|
|
}
|
|
|
|
var jsData = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText(path));
|
|
var unmatched_keys = jsData.EnumerateObject().Select(x => x.Name).Except(["db", "env", "args"]);
|
|
if (PreloadDebug)
|
|
{
|
|
if (unmatched_keys.Any())
|
|
Console.WriteLine($"Unexpected key(s) in preload settings: {string.Join(", ", unmatched_keys)}");
|
|
|
|
Console.WriteLine($"Loaded preload settings from {path}");
|
|
}
|
|
|
|
var allowedSources = Enum.GetValues<PackageHelper.NamedExecutable>()
|
|
.Select(MapExecutableName)
|
|
.Append(AnyExecutableMarker)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
var unmatched_db = result.db?.Keys.Where(x => !allowedSources.Contains(x)) ?? [];
|
|
var unmatched_env = result.env?.Keys.Where(x => !allowedSources.Contains(x)) ?? [];
|
|
var unmatched_args = result.args?.Keys.Where(x => !allowedSources.Contains(x)) ?? [];
|
|
|
|
if (PreloadDebug)
|
|
{
|
|
if (unmatched_db.Any())
|
|
Console.WriteLine($"Found unknown executable name(s) in db preload settings: {string.Join(", ", unmatched_db)}");
|
|
if (unmatched_env.Any())
|
|
Console.WriteLine($"Found unknown executable name(s) in env preload settings: {string.Join(", ", unmatched_env)}");
|
|
if (unmatched_args.Any())
|
|
Console.WriteLine($"Found unknown executable name(s) in args preload settings: {string.Join(", ", unmatched_args)}");
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Logging is usually not set up at this point
|
|
if (PreloadDebug)
|
|
Console.WriteLine($"Failed to load preload settings from {path}: {ex}");
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that the parent directory of a preload settings file in a shared location
|
|
/// has restricted permissions unless the user has
|
|
/// opted in with <c>--allow-insecure-datafolder</c>.
|
|
/// </summary>
|
|
/// <param name="path">The path to the preload settings file.</param>
|
|
private static void VerifyPreloadFileSecurity(string path)
|
|
{
|
|
try
|
|
{
|
|
// The parent directory is the trust boundary: if an attacker can write to the
|
|
// directory, they can create or replace the file regardless of the file's own ACL.
|
|
var dir = Path.GetDirectoryName(path);
|
|
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
|
|
return;
|
|
|
|
if (SystemIO.IO_OS.DirectoryHasPermissionUserRWOnly(dir, out var dirDetail))
|
|
return;
|
|
|
|
if (!Util.AllowInsecureDataFolder())
|
|
throw new UserInformationException(
|
|
$"The directory '{dir}' holding the preload settings file '{path}' is not securely protected ({dirDetail}). " +
|
|
$"A preload file can inject environment variables, command-line arguments, and database settings into the application, " +
|
|
$"so allowing untrusted write access to this directory is a security risk. " +
|
|
$"Restrict the permissions on the directory to the current user, SYSTEM and Administrators only, " +
|
|
$"or run with --{Util.AllowInsecureDatafolderOption} to bypass this check if you understand the risks.",
|
|
"InsecurePreloadFilePermissions");
|
|
|
|
if (PreloadDebug)
|
|
Console.WriteLine($"Preload settings directory '{dir}' is not securely protected ({dirDetail}), but --allow-insecure-datafolder is set, continuing.");
|
|
}
|
|
catch (UserInformationException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// If we cannot read the permissions, log a warning but do not block startup,
|
|
// as the directory may be on a filesystem that does not support ACLs (e.g. FAT32).
|
|
if (PreloadDebug)
|
|
Console.WriteLine($"Failed to verify preload directory permissions for '{path}': {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps the executable name to the preload settings key
|
|
/// </summary>
|
|
/// <param name="exe">The executable to map</param>
|
|
/// <returns>The mapped name</returns>
|
|
private static string MapExecutableName(PackageHelper.NamedExecutable exe)
|
|
=> exe switch
|
|
{
|
|
PackageHelper.NamedExecutable.TrayIcon => "tray",
|
|
PackageHelper.NamedExecutable.CommandLine => "cli",
|
|
PackageHelper.NamedExecutable.AutoUpdater => "autoupdater",
|
|
PackageHelper.NamedExecutable.Server => "server",
|
|
PackageHelper.NamedExecutable.WindowsService => "winservice",
|
|
PackageHelper.NamedExecutable.BackendTool => "backendtool",
|
|
PackageHelper.NamedExecutable.RecoveryTool => "recoverytool",
|
|
PackageHelper.NamedExecutable.BackendTester => "backendtester",
|
|
PackageHelper.NamedExecutable.SharpAESCrypt => "aescrypt",
|
|
PackageHelper.NamedExecutable.Snapshots => "snapshots",
|
|
PackageHelper.NamedExecutable.ServerUtil => "serverutil",
|
|
PackageHelper.NamedExecutable.Service => "service",
|
|
PackageHelper.NamedExecutable.Agent => "agent",
|
|
PackageHelper.NamedExecutable.ConfigureTool => "configuretool",
|
|
_ => AnyExecutableMarker,
|
|
};
|
|
|
|
/// <summary>
|
|
/// JSON root object for preload settings
|
|
/// </summary>
|
|
/// <param name="db">The database settings</param>
|
|
/// <param name="env">The environment variables</param>
|
|
/// <param name="args">The executable settings</param>
|
|
private sealed record PreloadSettingsRoot(
|
|
Dictionary<string, Dictionary<string, string?>>? db,
|
|
Dictionary<string, Dictionary<string, string?>>? env,
|
|
Dictionary<string, List<string>>? args
|
|
);
|
|
|
|
}
|