php-uptelligence/tests/Unit/WebhookSignatureVerificationTest.php
Snider 6f71edd14e fix(security): address P2 security items and migration mismatch
P2-058: Migration Mismatch
- Created new migration for vendor tracking tables (000004)
- Added explicit $table property to all models with uptelligence_ prefix
- Clarified dual-purpose nature (uptime monitoring + vendor tracking)
- Added appropriate indexes for common query patterns

P2-059: Webhook Signature Timing Attack Audit
- Verified all signature verification uses hash_equals()
- Added comprehensive tests in WebhookSignatureVerificationTest.php
- Tests cover all providers, grace periods, edge cases

P2-060: API Key Exposure in Logs
- Added redactSensitiveData() to AIAnalyzerService
- Added redactSensitiveData() to IssueGeneratorService
- Added redactSensitiveData() to VendorUpdateCheckerService
- Redacts API keys, tokens, bearer tokens, auth headers

P2-061: Missing Webhook Payload Validation
- Added MAX_PAYLOAD_SIZE (1MB) and MAX_JSON_DEPTH (32) limits
- Added validatePayloadSize() for DoS protection
- Added parseAndValidateJson() with depth limit
- Added validatePayloadStructure() for provider-specific validation
- Added hasExcessiveArraySize() to prevent memory exhaustion
- Added tests in WebhookPayloadValidationTest.php

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 13:29:26 +00:00

