Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Diagnostics/HealthChecker/Analyzer/Get-URLRewriteRule.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,14 @@ function Invoke-AnalyzerIISInformation {
$currentSection = $urlRewriteRules[$key]

if ($currentSection.Count -ne 0) {
# Collect <remove> 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
Comment on lines +615 to +619

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same response as the inbound comment — this is the same pattern applied to outbound rules. Not a concern for Exchange environments.

}
}

foreach ($rule in $currentSection.rule) {

if ($null -eq $rule) {
Expand All @@ -621,12 +629,24 @@ 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.
$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 <match> 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
Expand Down Expand Up @@ -682,6 +702,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
}
}
Comment on lines +705 to +710

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid edge case. The concern is about the "remove-then-readd" pattern where a rule is removed and re-added with different settings in the same section:

<rules>
    <remove name="InheritedRule" />
    <!-- Override inherited version with different config -->
    <rule name="InheritedRule" stopProcessing="false">
        <match url="different-pattern" />
        <action type="Rewrite" url="new-destination" />
    </rule>
</rules>

IIS processes this as: remove the inherited version, then add the new local version. Our up-front exclude collection would incorrectly hide the re-added rule because the name matches the <remove> entry.

However, this pattern is not used in Exchange environments. The typical Exchange pattern is a simple <remove> to exclude an inherited rule, not remove-then-readd to override it. Adding ordered section processing would increase complexity without practical benefit for HealthChecker's target scenarios. If this pattern surfaces in the wild, we can revisit.


foreach ($rule in $currentSection.rule) {

if ($null -eq $rule) {
Expand All @@ -690,6 +717,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]@{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
<system.webServer>
<rewrite>
<rules>
<remove name="AppHost Only Rule" />
<rule name="CVE-2022-41040 Mitigation" stopProcessing="true" patternSyntax="ECMAScript" enabled="true">
<match url=".*" />
<conditions logicalGrouping="MatchAll">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1334,6 +1334,14 @@
</conditions>
<action type="Redirect" url="https://{HTTP_HOST}/{R:1}" />
</rule>
<rule name="AppHost Only Rule" stopProcessing="true">
<match url=".*" />
<action type="None" />
</rule>
<rule name="Negate Match Test Rule" stopProcessing="true">
<match url=".*" negate="true" />
<action type="None" />
</rule>
</rules>
</rewrite>
<isapiFilters>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,18 @@ 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 <remove> in DWS web.config do not appear in detailed display
$inboundRuleNames | Should -Not -Contain "AppHost Only Rule"

# Verify match property resolves correctly when <match> 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"
Expand Down