Skip to content

Commit

Permalink
#1375: add support for x-forwarded-for header in IP limit component
Browse files Browse the repository at this point in the history
  • Loading branch information
Badgerati committed Feb 11, 2025
1 parent e9e7334 commit ef89791
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 57 deletions.
36 changes: 24 additions & 12 deletions docs/Hosting/IIS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,18 @@ The first thing you'll need to do so IIS can host your server is, in the same di
</configuration>
```

### PowerShell Permissions

Ensure IIS has access to the `pwsh.exe` processPath referenced in the `web.config` file above. If IIS doesn't have access, you'll see the `HTTP Error 502.5 - ANCM Out-Of-Process Startup Failure` error page.

If IIS doesn't have access to the whole path, you can either:

* Grant the user running your IIS application pool (or website) access to the path and `pwsh.exe`, or
* Install a standalone version of the `pwsh.exe` from the [PowerShell GitHub](https://github.com/PowerShell/PowerShell/releases), and into a path IIS can access.

!!! tip
If you installed PowerShell via the Microsoft Store, and the `pwsh.exe` is under the `WindowsApps` directory path, then IIS won't have access. Because this is WindowsApp, the recommended solution is to install a standalone `pwsh.exe` from the [PowerShell GitHub](https://github.com/PowerShell/PowerShell/releases), and use that path as the processPath in your `web.config`.

## IIS Setup

With the `web.config` file in place, it's then time to setup the site in IIS. The first thing to do is open up the IIS Manager, then once open, follow the below steps to setup your site:
Expand Down Expand Up @@ -272,18 +284,18 @@ Start-PodeServer {

If the required header is missing, then Pode responds with a 401. The retrieved user, like other authentication, is set on the [web event](../../Tutorials/WebEvent)'s `$WebEvent.Auth.User` property, and contains the same information as Pode's inbuilt Windows AD authenticator:

| Name | Type | Description |
| ---- | ---- | ----------- |
| UserType | string | Specifies if the user is a Domain or Local user |
| Identity | System.Security.Principal.WindowsIdentity | Returns the WindowsIdentity which can be used for Impersonation |
| AuthenticationType | string | Value is fixed to LDAP |
| DistinguishedName | string | The distinguished name of the user |
| Username | string | The user's username (without domain) |
| Name | string | The user's fullname |
| Email | string | The user's email address |
| FQDN | string | The FQDN of the AD server |
| Domain | string | The domain part of the user's username |
| Groups | string[] | All groups of which the the user is a member |
| Name | Type | Description |
| ------------------ | ----------------------------------------- | --------------------------------------------------------------- |
| UserType | string | Specifies if the user is a Domain or Local user |
| Identity | System.Security.Principal.WindowsIdentity | Returns the WindowsIdentity which can be used for Impersonation |
| AuthenticationType | string | Value is fixed to LDAP |
| DistinguishedName | string | The distinguished name of the user |
| Username | string | The user's username (without domain) |
| Name | string | The user's fullname |
| Email | string | The user's email address |
| FQDN | string | The FQDN of the AD server |
| Domain | string | The domain part of the user's username |
| Groups | string[] | All groups of which the the user is a member |

!!! note
If the authenticated user is a Local User, then the following properties will be empty: FQDN, Email, and DistinguishedName
Expand Down
16 changes: 16 additions & 0 deletions docs/Tutorials/Middleware/Types/Limiters/Components.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,22 @@ New-PodeLimitIPComponent -IP '10.0.1.0/16'
New-PodeLimitIPComponent -IP '10.0.1.0/16' -Group
```

By default, Pode will retrieve the IP address for the request from the Remote Address of the connecting socket. If you're using a proxy - such as a WAF, Load Balancer, or even IIS, then you can instead use the IP from the `X-Forwarded-For` header. The default IP used from the header will be the leftmost IP (typically the originating client IP), but you can also use either the rightmost IP (typically the IP of the last proxy), or all IPs.

```powershell
# use the leftmost IP in the X-Forwarded-For header
New-PodeLimitIPComponent -IP '10.0.0.92' -Location 'XForwardedFor'
# use the rightmost IP in the X-Forwarded-For header
New-PodeLimitIPComponent -IP '10.0.0.92' -Location 'XForwardedFor' -XForwardedForType 'Rightmost'
# check all of the IPs in the X-Forwarded-For header
New-PodeLimitIPComponent -IP '10.0.0.92' -Location 'XForwardedFor' -XForwardedForType 'All'
```

!!! note
When using `-XForwardedForType 'All'`, at least 1 of the IPs must match an IP from the `-IP` parameter.

## Route

