src/GuestUrlGuard.ps1
|
# 访客(Sandbox)模式下的 URL 抓取策略:禁止本机/内网/链路本地地址,避免 SSRF。 # 只做纯判定,不读全局状态;调用方自行决定是否处于 GuestMode。 function Test-GuestPrivateIPAddress([System.Net.IPAddress]$IP) { if ($null -eq $IP) { return $true } if ([System.Net.IPAddress]::IsLoopback($IP)) { return $true } $bytes = $IP.GetAddressBytes() if ($IP.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) { if ($bytes[0] -eq 0) { return $true } # 0.0.0.0/8 if ($bytes[0] -eq 10) { return $true } # 10/8 if ($bytes[0] -eq 100 -and $bytes[1] -ge 64 -and $bytes[1] -le 127) { return $true } # 100.64/10 CGNAT if ($bytes[0] -eq 127) { return $true } # 127/8 if ($bytes[0] -eq 169 -and $bytes[1] -eq 254) { return $true } # 169.254/16(含云元数据 169.254.169.254) if ($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) { return $true } # 172.16/12 if ($bytes[0] -eq 192 -and $bytes[1] -eq 0 -and $bytes[2] -eq 0) { return $true } # 192.0.0/24 if ($bytes[0] -eq 192 -and $bytes[1] -eq 0 -and $bytes[2] -eq 2) { return $true } # 192.0.2/24 TEST-NET-1 if ($bytes[0] -eq 192 -and $bytes[1] -eq 168) { return $true } # 192.168/16 if ($bytes[0] -eq 198 -and $bytes[1] -ge 18 -and $bytes[1] -le 19) { return $true } # 198.18/15 基准测试 if ($bytes[0] -eq 198 -and $bytes[1] -eq 51 -and $bytes[2] -eq 100) { return $true } # 198.51.100/24 TEST-NET-2 if ($bytes[0] -eq 203 -and $bytes[1] -eq 0 -and $bytes[2] -eq 113) { return $true } # 203.0.113/24 TEST-NET-3 if ($bytes[0] -ge 224) { return $true } # 组播/保留 return $false } # IPv6 if ($IP.IsIPv6Multicast -or $IP.IsIPv6LinkLocal -or $IP.IsIPv6SiteLocal) { return $true } if (($bytes[0] -band 0xFE) -eq 0xFC) { return $true } # fc00::/7 唯一本地 if ([System.Net.IPAddress]::IsLoopback($IP)) { return $true } # ::1 $isMapped = $true for ($i = 0; $i -lt 10; $i++) { if ($bytes[$i] -ne 0) { $isMapped = $false; break } } if ($isMapped -and $bytes[10] -eq 0xFF -and $bytes[11] -eq 0xFF) { # ::ffff:a.b.c.d $v4 = [System.Net.IPAddress]::new([byte[]]@($bytes[12], $bytes[13], $bytes[14], $bytes[15])) return (Test-GuestPrivateIPAddress $v4) } $firstTwelveZero = $true for ($i = 0; $i -lt 12; $i++) { if ($bytes[$i] -ne 0) { $firstTwelveZero = $false; break } } if ($firstTwelveZero) { # ::a.b.c.d(IPv4 兼容,含 ::) $v4 = [System.Net.IPAddress]::new([byte[]]@($bytes[12], $bytes[13], $bytes[14], $bytes[15])) return (Test-GuestPrivateIPAddress $v4) } if ($bytes[0] -eq 0x20 -and $bytes[1] -eq 0x02) { # 2002::/16 6to4(内嵌 IPv4) $v4 = [System.Net.IPAddress]::new([byte[]]@($bytes[2], $bytes[3], $bytes[4], $bytes[5])) return (Test-GuestPrivateIPAddress $v4) } if ($bytes[0] -eq 0x20 -and $bytes[1] -eq 0x01 -and $bytes[2] -eq 0 -and $bytes[3] -eq 0) { return $true } # 2001:0::/32 Teredo if ($bytes[0] -eq 0 -and $bytes[1] -eq 0x64 -and $bytes[2] -eq 0xFF -and $bytes[3] -eq 0x9B) { return $true } # 64:ff9b::/96 NAT64 return $false } # 返回 $true 表示访客模式允许抓取该 URL。非 http(s)、无法解析、或解析到本机/内网地址一律拒绝。 function Test-GuestUrlAllowed([string]$Url) { if ($Url -notmatch '(?i)^https?://') { return $false } try { $Uri = [uri]$Url } catch { return $false } if (-not $Uri.Host) { return $false } $HostName = $Uri.Host.Trim('[', ']') if ($HostName -ieq 'localhost' -or $HostName -like '*.localhost') { return $false } $Parsed = $null if ([System.Net.IPAddress]::TryParse($HostName, [ref]$Parsed)) { $Addresses = @($Parsed) } else { try { $Addresses = @([System.Net.Dns]::GetHostAddresses($HostName)) } catch { return $false } } if (-not $Addresses.Count) { return $false } foreach ($addr in $Addresses) { if (Test-GuestPrivateIPAddress $addr) { return $false } } return $true } # 返回 $true 表示访客模式允许读取该本地文件路径。只放行 Windows 目录($env:windir / $env:SystemRoot)下的文件: # 系统自带的图片资源无害,可满足「用系统 ico」这类正常需求;其余本地路径与 UNC 会任意读用户文件或触发 SMB 外连,一律拒绝。 function Test-GuestLocalFilePathAllowed([string]$Path) { if ([string]::IsNullOrWhiteSpace($Path)) { return $false } if ($Path.StartsWith('\\') -or $Path.StartsWith('//')) { return $false } try { $Full = [System.IO.Path]::GetFullPath($Path) } catch { return $false } foreach ($root in @($env:windir, $env:SystemRoot)) { if ([string]::IsNullOrWhiteSpace($root)) { continue } try { $RootFull = [System.IO.Path]::GetFullPath($root) } catch { continue } $RootFull = $RootFull.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar if ($Full.StartsWith($RootFull, [System.StringComparison]::OrdinalIgnoreCase)) { return $true } } return $false } # 解析 HTTP 重定向目标:相对地址按 BaseUrl 归一化,并强制用 Test-GuestUrlAllowed 校验。不允许返回 $null。 # 这是防 SSRF 的关键:Invoke-WebRequest 默认自动跟随 3xx,可被公网主机重定向到内网/云元数据。 function Resolve-GuestRedirectLocation([string]$BaseUrl, [string]$Location) { if ([string]::IsNullOrWhiteSpace($Location)) { return $null } try { $Target = [System.Uri]::new([System.Uri]$BaseUrl, $Location) } catch { return $null } if (-not (Test-GuestUrlAllowed $Target.AbsoluteUri)) { return $null } $Target.AbsoluteUri } # 访客模式下的受控 HTTP 请求:禁用自动重定向,逐跳校验 Location;按 MaxBytes 限制实际读取字节数(0 表示不限), # 避免超大响应被整体读进内存。Validator 可注入以便测试,默认即 Test-GuestUrlAllowed。 function Invoke-GuestHttpRequest { param( [Parameter(Mandatory)][string]$Url, [ValidateSet('GET', 'HEAD')][string]$Method = 'GET', [int]$MaxRedirects = 5, [int64]$MaxBytes = 0, [scriptblock]$Validator ) if (-not $Validator) { $Validator = { param($u) Test-GuestUrlAllowed $u } } $current = $Url for ($hop = 0; $hop -le $MaxRedirects; $hop++) { if (-not (& $Validator $current)) { throw "URL not allowed: $current" } $request = [System.Net.HttpWebRequest]::Create($current) $request.Method = $Method $request.AllowAutoRedirect = $false $request.AutomaticDecompression = [System.Net.DecompressionMethods]::GZip -bor [System.Net.DecompressionMethods]::Deflate $request.UserAgent = 'ps12exe-sandbox' $response = $null try { $response = $request.GetResponse() } catch [System.Net.WebException] { if ($_.Exception.Response) { $response = $_.Exception.Response } else { throw } } try { $status = [int]$response.StatusCode if ($status -ge 300 -and $status -lt 400) { $location = $response.Headers['Location'] $resolved = Resolve-GuestRedirectLocation $current $location if (-not $resolved) { throw "Blocked redirect target: $location" } $current = $resolved continue } if ($status -ge 400) { throw "HTTP $status for $current" } $bytes = [byte[]]@() if ($Method -eq 'GET') { $stream = $response.GetResponseStream() $memory = [System.IO.MemoryStream]::new() $buffer = [byte[]]::new(8192) try { while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { $memory.Write($buffer, 0, $read) if ($MaxBytes -gt 0 -and $memory.Length -gt $MaxBytes) { break } } } finally { $stream.Dispose() } $bytes = $memory.ToArray() $memory.Dispose() } $charset = $response.CharacterSet $encoding = if ($charset) { try { [System.Text.Encoding]::GetEncoding($charset) } catch { [System.Text.Encoding]::UTF8 } } else { [System.Text.Encoding]::UTF8 } return [pscustomobject]@{ StatusCode = $status ContentType = $response.ContentType Bytes = $bytes Text = if ($Method -eq 'GET') { $encoding.GetString($bytes) } else { '' } FinalUri = $current } } finally { $response.Close() } } throw "Too many redirects: $Url" } |