299 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Uptelligence\Tests\Unit;
use Core\Mod\Uptelligence\Models\UptelligenceWebhook;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
/**
* Tests for webhook signature verification timing safety.
*
* These tests verify that all signature verification methods use
* timing-safe comparison functions (hash_equals) to prevent
* timing attacks that could reveal valid signatures.
*/
class WebhookSignatureVerificationTest extends \Orchestra\Testbench\TestCase
{
protected function getPackageProviders($app): array
{
return [];
}
/**
* Test that GitHub signature verification uses hash_equals.
*/
#[Test]
public function it_verifies_github_signature_with_timing_safe_comparison(): void
{
$webhook = new UptelligenceWebhook([
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
'secret' => 'test-secret-key',
]);
$payload = '{"action":"published","release":{"tag_name":"v1.0.0"}}';
$validSignature = 'sha256=' . hash_hmac('sha256', $payload, 'test-secret-key');
$invalidSignature = 'sha256=' . hash_hmac('sha256', $payload, 'wrong-secret');
// Valid signature should pass
$this->assertTrue($webhook->verifySignature($payload, $validSignature));
// Invalid signature should fail
$this->assertFalse($webhook->verifySignature($payload, $invalidSignature));
// Signature without prefix should also work
$signatureWithoutPrefix = hash_hmac('sha256', $payload, 'test-secret-key');
$this->assertTrue($webhook->verifySignature($payload, $signatureWithoutPrefix));
}
/**
* Test that GitLab signature verification uses hash_equals.
*/
#[Test]
public function it_verifies_gitlab_signature_with_timing_safe_comparison(): void
{
$webhook = new UptelligenceWebhook([
'provider' => UptelligenceWebhook::PROVIDER_GITLAB,
'secret' => 'gitlab-secret-token',
]);
$payload = '{"object_kind":"release","action":"create"}';
// GitLab uses X-Gitlab-Token header (direct token comparison)
$this->assertTrue($webhook->verifySignature($payload, 'gitlab-secret-token'));
$this->assertFalse($webhook->verifySignature($payload, 'wrong-token'));
// Empty signature should fail when secret is set
$this->assertFalse($webhook->verifySignature($payload, ''));
$this->assertFalse($webhook->verifySignature($payload, null));
}
/**
* Test that npm signature verification uses hash_equals.
*/
#[Test]
public function it_verifies_npm_signature_with_timing_safe_comparison(): void
{
$webhook = new UptelligenceWebhook([
'provider' => UptelligenceWebhook::PROVIDER_NPM,
'secret' => 'npm-webhook-secret',
]);
$payload = '{"event":"package:publish","version":"1.0.0"}';
$validSignature = hash_hmac('sha256', $payload, 'npm-webhook-secret');
$this->assertTrue($webhook->verifySignature($payload, $validSignature));
$this->assertFalse($webhook->verifySignature($payload, 'invalid-signature'));
}
/**
* Test that Packagist signature verification uses hash_equals.
*/
#[Test]
public function it_verifies_packagist_signature_with_timing_safe_comparison(): void
{
$webhook = new UptelligenceWebhook([
'provider' => UptelligenceWebhook::PROVIDER_PACKAGIST,
'secret' => 'packagist-secret',
]);
$payload = '{"repository":{"url":"https://packagist.org/packages/vendor/package"}}';
// Packagist uses SHA-1 HMAC
$validSignature = hash_hmac('sha1', $payload, 'packagist-secret');
$this->assertTrue($webhook->verifySignature($payload, $validSignature));
$this->assertFalse($webhook->verifySignature($payload, 'wrong-signature'));
}
/**
* Test that custom webhook signature verification uses hash_equals.
*/
#[Test]
public function it_verifies_custom_signature_with_timing_safe_comparison(): void
{
$webhook = new UptelligenceWebhook([
'provider' => UptelligenceWebhook::PROVIDER_CUSTOM,
'secret' => 'custom-secret-key',
]);
$payload = '{"version":"2.0.0","event":"release"}';
$validSignature = 'sha256=' . hash_hmac('sha256', $payload, 'custom-secret-key');
$this->assertTrue($webhook->verifySignature($payload, $validSignature));
$this->assertFalse($webhook->verifySignature($payload, 'sha256=invalid'));
}
/**
* Test that signature verification skips when no secret is configured.
*/
#[Test]
public function it_skips_verification_when_no_secret_configured(): void
{
$webhook = new UptelligenceWebhook([
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
'secret' => null,
]);
$payload = '{"any":"payload"}';
// Should return true (skip verification) when no secret is set
$this->assertTrue($webhook->verifySignature($payload, null));
$this->assertTrue($webhook->verifySignature($payload, 'any-signature'));
}
/**
* Test that signature verification fails when secret is set but no signature provided.
*/
#[Test]
public function it_fails_when_secret_is_set_but_no_signature_provided(): void
{
$webhook = new UptelligenceWebhook([
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
'secret' => 'test-secret',
]);
$payload = '{"any":"payload"}';
$this->assertFalse($webhook->verifySignature($payload, null));
$this->assertFalse($webhook->verifySignature($payload, ''));
}
/**
* Test grace period allows previous secret.
*/
#[Test]
public function it_accepts_previous_secret_during_grace_period(): void
{
$webhook = new UptelligenceWebhook([
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
'secret' => 'new-secret',
'previous_secret' => 'old-secret',
'secret_rotated_at' => now(),
'grace_period_seconds' => 86400, // 24 hours
]);
$payload = '{"test":"payload"}';
// Both old and new secrets should work during grace period
$newSignature = 'sha256=' . hash_hmac('sha256', $payload, 'new-secret');
$oldSignature = 'sha256=' . hash_hmac('sha256', $payload, 'old-secret');
$wrongSignature = 'sha256=' . hash_hmac('sha256', $payload, 'wrong-secret');
$this->assertTrue($webhook->verifySignature($payload, $newSignature));
$this->assertTrue($webhook->verifySignature($payload, $oldSignature));
$this->assertFalse($webhook->verifySignature($payload, $wrongSignature));
}
/**
* Test that previous secret is rejected after grace period expires.
*/
#[Test]
public function it_rejects_previous_secret_after_grace_period(): void
{
$webhook = new UptelligenceWebhook([
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
'secret' => 'new-secret',
'previous_secret' => 'old-secret',
'secret_rotated_at' => now()->subDays(2), // 2 days ago
'grace_period_seconds' => 86400, // 24 hours (expired)
]);
$payload = '{"test":"payload"}';
$newSignature = 'sha256=' . hash_hmac('sha256', $payload, 'new-secret');
$oldSignature = 'sha256=' . hash_hmac('sha256', $payload, 'old-secret');
$this->assertTrue($webhook->verifySignature($payload, $newSignature));
$this->assertFalse($webhook->verifySignature($payload, $oldSignature));
}
/**
* Test various malformed signatures are rejected safely.
*/
#[Test]
#[DataProvider('malformedSignatures')]
public function it_safely_rejects_malformed_signatures(string $signature): void
{
$webhook = new UptelligenceWebhook([
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
'secret' => 'test-secret',
]);
$payload = '{"test":"payload"}';
$this->assertFalse($webhook->verifySignature($payload, $signature));
}
/**
* Data provider for malformed signatures.
*/
public static function malformedSignatures(): array
{
return [
'empty string' => [''],
'whitespace only' => [' '],
'sha256= without hash' => ['sha256='],
'sha1= prefix (github expects sha256)' => ['sha1=abc123'],
'random string' => ['not-a-valid-signature'],
'unicode characters' => ['sha256=\u0000\u0001\u0002'],
'very long string' => [str_repeat('a', 10000)],
'null bytes' => ["sha256=abc\x00def"],
'partial hash' => ['sha256=abc'],
];
}
/**
* Test that verification handles binary payloads correctly.
*/
#[Test]
public function it_handles_binary_payloads(): void
{
$webhook = new UptelligenceWebhook([
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
'secret' => 'binary-secret',
]);
// Payload with null bytes and binary data
$binaryPayload = "binary\x00payload\xff\xfe";
$validSignature = 'sha256=' . hash_hmac('sha256', $binaryPayload, 'binary-secret');
$this->assertTrue($webhook->verifySignature($binaryPayload, $validSignature));
}
/**
* Test that verification handles empty payload.
*/
#[Test]
public function it_handles_empty_payload(): void
{
$webhook = new UptelligenceWebhook([
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
'secret' => 'empty-payload-secret',
]);
$emptyPayload = '';
$validSignature = 'sha256=' . hash_hmac('sha256', $emptyPayload, 'empty-payload-secret');
$this->assertTrue($webhook->verifySignature($emptyPayload, $validSignature));
}
/**
* Test that verification handles large payloads.
*/
#[Test]
public function it_handles_large_payloads(): void
{
$webhook = new UptelligenceWebhook([
'provider' => UptelligenceWebhook::PROVIDER_GITHUB,
'secret' => 'large-payload-secret',
]);
// 1MB payload
$largePayload = str_repeat('{"data":"' . str_repeat('x', 1000) . '"}', 1000);
$validSignature = 'sha256=' . hash_hmac('sha256', $largePayload, 'large-payload-secret');
$this->assertTrue($webhook->verifySignature($largePayload, $validSignature));
}
}