A Route Component can be created via [`New-PodeLimitRouteComponent`](../../../../../Functions/Limit/New-PodeLimitRouteComponent). You can specify none, one, or more Route paths - if none are supplied, then the component will match every Route path. You can also use wildcard/regex to match multiple Routes.
Expand Down
5 changes: 4 additions & 1 deletion examples/IIS-Example.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,17 @@ Start-PodeServer {
New-PodeLoggingMethod -Terminal | Enable-PodeRequestLogging
New-PodeLoggingMethod -Terminal | Enable-PodeErrorLogging

# Add-PodeLimitAccessRule -Name 'DenyLocal' -Action Deny -Component @(
# New-PodeLimitIPComponent -IP localhost -Location XForwardedFor
# )

Add-PodeTask -Name 'Test' -ScriptBlock {
Start-Sleep -Seconds 10
'a message is never late, it arrives exactly when it means to' | Out-Default
}

Add-PodeRoute -Method Get -Path '/' -ScriptBlock {
Write-PodeJsonResponse -Value @{ Message = 'Hello' }
$WebEvent.Request | out-default
}

Add-PodeRoute -Method Get -Path '/run-task' -ScriptBlock {
Expand Down
2 changes: 1 addition & 1 deletion examples/web.config
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<remove name="WebDAVModule" />
</modules>

<aspNetCore processPath="pwsh.exe" arguments=".\iis-example.ps1" stdoutLogEnabled="true" stdoutLogFile=".\logs\stdout" hostingModel="OutOfProcess"/>
<aspNetCore processPath="pwsh.exe" arguments=".\IIS-Example.ps1" stdoutLogEnabled="true" stdoutLogFile=".\logs\stdout" hostingModel="OutOfProcess"/>

<security>
<authorization>
Expand Down
21 changes: 19 additions & 2 deletions src/Private/Helpers.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -433,11 +433,28 @@ function Get-PodeIPAddress {
$IP,

[switch]
$DualMode
$DualMode,

[switch]
$ContainsPort
)

# if we have a port, remove it
if ($ContainsPort) {
$ipRegex = Get-PodeHostIPRegex -Type IP
$portRegex = Get-PodePortRegex
$regex = "^$($ipRegex)(\:$($portRegex))?$"

if ($IP -imatch $regex) {
$IP = $Matches['host']
}
else {
$IP = ($IP -split ':')[0]
}
}

# any address for IPv4 (or IPv6 for DualMode)
if ([string]::IsNullOrWhiteSpace($IP) -or ($IP -iin @('*', 'all'))) {
if ([string]::IsNullOrEmpty($IP) -or ($IP -iin @('*', 'all'))) {
if ($DualMode) {
return [System.Net.IPAddress]::IPv6Any
}
Expand Down
140 changes: 99 additions & 41 deletions src/Public/Limit.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,15 @@ Creates a new Limit IP component. This supports the WebEvent, SmtpEvent, and Tcp
.PARAMETER IP
The IP address(es) to check. Supports raw IPs, subnets, local, and any.
.PARAMETER Location
Where to get the IP from: RemoteAddress or XForwardedFor. (Default: RemoteAddress)
.PARAMETER XForwardedForType
If the Location is XForwardedFor, which IP in the X-Forwarded-For header to use: Leftmost, Rightmost, or All. (Default: Leftmost)
If Leftmost, the first IP in the X-Forwarded-For header will be used.
If Rightmost, the last IP in the X-Forwarded-For header will be used.
If All, all IPs in the X-Forwarded-For header will be used - at least one must match.
.PARAMETER Group
If supplied, IPs in a subnet will be treated as a single entity.
Expand All @@ -673,6 +682,12 @@ New-PodeLimitIPComponent -IP 'all'
.EXAMPLE
New-PodeLimitIPComponent -IP '192.0.1.0/16' -Group
.EXAMPLE
New-PodeLimitIPComponent -IP '10.0.0.1' -Location XForwardedFor
.EXAMPLE
New-PodeLimitIPComponent -IP '192.0.1.0/16' -Group -Location XForwardedFor -XForwardedForType Rightmost
.OUTPUTS
A hashtable containing the options and scriptblock for the IP component.
The scriptblock will return the IP - or subnet for grouped - if found, or null if not.
Expand All @@ -685,6 +700,16 @@ function New-PodeLimitIPComponent {
[string[]]
$IP,

[Parameter()]
[ValidateSet('RemoteAddress', 'XForwardedFor')]
[string]
$Location = 'RemoteAddress',

[Parameter()]
[ValidateSet('Leftmost', 'Rightmost', 'All')]
[string]
$XForwardedForType = 'Leftmost',

[switch]
$Group
)
Expand Down Expand Up @@ -741,77 +766,110 @@ function New-PodeLimitIPComponent {
# pass back the IP component
return @{
Options = @{
IP = $ipDetails
Group = $Group.IsPresent
IP = $ipDetails
Location = $Location.ToLowerInvariant()
XForwardedForType = $XForwardedForType.ToLowerInvariant()
Group = $Group.IsPresent
}
ScriptBlock = {
param($options)

# current request ip - for webevent, smtpevent, or tcpevent
$ip = $null
# for webevent, we can get the ip from the remote address or x-forwarded-for
$ipAddresses = $null

if ($WebEvent) {
$ip = $WebEvent.Request.RemoteEndPoint.Address
switch ($options.Location) {
'remoteaddress' {
$ipAddresses = @($WebEvent.Request.RemoteEndPoint.Address)
}
'xforwardedfor' {
$xForwardedFor = $WebEvent.Request.Headers['X-Forwarded-For']
if ([string]::IsNullOrEmpty($xForwardedFor)) {
return $null
}

$xffIps = $xForwardedFor.Split(',')
switch ($options.XForwardedForType) {
'leftmost' {
$ipAddresses = @(Get-PodeIPAddress -IP $xffIps[0].Trim() -ContainsPort)
}
'rightmost' {
$ipAddresses = @(Get-PodeIPAddress -IP $xffIps[-1].Trim() -ContainsPort)
}
'all' {
$ipAddresses = @(foreach ($ip in $xffIps) { Get-PodeIPAddress -IP $ip.Trim() -ContainsPort })
}
}
}
}
}
elseif ($SmtpEvent) {
$ip = $SmtpEvent.Request.RemoteEndPoint.Address
$ipAddresses = @($SmtpEvent.Request.RemoteEndPoint.Address)
}
elseif ($TcpEvent) {
$ip = $TcpEvent.Request.RemoteEndPoint.Address
$ipAddresses = @($TcpEvent.Request.RemoteEndPoint.Address)
}

if ($null -eq $ip) {
# if we have no ip addresses, then return null
if (($null -eq $ipAddresses) -or ($ipAddresses.Length -eq 0)) {
return $null
}

$ipDetails = @{
Value = $ip.IPAddressToString
Family = $ip.AddressFamily
Bytes = $ip.GetAddressBytes()
}
# loop through each ip address
for ($i = $ipAddresses.Length - 1; $i -ge 0; $i--) {
$ip = $ipAddresses[$i]

# is the ip in the Raw list?
if ($options.IP.Raw.ContainsKey($ipDetails.Value)) {
return $ipDetails.Value
}
$ipDetails = @{
Value = $ip.IPAddressToString
Family = $ip.AddressFamily
Bytes = $ip.GetAddressBytes()
}

# is the ip in the Subnets list?
foreach ($subnet in $options.IP.Subnets.Keys) {
$subnetDetails = $options.IP.Subnets[$subnet]
if ($subnetDetails.Family -ne $ipDetails.Family) {
continue
# is the ip in the Raw list?
if ($options.IP.Raw.ContainsKey($ipDetails.Value)) {
return $ipDetails.Value
}

# if the ip is in the subnet range, then return the subnet
if (Test-PodeIPAddressInSubnet -IP $ipDetails.Bytes -Lower $subnetDetails.Lower -Upper $subnetDetails.Upper) {
if ($options.Group) {
return $subnet
# is the ip in the Subnets list?
foreach ($subnet in $options.IP.Subnets.Keys) {
$subnetDetails = $options.IP.Subnets[$subnet]
if ($subnetDetails.Family -ne $ipDetails.Family) {
continue
}

return $ipDetails.Value
# if the ip is in the subnet range, then return the subnet
if (Test-PodeIPAddressInSubnet -IP $ipDetails.Bytes -Lower $subnetDetails.Lower -Upper $subnetDetails.Upper) {
if ($options.Group) {
return $subnet
}

return $ipDetails.Value
}
}
}

# is the ip local?
if ($options.IP.Local) {
if ([System.Net.IPAddress]::IsLoopback($ip)) {
# is the ip local?
if ($options.IP.Local) {
if ([System.Net.IPAddress]::IsLoopback($ip)) {
if ($options.Group) {
return 'local'
}

return $ipDetails.Value
}
}

# is any allowed?
if ($options.IP.Any -and ($i -eq 0)) {
if ($options.Group) {
return 'local'
return '*'
}

return $ipDetails.Value
}
}

# is any allowed?
if ($options.IP.Any) {
if ($options.Group) {
return '*'
}

return $ipDetails.Value
}

# return null
# ip didn't match any rules
return $null
}
}
Expand Down

0 comments on commit ef89791

Please sign in to comment.