undf: assign 928-930; btcpayserver CWE-407 scan (3 defects)

btcpayserver-0001: WalletTransactionInfo.Merge Attachments.Any O(A*B) MEDIUM
btcpayserver-0002: AppService gap-fill series.All O(D*S) LOW-MEDIUM
btcpayserver-0003: StringExtensions.IsValidFileName GetInvalidFileNameChars O(F*I) MEDIUM
This commit is contained in:
russell@unturf.com 2026-03-31 09:39:59 -04:00
parent f92dc0f8aa
commit c2a1fc15cc
7 changed files with 378 additions and 1 deletions

View file

@ -925,5 +925,12 @@
"electrum-0002-0002": "UNDF-2026-000000924",
"cake_wallet-0002-0002": "UNDF-2026-000000925",
"cake_wallet-0003-0003": "UNDF-2026-000000926",
"cake_wallet-0004-0004": "UNDF-2026-000000927"
"cake_wallet-0004-0004": "UNDF-2026-000000927",
"btcpayserver-0001-0001": "UNDF-2026-000000928",
"btcpayserver-0002-0002": "UNDF-2026-000000929",
"btcpayserver-0003-0003": "UNDF-2026-000000930",
"sparrow-0001-0001": "UNDF-2026-000000931",
"sparrow-0002-0002": "UNDF-2026-000000932",
"trezor-0001-0001": "UNDF-2026-000000933",
"trezor-0002-0002": "UNDF-2026-000000934"
}

View file

@ -0,0 +1,24 @@
# UNDF: UNDF-2026-000000928
--- a/BTCPayServer/Data/WalletTransactionInfo.cs
+++ b/BTCPayServer/Data/WalletTransactionInfo.cs
@@ -99,10 +99,14 @@
result.LabelColors = new Dictionary<string, string>(LabelColors);
result.Attachments = new List<Attachment>(Attachments);
+
+ // CWE-407: Attachments.Any() inside Where() is O(A*B) where A = value.Attachments
+ // and B = this.Attachments. Build a HashSet of existing (Id, Type) pairs for O(1) lookup.
+ var existingAttachments = new HashSet<(string Id, string Type)>(
+ Attachments.Select(a => (a.Id, a.Type)));
+
foreach (var valueLabelColor in value.LabelColors)
{
result.LabelColors.TryAdd(valueLabelColor.Key, valueLabelColor.Value);
}
- foreach (var valueAttachment in value.Attachments.Where(valueAttachment => !Attachments.Any(attachment =>
- attachment.Id == valueAttachment.Id && attachment.Type == valueAttachment.Type)))
+ foreach (var valueAttachment in value.Attachments.Where(valueAttachment =>
+ !existingAttachments.Contains((valueAttachment.Id, valueAttachment.Type))))
{
result.Attachments.Add(valueAttachment);
}

View file

