Skip to content
Open
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
78 changes: 78 additions & 0 deletions Modules/CIPPCore/Private/Test-CIPPConditionFilter.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
function Test-CIPPConditionFilter {
<#
.SYNOPSIS
Returns a sanitized PowerShell condition string for an audit log / delta query condition.
.DESCRIPTION
Validates operator and property name against allowlists, sanitizes input values,
then returns a safe condition string suitable for [ScriptBlock]::Create().

This replaces the old Invoke-Expression pattern which was vulnerable to code injection
through unsanitized user-controlled fields.
.PARAMETER Condition
A single condition object with Property.label, Operator.value, and Input.value.
.OUTPUTS
[string] A sanitized PowerShell condition string, or $null if validation fails.
.FUNCTIONALITY
Internal
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory = $true)]
$Condition
)

# Operator allowlist - only these PowerShell comparison operators are permitted
$AllowedOperators = @(
'eq', 'ne', 'like', 'notlike', 'match', 'notmatch',
'gt', 'lt', 'ge', 'le', 'in', 'notin',
'contains', 'notcontains'
)

# Property name validation - only alphanumeric, underscores, and dots allowed
$SafePropertyRegex = [regex]'^[a-zA-Z0-9_.]+$'

# Value sanitization - block characters that enable code injection
$UnsafeValueRegex = [regex]'[;|`\$\{\}]'

$propertyName = $Condition.Property.label
$operatorValue = $Condition.Operator.value.ToLower()
$inputValue = $Condition.Input.value

# Validate operator against allowlist
if ($operatorValue -notin $AllowedOperators) {
Write-Warning "Blocked invalid operator '$($Condition.Operator.value)' in condition for property '$propertyName'"
return $null
}

# Validate property name to prevent injection via property paths
if (-not $SafePropertyRegex.IsMatch($propertyName)) {
Write-Warning "Blocked invalid property name '$propertyName' in condition"
return $null
}

# Build sanitized condition string
if ($inputValue -is [array]) {
# Sanitize each array element
$sanitizedItems = foreach ($item in $inputValue) {
$itemStr = [string]$item
if ($UnsafeValueRegex.IsMatch($itemStr)) {
Write-Warning "Blocked unsafe value in array for property '$propertyName': '$itemStr'"
return $null
}
$itemStr -replace "'", "''"
}
if ($null -eq $sanitizedItems) { return $null }
$arrayAsString = $sanitizedItems | ForEach-Object { "'$_'" }
$value = "@($($arrayAsString -join ', '))"
} else {
$valueStr = [string]$inputValue
if ($UnsafeValueRegex.IsMatch($valueStr)) {
Write-Warning "Blocked unsafe value for property '$propertyName': '$valueStr'"
return $null
}
$value = "'$($valueStr -replace "'", "''")'"
}

return "`$(`$_.$propertyName) -$operatorValue $value"
}
210 changes: 210 additions & 0 deletions Modules/CIPPCore/Private/Test-CIPPDynamicGroupFilter.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
function Test-CIPPDynamicGroupFilter {
<#
.SYNOPSIS
Returns a sanitized PowerShell condition string for a dynamic tenant group rule.
.DESCRIPTION
Validates all user-controlled inputs (property, operator, values) against allowlists
and sanitizes values before building the condition string. Returns a safe condition
string suitable for use in [ScriptBlock]::Create().

This replaces the old pattern of directly interpolating unsanitized user input into
scriptblock strings, which was vulnerable to code injection.
.PARAMETER Rule
A single rule object with .property, .operator, and .value fields.
.PARAMETER TenantGroupMembersCache
Hashtable of group memberships keyed by group ID.
.OUTPUTS
[string] A sanitized PowerShell condition string, or $null if validation fails.
.FUNCTIONALITY
Internal
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory = $true)]
$Rule,
[Parameter(Mandatory = $false)]
[hashtable]$TenantGroupMembersCache = @{}
)

$AllowedOperators = @('eq', 'ne', 'like', 'notlike', 'in', 'notin', 'contains', 'notcontains')
$AllowedProperties = @('delegatedAccessStatus', 'availableLicense', 'availableServicePlan', 'tenantGroupMember', 'customVariable')

# Regex for sanitizing string values - block characters that enable code injection
$SafeValueRegex = [regex]'^[^;|`\$\{\}\(\)]*$'
# Regex for GUID validation
$GuidRegex = [regex]'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
# Regex for safe identifiers (variable names, plan names, etc.)
$SafeIdentifierRegex = [regex]'^[a-zA-Z0-9_.\-\s\(\)]+$'

$Property = $Rule.property
$Operator = [string]($Rule.operator)
$OperatorLower = $Operator.ToLower()
$Value = $Rule.value

# Validate operator
if ($OperatorLower -notin $AllowedOperators) {
Write-Warning "Blocked invalid operator '$Operator' in dynamic group rule for property '$Property'"
return $null
}

# Validate property
if ($Property -notin $AllowedProperties) {
Write-Warning "Blocked invalid property '$Property' in dynamic group rule"
return $null
}

