From 4ad80de0a035e49236547590cf513a7d66d9aa6f Mon Sep 17 00:00:00 2001 From: David Paulson Date: Fri, 5 Jun 2026 13:38:59 -0500 Subject: [PATCH 1/3] Fix detailed URL rewrite display ignoring directives The detailed inbound and outbound URL rewrite rule display loops did not honor entries from inherited configuration sections. Rules that were explicitly removed via in a parent web.config still appeared in the detailed rule table and could be incorrectly flagged as misconfigured. Collect exclude lists from each vDir's Remove.Name entries and skip matching rules in both inbound and outbound detailed display loops. Added mock data (AppHost Only Rule + remove directive) and test assertion to verify removed rules do not appear in detailed output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Invoke-AnalyzerIISInformation.ps1 | 21 +++++++++++++++++++ .../Exchange/IIS/DefaultWebSite_web.config | 1 + .../E19/Exchange/IIS/applicationHost.config | 4 ++++ .../Tests/HealthChecker.E19.Main.Tests.ps1 | 3 +++ 4 files changed, 29 insertions(+) diff --git a/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 b/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 index e0e1cc14c7..d9a7c3dc76 100644 --- a/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 +++ b/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 @@ -612,6 +612,14 @@ function Invoke-AnalyzerIISInformation { $currentSection = $urlRewriteRules[$key] if ($currentSection.Count -ne 0) { + # Collect entries so inherited rules that are removed at a lower level are excluded. + $excludeRules = @() + foreach ($section in $currentSection) { + if ($null -ne $section.Remove) { + $excludeRules += $section.Remove.Name + } + } + foreach ($rule in $currentSection.rule) { if ($null -eq $rule) { @@ -621,6 +629,9 @@ function Invoke-AnalyzerIISInformation { # skip over disabled rules. Write-Verbose "skipping over disabled rule: $($rule.Name) for vDir '$key'" continue + } elseif ($rule.Name -in $excludeRules) { + Write-Verbose "skipping removed rule: $($rule.Name) for vDir '$key'" + continue } #multiple match type possibilities, but should only be one per rule. @@ -682,6 +693,13 @@ function Invoke-AnalyzerIISInformation { $currentSection = $urlOutboundRewriteRules[$key] if ($currentSection.Count -ne 0) { + $excludeOutboundRules = @() + foreach ($section in $currentSection) { + if ($null -ne $section.Remove) { + $excludeOutboundRules += $section.Remove.Name + } + } + foreach ($rule in $currentSection.rule) { if ($null -eq $rule) { @@ -690,6 +708,9 @@ function Invoke-AnalyzerIISInformation { } elseif ($rule.enabled -eq "false") { Write-Verbose "skipping over disabled outbound rule: $($rule.Name) for vDir '$key'" continue + } elseif ($rule.Name -in $excludeOutboundRules) { + Write-Verbose "skipping removed outbound rule: $($rule.Name) for vDir '$key'" + continue } $displayObject = [PSCustomObject]@{ 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 7d1d3b446f..40ac5b0c4f 100644 --- a/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/DefaultWebSite_web.config +++ b/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/DefaultWebSite_web.config @@ -43,6 +43,7 @@ + diff --git a/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config b/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config index 27a318bcc9..d190770f70 100644 --- a/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config +++ b/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config @@ -1334,6 +1334,10 @@ + + + + diff --git a/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 b/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 index 4ad67cffc8..b387288bbf 100644 --- a/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 +++ b/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 @@ -244,6 +244,9 @@ Describe "Testing Health Checker by Mock Data Imports" { $inboundRuleNames | Should -Contain "CVE-2022-41040 Mitigation" $inboundRuleNames | Should -Contain "Global Block Bad User Agents" + # Verify rules excluded by in DWS web.config do not appear in detailed display + $inboundRuleNames | Should -Not -Contain "AppHost Only Rule" + # Verify outbound URL rewrite rules are displayed (deduplicated across vDirs) $outboundRules = GetObject "Outbound URL Rewrite Rules" $outboundRules.Count | Should -Be 2 From c89525671d8a67d0b0a82e6d26c6312dfa0ed763 Mon Sep 17 00:00:00 2001 From: David Paulson Date: Fri, 5 Jun 2026 14:29:20 -0500 Subject: [PATCH 2/3] Process appHost-only IIS locations in URL rewrite rule extraction Some IIS locations like Microsoft-Server-ActiveSync/Proxy exist only in applicationHost.config and are not returned by Get-WebApplication. Get-URLRewriteRule previously only iterated WebConfigContent keys, so these appHost-only locations were skipped entirely and their inherited rewrite rules never appeared in the display. Build a combined location list from WebConfigContent keys plus any appHost location paths not already present. The existing walk-up logic handles null web.config content gracefully, so no loop body changes are needed. Added 3 unit tests using shared mock data covering appHost-only location processing, parent rule inheritance, and key deduplication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Analyzer/Get-URLRewriteRule.ps1 | 16 +++++++++++- .../Tests/Get-URLRewriteRule.Tests.ps1 | 26 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/Diagnostics/HealthChecker/Analyzer/Get-URLRewriteRule.ps1 b/Diagnostics/HealthChecker/Analyzer/Get-URLRewriteRule.ps1 index 1cb97c214e..a0d9bdaf82 100644 --- a/Diagnostics/HealthChecker/Analyzer/Get-URLRewriteRule.ps1 +++ b/Diagnostics/HealthChecker/Analyzer/Get-URLRewriteRule.ps1 @@ -37,7 +37,21 @@ function Get-URLRewriteRule { $appHostConfigLocations = $ApplicationHostConfig.configuration.Location.path } process { - foreach ($key in $WebConfigContent.Keys) { + # Build combined location list: WebConfigContent keys + appHost-only locations. + # Some IIS locations (e.g., EAS/Proxy) exist only in applicationHost.config and have + # no web.config entry from Get-WebApplication. We still need to walk up inheritance for them. + $allLocations = [System.Collections.Generic.List[string]]::new() + foreach ($wcKey in $WebConfigContent.Keys) { + $allLocations.Add($wcKey) + } + foreach ($appHostPath in $appHostConfigLocations) { + if (-not [string]::IsNullOrEmpty($appHostPath) -and + -not $WebConfigContent.ContainsKey($appHostPath)) { + $allLocations.Add($appHostPath) + } + } + + foreach ($key in $allLocations) { Write-Verbose "Working on key: $key" $continue = $true $clearInbound = $false diff --git a/Diagnostics/HealthChecker/Analyzer/Tests/Get-URLRewriteRule.Tests.ps1 b/Diagnostics/HealthChecker/Analyzer/Tests/Get-URLRewriteRule.Tests.ps1 index 0029955fed..90ce88ee86 100644 --- a/Diagnostics/HealthChecker/Analyzer/Tests/Get-URLRewriteRule.Tests.ps1 +++ b/Diagnostics/HealthChecker/Analyzer/Tests/Get-URLRewriteRule.Tests.ps1 @@ -96,6 +96,32 @@ Describe "Get-URLRewriteRule" { } } + Context "AppHost-only locations (no web.config entry)" { + + It "Should process Proxy location that only exists in appHost" { + # EAS/Proxy exists in applicationHost.config but is not returned by Get-WebApplication + # so it has no entry in $webConfigContent. The fix adds it from appHost locations. + $Script:result.Inbound.ContainsKey("Default Web Site/Microsoft-Server-ActiveSync/Proxy") | Should -Be $true + $Script:result.Outbound.ContainsKey("Default Web Site/Microsoft-Server-ActiveSync/Proxy") | Should -Be $true + } + + It "Should inherit parent rules for appHost-only Proxy location" { + # Proxy has no rules at its own appHost level. Walk-up should reach DWS web.config + # (CVE-2022-41040) and DWS appHost (disabled HTTPS redirect, AppHost Only Rule) and global. + $proxyRules = $Script:result.Inbound["Default Web Site/Microsoft-Server-ActiveSync/Proxy"] + $proxyRuleNames = @($proxyRules.rule.name | Where-Object { $null -ne $_ }) + $proxyRuleNames | Should -Contain "CVE-2022-41040 Mitigation" + $proxyRuleNames | Should -Contain "Global Block Bad User Agents" + } + + It "Should not duplicate keys that exist in both WebConfigContent and appHost" { + # "Default Web Site" exists in both $webConfigContent and appHost locations. + # It should appear exactly once in the result, not duplicated. + $dwsCount = @($Script:result.Inbound.Keys | Where-Object { $_ -eq "Default Web Site" }).Count + $dwsCount | Should -Be 1 + } + } + Context "Clear stops inheritance" { It "Should stop at clear in appHost location for Default Web Site/mapi" { From 4e04abda4c7ec2b51c5c614b9f0602b4fd862e17 Mon Sep 17 00:00:00 2001 From: David Paulson Date: Fri, 5 Jun 2026 15:03:09 -0500 Subject: [PATCH 3/3] Fix match property array bug when match has extra attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a rewrite rule element has extra attributes like negate or ignoreCase, Get-Member returns multiple properties causing \ to become an array. This broke the match value resolution, URL Match Problem check, and display — showing 'negate url - ' instead of 'url - .*'. Filter the property list to known match targets (url, serverVariable) before resolving the match value. Added mock rule with negate='true' and test assertion for correct MatchProperty display. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Analyzer/Invoke-AnalyzerIISInformation.ps1 | 15 ++++++++++++--- .../E19/Exchange/IIS/applicationHost.config | 4 ++++ .../Tests/HealthChecker.E19.Main.Tests.ps1 | 7 ++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 b/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 index d9a7c3dc76..cb0b208364 100644 --- a/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 +++ b/Diagnostics/HealthChecker/Analyzer/Invoke-AnalyzerIISInformation.ps1 @@ -635,9 +635,18 @@ function Invoke-AnalyzerIISInformation { } #multiple match type possibilities, but should only be one per rule. - $propertyType = ($rule.match | Get-Member | Where-Object { $_.MemberType -eq "Property" }).Name - $isUrlMatchProblem = $propertyType -eq "url" -and $rule.match.$propertyType -eq "*" - $matchProperty = "$propertyType - $($rule.match.$propertyType)" + $allProperties = @(($rule.match | Get-Member | Where-Object { $_.MemberType -eq "Property" }).Name) + # When has extra attributes (negate, ignoreCase), Get-Member returns multiple properties. + # Filter to the actual match target property for display and the URL Match Problem check. + $propertyType = ($allProperties | Where-Object { $_ -eq "url" -or $_ -eq "serverVariable" } | Select-Object -First 1) + + if ($null -eq $propertyType) { + $propertyType = $allProperties | Select-Object -First 1 + } + + $matchValue = $rule.match.$propertyType + $isUrlMatchProblem = $propertyType -eq "url" -and $matchValue -eq "*" + $matchProperty = "$propertyType - $matchValue" $displayObject = [PSCustomObject]@{ RewriteRuleName = $rule.name diff --git a/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config b/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config index d190770f70..84ec5b5f56 100644 --- a/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config +++ b/Diagnostics/HealthChecker/Tests/DataCollection/E19/Exchange/IIS/applicationHost.config @@ -1338,6 +1338,10 @@ + + + + diff --git a/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 b/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 index b387288bbf..7c14b9c0ab 100644 --- a/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 +++ b/Diagnostics/HealthChecker/Tests/HealthChecker.E19.Main.Tests.ps1 @@ -239,14 +239,19 @@ Describe "Testing Health Checker by Mock Data Imports" { # Verify inbound URL rewrite rules are displayed (deduplicated across vDirs) $inboundRules = GetObject "Inbound URL Rewrite Rules" - $inboundRules.Count | Should -Be 2 + $inboundRules.Count | Should -Be 3 $inboundRuleNames = $inboundRules.RewriteRuleName.Value $inboundRuleNames | Should -Contain "CVE-2022-41040 Mitigation" $inboundRuleNames | Should -Contain "Global Block Bad User Agents" + $inboundRuleNames | Should -Contain "Negate Match Test Rule" # Verify rules excluded by in DWS web.config do not appear in detailed display $inboundRuleNames | Should -Not -Contain "AppHost Only Rule" + # Verify match property resolves correctly when has extra attributes like negate + $negateRule = $inboundRules | Where-Object { $_.RewriteRuleName.Value -eq "Negate Match Test Rule" } + $negateRule.MatchProperty.Value | Should -Be "url - .*" + # Verify outbound URL rewrite rules are displayed (deduplicated across vDirs) $outboundRules = GetObject "Outbound URL Rewrite Rules" $outboundRules.Count | Should -Be 2