$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"