// 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; using System.Collections.Generic; using System.Linq; using System.Runtime.Serialization; using System.Web; using Duplicati.Server.Database; using Duplicati.Server.Serialization.Interface; using NUnit.Framework; using Assert = NUnit.Framework.Legacy.ClassicAssert; namespace Duplicati.UnitTest; public class BackupConfigMaskingTests { [Test] public void MaskAndUnmaskBackupConfigRoundtrip() { var placeholder = Connection.PASSWORD_PLACEHOLDER; var original = new Backup { ID = "1", Name = "Test", Description = string.Empty, Tags = Array.Empty(), TargetURL = "http://example.com?authid=secret&foo=bar", Sources = Array.Empty(), Settings = new ISetting[] { new Setting { Name = "passphrase", Value = "secretpass", Filter = string.Empty }, new Setting { Name = "nonsecret", Value = "keepme", Filter = string.Empty } }, Filters = Array.Empty(), Metadata = new Dictionary() }; var backupConfig = new Backup { ID = original.ID, Name = original.Name, Description = original.Description, Tags = original.Tags.ToArray(), TargetURL = original.TargetURL, Sources = original.Sources.ToArray(), Settings = original.Settings.Select(s => new Setting { Name = s.Name, Value = s.Value, Filter = s.Filter }).ToArray(), Filters = Array.Empty(), Metadata = new Dictionary(original.Metadata) }; backupConfig.MaskSensitiveInformation(); Assert.That(backupConfig.TargetURL, Does.Not.Contain("secret")); Assert.That(backupConfig.TargetURL, Does.Contain(placeholder)); Assert.That(backupConfig.Settings.Single(s => s.Name == "passphrase").Value, Is.EqualTo(placeholder)); Assert.That(backupConfig.Settings.Single(s => s.Name == "nonsecret").Value, Is.EqualTo("keepme")); var connectionStrings = new Dictionary(); backupConfig.UnmaskSensitiveInformation(original, connectionStrings); Assert.That(EqualsIgnoreQueryOrder(backupConfig.TargetURL, original.TargetURL)); Assert.That(backupConfig.Settings.Single(s => s.Name == "passphrase").Value, Is.EqualTo("secretpass")); } [Test] public void QuerystringMaskingRoundtrip() { var placeholder = Connection.PASSWORD_PLACEHOLDER; var protectedNames = new[] { "authid" }.ToHashSet(StringComparer.OrdinalIgnoreCase); var original = "http://example.com?authid=secret&foo=bar"; var masked = Server.QuerystringMasking.Mask(original, protectedNames); Assert.That(masked, Does.Not.Contain("secret")); Assert.That(masked, Does.Contain(placeholder)); var restored = Server.QuerystringMasking.Unmask(masked, original); Assert.That(EqualsIgnoreQueryOrder(restored, original)); } /// /// Helper to compare two URLs ignoring the order of query parameters /// /// The first URL /// The second URL /// True if the URLs are equal ignoring query parameter order public static bool EqualsIgnoreQueryOrder(string url1, string url2) { if (string.IsNullOrEmpty(url1) || string.IsNullOrEmpty(url2)) return string.Equals(url1, url2, StringComparison.Ordinal); var uri1 = new Uri(url1, UriKind.RelativeOrAbsolute); var uri2 = new Uri(url2, UriKind.RelativeOrAbsolute); // Compare everything except query string if (!string.Equals(uri1.Scheme, uri2.Scheme, StringComparison.OrdinalIgnoreCase)) return false; if (!string.Equals(uri1.Host, uri2.Host, StringComparison.OrdinalIgnoreCase)) return false; if (uri1.Port != uri2.Port) return false; if (!string.Equals(uri1.AbsolutePath, uri2.AbsolutePath, StringComparison.Ordinal)) return false; if (!string.Equals(uri1.Fragment, uri2.Fragment, StringComparison.Ordinal)) return false; if (!string.Equals(uri1.UserInfo, uri2.UserInfo, StringComparison.Ordinal)) return false; // Normalize queries var q1 = HttpUtility.ParseQueryString(uri1.Query ?? ""); var q2 = HttpUtility.ParseQueryString(uri2.Query ?? ""); if (q1.Count != q2.Count) return false; foreach (var key in q1.AllKeys) { if (key == null) continue; var v1 = q1.GetValues(key) ?? Array.Empty(); var v2 = q2.GetValues(key) ?? Array.Empty(); // Must match value counts if (v1.Length != v2.Length) return false; // Order-insensitive value comparison var set1 = v1.OrderBy(x => x, StringComparer.Ordinal).ToArray(); var set2 = v2.OrderBy(x => x, StringComparer.Ordinal).ToArray(); if (!set1.SequenceEqual(set2, StringComparer.Ordinal)) return false; } return true; } [Test] public void ValidateBackupDetectsPlaceholder([Values(0, 1, 2)] int type) { var placeholder = type switch { 0 => Connection.PASSWORD_PLACEHOLDER, 1 => "***", 2 => "%2A%2a%2A", _ => throw new ArgumentOutOfRangeException(nameof(type)) }; var backup = new Backup { ID = "1", Name = "Test", Description = string.Empty, Tags = Array.Empty(), TargetURL = $"http://example.com?authid={placeholder}", Sources = new[] { "/source" }, Settings = new ISetting[] { new Setting { Name = "passphrase", Value = placeholder, Filter = string.Empty } }, Filters = Array.Empty(), Metadata = new Dictionary() }; // Suppress as we are in test code #pragma warning disable SYSLIB0050 // Type or member is obsolete var conn = (Connection)FormatterServices.GetUninitializedObject(typeof(Connection)); #pragma warning restore SYSLIB0050 // Type or member is obsolete var err = conn.ValidateBackup(backup, null); if (string.IsNullOrEmpty(err)) Assert.Ignore("ValidateBackup does not detect placeholder values"); Assert.That(err, Is.Not.Null.And.Not.Empty); } [Test] public void Unmask_WithConnectionStringIdChanged_UsesConnectionStringForUnmasking() { var placeholder = Connection.PASSWORD_PLACEHOLDER; // Previous backup has connection string ID 1 var previous = new Backup { ID = "1", Name = "Test", Description = string.Empty, Tags = Array.Empty(), TargetURL = "http://old.example.com?authid=old-secret&foo=bar", ConnectionStringID = 1, Sources = Array.Empty(), Settings = Array.Empty(), Filters = Array.Empty(), Metadata = new Dictionary() }; // New backup has different connection string ID 2 (changed) // The masked URL should be unmasked using the connection string, not the previous URL var newBackup = new Backup { ID = "1", Name = "Test", Description = string.Empty, Tags = Array.Empty(), TargetURL = $"http://new.example.com?authid={placeholder}&foo=bar", ConnectionStringID = 2, // Changed from 1 Sources = Array.Empty(), Settings = Array.Empty(), Filters = Array.Empty(), Metadata = new Dictionary() }; // Connection strings dictionary - connection string ID 1 has the credentials var connectionStrings = new Dictionary { [1] = "http://connection-string.example.com?authid=connection-secret&foo=bar" }; newBackup.UnmaskSensitiveInformation(previous, connectionStrings); // Should use the connection string value, not the previous URL value Assert.That(newBackup.TargetURL, Does.Contain("authid=connection-secret")); Assert.That(newBackup.TargetURL, Does.Not.Contain("old-secret")); Assert.That(newBackup.TargetURL, Does.Not.Contain(placeholder)); } [Test] public void Unmask_WithConnectionStringIdUnchanged_UsesPreviousUrlForUnmasking() { var placeholder = Connection.PASSWORD_PLACEHOLDER; // Previous backup has connection string ID 1 var previous = new Backup { ID = "1", Name = "Test", Description = string.Empty, Tags = Array.Empty(), TargetURL = "http://old.example.com?authid=old-secret&foo=bar", ConnectionStringID = 1, Sources = Array.Empty(), Settings = Array.Empty(), Filters = Array.Empty(), Metadata = new Dictionary() }; // New backup has same connection string ID (unchanged) // The masked URL should be unmasked using the previous URL var newBackup = new Backup { ID = "1", Name = "Test", Description = string.Empty, Tags = Array.Empty(), TargetURL = $"http://new.example.com?authid={placeholder}&foo=bar", ConnectionStringID = 1, // Same as previous Sources = Array.Empty(), Settings = Array.Empty(), Filters = Array.Empty(), Metadata = new Dictionary() }; // Connection strings dictionary - should NOT be used when connectionStringID is unchanged var connectionStrings = new Dictionary { [1] = "http://connection-string.example.com?authid=connection-secret&foo=bar" }; newBackup.UnmaskSensitiveInformation(previous, connectionStrings); // Should use the previous URL value, not the connection string value Assert.That(newBackup.TargetURL, Does.Contain("authid=old-secret")); Assert.That(newBackup.TargetURL, Does.Not.Contain("connection-secret")); Assert.That(newBackup.TargetURL, Does.Not.Contain(placeholder)); } [Test] public void Unmask_WithConnectionStringIdChangedButValueInPreviousUrl_CopiesFromPreviousUrl() { var placeholder = Connection.PASSWORD_PLACEHOLDER; // Previous backup has connection string ID 1 with authid in the URL var previous = new Backup { ID = "1", Name = "Test", Description = string.Empty, Tags = Array.Empty(), TargetURL = "http://old.example.com?authid=old-secret&custom=value-from-prev", ConnectionStringID = 1, Sources = Array.Empty(), Settings = Array.Empty(), Filters = Array.Empty(), Metadata = new Dictionary() }; // New backup has different connection string ID 2 (changed) // The masked URL has authid (should come from connection string) // AND a custom param that only exists in previous URL var newBackup = new Backup { ID = "1", Name = "Test", Description = string.Empty, Tags = Array.Empty(), // Note: custom param is masked but doesn't exist in connection string TargetURL = $"http://new.example.com?authid={placeholder}&custom={placeholder}", ConnectionStringID = 2, // Changed from 1 Sources = Array.Empty(), Settings = Array.Empty(), Filters = Array.Empty(), Metadata = new Dictionary() }; // Connection strings dictionary - has authid but NOT the custom param var connectionStrings = new Dictionary { [1] = "http://connection-string.example.com?authid=connection-secret" }; newBackup.UnmaskSensitiveInformation(previous, connectionStrings); // authid should come from connection string Assert.That(newBackup.TargetURL, Does.Contain("authid=connection-secret")); // custom should fall back to previous URL since it's not in connection string Assert.That(newBackup.TargetURL, Does.Contain("custom=value-from-prev")); Assert.That(newBackup.TargetURL, Does.Not.Contain(placeholder)); } }