From 34508e0b448ad533e487cc492648208b693c33df Mon Sep 17 00:00:00 2001 From: David Paulson Date: Fri, 29 May 2026 14:15:30 -0500 Subject: [PATCH 1/3] Add mock rewrite rule data and unit tests for Get-URLRewriteRule Add inbound and outbound URL Rewrite rules to the E19 mock IIS config files to enable testing of rule extraction and inheritance logic. Create Get-URLRewriteRule.Tests.ps1 with 11 tests covering rule extraction from web.config, applicationHost.config per-location and global sections, inheritance walk-up, clear stops inheritance, remove entry collection, disabled rule preservation, and empty config handling. This is the test foundation for issue #2539 (Health Checker does not detect outbound rewrite rules). No production code is changed. Mock data additions: - applicationHost.config: global inbound rule, disabled inbound rule at Default Web Site, outbound rule at Default Web Site/owa, clear on inbound at Default Web Site/mapi - DefaultWebSite_web.config: CVE-2022-41040 style inbound rule - DefaultWebSite-OWA_web.config: CVE-2026-42897 style outbound rule - DefaultWebSite-MAPI_web.config: remove for inherited global rule Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tests/Get-URLRewriteRule.Tests.ps1 | 153 ++++++++++++++++++ .../IIS/DefaultWebSite-MAPI_web.config | 5 + .../IIS/DefaultWebSite-OWA_web.config | 17 ++ .../Exchange/IIS/DefaultWebSite_web.config | 13 ++ .../E19/Exchange/IIS/applicationHost.config | 41 +++++ 5 files changed, 229 insertions(+) create mode 100644 Diagnostics/HealthChecker/Analyzer/Tests/Get-URLRewriteRule.Tests.ps1 diff --git a/Diagnostics/HealthChecker/Analyzer/Tests/Get-URLRewriteRule.Tests.ps1 b/Diagnostics/HealthChecker/Analyzer/Tests/Get-URLRewriteRule.Tests.ps1 new file mode 100644 index 0000000000..f5e26f4d2f --- /dev/null +++ b/Diagnostics/HealthChecker/Analyzer/Tests/Get-URLRewriteRule.Tests.ps1 @@ -0,0 +1,153 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '', Justification = 'Pester testing file')] +[CmdletBinding()] +param() + +BeforeAll { + . $PSScriptRoot\..\..\..\..\Shared\PesterLoadFunctions.NotPublished.ps1 + $scriptContent = Get-PesterScriptContent -FilePath "$PSScriptRoot\..\Get-URLRewriteRule.ps1" + Invoke-Expression $scriptContent + function Invoke-CatchActions { throw "Called Invoke-CatchActions" } + + $Script:mockDataRoot = "$PSScriptRoot\..\..\Tests\DataCollection\E19\Exchange\IIS" + [xml]$Script:appHost = Get-Content "$Script:mockDataRoot\applicationHost.config" -Raw -Encoding UTF8 + + $Script:webConfigContent = @{ + "Default Web Site" = (Get-Content "$Script:mockDataRoot\DefaultWebSite_web.config" -Raw -Encoding UTF8) + "Default Web Site/owa" = (Get-Content "$Script:mockDataRoot\DefaultWebSite-OWA_web.config" -Raw -Encoding UTF8) + "Default Web Site/mapi" = (Get-Content "$Script:mockDataRoot\DefaultWebSite-MAPI_web.config" -Raw -Encoding UTF8) + "Default Web Site/EWS" = (Get-Content "$Script:mockDataRoot\DefaultWebSite-EWS_web.config" -Raw -Encoding UTF8) + } + + $Script:result = Get-URLRewriteRule -ApplicationHostConfig $Script:appHost -WebConfigContent $Script:webConfigContent +} + +Describe "Get-URLRewriteRule" { + + Context "Rule extraction from web.config" { + + It "Should find inbound rule from Default Web Site web.config" { + $siteRules = $Script:result["Default Web Site"] + $allRuleNames = @($siteRules.rule.name | Where-Object { $null -ne $_ }) + $allRuleNames | Should -Contain "CVE-2022-41040 Mitigation" + } + + It "Should collect remove entry from MAPI web.config" { + $mapiRules = $Script:result["Default Web Site/mapi"] + # First entry is from web.config which contains the element + $removeNames = @($mapiRules[0].remove.name) + $removeNames | Should -Contain "Global Block Bad User Agents" + } + } + + Context "Rule extraction from applicationHost.config per-location" { + + It "Should find disabled inbound rule from appHost Default Web Site location" { + $siteRules = $Script:result["Default Web Site"] + $allRuleNames = @($siteRules.rule.name | Where-Object { $null -ne $_ }) + $allRuleNames | Should -Contain "Disable HTTP - Redirect to HTTPS" + } + + It "Should preserve disabled attribute on appHost rule" { + $siteRules = $Script:result["Default Web Site"] + $disabledRule = $siteRules | ForEach-Object { $_.rule } | + Where-Object { $_.name -eq "Disable HTTP - Redirect to HTTPS" } + $disabledRule.enabled | Should -Be "false" + } + } + + Context "Rule extraction from applicationHost.config global section" { + + It "Should find global inbound rule" { + $siteRules = $Script:result["Default Web Site"] + $allRuleNames = @($siteRules.rule.name | Where-Object { $null -ne $_ }) + $allRuleNames | Should -Contain "Global Block Bad User Agents" + } + } + + Context "Inheritance walk-up" { + + It "Should collect rules from all 3 levels for Default Web Site" { + # web.config (CVE-2022-41040) + appHost location (disabled HTTPS redirect) + global (Block Bad User Agents) + $Script:result["Default Web Site"].Count | Should -Be 3 + } + + It "Should inherit parent and global rules for Default Web Site/owa" { + # OWA web.config has no inbound rules (only outbound which the function cannot see yet) + # OWA appHost location has no inbound rules (only outbound) + # Walks up to Default Web Site: web.config has CVE-2022-41040, appHost has disabled HTTPS redirect + # Then global has Block Bad User Agents + $owaRules = $Script:result["Default Web Site/owa"] + $allRuleNames = @($owaRules.rule.name | Where-Object { $null -ne $_ }) + $allRuleNames | Should -Contain "CVE-2022-41040 Mitigation" + $allRuleNames | Should -Contain "Disable HTTP - Redirect to HTTPS" + $allRuleNames | Should -Contain "Global Block Bad User Agents" + } + + It "Should inherit rules for vDir with no rewrite config" { + # EWS web.config has no rewrite section at all + # Should inherit from parent Default Web Site and global + $ewsRules = $Script:result["Default Web Site/EWS"] + $allRuleNames = @($ewsRules.rule.name | Where-Object { $null -ne $_ }) + $allRuleNames | Should -Contain "CVE-2022-41040 Mitigation" + $allRuleNames | Should -Contain "Global Block Bad User Agents" + } + } + + Context "Clear stops inheritance" { + + It "Should stop at clear in appHost location for Default Web Site/mapi" { + # MAPI web.config has (collected but no clear) + # MAPI appHost location has which stops inheritance + # Should NOT contain parent Default Web Site rules or global rules + $mapiRules = $Script:result["Default Web Site/mapi"] + $mapiRules.Count | Should -Be 2 + $allRuleNames = @($mapiRules.rule.name | Where-Object { $null -ne $_ }) + $allRuleNames | Should -Not -Contain "CVE-2022-41040 Mitigation" + $allRuleNames | Should -Not -Contain "Global Block Bad User Agents" + } + + It "Should stop at clear in web.config and not check appHost or parent" { + # Edge case: clear in web.config is a different code path (line 52) than clear in appHost (line 75) + # Our mock data only has clear in appHost, so use inline XML for this specific path + $clearWebConfig = '' + $emptyWebConfig = '' + $webConfigs = @{ + "Default Web Site/owa" = $clearWebConfig + "Default Web Site" = $emptyWebConfig + } + + $result = Get-URLRewriteRule -ApplicationHostConfig $Script:appHost -WebConfigContent $webConfigs + + # Should only have 1 entry (the clear node from web.config) - nothing from appHost or parent + $result["Default Web Site/owa"].Count | Should -Be 1 + $null -ne $result["Default Web Site/owa"][0].clear | Should -Be $true + } + } + + Context "Empty rewrite sections" { + + It "Should return empty list when no rewrite rules exist at any level" { + $emptyWebConfig = '' + [xml]$emptyAppHost = @" + + + + + + + +"@ + $webConfigs = @{ + "Default Web Site/test" = $emptyWebConfig + } + + $result = Get-URLRewriteRule -ApplicationHostConfig $emptyAppHost -WebConfigContent $webConfigs + + $result.ContainsKey("Default Web Site/test") | Should -Be $true + $result["Default Web Site/test"].Count | Should -Be 0 + } + } +} diff --git a/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/DefaultWebSite-MAPI_web.config b/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/DefaultWebSite-MAPI_web.config index 06a7ca1990..109ec0045e 100644 --- a/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/DefaultWebSite-MAPI_web.config +++ b/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/DefaultWebSite-MAPI_web.config @@ -46,6 +46,11 @@ + + + + + diff --git a/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/DefaultWebSite-OWA_web.config b/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/DefaultWebSite-OWA_web.config index 434ba7e695..220dd4807a 100644 --- a/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/DefaultWebSite-OWA_web.config +++ b/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/DefaultWebSite-OWA_web.config @@ -93,6 +93,23 @@ + + + + + + + + + + + + + + + + + diff --git a/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/DefaultWebSite_web.config b/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/DefaultWebSite_web.config index d79d254d32..7d1d3b446f 100644 --- a/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/DefaultWebSite_web.config +++ b/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/DefaultWebSite_web.config @@ -40,6 +40,19 @@ + + + + + + + + + + + + + diff --git a/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config b/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config index ad1b6ea4f7..7c76683deb 100644 --- a/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config +++ b/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config @@ -1191,6 +1191,18 @@ + + + + + + + + + + + + @@ -1304,6 +1316,17 @@ + + + + + + + + + + + @@ -1962,6 +1985,19 @@ + + + + + + + + + + + + + @@ -2369,6 +2405,11 @@ + + + + + From 703fa0a0d287a2570c6d74d28e2f33cb37c56afb Mon Sep 17 00:00:00 2001 From: David Paulson Date: Thu, 4 Jun 2026 09:00:43 -0500 Subject: [PATCH 2/3] Add outbound URL rewrite rule extraction, display, and tests Extend Get-URLRewriteRule to extract outbound rules (.rewrite.outboundRules) alongside inbound rules with independent clear/inheritance tracking. Return type changed from hashtable to PSCustomObject with Inbound/Outbound. Update Invoke-AnalyzerIISInformation to display outbound rules in the vDir summary table (OutURLRewrite column) and detailed rule display section. Fix pre-existing -notcontains bug (reversed operands) in remove exclusion filter for both inbound and outbound rule display. Add 6 outbound unit tests and scenario test assertions for URL rewrite rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Analyzer/Get-URLRewriteRule.ps1 | 101 ++++++++++++---- .../Invoke-AnalyzerIISInformation.ps1 | 77 +++++++++++- .../Tests/Get-URLRewriteRule.Tests.ps1 | 112 +++++++++++++++--- .../Tests/HealthChecker.E19.Main.Tests.ps1 | 14 +++ 4 files changed, 259 insertions(+), 45 deletions(-) diff --git a/Diagnostics/HealthChecker/Analyzer/Get-URLRewriteRule.ps1 b/Diagnostics/HealthChecker/Analyzer/Get-URLRewriteRule.ps1 index 8118a81b07..1cb97c214e 100644 --- a/Diagnostics/HealthChecker/Analyzer/Get-URLRewriteRule.ps1 +++ b/Diagnostics/HealthChecker/Analyzer/Get-URLRewriteRule.ps1 @@ -3,9 +3,10 @@ <# .SYNOPSIS - Pulls out URL Rewrite Rules from the web.config and applicationHost.config file to return a Hashtable of those settings. + Pulls out URL Rewrite Rules from the web.config and applicationHost.config file to return inbound and outbound rules. .DESCRIPTION This is a function that is designed to pull out the URL Rewrite Rules that are set on a location of IIS. + It extracts both inbound rules (from rewrite/rules) and outbound rules (from rewrite/outboundRules). Because you can set it on an individual web.config file or the parent site(s), or the ApplicationHostConfig file for the location We need to check all locations to properly determine what is all set. The ApplicationHostConfig file must be able to be converted to Xml, but the web.config file doesn't. @@ -14,11 +15,12 @@ 2. ApplicationHost.config file for the same location 3. Then move up one level (Default Web Site/mapi -> Default Web Site) and repeat 1 and 2 till no more locations. a. If the 'clear' flag was set at any point, we stop at that location in the process. + b. Inbound and outbound rules track the 'clear' flag independently. 4. Then there is a global setting in the ApplicationHost.config file. #> function Get-URLRewriteRule { [CmdletBinding()] - [OutputType([hashtable])] + [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $true)] [System.Xml.XmlNode]$ApplicationHostConfig, @@ -31,51 +33,82 @@ function Get-URLRewriteRule { begin { Write-Verbose "Calling: $($MyInvocation.MyCommand)" $urlRewriteRules = @{} + $urlOutboundRewriteRules = @{} $appHostConfigLocations = $ApplicationHostConfig.configuration.Location.path } process { foreach ($key in $WebConfigContent.Keys) { Write-Verbose "Working on key: $key" $continue = $true - $clear = $false + $clearInbound = $false + $clearOutbound = $false $currentKey = $key $urlRewriteRules.Add($key, (New-Object System.Collections.Generic.List[object])) + $urlOutboundRewriteRules.Add($key, (New-Object System.Collections.Generic.List[object])) do { Write-Verbose "Working on currentKey: $currentKey" try { # the Web.config is looked at first [xml]$content = $WebConfigContent[$currentKey] - $rules = $content.configuration.'system.webServer'.rewrite.rules - if ($null -ne $rules) { - $clear = $null -ne $rules.clear - $urlRewriteRules[$key].Add($rules) - } else { - Write-Verbose "No rewrite rules in the config file" + if (-not $clearInbound) { + $rules = $content.configuration.'system.webServer'.rewrite.rules + + if ($null -ne $rules) { + $clearInbound = $null -ne $rules.clear + $urlRewriteRules[$key].Add($rules) + } else { + Write-Verbose "No inbound rewrite rules in the config file" + } + } + + if (-not $clearOutbound) { + $outboundRules = $content.configuration.'system.webServer'.rewrite.outboundRules + + if ($null -ne $outboundRules) { + $clearOutbound = $null -ne $outboundRules.clear + $urlOutboundRewriteRules[$key].Add($outboundRules) + } else { + Write-Verbose "No outbound rewrite rules in the config file" + } } } catch { Write-Verbose "Failed to convert to xml" Invoke-CatchActions } - if (-not $clear) { + if (-not $clearInbound -or -not $clearOutbound) { # Now need to look at the applicationHost.config file to determine what is set at that location. # need to do this because of the case sensitive query to get the xmlNode - Write-Verbose "clear not set on config. Looking at the applicationHost.config file" + Write-Verbose "Looking at the applicationHost.config file" $appKey = $appHostConfigLocations | Where-Object { $_ -eq $currentKey } if ($appKey.Count -eq 1) { $location = $ApplicationHostConfig.SelectNodes("/configuration/location[@path = '$appKey']") if ($null -ne $location) { - $rules = $location.'system.webServer'.rewrite.rules - if ($null -ne $rules) { - $clear = $null -ne $rules.clear - $urlRewriteRules[$key].Add($rules) - } else { - Write-Verbose 'No rewrite rules in the applicationHost.config file' + if (-not $clearInbound) { + $rules = $location.'system.webServer'.rewrite.rules + + if ($null -ne $rules) { + $clearInbound = $null -ne $rules.clear + $urlRewriteRules[$key].Add($rules) + } else { + Write-Verbose "No inbound rewrite rules in the applicationHost.config file" + } + } + + if (-not $clearOutbound) { + $outboundRules = $location.'system.webServer'.rewrite.outboundRules + + if ($null -ne $outboundRules) { + $clearOutbound = $null -ne $outboundRules.clear + $urlOutboundRewriteRules[$key].Add($outboundRules) + } else { + Write-Verbose "No outbound rewrite rules in the applicationHost.config file" + } } } else { Write-Verbose "We didn't find the location for '$appKey' in the applicationHostConfig. This shouldn't occur." @@ -85,21 +118,34 @@ function Get-URLRewriteRule { } } - if ($clear) { - Write-Verbose "Clear was set, don't need to know what else was set." + if ($clearInbound -and $clearOutbound) { + Write-Verbose "Clear was set for both inbound and outbound, don't need to know what else was set." $continue = $false } else { $index = $currentKey.LastIndexOf("/") if ($index -eq -1) { $continue = $false - # look at the global configuration of the applicationHost.config file - $rules = $ApplicationHostConfig.configuration.'system.webServer'.rewrite.rules - if ($null -ne $rules) { - $urlRewriteRules[$key].Add($rules) - } else { - Write-Verbose "No global configuration for rewrite rules." + if (-not $clearInbound) { + # look at the global configuration of the applicationHost.config file + $rules = $ApplicationHostConfig.configuration.'system.webServer'.rewrite.rules + + if ($null -ne $rules) { + $urlRewriteRules[$key].Add($rules) + } else { + Write-Verbose "No global configuration for inbound rewrite rules." + } + } + + if (-not $clearOutbound) { + $outboundRules = $ApplicationHostConfig.configuration.'system.webServer'.rewrite.outboundRules + + if ($null -ne $outboundRules) { + $urlOutboundRewriteRules[$key].Add($outboundRules) + } else { + Write-Verbose "No global configuration for outbound rewrite rules." + } } } else { $currentKey = $currentKey.Substring(0, $index) @@ -111,6 +157,9 @@ function Get-URLRewriteRule { } } end { - return $urlRewriteRules + return [PSCustomObject]@{ + Inbound = $urlRewriteRules + Outbound = $urlOutboundRewriteRules + } } } diff --git a/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 b/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 index a403bea7e6..7bc441c453 100644 --- a/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 +++ b/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 @@ -416,10 +416,12 @@ function Invoke-AnalyzerIISInformation { WebConfigContent = $iisWebConfigContent } - $urlRewriteRules = $null + $urlRewriteResult = $null $ipFilterSettings = $null $authTypeSettings = $null - Get-URLRewriteRule @ruleParams | Invoke-RemotePipelineHandler -Result ([ref]$urlRewriteRules) + Get-URLRewriteRule @ruleParams | Invoke-RemotePipelineHandler -Result ([ref]$urlRewriteResult) + $urlRewriteRules = $urlRewriteResult.Inbound + $urlOutboundRewriteRules = $urlRewriteResult.Outbound Get-IPFilterSetting -ApplicationHostConfig ([xml]$applicationHostConfig) | Invoke-RemotePipelineHandler -Result ([ref]$ipFilterSettings) Get-IISAuthenticationType -ApplicationHostConfig ([xml]$applicationHostConfig) | Invoke-RemotePipelineHandler -Result ([ref]$authTypeSettings) $failedLocationsForAuth = @() @@ -440,6 +442,7 @@ function Invoke-AnalyzerIISInformation { $epValue = "None" $ep = $extendedProtectionConfiguration | Where-Object { $_.VirtualDirectoryName -eq $location.Path } $currentRewriteRules = $urlRewriteRules[$location.Path] + $currentOutboundRewriteRules = $urlOutboundRewriteRules[$location.Path] $authentication = $authTypeSettings[$location.Path] if ($currentRewriteRules.Count -ne 0) { @@ -455,7 +458,23 @@ function Invoke-AnalyzerIISInformation { } $displayRewriteRules = ($currentRewriteRules.rule | Where-Object { $_.enabled -ne "false" }).name | - Where-Object { $_ -notcontains $excludeRules } + Where-Object { $_ -notin $excludeRules } + } + + $displayOutboundRewriteRules = [string]::Empty + + if ($currentOutboundRewriteRules.Count -ne 0) { + $excludeOutboundRules = @() + foreach ($rule in $currentOutboundRewriteRules) { + $remove = $rule.Remove + + if ($null -ne $remove) { + $excludeOutboundRules += $remove.Name + } + } + + $displayOutboundRewriteRules = ($currentOutboundRewriteRules.rule | Where-Object { $_.enabled -ne "false" }).name | + Where-Object { $_ -notin $excludeOutboundRules } } if ($null -ne $ep) { @@ -484,7 +503,8 @@ function Invoke-AnalyzerIISInformation { ExtendedProtection = $epValue SslFlags = $sslFlag IPFilteringEnabled = $ipFilterEnabled - URLRewrite = $displayRewriteRules + InURLRewrite = $displayRewriteRules + OutURLRewrite = $displayOutboundRewriteRules Authentication = $authentication }) } @@ -635,6 +655,7 @@ function Invoke-AnalyzerIISInformation { IndentSpaces = 8 }) AddHtmlDetailRow = $false + TestingName = "Inbound URL Rewrite Rules" } Add-AnalyzedResultInformation @params @@ -651,6 +672,54 @@ function Invoke-AnalyzerIISInformation { } } + # Display Outbound URL Rewrite Rules. + # Same deduplication pattern as inbound - don't display rules on multiple vDirs by same name. + $alreadyDisplayedOutboundRules = @{} + $outboundDisplayKey = "DisplayKey" + $alreadyDisplayedOutboundRules.Add($outboundDisplayKey, (New-Object System.Collections.Generic.List[object])) + + foreach ($key in $urlOutboundRewriteRules.Keys) { + $currentSection = $urlOutboundRewriteRules[$key] + + if ($currentSection.Count -ne 0) { + foreach ($rule in $currentSection.rule) { + + if ($null -eq $rule) { + Write-Verbose "Outbound rule is NULL skipping." + continue + } elseif ($rule.enabled -eq "false") { + Write-Verbose "skipping over disabled outbound rule: $($rule.Name) for vDir '$key'" + continue + } + + $displayObject = [PSCustomObject]@{ + RewriteRuleName = $rule.name + ServerVariable = $rule.match.serverVariable + MatchPattern = $rule.match.pattern + PreCondition = $rule.preCondition + ActionType = $rule.action.type + } + + if (-not ($alreadyDisplayedOutboundRules.ContainsKey((($displayObject.RewriteRuleName))))) { + $alreadyDisplayedOutboundRules.Add($displayObject.RewriteRuleName, $displayObject) + $alreadyDisplayedOutboundRules[$outboundDisplayKey].Add($displayObject) + } + } + } + } + + if ($alreadyDisplayedOutboundRules[$outboundDisplayKey].Count -gt 0) { + $params = $baseParams + @{ + OutColumns = ([PSCustomObject]@{ + DisplayObject = $alreadyDisplayedOutboundRules[$outboundDisplayKey] + IndentSpaces = 8 + }) + AddHtmlDetailRow = $false + TestingName = "Outbound URL Rewrite Rules" + } + Add-AnalyzedResultInformation @params + } + foreach ($webApp in $iisWebApplications) { if ($correctLocations.ContainsKey($webApp.FriendlyName)) { if ($webApp.PhysicalPath -notlike "*$($correctLocations[$webApp.FriendlyName])") { diff --git a/Diagnostics/HealthChecker/Analyzer/Tests/Get-URLRewriteRule.Tests.ps1 b/Diagnostics/HealthChecker/Analyzer/Tests/Get-URLRewriteRule.Tests.ps1 index f5e26f4d2f..0029955fed 100644 --- a/Diagnostics/HealthChecker/Analyzer/Tests/Get-URLRewriteRule.Tests.ps1 +++ b/Diagnostics/HealthChecker/Analyzer/Tests/Get-URLRewriteRule.Tests.ps1 @@ -29,13 +29,13 @@ Describe "Get-URLRewriteRule" { Context "Rule extraction from web.config" { It "Should find inbound rule from Default Web Site web.config" { - $siteRules = $Script:result["Default Web Site"] + $siteRules = $Script:result.Inbound["Default Web Site"] $allRuleNames = @($siteRules.rule.name | Where-Object { $null -ne $_ }) $allRuleNames | Should -Contain "CVE-2022-41040 Mitigation" } It "Should collect remove entry from MAPI web.config" { - $mapiRules = $Script:result["Default Web Site/mapi"] + $mapiRules = $Script:result.Inbound["Default Web Site/mapi"] # First entry is from web.config which contains the element $removeNames = @($mapiRules[0].remove.name) $removeNames | Should -Contain "Global Block Bad User Agents" @@ -45,13 +45,13 @@ Describe "Get-URLRewriteRule" { Context "Rule extraction from applicationHost.config per-location" { It "Should find disabled inbound rule from appHost Default Web Site location" { - $siteRules = $Script:result["Default Web Site"] + $siteRules = $Script:result.Inbound["Default Web Site"] $allRuleNames = @($siteRules.rule.name | Where-Object { $null -ne $_ }) $allRuleNames | Should -Contain "Disable HTTP - Redirect to HTTPS" } It "Should preserve disabled attribute on appHost rule" { - $siteRules = $Script:result["Default Web Site"] + $siteRules = $Script:result.Inbound["Default Web Site"] $disabledRule = $siteRules | ForEach-Object { $_.rule } | Where-Object { $_.name -eq "Disable HTTP - Redirect to HTTPS" } $disabledRule.enabled | Should -Be "false" @@ -61,7 +61,7 @@ Describe "Get-URLRewriteRule" { Context "Rule extraction from applicationHost.config global section" { It "Should find global inbound rule" { - $siteRules = $Script:result["Default Web Site"] + $siteRules = $Script:result.Inbound["Default Web Site"] $allRuleNames = @($siteRules.rule.name | Where-Object { $null -ne $_ }) $allRuleNames | Should -Contain "Global Block Bad User Agents" } @@ -71,15 +71,15 @@ Describe "Get-URLRewriteRule" { It "Should collect rules from all 3 levels for Default Web Site" { # web.config (CVE-2022-41040) + appHost location (disabled HTTPS redirect) + global (Block Bad User Agents) - $Script:result["Default Web Site"].Count | Should -Be 3 + $Script:result.Inbound["Default Web Site"].Count | Should -Be 3 } It "Should inherit parent and global rules for Default Web Site/owa" { - # OWA web.config has no inbound rules (only outbound which the function cannot see yet) + # OWA web.config has no inbound rules (only outbound) # OWA appHost location has no inbound rules (only outbound) # Walks up to Default Web Site: web.config has CVE-2022-41040, appHost has disabled HTTPS redirect # Then global has Block Bad User Agents - $owaRules = $Script:result["Default Web Site/owa"] + $owaRules = $Script:result.Inbound["Default Web Site/owa"] $allRuleNames = @($owaRules.rule.name | Where-Object { $null -ne $_ }) $allRuleNames | Should -Contain "CVE-2022-41040 Mitigation" $allRuleNames | Should -Contain "Disable HTTP - Redirect to HTTPS" @@ -89,7 +89,7 @@ Describe "Get-URLRewriteRule" { It "Should inherit rules for vDir with no rewrite config" { # EWS web.config has no rewrite section at all # Should inherit from parent Default Web Site and global - $ewsRules = $Script:result["Default Web Site/EWS"] + $ewsRules = $Script:result.Inbound["Default Web Site/EWS"] $allRuleNames = @($ewsRules.rule.name | Where-Object { $null -ne $_ }) $allRuleNames | Should -Contain "CVE-2022-41040 Mitigation" $allRuleNames | Should -Contain "Global Block Bad User Agents" @@ -102,7 +102,7 @@ Describe "Get-URLRewriteRule" { # MAPI web.config has (collected but no clear) # MAPI appHost location has which stops inheritance # Should NOT contain parent Default Web Site rules or global rules - $mapiRules = $Script:result["Default Web Site/mapi"] + $mapiRules = $Script:result.Inbound["Default Web Site/mapi"] $mapiRules.Count | Should -Be 2 $allRuleNames = @($mapiRules.rule.name | Where-Object { $null -ne $_ }) $allRuleNames | Should -Not -Contain "CVE-2022-41040 Mitigation" @@ -122,14 +122,94 @@ Describe "Get-URLRewriteRule" { $result = Get-URLRewriteRule -ApplicationHostConfig $Script:appHost -WebConfigContent $webConfigs # Should only have 1 entry (the clear node from web.config) - nothing from appHost or parent - $result["Default Web Site/owa"].Count | Should -Be 1 - $null -ne $result["Default Web Site/owa"][0].clear | Should -Be $true + $result.Inbound["Default Web Site/owa"].Count | Should -Be 1 + $null -ne $result.Inbound["Default Web Site/owa"][0].clear | Should -Be $true + } + } + + Context "Outbound rule extraction from web.config" { + + It "Should find outbound rule from OWA web.config" { + $owaOutbound = $Script:result.Outbound["Default Web Site/owa"] + $allRuleNames = @($owaOutbound.rule.name | Where-Object { $null -ne $_ }) + $allRuleNames | Should -Contain "EOMT OWA CSP - outbound" + } + } + + Context "Outbound rule extraction from applicationHost.config per-location" { + + It "Should find outbound rule from appHost OWA location" { + $owaOutbound = $Script:result.Outbound["Default Web Site/owa"] + $allRuleNames = @($owaOutbound.rule.name | Where-Object { $null -ne $_ }) + $allRuleNames | Should -Contain "AppHost OWA Outbound Test" + } + } + + Context "Outbound rule structure" { + + It "Should preserve preCondition attribute on outbound rule" { + $owaOutbound = $Script:result.Outbound["Default Web Site/owa"] + $outboundRule = $owaOutbound | ForEach-Object { $_.rule } | + Where-Object { $_.name -eq "EOMT OWA CSP - outbound" } + $outboundRule.preCondition | Should -Be "EOMT OWA SPA HTML shell - precondition" + } + + It "Should have serverVariable on outbound match" { + $owaOutbound = $Script:result.Outbound["Default Web Site/owa"] + $outboundRule = $owaOutbound | ForEach-Object { $_.rule } | + Where-Object { $_.name -eq "EOMT OWA CSP - outbound" } + $outboundRule.match.serverVariable | Should -Be "RESPONSE_Content_Security_Policy" + } + } + + Context "Outbound inheritance and independent clear tracking" { + + It "Should have empty outbound for vDir with no outbound rules at any level" { + $ewsOutbound = $Script:result.Outbound["Default Web Site/EWS"] + $ewsOutbound.Count | Should -Be 0 + } + + It "Should continue outbound walk-up when only inbound has clear" { + # Inbound clear at child level should not block outbound from inheriting parent rules + $childConfig = '' + $parentConfig = @" + + + + + + +"@ + [xml]$testAppHost = @" + + + + + + + + + + +"@ + $webConfigs = @{ + "Default Web Site/test" = $childConfig + "Default Web Site" = $parentConfig + } + + $result = Get-URLRewriteRule -ApplicationHostConfig $testAppHost -WebConfigContent $webConfigs + + # Inbound: should have 1 entry (the clear node) - walk-up stopped + $result.Inbound["Default Web Site/test"].Count | Should -Be 1 + # Outbound: should have inherited from parent - walk-up NOT blocked by inbound clear + $outboundNames = @($result.Outbound["Default Web Site/test"].rule.name | Where-Object { $null -ne $_ }) + $outboundNames | Should -Contain "Parent Outbound Rule" } } Context "Empty rewrite sections" { - It "Should return empty list when no rewrite rules exist at any level" { + It "Should return empty lists when no rewrite rules exist at any level" { $emptyWebConfig = '' [xml]$emptyAppHost = @" @@ -146,8 +226,10 @@ Describe "Get-URLRewriteRule" { $result = Get-URLRewriteRule -ApplicationHostConfig $emptyAppHost -WebConfigContent $webConfigs - $result.ContainsKey("Default Web Site/test") | Should -Be $true - $result["Default Web Site/test"].Count | Should -Be 0 + $result.Inbound.ContainsKey("Default Web Site/test") | Should -Be $true + $result.Inbound["Default Web Site/test"].Count | Should -Be 0 + $result.Outbound.ContainsKey("Default Web Site/test") | Should -Be $true + $result.Outbound["Default Web Site/test"].Count | Should -Be 0 } } } diff --git a/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 b/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 index 349212f656..970c15a7d5 100644 --- a/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 +++ b/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 @@ -173,6 +173,20 @@ Describe "Testing Health Checker by Mock Data Imports" { SetActiveDisplayGrouping "Exchange IIS Information" $tokenCacheModuleInformation = GetObject "TokenCacheModule loaded" $tokenCacheModuleInformation | Should -Be $null # null because we are loaded and only display if we aren't loaded. + + # Verify inbound URL rewrite rules are displayed (deduplicated across vDirs) + $inboundRules = GetObject "Inbound URL Rewrite Rules" + $inboundRules.Count | Should -Be 2 + $inboundRuleNames = $inboundRules.RewriteRuleName.Value + $inboundRuleNames | Should -Contain "CVE-2022-41040 Mitigation" + $inboundRuleNames | Should -Contain "Global Block Bad User Agents" + + # Verify outbound URL rewrite rules are displayed (deduplicated across vDirs) + $outboundRules = GetObject "Outbound URL Rewrite Rules" + $outboundRules.Count | Should -Be 2 + $outboundRuleNames = $outboundRules.RewriteRuleName.Value + $outboundRuleNames | Should -Contain "EOMT OWA CSP - outbound" + $outboundRuleNames | Should -Contain "AppHost OWA Outbound Test" } } From b83bd3b3a8504748594fc0f8dd8152caea3c361e Mon Sep 17 00:00:00 2001 From: David Paulson Date: Thu, 4 Jun 2026 09:26:37 -0500 Subject: [PATCH 3/3] Add yellow warning for global IIS rewrite rules Check applicationHost.config for globalRules entries and display a yellow warning when detected, as these apply server-wide and could interfere with Exchange traffic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Analyzer/Invoke-AnalyzerIISInformation.ps1 | 14 ++++++++++++++ .../E19/Exchange/IIS/applicationHost.config | 9 +++++++++ .../Tests/HealthChecker.E19.Main.Tests.ps1 | 4 ++++ 3 files changed, 27 insertions(+) diff --git a/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 b/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 index 7bc441c453..e0e1cc14c7 100644 --- a/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 +++ b/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 @@ -720,6 +720,20 @@ function Invoke-AnalyzerIISInformation { Add-AnalyzedResultInformation @params } + $globalRules = ([xml]$applicationHostConfig).configuration.'system.webServer'.rewrite.globalRules + + if ($null -ne $globalRules -and + $null -ne $globalRules.rule) { + $params = $baseParams + @{ + Name = "Global IIS Rewrite Rules Detected" + Details = "Global URL Rewrite rules are defined in applicationHost.config and apply to all sites on this server." + + "`r`n`t`tReview these rules to ensure they are expected and not interfering with Exchange traffic." + DisplayWriteType = "Yellow" + TestingName = "Global IIS Rewrite Rules" + } + Add-AnalyzedResultInformation @params + } + foreach ($webApp in $iisWebApplications) { if ($correctLocations.ContainsKey($webApp.FriendlyName)) { if ($webApp.PhysicalPath -notlike "*$($correctLocations[$webApp.FriendlyName])") { diff --git a/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config b/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config index 7c76683deb..27a318bcc9 100644 --- a/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config +++ b/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config @@ -1192,6 +1192,15 @@ + + + + + + + + + diff --git a/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 b/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 index 970c15a7d5..3ce3d7e320 100644 --- a/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 +++ b/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 @@ -187,6 +187,10 @@ Describe "Testing Health Checker by Mock Data Imports" { $outboundRuleNames = $outboundRules.RewriteRuleName.Value $outboundRuleNames | Should -Contain "EOMT OWA CSP - outbound" $outboundRuleNames | Should -Contain "AppHost OWA Outbound Test" + + # Verify global IIS rewrite rules warning is displayed + $globalRulesWarning = GetObject "Global IIS Rewrite Rules" + $globalRulesWarning | Should -Not -BeNullOrEmpty } }