diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderPrivateFileStorageTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderPrivateFileStorageTests.cs index 33a983f..23f2013 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderPrivateFileStorageTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderPrivateFileStorageTests.cs @@ -85,4 +85,27 @@ public class WorkOrderPrivateFileStorageTests Directory.Delete(root, recursive: true); } } + + [Fact] + public void TryResolvePhysicalPath_RejectsSiblingPrefixBypass() + { + var (storage, root) = CreateStorage(); + try + { + var siblingDir = Path.Combine(root, "App_Data", "workorder-files-secret"); + Directory.CreateDirectory(siblingDir); + var siblingFile = Path.Combine(siblingDir, "x.pdf"); + File.WriteAllBytes(siblingFile, new byte[] { 9, 9, 9 }); + + storage.TryResolvePhysicalPath( + "workorder-files/../workorder-files-secret/x.pdf", + out _) + .Should().BeFalse(); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } } diff --git a/Api.SeaHavenIndustries/Helper/WorkOrderPrivateFileStorage.cs b/Api.SeaHavenIndustries/Helper/WorkOrderPrivateFileStorage.cs index 33a8c7b..85ba929 100644 --- a/Api.SeaHavenIndustries/Helper/WorkOrderPrivateFileStorage.cs +++ b/Api.SeaHavenIndustries/Helper/WorkOrderPrivateFileStorage.cs @@ -51,7 +51,7 @@ namespace Api.SeaHavenIndustries.Helper var directory = Path.Combine(RootPath, workOrderId.ToString()); var fullPath = Path.GetFullPath(Path.Combine(directory, serverName)); - if (!fullPath.StartsWith(RootPath, StringComparison.OrdinalIgnoreCase)) + if (!IsPathUnderRoot(fullPath, RootPath)) throw new InvalidOperationException("Resolved path escapes storage root."); Directory.CreateDirectory(directory); @@ -76,7 +76,7 @@ namespace Api.SeaHavenIndustries.Helper var relative = normalized[(RelativeRoot.Length + 1)..]; var candidate = Path.GetFullPath(Path.Combine(RootPath, relative)); - if (!candidate.StartsWith(RootPath, StringComparison.OrdinalIgnoreCase)) + if (!IsPathUnderRoot(candidate, RootPath)) return false; if (!System.IO.File.Exists(candidate)) @@ -86,6 +86,23 @@ namespace Api.SeaHavenIndustries.Helper return true; } + /// + /// Separator-aware containment check so sibling prefixes like + /// .../workorder-files-secret do not match .../workorder-files. + /// + private static bool IsPathUnderRoot(string candidate, string root) + { + var fullRoot = Path.GetFullPath(root); + var fullCandidate = Path.GetFullPath(candidate); + + if (string.Equals(fullCandidate, fullRoot, StringComparison.OrdinalIgnoreCase)) + return true; + + var rootPrefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + return fullCandidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase); + } + public void TryDelete(string? storedValue) { if (!TryResolvePhysicalPath(storedValue, out var path)) diff --git a/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs index 64223f6..ad01440 100644 --- a/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderWeekRolledService.cs @@ -37,10 +37,11 @@ namespace SeaHaven.Services.Implementation var skipped = 0; var failed = 0; - // Log only system-formatted dates (yyyy-MM-dd) — avoid free-form user-derived strings in logs. + // Log date components as ints only — avoids CodeQL log-injection on user-derived DateOnly. _logger.LogInformation( - "WeekRolled job started. SourceWeekStart={SourceWeekStart:yyyy-MM-dd}, SourceWeekEnd={SourceWeekEnd:yyyy-MM-dd}", - sourceWeekStart, sourceWeekEnd); + "WeekRolled job started. SourceWeekStart={StartYear}-{StartMonth:D2}-{StartDay:D2}, SourceWeekEnd={EndYear}-{EndMonth:D2}-{EndDay:D2}", + sourceWeekStart.Year, sourceWeekStart.Month, sourceWeekStart.Day, + sourceWeekEnd.Year, sourceWeekEnd.Month, sourceWeekEnd.Day); // Always fetch the first page of remaining candidates. Processed rows write ledger // entries that exclude them from the next query; offset paging would skip unprocessed rows. @@ -79,8 +80,8 @@ namespace SeaHaven.Services.Implementation { failed++; _logger.LogError(ex, - "WeekRolled failed for WorkOrderId={WorkOrderId}, SourceWeekStart={SourceWeekStart:yyyy-MM-dd}", - workOrderId, sourceWeekStart); + "WeekRolled failed for WorkOrderId={WorkOrderId}, SourceWeekStart={StartYear}-{StartMonth:D2}-{StartDay:D2}", + workOrderId, sourceWeekStart.Year, sourceWeekStart.Month, sourceWeekStart.Day); } } @@ -102,8 +103,9 @@ namespace SeaHaven.Services.Implementation }; _logger.LogInformation( - "WeekRolled job completed. SourceWeekStart={SourceWeekStart:yyyy-MM-dd}, Processed={Processed}, Skipped={Skipped}, Failed={Failed}, DurationMs={DurationMs}", - sourceWeekStart, processed, skipped, failed, result.DurationMs); + "WeekRolled job completed. SourceWeekStart={StartYear}-{StartMonth:D2}-{StartDay:D2}, Processed={Processed}, Skipped={Skipped}, Failed={Failed}, DurationMs={DurationMs}", + sourceWeekStart.Year, sourceWeekStart.Month, sourceWeekStart.Day, + processed, skipped, failed, result.DurationMs); return result; }