functions/SecurityHelpers.ps1
|
# Security Helper Functions for EmojiTools Module <# .SYNOPSIS Internal security helper functions for input validation and secure operations. .DESCRIPTION Provides centralized security functions including: - URL validation and sanitization - Path validation to prevent traversal attacks - Secure credential handling - Input sanitization #> function Test-SecureUrl { <# .SYNOPSIS Validates that a URL is secure and properly formatted. .PARAMETER Url The URL to validate .PARAMETER RequireHttps If specified, only HTTPS URLs are allowed .OUTPUTS Boolean indicating if URL is valid and secure #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Url, [Parameter(Mandatory = $false)] [switch]$RequireHttps ) # Check for null or empty if ([string]::IsNullOrWhiteSpace($Url)) { return $false } # Check for suspicious patterns BEFORE parsing (URI constructor normalizes paths) $suspiciousPatterns = @( 'javascript:', 'data:', 'file:', 'vbscript:', '..', # Path traversal (both / and \) '%00', # Null byte '<', # HTML/Script injection '>' ) foreach ($pattern in $suspiciousPatterns) { if ($Url -like "*$pattern*") { return $false } } # Validate URL format try { $uri = [System.Uri]$Url # Must be absolute URL if (-not $uri.IsAbsoluteUri) { return $false } # Check scheme if ($RequireHttps -and $uri.Scheme -ne 'https') { return $false } if ($uri.Scheme -notin @('http', 'https')) { return $false } return $true } catch { return $false } } function Test-SecurePath { <# .SYNOPSIS Validates that a path is safe and doesn't contain traversal attempts. .PARAMETER Path The path to validate .PARAMETER BaseDirectory Optional base directory to ensure path stays within .OUTPUTS Boolean indicating if path is secure #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $false)] [string]$BaseDirectory ) if ([string]::IsNullOrWhiteSpace($Path)) { return $false } # Check for path traversal patterns $dangerousPatterns = @( '..', # Path traversal (literal) '%2e%2e', # URL-encoded path traversal '..%5c', # Mixed encoding traversal '..%2f', # Mixed encoding traversal '%00', # Null byte '<', # Injection '>' ) foreach ($pattern in $dangerousPatterns) { if ($Path -like "*$pattern*") { return $false } } # If base directory specified, ensure path is within it if ($BaseDirectory) { try { $fullPath = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($BaseDirectory, $Path)) $baseFullPath = [System.IO.Path]::GetFullPath($BaseDirectory) if (-not $fullPath.StartsWith($baseFullPath, [StringComparison]::OrdinalIgnoreCase)) { return $false } } catch { return $false } } return $true } function ConvertTo-SecureApiKey { <# .SYNOPSIS Converts a plain text API key to a SecureString. .DESCRIPTION Safely converts an API key from plain text to SecureString format for secure storage in memory. This should be used when accepting API keys from user input. .PARAMETER ApiKey The API key in plain text .OUTPUTS SecureString containing the API key #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Necessary for converting user input to SecureString for secure storage')] [CmdletBinding()] [OutputType([SecureString])] param( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$ApiKey ) if ([string]::IsNullOrWhiteSpace($ApiKey)) { return $null } return ConvertTo-SecureString -String $ApiKey -AsPlainText -Force } function ConvertFrom-SecureApiKey { <# .SYNOPSIS Converts a SecureString API key back to plain text for use in API calls. WARNING: Only use this immediately before API call and clear result immediately after. .PARAMETER SecureApiKey The SecureString containing the API key .OUTPUTS Plain text API key string #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory = $true)] [ValidateNotNull()] [SecureString]$SecureApiKey ) try { $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureApiKey) # Use PtrToStringUni explicitly for cross-platform compatibility # SecureString always uses UTF-16 (Unicode) encoding return [System.Runtime.InteropServices.Marshal]::PtrToStringUni($bstr) } finally { if ($bstr -ne [IntPtr]::Zero) { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } } function Invoke-SecureWebRequest { <# .SYNOPSIS Wrapper around Invoke-RestMethod with security best practices applied. .PARAMETER Uri The URI to request .PARAMETER Method HTTP method (default: Get) .PARAMETER TimeoutSeconds Request timeout in seconds (default: 30) .PARAMETER MaxRetries Maximum number of retry attempts (default: 3) .PARAMETER Headers Optional headers dictionary .OUTPUTS Response from web request #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateScript( { if (-not (Test-SecureUrl -Url $_ -RequireHttps)) { throw "Invalid or insecure URL: $_. Only HTTPS URLs are allowed." } $true })] [string]$Uri, [Parameter(Mandatory = $false)] [ValidateSet('Get', 'Post', 'Put', 'Delete', 'Patch')] [string]$Method = 'Get', [Parameter(Mandatory = $false)] [ValidateRange(1, 300)] [int]$TimeoutSeconds = 30, [Parameter(Mandatory = $false)] [ValidateRange(0, 10)] [int]$MaxRetries = 3, [Parameter(Mandatory = $false)] [hashtable]$Headers ) $attempt = 0 $lastError = $null while ($attempt -lt $MaxRetries) { try { $attempt++ $params = @{ Uri = $Uri Method = $Method TimeoutSec = $TimeoutSeconds ErrorAction = 'Stop' } if ($Headers) { $params['Headers'] = $Headers } # Add user agent for identification if (-not $params.ContainsKey('Headers')) { $params['Headers'] = @{} } $params['Headers']['User-Agent'] = 'EmojiTools-PowerShell/1.0' Write-Verbose "Secure web request attempt $attempt of $MaxRetries to: $Uri" return Invoke-RestMethod @params } catch { $lastError = $_ Write-Verbose "Request attempt $attempt failed: $($_.Exception.Message)" # Don't retry on client errors (4xx) if ($_.Exception.Response.StatusCode -ge 400 -and $_.Exception.Response.StatusCode -lt 500) { throw } # Exponential backoff before retry if ($attempt -lt $MaxRetries) { $waitSeconds = [Math]::Pow(2, $attempt) Write-Verbose "Waiting $waitSeconds seconds before retry..." Start-Sleep -Seconds $waitSeconds } } } # All retries failed throw "Web request failed after $MaxRetries attempts. Last error: $($lastError.Exception.Message)" } function Write-SecureError { <# .SYNOPSIS Writes an error message with sensitive information removed. .PARAMETER Message The error message .PARAMETER Exception Optional exception to include (will be sanitized) .PARAMETER Category Error category #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Message, [Parameter(Mandatory = $false)] [System.Exception]$Exception, [Parameter(Mandatory = $false)] [System.Management.Automation.ErrorCategory]$Category = [System.Management.Automation.ErrorCategory]::NotSpecified ) # Sanitize message - remove potential file paths, API keys, etc. $sanitizedMessage = $Message # Remove full file paths, keep only filename $sanitizedMessage = $sanitizedMessage -replace '[A-Za-z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*([^\\/:*?"<>|\r\n]+)', '$1' # Remove potential API keys (long alphanumeric strings with optional underscores) $sanitizedMessage = $sanitizedMessage -replace '\b[A-Za-z0-9_]{32,}\b', '[REDACTED]' # Remove URLs but keep domain $sanitizedMessage = $sanitizedMessage -replace 'https?://([^/\s]+)[^\s]*', 'https://$1/[...]' if ($Exception) { Write-Error -Message $sanitizedMessage -Exception $Exception -Category $Category } else { Write-Error -Message $sanitizedMessage -Category $Category } } # Functions loaded via dot-sourcing in main module |