From 8ac48f0e8ee760380ae35693ea0f00a1d0dd5627 Mon Sep 17 00:00:00 2001 From: Kenneth Skovhede Date: Fri, 5 Sep 2025 10:39:51 +0200 Subject: [PATCH] Added tests for masking --- Duplicati/UnitTest/MaskingUnmaskingTest.cs | 178 +++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 Duplicati/UnitTest/MaskingUnmaskingTest.cs diff --git a/Duplicati/UnitTest/MaskingUnmaskingTest.cs b/Duplicati/UnitTest/MaskingUnmaskingTest.cs new file mode 100644 index 000000000..d0ed433d7 --- /dev/null +++ b/Duplicati/UnitTest/MaskingUnmaskingTest.cs @@ -0,0 +1,178 @@ +// Copyright (C) 2025, 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.Reflection; +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")); + + backupConfig.UnmaskSensitiveInformation(original); + + 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() + { + var placeholder = Connection.PASSWORD_PLACEHOLDER; + + 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() + }; + + var conn = (Connection)FormatterServices.GetUninitializedObject(typeof(Connection)); + 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); + } +} +