@ -0,0 +1,100 @@
// Unit test for btcpayserver-0001: WalletTransactionInfo.Merge O(A*B) attachment dedup
// Demonstrates quadratic vs linear behavior when merging wallet transaction attachments.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace BTCPayServer.Tests.CWE407
{
// Minimal Attachment stand-in
public class Attachment
{
public string Id { get; set; }
public string Type { get; set; }
}
public static class BtcPayServer0001Test
{
// --- ORIGINAL (defective): O(A * B) ---
static List<Attachment> MergeOriginal(List<Attachment> existing, List<Attachment> incoming)
{
var result = new List<Attachment>(existing);
foreach (var va in incoming.Where(va =>
!existing.Any(a => a.Id == va.Id && a.Type == va.Type)))
{
result.Add(va);
}
return result;
}
// --- PATCHED: O(A + B) ---
static List<Attachment> MergePatched(List<Attachment> existing, List<Attachment> incoming)
{
var result = new List<Attachment>(existing);
var existingSet = new HashSet<(string, string)>(
existing.Select(a => (a.Id, a.Type)));
foreach (var va in incoming.Where(va =>
!existingSet.Contains((va.Id, va.Type))))
{
result.Add(va);
}
return result;
}
static List<Attachment> MakeAttachments(int count, string prefix)
{
var list = new List<Attachment>(count);
for (int i = 0; i < count; i++)
list.Add(new Attachment { Id = $"{prefix}-{i}", Type = "invoice" });
return list;
}
public static void Main(string[] args)
{
int N = 2000;
var existing = MakeAttachments(N, "existing");
var incoming = MakeAttachments(N, "incoming"); // all new, worst case
// Warmup
MergeOriginal(existing, incoming);
MergePatched(existing, incoming);
var sw = Stopwatch.StartNew();
for (int i = 0; i < 5; i++)
MergeOriginal(existing, incoming);
sw.Stop();
long originalMs = sw.ElapsedMilliseconds;
sw.Restart();
for (int i = 0; i < 5; i++)
MergePatched(existing, incoming);
sw.Stop();
long patchedMs = sw.ElapsedMilliseconds;
double ratio = (double)originalMs / Math.Max(1, patchedMs);
Console.WriteLine($"N = {N}");
Console.WriteLine($"Original (List.Any): {originalMs} ms");
Console.WriteLine($"Patched (HashSet): {patchedMs} ms");
Console.WriteLine($"Speedup ratio: {ratio:F1}x");
// Correctness: both produce same count
var origResult = MergeOriginal(existing, incoming);
var patchResult = MergePatched(existing, incoming);
bool correctness = origResult.Count == patchResult.Count &&
origResult.Count == N + N;
Console.WriteLine($"Correctness: {(correctness ? "PASS" : "FAIL")}");
// Dedup correctness: overlapping
var overlap = MakeAttachments(N, "existing"); // same IDs as existing
var origOverlap = MergeOriginal(existing, overlap);
var patchOverlap = MergePatched(existing, overlap);
bool dedupCorrect = origOverlap.Count == N && patchOverlap.Count == N;
Console.WriteLine($"Dedup correctness: {(dedupCorrect ? "PASS" : "FAIL")}");
if (!correctness || !dedupCorrect)
Environment.Exit(1);
}
}
}

View file

@ -0,0 +1,23 @@
# UNDF: UNDF-2026-000000929
--- a/BTCPayServer/Services/Apps/AppService.cs
+++ b/BTCPayServer/Services/Apps/AppService.cs
@@ -113,9 +113,12 @@
// fill up the gaps
+ // CWE-407: series.All(e => e.Date != date) inside foreach is O(D*S) where
+ // D = numberOfDays and S = series.Count. Build a HashSet for O(1) lookup.
+ var existingDates = new HashSet<DateTime>(series.Select(e => e.Date));
foreach (var i in Enumerable.Range(0, numberOfDays))
{
var date = (DateTimeOffset.UtcNow - TimeSpan.FromDays(i)).Date;
- if (series.All(e => e.Date != date))
+ if (!existingDates.Contains(date))
{
series.Add(new AppSalesStatsItem
{
Date = date,
Label = date.ToString("MMM dd", CultureInfo.InvariantCulture)
});
+ existingDates.Add(date);
}
}

View file

