Files
duplicati/Duplicati/UnitTest/MaskingUnmaskingTest.cs
T

341 lines
14 KiB
C#

// 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.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<string>(),
TargetURL = "http://example.com?authid=secret&foo=bar",
Sources = Array.Empty<string>(),
Settings = new ISetting[]
{
new Setting { Name = "passphrase", Value = "secretpass", Filter = string.Empty },
new Setting { Name = "nonsecret", Value = "keepme", Filter = string.Empty }
},
Filters = Array.Empty<IFilter>(),
Metadata = new Dictionary<string, string>()
};
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<IFilter>(),
Metadata = new Dictionary<string, string>(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<long, string>();
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));
}
/// <summary>
/// Helper to compare two URLs ignoring the order of query parameters
/// </summary>
/// <param name="url1">The first URL</param>
/// <param name="url2">The second URL</param>
/// <returns>True if the URLs are equal ignoring query parameter order</returns>
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<string>();
var v2 = q2.GetValues(key) ?? Array.Empty<string>();
// 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<string>(),
TargetURL = $"http://example.com?authid={placeholder}",
Sources = new[] { "/source" },
Settings = new ISetting[]
{
new Setting { Name = "passphrase", Value = placeholder, Filter = string.Empty }
},
Filters = Array.Empty<IFilter>(),
Metadata = new Dictionary<string, string>()
};
// 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<string>(),
TargetURL = "http://old.example.com?authid=old-secret&foo=bar",
ConnectionStringID = 1,
Sources = Array.Empty<string>(),
Settings = Array.Empty<ISetting>(),
Filters = Array.Empty<IFilter>(),
Metadata = new Dictionary<string, string>()
};
// 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<string>(),
TargetURL = $"http://new.example.com?authid={placeholder}&foo=bar",
ConnectionStringID = 2, // Changed from 1
Sources = Array.Empty<string>(),
Settings = Array.Empty<ISetting>(),
Filters = Array.Empty<IFilter>(),
Metadata = new Dictionary<string, string>()
};
// Connection strings dictionary - connection string ID 1 has the credentials
var connectionStrings = new Dictionary<long, string>
{
[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<string>(),
TargetURL = "http://old.example.com?authid=old-secret&foo=bar",
ConnectionStringID = 1,
Sources = Array.Empty<string>(),
Settings = Array.Empty<ISetting>(),
Filters = Array.Empty<IFilter>(),
Metadata = new Dictionary<string, string>()
};
// 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<string>(),
TargetURL = $"http://new.example.com?authid={placeholder}&foo=bar",
ConnectionStringID = 1, // Same as previous
Sources = Array.Empty<string>(),
Settings = Array.Empty<ISetting>(),
Filters = Array.Empty<IFilter>(),
Metadata = new Dictionary<string, string>()
};
// Connection strings dictionary - should NOT be used when connectionStringID is unchanged
var connectionStrings = new Dictionary<long, string>
{
[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<string>(),
TargetURL = "http://old.example.com?authid=old-secret&custom=value-from-prev",
ConnectionStringID = 1,
Sources = Array.Empty<string>(),
Settings = Array.Empty<ISetting>(),
Filters = Array.Empty<IFilter>(),
Metadata = new Dictionary<string, string>()
};
// 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<string>(),
// 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<string>(),
Settings = Array.Empty<ISetting>(),
Filters = Array.Empty<IFilter>(),
Metadata = new Dictionary<string, string>()
};
// Connection strings dictionary - has authid but NOT the custom param
var connectionStrings = new Dictionary<long, string>
{
[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));
}
}