From 1af30d28141b6c36b5624e948c806d0751f26c35 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:38:55 +0000 Subject: [PATCH] fix: validate output paths in reporters to prevent path traversal Add PathValidator helper that rejects paths containing ".." traversal sequences and normalizes all paths via Path.GetFullPath. Applied to: - JUnitReporter: environment variable (JUNIT_XML_OUTPUT_PATH), command-line argument (--junit-output-path), and default output path - GitHubReporter: environment variable (GITHUB_STEP_SUMMARY) - JUnitReporterCommandProvider: command-line argument validation Closes #4881 --- .../JUnitReporterCommandProvider.cs | 13 ++++ TUnit.Engine/Helpers/PathValidator.cs | 73 +++++++++++++++++++ TUnit.Engine/Reporters/GitHubReporter.cs | 4 +- TUnit.Engine/Reporters/JUnitReporter.cs | 21 +++--- 4 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 TUnit.Engine/Helpers/PathValidator.cs diff --git a/TUnit.Engine/CommandLineProviders/JUnitReporterCommandProvider.cs b/TUnit.Engine/CommandLineProviders/JUnitReporterCommandProvider.cs index 39977ee5d6..09f6efe77d 100644 --- a/TUnit.Engine/CommandLineProviders/JUnitReporterCommandProvider.cs +++ b/TUnit.Engine/CommandLineProviders/JUnitReporterCommandProvider.cs @@ -1,6 +1,7 @@ using Microsoft.Testing.Platform.CommandLine; using Microsoft.Testing.Platform.Extensions; using Microsoft.Testing.Platform.Extensions.CommandLine; +using TUnit.Engine.Helpers; namespace TUnit.Engine.CommandLineProviders; @@ -34,6 +35,18 @@ public Task ValidateOptionArgumentsAsync( CommandLineOption commandOption, string[] arguments) { + if (commandOption.Name == JUnitOutputPathOption && arguments.Length == 1) + { + try + { + PathValidator.ValidateAndNormalizePath(arguments[0], JUnitOutputPathOption); + } + catch (ArgumentException ex) + { + return Task.FromResult(ValidationResult.Invalid(ex.Message)); + } + } + return ValidationResult.ValidTask; } diff --git a/TUnit.Engine/Helpers/PathValidator.cs b/TUnit.Engine/Helpers/PathValidator.cs new file mode 100644 index 0000000000..cb5e5c6457 --- /dev/null +++ b/TUnit.Engine/Helpers/PathValidator.cs @@ -0,0 +1,73 @@ +namespace TUnit.Engine.Helpers; + +internal static class PathValidator +{ + /// + /// Validates and normalizes a file path to prevent path traversal attacks. + /// Returns the normalized full path if valid, or throws an if the path is unsafe. + /// + /// The path to validate. + /// The name of the parameter for error messages. + /// The normalized, validated full path. + /// Thrown when the path is null, empty, or contains path traversal sequences. + internal static string ValidateAndNormalizePath(string? path, string parameterName) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("Path cannot be null or empty.", parameterName); + } + + // At this point path is guaranteed non-null and non-whitespace + var validatedPath = path!; + + // Reject paths containing path traversal sequences before normalization + // This catches attempts like "../../etc/passwd" or "foo/..\\bar" + if (ContainsPathTraversal(validatedPath)) + { + throw new ArgumentException( + $"Path contains path traversal sequences and is not allowed: '{validatedPath}'", + parameterName); + } + + // Normalize the path to resolve any remaining relative segments + var fullPath = Path.GetFullPath(validatedPath); + + // After normalization, verify the result doesn't differ from what we'd expect + // (e.g., a crafted path that sneaks through the string check) + // The normalized path should not escape above the current working directory + // for relative paths, or should be a valid absolute path + if (!Path.IsPathRooted(validatedPath)) + { + var currentDir = Path.GetFullPath(Directory.GetCurrentDirectory()); + + if (!fullPath.StartsWith(currentDir, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Relative path resolves outside the current working directory and is not allowed: '{validatedPath}'", + parameterName); + } + } + + return fullPath; + } + + private static bool ContainsPathTraversal(string path) + { + // Check for ".." segments which indicate path traversal + // We need to check both forward and backward slash separators + var normalized = path.Replace('\\', '/'); + + // Split on '/' and check for ".." segments + var segments = normalized.Split('/'); + + foreach (var segment in segments) + { + if (segment == "..") + { + return true; + } + } + + return false; + } +} diff --git a/TUnit.Engine/Reporters/GitHubReporter.cs b/TUnit.Engine/Reporters/GitHubReporter.cs index e49c7f9d68..729e65a41a 100644 --- a/TUnit.Engine/Reporters/GitHubReporter.cs +++ b/TUnit.Engine/Reporters/GitHubReporter.cs @@ -9,6 +9,7 @@ using TUnit.Engine.Configuration; using TUnit.Engine.Constants; using TUnit.Engine.Framework; +using TUnit.Engine.Helpers; namespace TUnit.Engine.Reporters; @@ -43,7 +44,8 @@ public async Task IsEnabledAsync() return false; } - _outputSummaryFilePath = fileName; + // Validate and normalize the path to prevent path traversal attacks + _outputSummaryFilePath = PathValidator.ValidateAndNormalizePath(fileName, "GITHUB_STEP_SUMMARY"); // Determine reporter style from environment variable or default to collapsible var styleEnv = Environment.GetEnvironmentVariable(EnvironmentConstants.GitHubReporterStyle); diff --git a/TUnit.Engine/Reporters/JUnitReporter.cs b/TUnit.Engine/Reporters/JUnitReporter.cs index 0fda21bca1..501cd30ef7 100644 --- a/TUnit.Engine/Reporters/JUnitReporter.cs +++ b/TUnit.Engine/Reporters/JUnitReporter.cs @@ -7,6 +7,7 @@ using TUnit.Engine.Configuration; using TUnit.Engine.Constants; using TUnit.Engine.Framework; +using TUnit.Engine.Helpers; using TUnit.Engine.Xml; namespace TUnit.Engine.Reporters; @@ -37,8 +38,11 @@ public async Task IsEnabledAsync() // Determine output path (only if not already set via command-line argument) if (string.IsNullOrEmpty(_outputPath)) { - _outputPath = Environment.GetEnvironmentVariable(EnvironmentConstants.JUnitXmlOutputPath) - ?? GetDefaultOutputPath(); + var envPath = Environment.GetEnvironmentVariable(EnvironmentConstants.JUnitXmlOutputPath); + + _outputPath = envPath is not null + ? PathValidator.ValidateAndNormalizePath(envPath, nameof(EnvironmentConstants.JUnitXmlOutputPath)) + : GetDefaultOutputPath(); } _isEnabled = true; @@ -101,18 +105,17 @@ public async Task AfterRunAsync(int exitCode, CancellationToken cancellation) internal void SetOutputPath(string path) { - if (string.IsNullOrWhiteSpace(path)) - { - throw new ArgumentException("Output path cannot be null or empty", nameof(path)); - } - - _outputPath = path; + _outputPath = PathValidator.ValidateAndNormalizePath(path, nameof(path)); } private static string GetDefaultOutputPath() { var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; - return Path.Combine("TestResults", $"{assemblyName}-junit.xml"); + + // Sanitize assembly name to remove any characters that could be used for path traversal + var sanitizedName = string.Concat(assemblyName.Split(Path.GetInvalidFileNameChars())); + + return Path.GetFullPath(Path.Combine("TestResults", $"{sanitizedName}-junit.xml")); } private static async Task WriteXmlFileAsync(string path, string content, CancellationToken cancellationToken)