chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
$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"
|
||||
Reference in New Issue
Block a user