# Helper: sanitize a single string value for safe embedding in a quoted string
function Protect-StringValue {
param([string]$InputValue)
# Escape single quotes by doubling them (PowerShell string escaping)
$escaped = $InputValue -replace "'", "''"
# Block any remaining injection characters
if (-not $SafeValueRegex.IsMatch($escaped)) {
Write-Warning "Blocked unsafe value: '$InputValue'"
return $null
}
return $escaped
}

# Helper: sanitize and format an array of string values for embedding in @('a','b')
function Protect-StringArray {
param([array]$InputValues)
$sanitized = foreach ($v in $InputValues) {
$clean = Protect-StringValue -InputValue ([string]$v)
if ($null -eq $clean) { return $null }
"'$clean'"
}
return "@($($sanitized -join ', '))"
}

switch ($Property) {
'delegatedAccessStatus' {
$safeValue = Protect-StringValue -InputValue ([string]$Value.value)
if ($null -eq $safeValue) { return $null }
return "`$_.delegatedPrivilegeStatus -$OperatorLower '$safeValue'"
}
'availableLicense' {
if ($OperatorLower -in @('in', 'notin')) {
$arrayValues = @(if ($Value -is [array]) { $Value.guid } else { @($Value.guid) })
# Validate each GUID
foreach ($g in $arrayValues) {
if (![string]::IsNullOrEmpty($g) -and -not $GuidRegex.IsMatch($g)) {
Write-Warning "Blocked invalid GUID in availableLicense rule: '$g'"
return $null
}
}
$arrayAsString = ($arrayValues | Where-Object { ![string]::IsNullOrEmpty($_) }) | ForEach-Object { "'$_'" }
if ($OperatorLower -eq 'in') {
return "(`$_.skuId | Where-Object { `$_ -in @($($arrayAsString -join ', ')) }).Count -gt 0"
} else {
return "(`$_.skuId | Where-Object { `$_ -in @($($arrayAsString -join ', ')) }).Count -eq 0"
}
} else {
$guid = [string]$Value.guid
if (![string]::IsNullOrEmpty($guid) -and -not $GuidRegex.IsMatch($guid)) {
Write-Warning "Blocked invalid GUID in availableLicense rule: '$guid'"
return $null
}
return "`$_.skuId -$OperatorLower '$guid'"
}
}
'availableServicePlan' {
if ($OperatorLower -in @('in', 'notin')) {
$arrayValues = @(if ($Value -is [array]) { $Value.value } else { @($Value.value) })
foreach ($v in $arrayValues) {
if (![string]::IsNullOrEmpty($v) -and -not $SafeIdentifierRegex.IsMatch($v)) {
Write-Warning "Blocked invalid service plan name: '$v'"
return $null
}
}
$arrayAsString = ($arrayValues | Where-Object { ![string]::IsNullOrEmpty($_) }) | ForEach-Object { "'$_'" }
if ($OperatorLower -eq 'in') {
return "(`$_.servicePlans | Where-Object { `$_ -in @($($arrayAsString -join ', ')) }).Count -gt 0"
} else {
return "(`$_.servicePlans | Where-Object { `$_ -in @($($arrayAsString -join ', ')) }).Count -eq 0"
}
} else {
$safeValue = Protect-StringValue -InputValue ([string]$Value.value)
if ($null -eq $safeValue) { return $null }
return "`$_.servicePlans -$OperatorLower '$safeValue'"
}
}
'tenantGroupMember' {
if ($OperatorLower -in @('in', 'notin')) {
$ReferencedGroupIds = @($Value.value)
# Validate group IDs are GUIDs
foreach ($gid in $ReferencedGroupIds) {
if (![string]::IsNullOrEmpty($gid) -and -not $GuidRegex.IsMatch($gid)) {
Write-Warning "Blocked invalid group ID in tenantGroupMember rule: '$gid'"
return $null
}
}

$AllMembers = [System.Collections.Generic.HashSet[string]]::new()
foreach ($GroupId in $ReferencedGroupIds) {
if ($TenantGroupMembersCache.ContainsKey($GroupId)) {
foreach ($MemberId in $TenantGroupMembersCache[$GroupId]) {
[void]$AllMembers.Add($MemberId)
}
}
}

$MemberArray = $AllMembers | ForEach-Object { "'$_'" }
$MemberArrayString = $MemberArray -join ', '

if ($OperatorLower -eq 'in') {
return "`$_.customerId -in @($MemberArrayString)"
} else {
return "`$_.customerId -notin @($MemberArrayString)"
}
} else {
$ReferencedGroupId = [string]$Value.value
if (![string]::IsNullOrEmpty($ReferencedGroupId) -and -not $GuidRegex.IsMatch($ReferencedGroupId)) {
Write-Warning "Blocked invalid group ID: '$ReferencedGroupId'"
return $null
}
return "`$_.customerId -$OperatorLower `$script:TenantGroupMembersCache['$ReferencedGroupId']"
}
}
'customVariable' {
$VariableName = if ($Value.variableName -is [string]) {
$Value.variableName
} elseif ($Value.variableName.value) {
$Value.variableName.value
} else {
[string]$Value.variableName
}
# Validate variable name - alphanumeric, underscores, hyphens, dots only
if (-not $SafeIdentifierRegex.IsMatch($VariableName)) {
Write-Warning "Blocked invalid custom variable name: '$VariableName'"
return $null
}
$ExpectedValue = Protect-StringValue -InputValue ([string]$Value.value)
if ($null -eq $ExpectedValue) { return $null }

switch ($OperatorLower) {
'eq' {
return "(`$_.customVariables.ContainsKey('$VariableName') -and `$_.customVariables['$VariableName'].Value -eq '$ExpectedValue')"
}
'ne' {
return "(-not `$_.customVariables.ContainsKey('$VariableName') -or `$_.customVariables['$VariableName'].Value -ne '$ExpectedValue')"
}
'like' {
return "(`$_.customVariables.ContainsKey('$VariableName') -and `$_.customVariables['$VariableName'].Value -like '*$ExpectedValue*')"
}
'notlike' {
return "(-not `$_.customVariables.ContainsKey('$VariableName') -or `$_.customVariables['$VariableName'].Value -notlike '*$ExpectedValue*')"
}
default {
Write-Warning "Unsupported operator '$OperatorLower' for customVariable"
return $null
}
}
}
default {
Write-Warning "Unknown property type: $Property"
return $null
}
}
}
44 changes: 21 additions & 23 deletions Modules/CIPPCore/Public/DeltaQueries/Test-DeltaQueryConditions.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -106,35 +106,33 @@ function Test-DeltaQueryConditions {
$conditions = $Trigger.Conditions | ConvertFrom-Json | Where-Object { $_.Input.value -ne '' -and $_.Input.value -ne $null }

if ($conditions) {
# Initialize collections for condition strings
$conditionStrings = [System.Collections.Generic.List[string]]::new()
# Build human-readable clause for logging
$CIPPClause = [System.Collections.Generic.List[string]]::new()
foreach ($condition in $conditions) {
$CIPPClause.Add("$($condition.Property.label) is $($condition.Operator.label) $($condition.Input.value)")
}
Write-Information "Testing delta query conditions: $($CIPPClause -join ' and ')"

# Build sanitized condition strings instead of direct evaluation
$conditionStrings = [System.Collections.Generic.List[string]]::new()
$validConditions = $true
foreach ($condition in $conditions) {
# Handle array vs single values
$value = if ($condition.Input.value -is [array]) {
$arrayAsString = $condition.Input.value | ForEach-Object {
"'$_'"
}
"@($($arrayAsString -join ', '))"
} else {
"'$($condition.Input.value)'"
$sanitized = Test-CIPPConditionFilter -Condition $condition
if ($null -eq $sanitized) {
Write-Warning "Skipping due to invalid condition for property '$($condition.Property.label)'"
$validConditions = $false
break
}

# Build PowerShell condition string
$conditionStrings.Add("`$(`$_.$($condition.Property.label)) -$($condition.Operator.value) $value")
$CIPPClause.Add("$($condition.Property.label) is $($condition.Operator.label) $value")
$conditionStrings.Add($sanitized)
}

# Join all conditions with AND
$finalCondition = $conditionStrings -join ' -AND '

Write-Information "Testing delta query conditions: $finalCondition"
Write-Information "Human readable: $($CIPPClause -join ' and ')"

# Apply conditions to filter the data using a script block instead of Invoke-Expression
$scriptBlock = [scriptblock]::Create("param(`$_) $finalCondition")
$MatchedData = $Data | Where-Object $scriptBlock
if ($validConditions -and $conditionStrings.Count -gt 0) {
$WhereString = $conditionStrings -join ' -and '
$WhereBlock = [ScriptBlock]::Create($WhereString)
$MatchedData = $Data | Where-Object $WhereBlock
} else {
$MatchedData = @()
}
} else {
Write-Information 'No valid conditions found in trigger configuration.'
$MatchedData = $Data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ function Invoke-ExecTenantGroup {
$dynamicRules = $Request.Body.dynamicRules
$ruleLogic = $Request.Body.ruleLogic ?? 'and'

# Validate dynamic rules to prevent code injection
if ($groupType -eq 'dynamic' -and $dynamicRules) {
$AllowedDynamicOperators = @('eq', 'ne', 'like', 'notlike', 'in', 'notin', 'contains', 'notcontains')
$AllowedDynamicProperties = @('delegatedAccessStatus', 'availableLicense', 'availableServicePlan', 'tenantGroupMember', 'customVariable')
foreach ($rule in $dynamicRules) {
if ($rule.operator -and $rule.operator.ToLower() -notin $AllowedDynamicOperators) {
return ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::BadRequest
Body = @{ Results = "Invalid operator in dynamic rule: $($rule.operator)" }
})
}
if ($rule.property -and $rule.property -notin $AllowedDynamicProperties) {
return ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::BadRequest
Body = @{ Results = "Invalid property in dynamic rule: $($rule.property)" }
})
}
}
}

$AllowedGroups = Test-CippAccess -Request $Request -GroupList
if ($AllowedGroups -notcontains 'AllGroups') {
return ([HttpResponseContext]@{
Expand Down
Loading