|
- $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 <html> root'
- }
- Assert-TemplateNode $document.DocumentElement
- if (-not @($document.DocumentElement.ChildNodes | Where-Object { $_.LocalName -eq 'body' }).Count) {
- throw 'Template must have a <body> 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
- }
|