Files
duplicati/Duplicati/Library/SQLiteHelper/SQLiteLoader.cs
T
Kenneth Skovhede a7ef8e42f2 Lock permissions on datafolder
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.
2026-07-10 15:03:32 +02:00

315 lines
16 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.Globalization;
using System.Threading.Tasks;
using Duplicati.Library.Common.IO;
using Duplicati.Library.Interface;
using Duplicati.Library.Utility;
namespace Duplicati.Library.SQLiteHelper
{
/// <summary>
/// Provides methods to load and manage SQLite connections, including handling encrypted databases.
/// </summary>
public static class SQLiteLoader
{
/// <summary>
/// The tag used for logging.
/// </summary>
private static readonly string LOGTAG = Logging.Log.LogTagFromType(typeof(SQLiteLoader));
/// <summary>
/// Helper method with logic to handle opening a database in possibly encrypted format.
/// </summary>
/// <param name="con">The SQLite connection object.</param>
/// <param name="databasePath">The location of Duplicati's database.</param>
/// <param name="decryptionPassword">The password to use for decryption.</param>
/// <returns>A task that completes when the database is opened.</returns>
/// <exception cref="UserInformationException">Thrown if the database cannot be opened or decrypted.</exception>
public static async Task OpenDatabaseAsync(Microsoft.Data.Sqlite.SqliteConnection con, string databasePath, string? decryptionPassword)
{
if (!string.IsNullOrWhiteSpace(decryptionPassword) && SQLiteRC4Decrypter.IsDatabaseEncrypted(databasePath))
{
Logging.Log.WriteWarningMessage(LOGTAG, "SQLiteRC4Decrypter", null, "Database is encrypted, attempting to decrypt...");
try
{
SQLiteRC4Decrypter.DecryptSQLiteFile(databasePath, decryptionPassword);
Logging.Log.WriteInformationMessage(LOGTAG, "SQLiteRC4Decrypter", "Database decrypted successfully.");
}
catch (Exception ex)
{
Logging.Log.WriteErrorMessage(LOGTAG, "SQLiteRC4Decrypter", ex, "Failed to decrypt database");
throw new UserInformationException($"The database appears to be encrypted, but the decrypting failed. Please check the password. Error message: {ex.Message}", "RC4DecryptionFailed", ex);
}
}
try
{
//Attempt to open in preferred state
await OpenSQLiteFileAsync(con, databasePath)
.ConfigureAwait(false);
await TestSQLiteFileAsync(con).ConfigureAwait(false);
}
catch
{
try { await con.DisposeAsync().ConfigureAwait(false); }
catch { }
throw;
}
if (con.State != System.Data.ConnectionState.Open)
throw new UserInformationException("Failed to open database for unknown reason, check the logs to see error messages", "DatabaseOpenFailed");
}
/// <summary>
/// Loads an SQLite connection instance and opening the database.
/// </summary>
/// <returns>The SQLite connection instance.</returns>
/// <remarks>
/// This method is synchronous and should be used when you need to load the connection immediately. It calls the asynchronous version and waits for it to complete.
/// </remarks>
public static Microsoft.Data.Sqlite.SqliteConnection LoadConnection()
{
return LoadConnectionAsync().Await();
}
/// <summary>
/// Loads an SQLite connection instance and opening the database.
/// </summary>
/// <returns>A task that when awaited returns the SQLite connection instance.</returns>
public static async Task<Microsoft.Data.Sqlite.SqliteConnection> LoadConnectionAsync()
{
Microsoft.Data.Sqlite.SqliteConnection? con = null;
SetEnvironmentVariablesForSQLiteTempDir();
try
{
con = new Microsoft.Data.Sqlite.SqliteConnection();
}
catch (Exception ex)
{
Logging.Log.WriteErrorMessage(LOGTAG, "FailedToLoadConnectionSQLite", ex, "Failed to load connection.");
await DisposeConnectionAsync(con).ConfigureAwait(false);
throw;
}
return con ?? throw new InvalidOperationException("Failed to load connection");
}
/// <summary>
/// Applies user-supplied custom pragmas to the SQLite connection.
/// </summary>
/// <param name="con">The connection to apply the pragmas to.</param>
/// <returns>A task that when awaited returns the connection with the pragmas applied.</returns>
public static async Task<Microsoft.Data.Sqlite.SqliteConnection> ApplyCustomPragmasAsync(Microsoft.Data.Sqlite.SqliteConnection con)
{
Dictionary<string, string> customOptions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "synchronous", "NORMAL" }, // NORMAL is more performant than FULL (the default), but less safe, as it no longer blocks until the disk sync syscall to the OS has completed.
{ "temp_store", "MEMORY" }, // Use memory for temporary storage to improve performance.
{ "journal_mode", "WAL" }, // Use Write-Ahead Logging for better concurrency and performance.
{ "cache_size", "-65536" }, // Set cache size to 64 MB (negative value means in KB, so -64000 = 64 MB). Default is 2000 pages, which is 2 MB (2000 * 1024 bytes).
{ "mmap_size", "67108864" }, // 64 MB.
{ "threads", "8" }, // Use 8 threads for parallel processing where applicable.
{ "shared_cache", "true" } //
};
// Override the default options with any custom options set in the environment variable.
var customOptionsEnv = Environment.GetEnvironmentVariable("CUSTOMSQLITEOPTIONS_DUPLICATI") ?? string.Empty;
if (!string.IsNullOrWhiteSpace(customOptionsEnv))
{
foreach (var opt in customOptionsEnv.Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
{
var parts = opt.Split(new[] { '=' }, 2);
if (parts.Length == 2)
customOptions[parts[0].Trim()] = parts[1].Trim();
}
}
using (var cmd = con.CreateCommand())
{
foreach (var (key, value) in customOptions)
{
var opt = $"{key}={value}";
Logging.Log.WriteVerboseMessage(LOGTAG, "CustomSQLiteOption", @"Setting custom SQLite option '{0}'.", opt);
try
{
cmd.CommandText = string.Format(CultureInfo.InvariantCulture, "PRAGMA {0}", opt);
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logging.Log.WriteWarningMessage(LOGTAG, "CustomSQLiteOption", ex, @"Error setting custom SQLite option '{0}'.", opt);
}
}
}
return con;
}
/// <summary>
/// Loads an SQLite connection instance and opening the database with a specified page cache size.
/// </summary>
/// <param name="targetpath">The optional path to the database.</param>
/// <returns>The SQLite connection instance.</returns>
/// <remarks>
/// This method is synchronous and should be used when you need to load the connection immediately. It calls the asynchronous version and waits for it to complete.
/// </remarks>
public static Microsoft.Data.Sqlite.SqliteConnection LoadConnection(string targetpath)
{
return LoadConnectionAsync(targetpath).Await();
}
/// <summary>
/// Loads an SQLite connection instance and opening the database.
/// </summary>
/// <param name="targetpath">The optional path to the database.</param>
/// <returns>A task that when waited returns the SQLite connection instance.</returns>
public static async Task<Microsoft.Data.Sqlite.SqliteConnection> LoadConnectionAsync(string targetpath)
{
if (string.IsNullOrWhiteSpace(targetpath))
throw new ArgumentNullException(nameof(targetpath));
var con = await LoadConnectionAsync().ConfigureAwait(false);
try
{
await OpenSQLiteFileAsync(con, targetpath).ConfigureAwait(false);
}
catch (Exception ex)
{
Logging.Log.WriteErrorMessage(LOGTAG, "FailedToLoadConnectionSQLite", ex, @"Failed to load connection with path '{0}'.", targetpath);
await DisposeConnectionAsync(con).ConfigureAwait(false);
throw;
}
// set custom Sqlite options
return await ApplyCustomPragmasAsync(con)
.ConfigureAwait(false);
}
/// <summary>
/// Returns the SQLiteCommand type for the current architecture.
/// </summary>
public static Type SQLiteConnectionType
{
get
{
return typeof(Microsoft.Data.Sqlite.SqliteConnection);
}
}
/// <summary>
/// Returns the version string from the SQLite type.
/// </summary>
public static string? SQLiteVersion
{
get
{
var versionString = SQLiteConnectionType.GetProperty("SQLiteVersion")?.GetValue(null, null) as string;
if (string.IsNullOrWhiteSpace(versionString))
{
// Support for Microsoft.Data.SQLite
// NOTE: Has an issue with ? as position parameters
var inst = Activator.CreateInstance(SQLiteConnectionType);
versionString = SQLiteConnectionType.GetProperty("ServerVersion")?.GetValue(inst, null) as string;
}
return versionString;
}
}
/// <summary>
/// Set environment variables to be used by SQLite to determine which folder to use for temporary files.
/// From SQLite's documentation, SQLITE_TMPDIR is used for unix-like systems.
/// For Windows, TMP and TEMP environment variables are used.
/// </summary>
private static void SetEnvironmentVariablesForSQLiteTempDir()
{
// Allow the user to override the temp folder for SQLite
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SQLITE_TMPDIR")))
Environment.SetEnvironmentVariable("SQLITE_TMPDIR", Utility.TempFolder.SystemTempPath);
Environment.SetEnvironmentVariable("TMPDIR", Utility.TempFolder.SystemTempPath);
Environment.SetEnvironmentVariable("TMP", Utility.TempFolder.SystemTempPath);
Environment.SetEnvironmentVariable("TEMP", Utility.TempFolder.SystemTempPath);
}
/// <summary>
/// Wrapper to dispose the SQLite connection.
/// </summary>
/// <param name="con">The connection to close.</param>
/// <returns>A task that completes when the connection is disposed.</returns>
private static async Task DisposeConnectionAsync(Microsoft.Data.Sqlite.SqliteConnection? con)
{
if (con != null)
try { await con.DisposeAsync().ConfigureAwait(false); }
catch (Exception ex) { Logging.Log.WriteExplicitMessage(LOGTAG, "ConnectionDisposeError", ex, "Failed to dispose connection"); }
}
/// <summary>
/// Opens the SQLite file in the given connection, creating the file if required.
/// </summary>
/// <param name="con">The connection to use.</param>
/// <param name="path">Path to the file to open, which may not exist.</param>
/// <returns>A task that completes when the file is opened.</returns>
private static async Task OpenSQLiteFileAsync(Microsoft.Data.Sqlite.SqliteConnection con, string path)
{
// Capture whether the file already existed BEFORE opening (which creates it),
// so we can distinguish a database we create from a pre-existing one.
var fileExistedBefore = System.IO.File.Exists(path);
con.ConnectionString = $"Data Source={path};Pooling=false";
await con.OpenAsync().ConfigureAwait(false);
// A database file we create is always locked down. For a pre-existing file we skip
// the lockdown when the operator has opted in to an insecure data folder, mirroring
// the data-folder behavior (a deliberately shared folder must not have its contents
// forced closed). On filesystems that do not support restrictive permissions
// (e.g. FAT32) this throws and is logged as a warning rather than failing. The
// containing data folder is separately verified and locked down (see
// DataFolderManager), so this is defense-in-depth on the file itself.
if (fileExistedBefore && Util.AllowInsecureDataFolder())
return;
try { SystemIO.IO_OS.FileSetPermissionUserRWOnly(path); }
catch (Exception ex) { Logging.Log.WriteWarningMessage(LOGTAG, "SQLiteFilePermissionError", ex, "Failed to set permissions on SQLite file '{0}'", path); }
}
/// <summary>
/// Tests the SQLite connection, throwing an exception if the connection does not work.
/// </summary>
/// <param name="con">The connection to test.</param>
/// <returns>A task that completes when the test query is executed.</returns>
private static async Task TestSQLiteFileAsync(Microsoft.Data.Sqlite.SqliteConnection con)
{
// Do a dummy query to make sure we have a working db
using var cmd = con.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM SQLITE_MASTER";
await cmd.ExecuteScalarAsync().ConfigureAwait(false);
}
}
}