$ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'mail-common.ps1') function Get-TemplateLimits { param($Context) $settings = $Context.Settings if ($settings['TEMPLATE_IMAGE_MODE'] -ne 'inline_cid') { throw 'TEMPLATE_IMAGE_MODE must be inline_cid' } $types = @($settings['TEMPLATE_IMAGE_TYPES'].Split(',') | ForEach-Object { $_.Trim().ToLowerInvariant() }) if (-not $types.Count -or @($types | Where-Object { $_ -notin @('png', 'jpeg', 'gif') }).Count) { throw 'TEMPLATE_IMAGE_TYPES must contain only png, jpeg, and gif' } $imageLimit = 0 $messageLimit = 0 if (-not [int]::TryParse($settings['TEMPLATE_IMAGE_MAX_BYTES'], [ref]$imageLimit) -or $imageLimit -lt 1 -or $imageLimit -gt 10485760) { throw 'Invalid TEMPLATE_IMAGE_MAX_BYTES' } if (-not [int]::TryParse($settings['TEMPLATE_MESSAGE_MAX_BYTES'], [ref]$messageLimit) -or $messageLimit -lt $imageLimit -or $messageLimit -gt 52428800) { throw 'Invalid TEMPLATE_MESSAGE_MAX_BYTES' } $root = $settings['TEMPLATE_ROOT'] if (-not [System.IO.Path]::IsPathRooted($root)) { $root = Join-Path (Split-Path -Parent $PSScriptRoot) $root } return @{ Root = [System.IO.Path]::GetFullPath($root); Types = $types; ImageLimit = $imageLimit; MessageLimit = $messageLimit } } function Get-HashHex { param([byte[]]$Bytes) $hash = [System.Security.Cryptography.SHA256]::Create() try { return ([BitConverter]::ToString($hash.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() } finally { $hash.Dispose() } } function Assert-SafeStyle { param([string]$Style) $allowed = @('color', 'background-color', 'font-family', 'font-size', 'font-weight', 'text-align', 'text-decoration', 'line-height', 'margin', 'margin-top', 'margin-bottom', 'padding', 'padding-top', 'padding-bottom', 'border', 'border-color', 'border-width', 'border-style', 'width', 'height', 'display') foreach ($declaration in $Style.Split(';')) { $part = $declaration.Trim() if (-not $part) { continue } $match = [regex]::Match($part, '^([a-z-]+)\s*:\s*(.+)$', 'IgnoreCase') if (-not $match.Success -or $match.Groups[1].Value.ToLowerInvariant() -notin $allowed) { throw "Unsupported CSS declaration: $part" } $value = $match.Groups[2].Value if ($value -notmatch "^[a-zA-Z0-9# ,.%()'-]+$" -or $value -match '(?i)(expression|url|behavior|import|javascript|vbscript|position|fixed)') { throw "Unsafe CSS value: $part" } } } function Assert-TemplateNode { param([System.Xml.XmlNode]$Node) if ($Node.NodeType -eq [System.Xml.XmlNodeType]::Comment) { throw 'HTML comments are not supported' } if ($Node.NodeType -eq [System.Xml.XmlNodeType]::Text -or $Node.NodeType -eq [System.Xml.XmlNodeType]::Whitespace -or $Node.NodeType -eq [System.Xml.XmlNodeType]::SignificantWhitespace) { return } if ($Node.NodeType -ne [System.Xml.XmlNodeType]::Element) { throw 'Unsupported HTML node' } $tags = @('html', 'head', 'title', 'body', 'div', 'span', 'p', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'b', 'i', 'u', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'a', 'img') $tag = $Node.LocalName.ToLowerInvariant() if ($Node.NamespaceURI -and $Node.NamespaceURI -ne 'http://www.w3.org/1999/xhtml') { throw "Unsupported namespace on <$tag>" } if ($tag -notin $tags) { throw "Unsupported HTML element: <$tag>" } foreach ($attribute in $Node.Attributes) { $name = $attribute.Name.ToLowerInvariant() $value = $attribute.Value if ($name -eq 'xmlns' -and $tag -eq 'html' -and $value -eq 'http://www.w3.org/1999/xhtml') { continue } if ($name -eq 'style') { Assert-SafeStyle $value; continue } if ($name -eq 'class' -and $value -match '^[a-zA-Z0-9_ -]{1,100}$') { continue } if ($name -eq 'alt' -and $tag -eq 'img') { continue } if ($name -eq 'src' -and $tag -eq 'img') { continue } if ($name -eq 'href' -and $tag -eq 'a' -and $value -match '^(https://[^\s<>"'']+|mailto:[^\s<>"'']+)$') { continue } if ($name -in @('width', 'height', 'border', 'cellpadding', 'cellspacing') -and $value -match '^[0-9]{1,4}(%|px)?$') { continue } if ($name -eq 'align' -and $value -in @('left', 'center', 'right')) { continue } if ($name -eq 'valign' -and $value -in @('top', 'middle', 'bottom')) { continue } if ($name -eq 'bgcolor' -and $value -match '^#[0-9a-fA-F]{6}$') { continue } throw "Unsupported or unsafe attribute $name on <$tag>" } foreach ($child in $Node.ChildNodes) { Assert-TemplateNode $child } } function Read-SafeTemplate { param([string]$Path) if ([System.IO.Path]::GetExtension($Path).ToLowerInvariant() -notin @('.html', '.htm')) { throw 'Select a .html or .htm template' } return Read-SafeTemplateXml $Path $false } function Read-SafeTemplateXml { param([string]$Source, [bool]$IsText) $readerSettings = New-Object System.Xml.XmlReaderSettings $readerSettings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit $readerSettings.XmlResolver = $null $readerSettings.MaxCharactersInDocument = 2097152 if ($IsText) { $sourceReader = New-Object System.IO.StringReader($Source) $reader = [System.Xml.XmlReader]::Create($sourceReader, $readerSettings) } else { $reader = [System.Xml.XmlReader]::Create($Source, $readerSettings) } try { $document = New-Object System.Xml.XmlDocument $document.XmlResolver = $null $document.Load($reader) } finally { $reader.Dispose() if ($IsText) { $sourceReader.Dispose() } } if ($document.DocumentElement.LocalName.ToLowerInvariant() -ne 'html') { throw 'Template must have an root' } Assert-TemplateNode $document.DocumentElement if (-not @($document.DocumentElement.ChildNodes | Where-Object { $_.LocalName -eq 'body' }).Count) { throw 'Template must have a element' } return $document } function Get-TemplateImages { param($Document, [string]$TemplatePath, $Limits) $base = [System.IO.Path]::GetDirectoryName($TemplatePath) $images = New-Object System.Collections.ArrayList $seen = @{} foreach ($node in $Document.GetElementsByTagName('img')) { $relative = $node.GetAttribute('src') if (-not $relative -or $relative -notmatch '^[a-zA-Z0-9_][a-zA-Z0-9_./ -]*$' -or $relative -match '(^|/)\.\.?(/|$)' -or $relative.Contains('//')) { throw "Image src must be a simple relative path: $relative" } $imagePath = [System.IO.Path]::GetFullPath((Join-Path $base $relative)) if (-not $imagePath.StartsWith($base + [System.IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { throw "Image is outside template folder: $relative" } if (-not (Test-Path -LiteralPath $imagePath -PathType Leaf)) { throw "Missing image: $relative" } $ext = [System.IO.Path]::GetExtension($imagePath).ToLowerInvariant() $type = switch ($ext) { '.png' { 'png' } '.jpg' { 'jpeg' } '.jpeg' { 'jpeg' } '.gif' { 'gif' } default { '' } } if ($type -notin $Limits.Types) { throw "Image type is not enabled: $relative" } $length = (Get-Item -LiteralPath $imagePath).Length if ($length -lt 8 -or $length -gt $Limits.ImageLimit) { throw "Image size is outside the configured limit: $relative" } if (-not $seen.ContainsKey($relative)) { $bytes = [System.IO.File]::ReadAllBytes($imagePath) $valid = switch ($type) { 'png' { $bytes.Length -ge 8 -and ([BitConverter]::ToString($bytes,0,8) -eq '89-50-4E-47-0D-0A-1A-0A') } 'jpeg' { $bytes.Length -ge 4 -and $bytes[0] -eq 255 -and $bytes[1] -eq 216 -and $bytes[-2] -eq 255 -and $bytes[-1] -eq 217 } 'gif' { $bytes.Length -ge 6 -and [System.Text.Encoding]::ASCII.GetString($bytes,0,6) -in @('GIF87a','GIF89a') } } if (-not $valid) { throw "Image signature does not match its type: $relative" } $cid = 'mm-' + [guid]::NewGuid().ToString('N') + '@local' $seen[$relative] = $cid $null = $images.Add(@{ Path = $relative; Bytes = $bytes; Type = 'image/' + $type; Hash = Get-HashHex $bytes; Cid = $cid }) } $node.SetAttribute('src', 'cid:' + $seen[$relative]) } return ,$images } function Add-TextParameter { param($Command, [string]$Name, [int]$Size, [string]$Value) $null = $Command.Parameters.Add($Name, [System.Data.OleDb.OleDbType]::VarWChar, $Size) $Command.Parameters[$Command.Parameters.Count - 1].Value = $Value }