@ -0,0 +1,114 @@
// Unit test for btcpayserver-0002: AppService gap-fill O(D*S) series.All inside foreach
// Demonstrates quadratic vs linear behavior when filling date gaps in sales stats.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace BTCPayServer.Tests.CWE407
{
public class SalesItem
{
public DateTime Date { get; set; }
public string Label { get; set; }
public int SalesCount { get; set; }
}
public static class BtcPayServer0002Test
{
// --- ORIGINAL (defective): O(D * S) ---
static void FillGapsOriginal(List<SalesItem> series, int numberOfDays)
{
foreach (var i in Enumerable.Range(0, numberOfDays))
{
var date = (DateTimeOffset.UtcNow - TimeSpan.FromDays(i)).Date;
if (series.All(e => e.Date != date))
{
series.Add(new SalesItem { Date = date, Label = date.ToString("MMM dd") });
}
}
}
// --- PATCHED: O(D + S) ---
static void FillGapsPatched(List<SalesItem> series, int numberOfDays)
{
var existingDates = new HashSet<DateTime>(series.Select(e => e.Date));
foreach (var i in Enumerable.Range(0, numberOfDays))
{
var date = (DateTimeOffset.UtcNow - TimeSpan.FromDays(i)).Date;
if (!existingDates.Contains(date))
{
series.Add(new SalesItem { Date = date, Label = date.ToString("MMM dd") });
existingDates.Add(date);
}
}
}
static List<SalesItem> MakeSparse(int days, int gapEvery)
{
var list = new List<SalesItem>();
for (int i = 0; i < days; i++)
{
if (i % gapEvery != 0) continue;
var date = (DateTimeOffset.UtcNow - TimeSpan.FromDays(i)).Date;
list.Add(new SalesItem { Date = date, Label = date.ToString("MMM dd"), SalesCount = 1 });
}
return list;
}
public static void Main(string[] args)
{
int D = 3000; // exaggerated for benchmark
int gapEvery = 5;
// Warmup
var warmup1 = MakeSparse(D, gapEvery);
FillGapsOriginal(warmup1, D);
var warmup2 = MakeSparse(D, gapEvery);
FillGapsPatched(warmup2, D);
var sw = Stopwatch.StartNew();
for (int run = 0; run < 3; run++)
{
var s = MakeSparse(D, gapEvery);
FillGapsOriginal(s, D);
}
sw.Stop();
long originalMs = sw.ElapsedMilliseconds;
sw.Restart();
for (int run = 0; run < 3; run++)
{
var s = MakeSparse(D, gapEvery);
FillGapsPatched(s, D);
}
sw.Stop();
long patchedMs = sw.ElapsedMilliseconds;
double ratio = (double)originalMs / Math.Max(1, patchedMs);
Console.WriteLine($"D = {D}, gap every {gapEvery} days");
Console.WriteLine($"Original (List.All): {originalMs} ms");
Console.WriteLine($"Patched (HashSet): {patchedMs} ms");
Console.WriteLine($"Speedup ratio: {ratio:F1}x");
// Correctness
var orig = MakeSparse(D, gapEvery);
FillGapsOriginal(orig, D);
var patched = MakeSparse(D, gapEvery);
FillGapsPatched(patched, D);
bool correct = orig.Count == patched.Count && orig.Count == D;
Console.WriteLine($"Count match: {(correct ? "PASS" : "FAIL")} (orig={orig.Count}, patched={patched.Count})");
// Verify no duplicate dates
bool noDupsOrig = orig.Select(e => e.Date).Distinct().Count() == orig.Count;
bool noDupsPatched = patched.Select(e => e.Date).Distinct().Count() == patched.Count;
Console.WriteLine($"No dups (original): {(noDupsOrig ? "PASS" : "FAIL")}");
Console.WriteLine($"No dups (patched): {(noDupsPatched ? "PASS" : "FAIL")}");
if (!correct || !noDupsOrig || !noDupsPatched)
Environment.Exit(1);
}
}
}

View file

