diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 index 7c91c25e73..e80b825ea3 100644 --- a/scripts/deploy.ps1 +++ b/scripts/deploy.ps1 @@ -257,8 +257,16 @@ if ($DryRun) { Write-Host "Updating CloudFront KeyValueStore" -ForegroundColor Cyan Update-CloudFrontKVS + # A config-sync failure must not strand the content phases 1 and 2 already uploaded, so the + # error is held and rethrown after the invalidation and cleanup. The build still goes red. Write-Host 'Syncing CloudFront configuration' -ForegroundColor Cyan - Sync-CloudFrontConfig + $configError = $null + try { + Sync-CloudFrontConfig + } catch { + $configError = $_ + Write-Host " CloudFront config sync failed: $_" -ForegroundColor Red + } if ($CloudFrontDistributionId) { Write-Host "Invalidating CloudFront distribution $CloudFrontDistributionId" -ForegroundColor Cyan @@ -276,6 +284,8 @@ if ($DryRun) { --delete if ($LASTEXITCODE) { throw 'aws s3 sync (hashed assets – cleanup) failed' } + if ($configError) { throw $configError } + } Write-Host 'Deployment completed successfully.' -ForegroundColor Green diff --git a/scripts/lib/cloudfront-common.ps1 b/scripts/lib/cloudfront-common.ps1 index bf50666791..cd682b4c59 100644 --- a/scripts/lib/cloudfront-common.ps1 +++ b/scripts/lib/cloudfront-common.ps1 @@ -35,6 +35,36 @@ function Invoke-CloudFrontInvalidation { if ($LASTEXITCODE) { throw 'CloudFront invalidation failed' } } +# get-response-headers-policy returns `{}` for headers that are not configured, but +# update-response-headers-policy refuses them: once XSSProtection, FrameOptions or ReferrerPolicy +# appears in the payload it requires its own fields. The read shape is therefore not a valid write +# shape, so empty objects are dropped before the config goes back. Empty arrays are left alone, +# since {Quantity: 0, Items: []} is valid on the way in. +function Remove-EmptyObjects { + param([AllowNull()] $Node) + + if ($null -eq $Node) { return $null } + + if ($Node -is [System.Collections.IEnumerable] -and $Node -isnot [string]) { + $items = [System.Collections.ArrayList]::new() + foreach ($item in $Node) { [void]$items.Add((Remove-EmptyObjects $item)) } + # Comma-prefix keeps a one-element array an array; returning it bare would unroll it to a + # scalar and turn {Items: ["x"]} into {Items: "x"}, which CloudFront rejects. + return , $items.ToArray() + } + + if ($Node -isnot [System.Management.Automation.PSCustomObject]) { return $Node } + + $kept = [ordered]@{} + foreach ($property in $Node.PSObject.Properties) { + $value = Remove-EmptyObjects $property.Value + $isEmptyObject = $value -is [System.Management.Automation.PSCustomObject] -and + @($value.PSObject.Properties).Count -eq 0 + if (-not $isEmptyObject) { $kept[$property.Name] = $value } + } + return [pscustomobject]$kept +} + # Paths where $Candidate differs from $Reference: node kind, array length, added or dropped # property, or scalar value. Proves a patch touched only the field it meant to. function Get-JsonDifference { diff --git a/scripts/sync-csp.ps1 b/scripts/sync-csp.ps1 index 4492eaded2..b441f00670 100644 --- a/scripts/sync-csp.ps1 +++ b/scripts/sync-csp.ps1 @@ -50,7 +50,7 @@ $current = aws cloudfront get-response-headers-policy --id $ResponseHeadersPolic if ($LASTEXITCODE) { throw 'aws get-response-headers-policy failed' } $etag = $current.ETag -$config = $current.ResponseHeadersPolicy.ResponseHeadersPolicyConfig +$config = Remove-EmptyObjects $current.ResponseHeadersPolicy.ResponseHeadersPolicyConfig if (Compare-CloudFrontValue -Live $config.SecurityHeadersConfig.ContentSecurityPolicy.ContentSecurityPolicy -Local $csp -Noun 'policy') { return } @@ -105,7 +105,8 @@ try { $reread = aws cloudfront get-response-headers-policy --id $ResponseHeadersPolicyId | ConvertFrom-Json if ($LASTEXITCODE) { throw 'aws get-response-headers-policy failed on re-read' } -$now = $reread.ResponseHeadersPolicy.ResponseHeadersPolicyConfig | ConvertTo-Json -Depth 30 -Compress | ConvertFrom-Json +$now = Remove-EmptyObjects $reread.ResponseHeadersPolicy.ResponseHeadersPolicyConfig | + ConvertTo-Json -Depth 30 -Compress | ConvertFrom-Json $postDiffs = @(Get-JsonDifference -Reference $before -Candidate $now | Where-Object { $_ -notlike "$CspPath*" }) if ($postDiffs.Count) { throw "The policy changed outside the CSP: $($postDiffs -join '; '). Restore the affected headers from the CloudFront console."