MarketAlly.AIPlugin.Extensions/MarketAlly.AIPlugin.Refacto.../Security/SecurePathValidator.cs

336 lines
13 KiB
C#
Executable File

using System;
using System.IO;
using System.Linq;
using System.Security;
using System.Text.RegularExpressions;
namespace MarketAlly.AIPlugin.Refactoring.Security
{
public static class SecurePathValidator
{
private static readonly string[] ForbiddenPaths = new[]
{
"program files", "programdata",
"boot", "etc", "bin", "sbin", "usr", "var"
};
private static readonly string[] SystemDirectories = new[]
{
"windows\\system32", "windows\\system", "windows\\syswow64"
};
private static readonly string[] DangerousExtensions = new[]
{
".exe", ".dll", ".com", ".bat", ".cmd", ".scr", ".vbs", ".js",
".jar", ".msi", ".ps1", ".psm1", ".psd1", ".sys", ".inf"
};
private static readonly Regex UnsafePathChars = new(
@"[<>""|?*\x00-\x1f]|(\.\./)|(\.\.)\\",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex ReservedNames = new(
@"^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>
/// Validates and normalizes a file path to prevent path traversal attacks
/// </summary>
/// <param name="inputPath">The input path to validate</param>
/// <param name="basePath">The base directory that the path should be contained within</param>
/// <returns>The normalized and validated full path</returns>
/// <exception cref="SecurityException">Thrown when path validation fails</exception>
/// <exception cref="ArgumentException">Thrown when input parameters are invalid</exception>
public static string ValidateAndNormalizePath(string inputPath, string basePath)
{
if (string.IsNullOrWhiteSpace(inputPath))
throw new ArgumentException("Input path cannot be null or empty", nameof(inputPath));
if (string.IsNullOrWhiteSpace(basePath))
throw new ArgumentException("Base path cannot be null or empty", nameof(basePath));
// Remove any unsafe characters first
ValidatePathCharacters(inputPath);
try
{
// Normalize the base path
var normalizedBasePath = Path.GetFullPath(basePath);
// Combine and get full path to resolve any relative path components
var combinedPath = Path.Combine(normalizedBasePath, inputPath);
var fullPath = Path.GetFullPath(combinedPath);
// Ensure the resolved path is still within the base directory
if (!IsPathWithinBase(fullPath, normalizedBasePath))
{
throw new SecurityException($"Path traversal attempt detected: '{inputPath}' resolves outside base directory '{basePath}'");
}
// Additional security checks
ValidatePathSafety(fullPath);
return fullPath;
}
catch (Exception ex) when (!(ex is SecurityException))
{
throw new SecurityException($"Invalid path format: '{inputPath}'", ex);
}
}
/// <summary>
/// Validates a path without requiring a base directory
/// </summary>
/// <param name="path">The path to validate</param>
/// <returns>The normalized full path</returns>
/// <exception cref="SecurityException">Thrown when path validation fails</exception>
public static string ValidatePath(string path)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Path cannot be null or empty", nameof(path));
ValidatePathCharacters(path);
try
{
var fullPath = Path.GetFullPath(path);
ValidatePathSafety(fullPath);
return fullPath;
}
catch (Exception ex) when (!(ex is SecurityException))
{
throw new SecurityException($"Invalid path format: '{path}'", ex);
}
}
/// <summary>
/// Validates that a file path is safe for code analysis operations
/// </summary>
/// <param name="filePath">The file path to validate</param>
/// <returns>True if the file is safe to analyze</returns>
public static bool IsFilePathSafeForAnalysis(string filePath)
{
try
{
var validatedPath = ValidatePath(filePath);
var extension = Path.GetExtension(validatedPath).ToLowerInvariant();
// Only allow specific file types for analysis
var allowedExtensions = new[] { ".cs", ".csx", ".txt", ".md", ".json", ".xml", ".xaml" };
if (!allowedExtensions.Contains(extension))
{
return false;
}
// Check if it's in a safe directory
return !IsInDangerousDirectory(validatedPath);
}
catch
{
return false;
}
}
/// <summary>
/// Creates a safe file name by removing or replacing dangerous characters
/// </summary>
/// <param name="fileName">The original file name</param>
/// <returns>A sanitized file name</returns>
public static string CreateSafeFileName(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
throw new ArgumentException("File name cannot be null or empty", nameof(fileName));
var invalidChars = Path.GetInvalidFileNameChars();
var safeName = string.Concat(fileName.Where(c => !invalidChars.Contains(c)));
// Replace multiple spaces with single space
safeName = Regex.Replace(safeName, @"\s+", " ");
// Trim and limit length
safeName = safeName.Trim();
if (safeName.Length > 200)
{
var extension = Path.GetExtension(safeName);
var nameWithoutExt = Path.GetFileNameWithoutExtension(safeName);
safeName = nameWithoutExt.Substring(0, 200 - extension.Length) + extension;
}
// Check for reserved names
if (ReservedNames.IsMatch(safeName))
{
safeName = "_" + safeName;
}
return string.IsNullOrWhiteSpace(safeName) ? "safe_file" : safeName;
}
/// <summary>
/// Validates directory path for safe operations
/// </summary>
/// <param name="directoryPath">The directory path to validate</param>
/// <param name="basePath">Optional base path for containment checking</param>
/// <returns>The validated directory path</returns>
public static string ValidateDirectoryPath(string directoryPath, string? basePath = null)
{
var validatedPath = basePath != null
? ValidateAndNormalizePath(directoryPath, basePath)
: ValidatePath(directoryPath);
if (IsInDangerousDirectory(validatedPath))
{
throw new SecurityException($"Directory path is in a dangerous location: '{directoryPath}'");
}
return validatedPath;
}
private static void ValidatePathCharacters(string path)
{
if (UnsafePathChars.IsMatch(path))
{
throw new SecurityException($"Path contains unsafe characters: '{path}'");
}
// Check for null bytes (additional security)
if (path.Contains('\0'))
{
throw new SecurityException("Path contains null bytes");
}
// Check for excessively long paths
if (path.Length > 32767) // Max path length on Windows
{
throw new SecurityException("Path is too long");
}
}
private static bool IsPathWithinBase(string fullPath, string basePath)
{
// Normalize both paths for comparison
var normalizedFullPath = fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var normalizedBasePath = basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return normalizedFullPath.StartsWith(normalizedBasePath, StringComparison.OrdinalIgnoreCase) &&
(normalizedFullPath.Length == normalizedBasePath.Length ||
normalizedFullPath[normalizedBasePath.Length] == Path.DirectorySeparatorChar ||
normalizedFullPath[normalizedBasePath.Length] == Path.AltDirectorySeparatorChar);
}
private static void ValidatePathSafety(string fullPath)
{
var fileName = Path.GetFileName(fullPath);
var extension = Path.GetExtension(fullPath).ToLowerInvariant();
// Check for dangerous file extensions
if (DangerousExtensions.Contains(extension))
{
throw new SecurityException($"File extension '{extension}' is not allowed for security reasons");
}
// Check for reserved file names
if (ReservedNames.IsMatch(fileName))
{
throw new SecurityException($"File name '{fileName}' is a reserved system name");
}
// Check if it's in a dangerous directory
if (IsInDangerousDirectory(fullPath))
{
throw new SecurityException($"Path is in a dangerous system directory: '{fullPath}'");
}
}
private static bool IsInDangerousDirectory(string fullPath)
{
var normalizedPath = fullPath.ToLowerInvariant().Replace('/', '\\');
// Check for specific system directories
foreach (var systemDir in SystemDirectories)
{
if (normalizedPath.Contains(systemDir, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
var pathParts = normalizedPath
.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.Where(part => !string.IsNullOrEmpty(part))
.ToArray();
// Check if any part of the path matches forbidden directories
return pathParts.Any(part => ForbiddenPaths.Any(forbidden =>
part.Equals(forbidden, StringComparison.OrdinalIgnoreCase) ||
part.StartsWith(forbidden, StringComparison.OrdinalIgnoreCase)));
}
/// <summary>
/// Gets the relative path from base to target, ensuring it's safe
/// </summary>
/// <param name="basePath">The base directory path</param>
/// <param name="targetPath">The target file path</param>
/// <returns>Safe relative path</returns>
public static string GetSafeRelativePath(string basePath, string targetPath)
{
var validatedBasePath = ValidatePath(basePath);
var validatedTargetPath = ValidateAndNormalizePath(targetPath, validatedBasePath);
try
{
return Path.GetRelativePath(validatedBasePath, validatedTargetPath);
}
catch (Exception ex)
{
throw new SecurityException($"Cannot create safe relative path from '{basePath}' to '{targetPath}'", ex);
}
}
/// <summary>
/// Validates that a path is safe for temporary file operations
/// </summary>
/// <param name="tempPath">The temporary path to validate</param>
/// <returns>Validated temporary path</returns>
public static string ValidateTempPath(string tempPath)
{
var systemTempPath = Path.GetTempPath();
var validatedPath = ValidateAndNormalizePath(tempPath, systemTempPath);
// Additional validation for temp files
var fileName = Path.GetFileName(validatedPath);
if (fileName.StartsWith(".", StringComparison.OrdinalIgnoreCase))
{
throw new SecurityException("Temporary file names cannot start with '.'");
}
return validatedPath;
}
}
/// <summary>
/// Extension methods for easy path validation
/// </summary>
public static class PathValidationExtensions
{
public static string ValidateAsSecurePath(this string path)
{
return SecurePathValidator.ValidatePath(path);
}
public static string ValidateAsSecurePath(this string path, string basePath)
{
return SecurePathValidator.ValidateAndNormalizePath(path, basePath);
}
public static bool IsSecureForAnalysis(this string filePath)
{
return SecurePathValidator.IsFilePathSafeForAnalysis(filePath);
}
public static string ToSafeFileName(this string fileName)
{
return SecurePathValidator.CreateSafeFileName(fileName);
}
}
}