@ -0,0 +1,26 @@
# UNDF: UNDF-2026-000000930
--- a/BTCPayServer.Abstractions/Extensions/StringExtensions.cs
+++ b/BTCPayServer.Abstractions/Extensions/StringExtensions.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -6,9 +7,16 @@
public static class StringExtensions
{
+ // CWE-407: Path.GetInvalidFileNameChars() allocates a new char[] on every call.
+ // Contains() on char[] is O(I) per character. For a filename of length F, this
+ // is O(F * I) with I = ~41 invalid chars, plus F array allocations.
+ // Fix: cache in a static HashSet<char> for O(1) lookup and zero allocation.
+ private static readonly HashSet<char> InvalidFileNameChars = new HashSet<char>(Path.GetInvalidFileNameChars());
+
public static bool IsValidFileName(this string fileName)
{
- return !fileName.ToCharArray().Any(c => Path.GetInvalidFileNameChars().Contains(c)
+ return !fileName.ToCharArray().Any(c => InvalidFileNameChars.Contains(c)
|| c == Path.AltDirectorySeparatorChar
|| c == Path.DirectorySeparatorChar
|| c == Path.PathSeparator

View file

@ -0,0 +1,83 @@
// Unit test for btcpayserver-0003: StringExtensions.IsValidFileName O(F*I)
// Path.GetInvalidFileNameChars() allocates new char[] per call, Contains is O(I).
// Fix: static HashSet<char> for O(1) lookup, zero allocation per call.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
namespace BTCPayServer.Tests.CWE407
{
public static class BtcPayServer0003Test
{
// --- ORIGINAL (defective): O(F * I), allocates char[] per char ---
static bool IsValidFileNameOriginal(string fileName)
{
return !fileName.ToCharArray().Any(c => Path.GetInvalidFileNameChars().Contains(c)
|| c == Path.AltDirectorySeparatorChar
|| c == Path.DirectorySeparatorChar
|| c == Path.PathSeparator
|| c == '\\');
}
// --- PATCHED: O(F), static HashSet ---
private static readonly HashSet<char> InvalidFileNameChars = new HashSet<char>(Path.GetInvalidFileNameChars());
static bool IsValidFileNamePatched(string fileName)
{
return !fileName.ToCharArray().Any(c => InvalidFileNameChars.Contains(c)
|| c == Path.AltDirectorySeparatorChar
|| c == Path.DirectorySeparatorChar
|| c == Path.PathSeparator
|| c == '\\');
}
public static void Main(string[] args)
{
// Generate a long valid filename to stress the inner loop
string longName = new string('a', 5000) + ".txt";
int iterations = 10000;
// Warmup
IsValidFileNameOriginal(longName);
IsValidFileNamePatched(longName);
var sw = Stopwatch.StartNew();
for (int i = 0; i < iterations; i++)
IsValidFileNameOriginal(longName);
sw.Stop();
long originalMs = sw.ElapsedMilliseconds;
sw.Restart();
for (int i = 0; i < iterations; i++)
IsValidFileNamePatched(longName);
sw.Stop();
long patchedMs = sw.ElapsedMilliseconds;
double ratio = (double)originalMs / Math.Max(1, patchedMs);
Console.WriteLine($"Filename length: {longName.Length}, iterations: {iterations}");
Console.WriteLine($"Original (GetInvalidFileNameChars per char): {originalMs} ms");
Console.WriteLine($"Patched (static HashSet): {patchedMs} ms");
Console.WriteLine($"Speedup ratio: {ratio:F1}x");
// Correctness
bool c1 = IsValidFileNameOriginal("hello.txt") == IsValidFileNamePatched("hello.txt");
bool c2 = IsValidFileNameOriginal("bad<name>.txt") == IsValidFileNamePatched("bad<name>.txt");
bool c3 = IsValidFileNameOriginal("path/sep.txt") == IsValidFileNamePatched("path/sep.txt");
bool c4 = IsValidFileNameOriginal("ok-file_2024.pdf") == IsValidFileNamePatched("ok-file_2024.pdf");
bool c5 = IsValidFileNameOriginal("has:colon") == IsValidFileNamePatched("has:colon");
bool c6 = IsValidFileNameOriginal("") == IsValidFileNamePatched("");
bool allCorrect = c1 && c2 && c3 && c4 && c5;
Console.WriteLine($"Correctness (valid): {(c1 ? "PASS" : "FAIL")}");
Console.WriteLine($"Correctness (angle): {(c2 ? "PASS" : "FAIL")}");
Console.WriteLine($"Correctness (pathsep): {(c3 ? "PASS" : "FAIL")}");
Console.WriteLine($"Correctness (dashes): {(c4 ? "PASS" : "FAIL")}");
Console.WriteLine($"Correctness (colon): {(c5 ? "PASS" : "FAIL")}");
if (!allCorrect)
Environment.Exit(1);
}
}
}