Files
story/scripts/security-http-smoke.ps1
T

99 lines
6.0 KiB
PowerShell

$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$projectRoot = Split-Path -Parent $PSScriptRoot
Set-Location -LiteralPath $projectRoot
$api = 'http://localhost:58080'
$callbacks = 'http://localhost:58081'
$tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
$tenantId = '10000000-0000-0000-0000-000000000001'
$deviceId = '10000000-0000-0000-0000-000000000511'
$deviceSecret = [Text.Encoding]::UTF8.GetBytes('local-simulated-device-secret-32-bytes-minimum')
$sentinel = 'security-body-' + [Guid]::NewGuid().ToString('N')
function Get-Token([string]$Username) {
return (Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{
grant_type = 'password'; client_id = 'voice-platform-web'; username = $Username; password = 'LocalOnly123!'; scope = 'openid'
}).access_token
}
function Get-StatusAndBody([scriptblock]$Action) {
try {
$response = & $Action
return [pscustomobject]@{ Status = [int]$response.StatusCode; Body = [string]$response.Content }
} catch {
$status = [int]$_.Exception.Response.StatusCode
$body = ''
if ($_.ErrorDetails.Message) { $body = [string]$_.ErrorDetails.Message }
return [pscustomobject]@{ Status = $status; Body = $body }
}
}
function Invoke-Scalar([string]$Sql) {
$result = $Sql | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -t -A
if ($LASTEXITCODE -ne 0) { throw 'Security smoke SQL failed' }
return ($result | Out-String).Trim()
}
function New-SignedConfigHeaders {
$path = '/device/v2/config'
$digest = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([byte[]]::new(0))).ToLowerInvariant()
$timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
$nonce = [Guid]::NewGuid().ToString('N')
$canonical = "GET`n$path`n$timestamp`n$nonce`n$digest"
$hmac = [Security.Cryptography.HMACSHA256]::new($deviceSecret)
try {
$signature = [Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonical))).TrimEnd('=').Replace('+', '-').Replace('/', '_')
} finally { $hmac.Dispose() }
return @{
'X-Device-Id' = $deviceId
'X-Device-Timestamp' = $timestamp
'X-Device-Nonce' = $nonce
'X-Device-Content-SHA256' = $digest
'X-Device-Signature' = $signature
}
}
$owner = Get-Token 'owner'
$outsider = Get-Token 'outsider'
$countsBefore = Invoke-Scalar "SELECT count(*)||'|'||(SELECT count(*) FROM subject_profile) FROM household"
$me = Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/me" -Headers @{ Authorization = "Bearer $owner" }
if ($me.StatusCode -ne 200 -or $me.Headers['X-Content-Type-Options'] -ne 'nosniff' -or $me.Headers['X-Frame-Options'] -ne 'DENY' -or $me.Headers['Referrer-Policy'] -ne 'no-referrer') {
throw 'Security response headers are incomplete'
}
$anonymous = Get-StatusAndBody { Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/me" }
if ($anonymous.Status -ne 401) { throw "Anonymous access was not rejected: $($anonymous.Status)" }
$opsDenied = Get-StatusAndBody { Invoke-WebRequest -UseBasicParsing -Uri "$api/api/v1/ops/alerts" -Headers @{ Authorization = "Bearer $outsider"; 'X-Tenant-Id' = $tenantId } }
if ($opsDenied.Status -ne 403) { throw "Viewer reached operations alerts: $($opsDenied.Status)" }
foreach ($path in @('/api/v1/subjects/%27%20OR%201%3D1--','/api/v1/subjects/..%2F..%2Fmetrics','/api/v1/privacy-requests/not-a-uuid')) {
$result = Get-StatusAndBody { Invoke-WebRequest -UseBasicParsing -Uri ($api + $path) -Headers @{ Authorization = "Bearer $owner" } }
if ($result.Status -ne 400) { throw "Malformed resource path did not fail safely: $path -> $($result.Status)" }
if ($result.Body -match '(?i)(sqlstate|syntax error|postgres|pgx|select\s|database error)') { throw "Database detail leaked for malformed path: $path" }
}
$largeBody = '{"name":"' + $sentinel + (('x') * 1100000) + '"}'
$oversized = Get-StatusAndBody { Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$api/api/v1/households" -Headers @{ Authorization = "Bearer $owner" } -ContentType 'application/json' -Body $largeBody }
if ($oversized.Status -ne 413 -or $oversized.Body -notmatch 'REQUEST_BODY_TOO_LARGE') { throw "Oversized request was not rejected with safe 413: $($oversized.Status)" }
$invalidCallback = Get-StatusAndBody { Invoke-WebRequest -UseBasicParsing -Method Post -Uri "$callbacks/callbacks/v1/vertc/voice-chat" -Headers @{
'X-Provider-Timestamp' = [DateTime]::UtcNow.ToString('o')
'X-Provider-Signature' = 'invalid-signature'
} -ContentType 'application/json' -Body '{"provider":"mock"}' }
if ($invalidCallback.Status -ne 401 -or $invalidCallback.Body -notmatch 'PROVIDER_CALLBACK_AUTHENTICATION_FAILED') { throw "Invalid callback signature was not rejected safely: $($invalidCallback.Status)" }
$signedHeaders = New-SignedConfigHeaders
$firstDevice = Get-StatusAndBody { Invoke-WebRequest -UseBasicParsing -Uri "$api/device/v2/config" -Headers $signedHeaders }
$replayDevice = Get-StatusAndBody { Invoke-WebRequest -UseBasicParsing -Uri "$api/device/v2/config" -Headers $signedHeaders }
if ($firstDevice.Status -ne 200 -or $replayDevice.Status -ne 401) { throw "Device nonce replay result was unexpected: first=$($firstDevice.Status) replay=$($replayDevice.Status)" }
$countsAfter = Invoke-Scalar "SELECT count(*)||'|'||(SELECT count(*) FROM subject_profile) FROM household"
if ($countsAfter -ne $countsBefore) { throw "Security probes changed business counts: before=$countsBefore after=$countsAfter" }
$recentLogs = docker compose logs --since 2m api callbacks | Out-String
if ($recentLogs.Contains($sentinel)) { throw 'Oversized request content appeared in service logs' }
Write-Host "HTTP security smoke passed: authn_401=true, authz_403=true, malformed_paths_400=true, oversized_413=true, callback_signature_401=true, device_replay_401=true, security_headers=true, body_not_logged=true, business_counts=$countsAfter"