diff --git a/Duplicati/Library/RestAPI/Database/Backup.cs b/Duplicati/Library/RestAPI/Database/Backup.cs
index 791436cbe..0eb658ed6 100644
--- a/Duplicati/Library/RestAPI/Database/Backup.cs
+++ b/Duplicati/Library/RestAPI/Database/Backup.cs
@@ -166,7 +166,7 @@ namespace Duplicati.Server.Database
var prevSettings = previous.Settings.ToDictionary(x => x.Name, x => x.Value, StringComparer.OrdinalIgnoreCase);
foreach (var setting in this.Settings)
{
- if (setting.Value == Connection.PASSWORD_PLACEHOLDER)
+ if (Connection.IsPasswordPlaceholder(setting.Value))
{
if (prevSettings.TryGetValue(setting.Name, out var prevValue))
setting.Value = prevValue;
diff --git a/Duplicati/Library/RestAPI/Database/Connection.cs b/Duplicati/Library/RestAPI/Database/Connection.cs
index 11110241b..dee9642fa 100644
--- a/Duplicati/Library/RestAPI/Database/Connection.cs
+++ b/Duplicati/Library/RestAPI/Database/Connection.cs
@@ -33,6 +33,7 @@ using System.Data;
using Duplicati.Library.Main.Database;
using System.Globalization;
using Microsoft.Extensions.DependencyInjection;
+using System.Text.RegularExpressions;
#nullable enable
@@ -309,6 +310,29 @@ namespace Duplicati.Server.Database
}
}
+ ///
+ /// Checks if the given value is the password placeholder.
+ ///
+ /// The value to check.
+ /// true if the value is the password placeholder; otherwise false.
+ public static bool IsPasswordPlaceholder(string? value)
+ => string.IsNullOrWhiteSpace(value)
+ ? false
+ : value.Length > 3 && value.All(c => c == '*');
+
+ ///
+ /// Regular expression to match a URL password mask (3 or more asterisks or %2A)
+ ///
+ private static Regex UrlPasswordMaskRegex = new Regex(@"(\*|%2A){3,}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ ///
+ /// Checks if the given url contains a password placeholder.
+ ///
+ /// The url to check.
+ /// true if the value contains a password placeholder; otherwise false.
+ public static bool UrlContainsPasswordPlaceholder(string? url)
+ => !string.IsNullOrWhiteSpace(url) && UrlPasswordMaskRegex.IsMatch(url);
+
public ISetting[] GetSettings(long id)
{
lock (m_lock)
@@ -346,7 +370,7 @@ namespace Duplicati.Server.Database
cmd => cmd.SetCommandAndParameters(@"INSERT INTO ""Option"" (""BackupID"", ""Filter"", ""Name"", ""Value"") VALUES (@BackupId, @Filter, @Name, @Value)"),
(cmd, f) =>
{
- if (PASSWORD_PLACEHOLDER.Equals(f.Value))
+ if (IsPasswordPlaceholder(f.Value))
throw new Exception("Attempted to save a property with the placeholder password");
cmd.SetParameterValue("@BackupId", id)
@@ -543,10 +567,10 @@ namespace Duplicati.Server.Database
if (string.IsNullOrWhiteSpace(item.TargetURL))
return "Missing a target";
- if (item.TargetURL.Contains(PASSWORD_PLACEHOLDER))
- return "TargetURL contains password placeholder value";
- if (item.Settings != null && item.Settings.Any(x => PASSWORD_PLACEHOLDER.Equals(x.Value)))
- return "Settings contains password placeholder value";
+ if (UrlContainsPasswordPlaceholder(item.TargetURL))
+ return "TargetURL is password placeholder value";
+ if (item.Settings != null && item.Settings.Any(x => IsPasswordPlaceholder(x.Value)))
+ return "Settings is password placeholder value";
if (item.Sources == null || item.Sources.Any(x => string.IsNullOrWhiteSpace(x)) || item.Sources.Length == 0)
return "Invalid source list";
@@ -699,7 +723,7 @@ namespace Duplicati.Server.Database
},
(cmd, n) =>
{
- if (n.TargetURL.IndexOf(PASSWORD_PLACEHOLDER, StringComparison.Ordinal) >= 0)
+ if (UrlContainsPasswordPlaceholder(n.TargetURL))
throw new Exception("Attempted to save a backup with the password placeholder");
if (update && long.Parse(n.ID) <= 0)
throw new Exception("Invalid update, cannot update application settings through update method");
diff --git a/Duplicati/Library/RestAPI/Database/QuerystringMasking.cs b/Duplicati/Library/RestAPI/Database/QuerystringMasking.cs
index 00037210f..7bf5966d7 100644
--- a/Duplicati/Library/RestAPI/Database/QuerystringMasking.cs
+++ b/Duplicati/Library/RestAPI/Database/QuerystringMasking.cs
@@ -97,8 +97,8 @@ public static class QuerystringMasking
var newValues = GetValuesCaseInsensitive(newQuery, key);
if (newValues is null || newValues.Length == 0) continue;
- // If any value is the mask, replace the entire set for that key from the previous URL
- if (newValues.Any(v => string.Equals(v, Connection.PASSWORD_PLACEHOLDER, StringComparison.Ordinal)))
+ // If any value contains the mask, replace the entire set for that key from the previous URL
+ if (newValues.Any(v => Connection.IsPasswordPlaceholder(v)))
{
var prevValues = GetValuesCaseInsensitive(prevQuery, key);
if (prevValues is null || prevValues.Length == 0)
diff --git a/Duplicati/Server/webroot/ngax/scripts/directives/backupEditUri.js b/Duplicati/Server/webroot/ngax/scripts/directives/backupEditUri.js
index d1e35318d..21a1b68cb 100644
--- a/Duplicati/Server/webroot/ngax/scripts/directives/backupEditUri.js
+++ b/Duplicati/Server/webroot/ngax/scripts/directives/backupEditUri.js
@@ -3,10 +3,11 @@ backupApp.directive('backupEditUri', function(gettextCatalog) {
restrict: 'E',
scope: {
uri: '=uri',
+ backupId: '=backupId',
setBuilduriFn: '&'
},
templateUrl: 'templates/edituri.html',
- controller: function($scope, AppService, AppUtils, SystemInfo, EditUriBackendConfig, DialogService, EditUriBuiltins) {
+ controller: function($scope, AppService, AppUtils, SystemInfo, EditUriBackendConfig, DialogService) {
var scope = $scope;
scope.AppUtils = AppUtils;
@@ -41,7 +42,7 @@ backupApp.directive('backupEditUri', function(gettextCatalog) {
dlg.dismiss();
dlg = DialogService.dialog(gettextCatalog.getString('Testing …'), gettextCatalog.getString('Testing connection …'), [], null, function() {
- AppService.postJson('/remoteoperation/test', { path: uri }).then(function() {
+ AppService.postJson('/remoteoperation/test', { path: uri, backupId: scope.backupId }).then(function() {
scope.Testing = false;
dlg.dismiss();
@@ -202,6 +203,7 @@ backupApp.directive('backupEditUri', function(gettextCatalog) {
}
$scope.testConnection = function() {
+ console.log(scope);
builduri(performConnectionTest);
};
diff --git a/Duplicati/Server/webroot/ngax/templates/addoredit.html b/Duplicati/Server/webroot/ngax/templates/addoredit.html
index c626bfd4d..0965f7ee8 100644
--- a/Duplicati/Server/webroot/ngax/templates/addoredit.html
+++ b/Duplicati/Server/webroot/ngax/templates/addoredit.html
@@ -107,7 +107,7 @@
-
+
diff --git a/Duplicati/UnitTest/MaskingUnmaskingTest.cs b/Duplicati/UnitTest/MaskingUnmaskingTest.cs
index b56dd9b0a..ecbdd74bd 100644
--- a/Duplicati/UnitTest/MaskingUnmaskingTest.cs
+++ b/Duplicati/UnitTest/MaskingUnmaskingTest.cs
@@ -21,7 +21,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
-using System.Reflection;
using System.Runtime.Serialization;
using System.Web;
using Duplicati.Server.Database;
@@ -146,9 +145,15 @@ public class BackupConfigMaskingTests
}
[Test]
- public void ValidateBackupDetectsPlaceholder()
+ public void ValidateBackupDetectsPlaceholder([Values(0, 1, 2)] int type)
{
- var placeholder = Connection.PASSWORD_PLACEHOLDER;
+ var placeholder = type switch
+ {
+ 0 => Connection.PASSWORD_PLACEHOLDER,
+ 1 => "***",
+ 2 => "%2A%2a%2A",
+ _ => throw new ArgumentOutOfRangeException(nameof(type))
+ };
var backup = new Backup
{
diff --git a/Duplicati/WebserverCore/Dto/V2/DestinationTestRequestDto.cs b/Duplicati/WebserverCore/Dto/V2/DestinationTestRequestDto.cs
index 60fe93426..dbf4d2ff2 100644
--- a/Duplicati/WebserverCore/Dto/V2/DestinationTestRequestDto.cs
+++ b/Duplicati/WebserverCore/Dto/V2/DestinationTestRequestDto.cs
@@ -30,6 +30,11 @@ public sealed record DestinationTestRequestDto
///
public required string DestinationUrl { get; init; }
+ ///
+ /// The backup ID, if known
+ ///
+ public string? BackupId { get; init; }
+
///
/// Any additional options to pass to the destination
///
diff --git a/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs b/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs
index e8a4c32c7..6ef6735cd 100644
--- a/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs
+++ b/Duplicati/WebserverCore/Endpoints/V1/RemoteOperation.cs
@@ -32,7 +32,7 @@ namespace Duplicati.WebserverCore.Endpoints.V1
{
public class RemoteOperation : IEndpointV1
{
- private record RemoteOperationInput(string path);
+ private record RemoteOperationInput(string path, string? backupId);
public static void Map(RouteGroupBuilder group)
{
@@ -41,11 +41,11 @@ namespace Duplicati.WebserverCore.Endpoints.V1
.RequireAuthorization();
group.MapPost("/remoteoperation/test", ([FromServices] Connection connection, [FromServices] IApplicationSettings applicationSettings, [FromQuery] bool? autocreate, [FromBody] RemoteOperationInput input, CancellationToken cancelToken)
- => ExecuteTest(connection, applicationSettings, input.path, autocreate ?? false, cancelToken))
+ => ExecuteTest(connection, applicationSettings, input.path, input.backupId, autocreate ?? false, cancelToken))
.RequireAuthorization();
- group.MapPost("/remoteoperation/create", ([FromBody] RemoteOperationInput input, CancellationToken cancelToken)
- => ExecuteCreate(input.path, cancelToken))
+ group.MapPost("/remoteoperation/create", ([FromServices] Connection connection, [FromBody] RemoteOperationInput input, CancellationToken cancelToken)
+ => ExecuteCreate(connection, input.path, input.backupId, cancelToken))
.RequireAuthorization();
}
@@ -121,12 +121,26 @@ namespace Duplicati.WebserverCore.Endpoints.V1
}
}
- private static async Task ExecuteTest(Connection connection, IApplicationSettings applicationSettings, string url, bool autoCreate, CancellationToken cancelToken)
+ private static string UnmaskUrl(Connection connection, string maskedurl, string? backupId)
+ {
+ var previousUrl = !string.IsNullOrWhiteSpace(backupId) ? connection.GetBackup(backupId)?.TargetURL : null;
+ var unmasked = string.IsNullOrWhiteSpace(previousUrl)
+ ? maskedurl
+ : QuerystringMasking.Unmask(maskedurl, previousUrl);
+
+ if (Connection.UrlContainsPasswordPlaceholder(unmasked))
+ throw new ArgumentException("Unmasked URL contains password placeholder");
+
+ return unmasked;
+ }
+
+ private static async Task ExecuteTest(Connection connection, IApplicationSettings applicationSettings, string maskedurl, string? backupId, bool autoCreate, CancellationToken cancelToken)
{
TupleDisposeWrapper? wrapper = null;
try
{
+ var url = UnmaskUrl(connection, maskedurl, backupId);
wrapper = await GetBackend(connection, applicationSettings, url, cancelToken);
using (var b = wrapper.Backend)
@@ -181,11 +195,12 @@ namespace Duplicati.WebserverCore.Endpoints.V1
}
- private static async Task ExecuteCreate(string uri, CancellationToken cancelToken)
+ private static async Task ExecuteCreate(Connection connection, string maskedurl, string? backupId, CancellationToken cancelToken)
{
try
{
- using (var b = Duplicati.Library.DynamicLoader.BackendLoader.GetBackend(uri, new Dictionary()))
+ var url = UnmaskUrl(connection, maskedurl, backupId);
+ using (var b = Duplicati.Library.DynamicLoader.BackendLoader.GetBackend(url, new Dictionary()))
await b.CreateFolderAsync(cancelToken).ConfigureAwait(false);
}
catch (UserInformationException uex)
diff --git a/Duplicati/WebserverCore/Endpoints/V2/DestinationVerify.cs b/Duplicati/WebserverCore/Endpoints/V2/DestinationVerify.cs
index 8d041dceb..079be069b 100644
--- a/Duplicati/WebserverCore/Endpoints/V2/DestinationVerify.cs
+++ b/Duplicati/WebserverCore/Endpoints/V2/DestinationVerify.cs
@@ -108,6 +108,18 @@ public class DestinationVerify : IEndpointV2
}
}
+ private static string UnmaskUrl(Connection connection, string maskedurl, string? backupId)
+ {
+ var previousUrl = !string.IsNullOrWhiteSpace(backupId) ? connection.GetBackup(backupId)?.TargetURL : null;
+ var unmasked = string.IsNullOrWhiteSpace(previousUrl)
+ ? maskedurl
+ : QuerystringMasking.Unmask(maskedurl, previousUrl);
+
+ if (Connection.UrlContainsPasswordPlaceholder(unmasked))
+ throw new ArgumentException("Unmasked URL contains password placeholder");
+
+ return unmasked;
+ }
private static async Task ExecuteTest(Connection connection, IApplicationSettings applicationSettings, DestinationTestRequestDto input, CancellationToken cancelToken)
{
@@ -115,7 +127,8 @@ public class DestinationVerify : IEndpointV2
try
{
- wrapper = await GetBackend(connection, applicationSettings, input.DestinationUrl, cancelToken);
+ var url = UnmaskUrl(connection, input.DestinationUrl, input.BackupId);
+ wrapper = await GetBackend(connection, applicationSettings, url, cancelToken);
using (var b = wrapper.Backend)
{