Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions TUnit.Engine/CommandLineProviders/JUnitReporterCommandProvider.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -34,6 +35,18 @@ public Task<ValidationResult> 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;
}

Expand Down
73 changes: 73 additions & 0 deletions TUnit.Engine/Helpers/PathValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
namespace TUnit.Engine.Helpers;

internal static class PathValidator
{
/// <summary>
/// Validates and normalizes a file path to prevent path traversal attacks.
/// Returns the normalized full path if valid, or throws an <see cref="ArgumentException"/> if the path is unsafe.
/// </summary>
/// <param name="path">The path to validate.</param>
/// <param name="parameterName">The name of the parameter for error messages.</param>
/// <returns>The normalized, validated full path.</returns>
/// <exception cref="ArgumentException">Thrown when the path is null, empty, or contains path traversal sequences.</exception>
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;
}
}
4 changes: 3 additions & 1 deletion TUnit.Engine/Reporters/GitHubReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using TUnit.Engine.Configuration;
using TUnit.Engine.Constants;
using TUnit.Engine.Framework;
using TUnit.Engine.Helpers;

namespace TUnit.Engine.Reporters;

Expand Down Expand Up @@ -43,7 +44,8 @@ public async Task<bool> 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);
Expand Down
21 changes: 12 additions & 9 deletions TUnit.Engine/Reporters/JUnitReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -37,8 +38,11 @@ public async Task<bool> 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;
Expand Down Expand Up @@ -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)
Expand Down
Loading