php-uptelligence/Controllers/Api/WebhookController.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

508 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Uptelligence\Controllers\Api;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Log;
use Core\Mod\Uptelligence\Jobs\ProcessUptelligenceWebhook;
use Core\Mod\Uptelligence\Models\UptelligenceWebhook;
use Core\Mod\Uptelligence\Models\UptelligenceWebhookDelivery;
use Core\Mod\Uptelligence\Services\WebhookReceiverService;
/**
* WebhookController - receives incoming vendor release webhooks.
*
* Handles webhooks from GitHub, GitLab, npm, Packagist, and custom sources.
* Webhooks are validated, logged, and dispatched to a job for async processing.
*/
class WebhookController extends Controller
{
/**
* Maximum allowed payload size in bytes (1 MB).
*/
protected const MAX_PAYLOAD_SIZE = 1048576;
/**
* Maximum allowed JSON nesting depth.
*/
protected const MAX_JSON_DEPTH = 32;
public function __construct(
protected WebhookReceiverService $service,
) {}
/**
* Receive a webhook for a vendor.
*
* POST /api/uptelligence/webhook/{webhook}
*/
public function receive(Request $request, UptelligenceWebhook $webhook): Response
{
// Check if webhook is enabled
if (! $webhook->isActive()) {
Log::warning('Uptelligence webhook received for disabled endpoint', [
'webhook_id' => $webhook->id,
'vendor_id' => $webhook->vendor_id,
]);
return response('Webhook disabled', 403);
}
// Check circuit breaker
if ($webhook->isCircuitBroken()) {
Log::warning('Uptelligence webhook endpoint circuit breaker open', [
'webhook_id' => $webhook->id,
'failure_count' => $webhook->failure_count,
]);
return response('Service unavailable', 503);
}
// Get raw payload
$payload = $request->getContent();
// Validate payload size (DoS protection)
$payloadValidation = $this->validatePayloadSize($payload, $webhook->id);
if ($payloadValidation !== null) {
return $payloadValidation;
}
// Verify signature
$signature = $this->extractSignature($request, $webhook->provider);
$signatureStatus = $this->service->verifySignature($webhook, $payload, $signature);
if ($signatureStatus === UptelligenceWebhookDelivery::SIGNATURE_INVALID) {
Log::warning('Uptelligence webhook signature verification failed', [
'webhook_id' => $webhook->id,
'vendor_id' => $webhook->vendor_id,
'source_ip' => $request->ip(),
]);
return response('Invalid signature', 401);
}
// Parse and validate JSON payload
$data = $this->parseAndValidateJson($payload, $webhook->id);
if ($data === null) {
return response('Invalid JSON payload', 400);
}
// Validate payload structure
$structureValidation = $this->validatePayloadStructure($data, $webhook);
if ($structureValidation !== null) {
return $structureValidation;
}
// Determine event type
$eventType = $this->determineEventType($request, $data, $webhook->provider);
// Create delivery log
$delivery = UptelligenceWebhookDelivery::create([
'webhook_id' => $webhook->id,
'vendor_id' => $webhook->vendor_id,
'event_type' => $eventType,
'provider' => $webhook->provider,
'payload' => $data,
'status' => UptelligenceWebhookDelivery::STATUS_PENDING,
'source_ip' => $request->ip(),
'signature_status' => $signatureStatus,
]);
Log::info('Uptelligence webhook received', [
'delivery_id' => $delivery->id,
'webhook_id' => $webhook->id,
'vendor_id' => $webhook->vendor_id,
'event_type' => $eventType,
]);
// Update webhook last received timestamp
$webhook->markReceived();
// Dispatch job for async processing
ProcessUptelligenceWebhook::dispatch($delivery);
return response('Accepted', 202);
}
/**
* Extract signature from request headers based on provider.
*/
protected function extractSignature(Request $request, string $provider): ?string
{
return match ($provider) {
UptelligenceWebhook::PROVIDER_GITHUB => $this->extractGitHubSignature($request),
UptelligenceWebhook::PROVIDER_GITLAB => $request->header('X-Gitlab-Token'),
UptelligenceWebhook::PROVIDER_NPM => $request->header('X-Npm-Signature'),
UptelligenceWebhook::PROVIDER_PACKAGIST => $request->header('X-Hub-Signature'),
default => $this->extractGenericSignature($request),
};
}
/**
* Extract GitHub signature (prefers SHA-256).
*/
protected function extractGitHubSignature(Request $request): ?string
{
// Prefer SHA-256
$signature = $request->header('X-Hub-Signature-256');
if ($signature) {
return $signature;
}
// Fall back to SHA-1 (legacy)
return $request->header('X-Hub-Signature');
}
/**
* Extract signature from generic headers.
*/
protected function extractGenericSignature(Request $request): ?string
{
$signatureHeaders = [
'X-Signature',
'X-Hub-Signature-256',
'X-Hub-Signature',
'X-Webhook-Signature',
'Signature',
];
foreach ($signatureHeaders as $header) {
$value = $request->header($header);
if ($value) {
return $value;
}
}
return null;
}
/**
* Determine the event type from request and payload.
*/
protected function determineEventType(Request $request, array $data, string $provider): string
{
return match ($provider) {
UptelligenceWebhook::PROVIDER_GITHUB => $this->determineGitHubEventType($request, $data),
UptelligenceWebhook::PROVIDER_GITLAB => $this->determineGitLabEventType($request, $data),
UptelligenceWebhook::PROVIDER_NPM => $this->determineNpmEventType($data),
UptelligenceWebhook::PROVIDER_PACKAGIST => $this->determinePackagistEventType($data),
default => $this->determineGenericEventType($request, $data),
};
}
/**
* Determine GitHub event type.
*/
protected function determineGitHubEventType(Request $request, array $data): string
{
$event = $request->header('X-GitHub-Event', 'unknown');
$action = $data['action'] ?? 'unknown';
return "github.{$event}.{$action}";
}
/**
* Determine GitLab event type.
*/
protected function determineGitLabEventType(Request $request, array $data): string
{
$objectKind = $data['object_kind'] ?? 'unknown';
$action = $data['action'] ?? 'unknown';
return "gitlab.{$objectKind}.{$action}";
}
/**
* Determine npm event type.
*/
protected function determineNpmEventType(array $data): string
{
$event = $data['event'] ?? 'package:unknown';
$normalised = str_replace(':', '.', $event);
return "npm.{$normalised}";
}
/**
* Determine Packagist event type.
*/
protected function determinePackagistEventType(array $data): string
{
// Packagist webhooks typically indicate an update
return 'packagist.package.update';
}
/**
* Determine generic event type.
*/
protected function determineGenericEventType(Request $request, array $data): string
{
// Check headers
$eventType = $request->header('X-Event-Type')
?? $request->header('X-Webhook-Event');
if ($eventType) {
return "custom.{$eventType}";
}
// Check payload
$event = $data['event']
?? $data['event_type']
?? $data['action']
?? 'unknown';
return "custom.{$event}";
}
/**
* Test endpoint to verify webhook configuration.
*
* POST /api/uptelligence/webhook/{webhook}/test
*/
public function test(Request $request, UptelligenceWebhook $webhook): Response
{
// This endpoint is for testing - requires the webhook to exist
// and optionally verifies signature
$payload = $request->getContent();
$signature = $this->extractSignature($request, $webhook->provider);
$signatureStatus = $this->service->verifySignature($webhook, $payload, $signature);
return response()->json([
'status' => 'ok',
'webhook_id' => $webhook->uuid,
'vendor_id' => $webhook->vendor_id,
'provider' => $webhook->provider,
'is_active' => $webhook->is_active,
'signature_status' => $signatureStatus,
'has_secret' => ! empty($webhook->secret),
]);
}
// -------------------------------------------------------------------------
// Payload Validation Methods
// -------------------------------------------------------------------------
/**
* Validate payload size to prevent DoS attacks.
*/
protected function validatePayloadSize(string $payload, int $webhookId): ?Response
{
$payloadSize = strlen($payload);
if ($payloadSize > self::MAX_PAYLOAD_SIZE) {
Log::warning('Uptelligence webhook payload too large', [
'webhook_id' => $webhookId,
'payload_size' => $payloadSize,
'max_size' => self::MAX_PAYLOAD_SIZE,
]);
return response('Payload too large', 413);
}
if ($payloadSize === 0) {
Log::warning('Uptelligence webhook empty payload', [
'webhook_id' => $webhookId,
]);
return response('Empty payload', 400);
}
return null;
}
/**
* Parse and validate JSON payload with depth limit.
*
* Returns the parsed data or null on failure.
*/
protected function parseAndValidateJson(string $payload, int $webhookId): ?array
{
// Parse with depth limit to prevent deeply nested JSON attacks
$data = json_decode($payload, true, self::MAX_JSON_DEPTH);
if (json_last_error() !== JSON_ERROR_NONE) {
$errorMessage = json_last_error_msg();
// Check for depth-related errors
if (json_last_error() === JSON_ERROR_DEPTH) {
Log::warning('Uptelligence webhook JSON too deeply nested', [
'webhook_id' => $webhookId,
'max_depth' => self::MAX_JSON_DEPTH,
]);
} else {
Log::warning('Uptelligence webhook invalid JSON payload', [
'webhook_id' => $webhookId,
'error' => $errorMessage,
]);
}
return null;
}
// Ensure payload is an object/array (not a scalar)
if (! is_array($data)) {
Log::warning('Uptelligence webhook payload must be an object', [
'webhook_id' => $webhookId,
'type' => gettype($data),
]);
return null;
}
return $data;
}
/**
* Validate payload structure based on provider.
*
* Performs basic schema validation to ensure expected fields exist.
*/
protected function validatePayloadStructure(array $data, UptelligenceWebhook $webhook): ?Response
{
$provider = $webhook->provider;
$webhookId = $webhook->id;
// Validate based on provider
$validation = match ($provider) {
UptelligenceWebhook::PROVIDER_GITHUB => $this->validateGitHubPayload($data),
UptelligenceWebhook::PROVIDER_GITLAB => $this->validateGitLabPayload($data),
UptelligenceWebhook::PROVIDER_NPM => $this->validateNpmPayload($data),
UptelligenceWebhook::PROVIDER_PACKAGIST => $this->validatePackagistPayload($data),
default => $this->validateCustomPayload($data),
};
if ($validation !== true) {
Log::warning('Uptelligence webhook payload validation failed', [
'webhook_id' => $webhookId,
'provider' => $provider,
'error' => $validation,
]);
return response('Invalid payload structure: ' . $validation, 400);
}
return null;
}
/**
* Validate GitHub webhook payload structure.
*/
protected function validateGitHubPayload(array $data): string|bool
{
// GitHub webhooks should have an action field for most events
// Release events have release object
if (isset($data['release'])) {
if (! is_array($data['release'])) {
return 'release must be an object';
}
}
// Check for suspiciously large arrays (potential DoS)
if ($this->hasExcessiveArraySize($data)) {
return 'payload contains excessively large arrays';
}
return true;
}
/**
* Validate GitLab webhook payload structure.
*/
protected function validateGitLabPayload(array $data): string|bool
{
// GitLab webhooks typically have object_kind
if (isset($data['object_kind']) && ! is_string($data['object_kind'])) {
return 'object_kind must be a string';
}
if ($this->hasExcessiveArraySize($data)) {
return 'payload contains excessively large arrays';
}
return true;
}
/**
* Validate npm webhook payload structure.
*/
protected function validateNpmPayload(array $data): string|bool
{
// npm webhooks should have event field
if (isset($data['event']) && ! is_string($data['event'])) {
return 'event must be a string';
}
if ($this->hasExcessiveArraySize($data)) {
return 'payload contains excessively large arrays';
}
return true;
}
/**
* Validate Packagist webhook payload structure.
*/
protected function validatePackagistPayload(array $data): string|bool
{
// Packagist should have package or repository info
if (isset($data['versions']) && ! is_array($data['versions'])) {
return 'versions must be an array';
}
if ($this->hasExcessiveArraySize($data)) {
return 'payload contains excessively large arrays';
}
return true;
}
/**
* Validate custom webhook payload structure.
*/
protected function validateCustomPayload(array $data): string|bool
{
// Minimal validation for custom webhooks
if ($this->hasExcessiveArraySize($data)) {
return 'payload contains excessively large arrays';
}
return true;
}
/**
* Check if payload contains excessively large arrays (DoS protection).
*
* Recursively checks array sizes to prevent memory exhaustion
* from payloads with many elements at any nesting level.
*/
protected function hasExcessiveArraySize(array $data, int $maxElements = 1000, int $depth = 0): bool
{
// Prevent infinite recursion
if ($depth > self::MAX_JSON_DEPTH) {
return true;
}
$totalElements = 0;
foreach ($data as $value) {
$totalElements++;
if ($totalElements > $maxElements) {
return true;
}
if (is_array($value)) {
if ($this->hasExcessiveArraySize($value, $maxElements - $totalElements, $depth + 1)) {
return true;
}
}
}
return false;
}
}