diff --git a/backend/app/Exceptions/Handler.php b/backend/app/Exceptions/Handler.php index 0c9ec8663a..37c7e8f4c7 100644 --- a/backend/app/Exceptions/Handler.php +++ b/backend/app/Exceptions/Handler.php @@ -4,7 +4,7 @@ use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Sentry\Laravel\Facade as Sentry; -use Symfony\Component\Routing\Exception\ResourceNotFoundException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException as SymfonyResourceNotFoundException; use Throwable; class Handler extends ExceptionHandler @@ -15,7 +15,8 @@ class Handler extends ExceptionHandler * @var array */ protected $dontReport = [ - // Add exceptions that shouldn't be reported + ResourceNotFoundException::class, + SymfonyResourceNotFoundException::class, ]; /** @@ -54,7 +55,7 @@ public function report(Throwable $e) */ public function render($request, Throwable $exception) { - if ($exception instanceof ResourceNotFoundException) { + if ($exception instanceof ResourceNotFoundException || $exception instanceof SymfonyResourceNotFoundException) { return response()->json([ 'message' => $exception->getMessage() ?: 'Resource not found', ], 404); diff --git a/backend/app/Http/Actions/PromoCodes/GetPromoCodeAction.php b/backend/app/Http/Actions/PromoCodes/GetPromoCodeAction.php index 8432a9c617..d7aaef520f 100644 --- a/backend/app/Http/Actions/PromoCodes/GetPromoCodeAction.php +++ b/backend/app/Http/Actions/PromoCodes/GetPromoCodeAction.php @@ -3,6 +3,8 @@ namespace HiEvents\Http\Actions\PromoCodes; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; +use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; use HiEvents\Resources\PromoCode\PromoCodeResource; @@ -18,12 +20,22 @@ public function __construct(PromoCodeRepositoryInterface $promoCodeRepository) $this->promoCodeRepository = $promoCodeRepository; } + /** + * @throws ResourceNotFoundException + */ public function __invoke(Request $request, int $eventId, int $promoCodeId): JsonResponse { $this->isActionAuthorized($eventId, EventDomainObject::class); - $codes = $this->promoCodeRepository->findById($promoCodeId); + $promoCode = $this->promoCodeRepository->findFirstWhere([ + PromoCodeDomainObjectAbstract::ID => $promoCodeId, + PromoCodeDomainObjectAbstract::EVENT_ID => $eventId, + ]); - return $this->resourceResponse(PromoCodeResource::class, $codes); + if ($promoCode === null) { + throw new ResourceNotFoundException(__('Promo code not found')); + } + + return $this->resourceResponse(PromoCodeResource::class, $promoCode); } } diff --git a/backend/app/Http/Request/Webhook/UpsertWebhookRequest.php b/backend/app/Http/Request/Webhook/UpsertWebhookRequest.php index 28aa337aa5..dace41652c 100644 --- a/backend/app/Http/Request/Webhook/UpsertWebhookRequest.php +++ b/backend/app/Http/Request/Webhook/UpsertWebhookRequest.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\Status\WebhookStatus; use HiEvents\Http\Request\BaseRequest; use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; +use HiEvents\Validators\Rules\NoInternalUrlRule; use Illuminate\Validation\Rule; class UpsertWebhookRequest extends BaseRequest @@ -12,7 +13,7 @@ class UpsertWebhookRequest extends BaseRequest public function rules(): array { return [ - 'url' => 'required|url', + 'url' => ['required', 'url', new NoInternalUrlRule()], 'event_types.*' => ['required', Rule::in(DomainEventType::valuesArray())], 'status' => ['nullable', Rule::in(WebhookStatus::valuesArray())], ]; diff --git a/backend/app/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandler.php b/backend/app/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandler.php index 33ceacf84b..efc554ee51 100644 --- a/backend/app/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandler.php +++ b/backend/app/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandler.php @@ -6,6 +6,7 @@ use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract; use HiEvents\DomainObjects\PromoCodeDomainObject; use HiEvents\Exceptions\ResourceConflictException; +use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Helper\DateHelper; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; @@ -26,9 +27,19 @@ public function __construct( /** * @throws ResourceConflictException * @throws UnrecognizedProductIdException + * @throws ResourceNotFoundException */ public function handle(int $promoCodeId, UpsertPromoCodeDTO $promoCodeDTO): PromoCodeDomainObject { + $promoCode = $this->promoCodeRepository->findFirstWhere([ + PromoCodeDomainObjectAbstract::ID => $promoCodeId, + PromoCodeDomainObjectAbstract::EVENT_ID => $promoCodeDTO->event_id, + ]); + + if ($promoCode === null) { + throw new ResourceNotFoundException(__('Promo code not found')); + } + $this->eventProductValidationService->validateProductIds( productIds: $promoCodeDTO->applicable_product_ids, eventId: $promoCodeDTO->event_id diff --git a/backend/app/Validators/Rules/NoInternalUrlRule.php b/backend/app/Validators/Rules/NoInternalUrlRule.php new file mode 100644 index 0000000000..24096a108a --- /dev/null +++ b/backend/app/Validators/Rules/NoInternalUrlRule.php @@ -0,0 +1,123 @@ +isBlockedHost($host)) { + $fail(__('The :attribute cannot point to localhost or internal addresses.')); + return; + } + + if ($this->isBlockedTld($host)) { + $fail(__('The :attribute cannot use reserved domain names.')); + return; + } + + if ($this->isCloudMetadataHost($host)) { + $fail(__('The :attribute cannot point to cloud metadata endpoints.')); + return; + } + + if ($this->isPrivateIpAddress($host)) { + $fail(__('The :attribute cannot point to private or internal IP addresses.')); + return; + } + } + + private function isBlockedHost(string $host): bool + { + return in_array($host, self::BLOCKED_HOSTS, true); + } + + private function isBlockedTld(string $host): bool + { + foreach (self::BLOCKED_TLDS as $tld) { + if (str_ends_with($host, $tld)) { + return true; + } + } + return false; + } + + private function isCloudMetadataHost(string $host): bool + { + foreach (self::CLOUD_METADATA_HOSTS as $metadataHost) { + if ($host === $metadataHost || str_ends_with($host, '.' . $metadataHost)) { + return true; + } + } + return false; + } + + private function isPrivateIpAddress(string $host): bool + { + $ip = gethostbyname($host); + + if ($ip === $host && !filter_var($host, FILTER_VALIDATE_IP)) { + return false; + } + + if (!filter_var($ip, FILTER_VALIDATE_IP)) { + return false; + } + + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + return true; + } + + if (str_starts_with($ip, '169.254.')) { + return true; + } + + return false; + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandlerTest.php new file mode 100644 index 0000000000..a5d59ffff9 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandlerTest.php @@ -0,0 +1,166 @@ +promoCodeRepository = m::mock(PromoCodeRepositoryInterface::class); + $this->eventProductValidationService = m::mock(EventProductValidationService::class); + $this->eventRepository = m::mock(EventRepositoryInterface::class); + + $this->handler = new UpdatePromoCodeHandler( + $this->promoCodeRepository, + $this->eventProductValidationService, + $this->eventRepository + ); + } + + public function testHandleThrowsExceptionWhenPromoCodeNotFoundForEvent(): void + { + $promoCodeId = 1; + $eventId = 2; + $dto = new UpsertPromoCodeDTO( + code: 'testcode', + event_id: $eventId, + applicable_product_ids: [], + discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE, + discount: 10.0, + expiry_date: null, + max_allowed_usages: null + ); + + $this->promoCodeRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + 'id' => $promoCodeId, + 'event_id' => $eventId, + ]) + ->andReturn(null); + + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage('Promo code not found'); + + $this->handler->handle($promoCodeId, $dto); + } + + public function testHandleVerifiesPromoCodeBelongsToEvent(): void + { + $promoCodeId = 1; + $eventIdFromRequest = 2; + $attackerEventId = 999; + + $dto = new UpsertPromoCodeDTO( + code: 'testcode', + event_id: $attackerEventId, + applicable_product_ids: [], + discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE, + discount: 10.0, + expiry_date: null, + max_allowed_usages: null + ); + + $this->promoCodeRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + 'id' => $promoCodeId, + 'event_id' => $attackerEventId, + ]) + ->andReturn(null); + + $this->promoCodeRepository + ->shouldNotReceive('updateFromArray'); + + $this->expectException(ResourceNotFoundException::class); + + $this->handler->handle($promoCodeId, $dto); + } + + public function testHandleSuccessfullyUpdatesPromoCodeWhenOwnershipVerified(): void + { + $promoCodeId = 1; + $eventId = 2; + $dto = new UpsertPromoCodeDTO( + code: 'testcode', + event_id: $eventId, + applicable_product_ids: [], + discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE, + discount: 10.0, + expiry_date: null, + max_allowed_usages: null + ); + + $existingPromoCode = m::mock(PromoCodeDomainObject::class); + $existingPromoCode->shouldReceive('getId')->andReturn($promoCodeId); + + $event = m::mock(EventDomainObject::class); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + + $updatedPromoCode = m::mock(PromoCodeDomainObject::class); + + $this->promoCodeRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + 'id' => $promoCodeId, + 'event_id' => $eventId, + ]) + ->andReturn($existingPromoCode); + + $this->eventProductValidationService + ->shouldReceive('validateProductIds') + ->once(); + + $this->promoCodeRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with([ + 'event_id' => $eventId, + 'code' => 'testcode', + ]) + ->andReturn($existingPromoCode); + + $this->eventRepository + ->shouldReceive('findById') + ->once() + ->with($eventId) + ->andReturn($event); + + $this->promoCodeRepository + ->shouldReceive('updateFromArray') + ->once() + ->andReturn($updatedPromoCode); + + $result = $this->handler->handle($promoCodeId, $dto); + + $this->assertSame($updatedPromoCode, $result); + } + + protected function tearDown(): void + { + m::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Validators/Rules/NoInternalUrlRuleTest.php b/backend/tests/Unit/Validators/Rules/NoInternalUrlRuleTest.php new file mode 100644 index 0000000000..64484c0b2b --- /dev/null +++ b/backend/tests/Unit/Validators/Rules/NoInternalUrlRuleTest.php @@ -0,0 +1,115 @@ +rule = new NoInternalUrlRule(); + $this->failedMessages = []; + } + + private function validate(string $url): bool + { + $this->failedMessages = []; + $failed = false; + + $this->rule->validate('url', $url, function ($message) use (&$failed) { + $failed = true; + $this->failedMessages[] = $message; + }); + + return !$failed; + } + + public function testAcceptsValidExternalUrls(): void + { + $this->assertTrue($this->validate('https://example.com/webhook')); + $this->assertTrue($this->validate('https://api.stripe.com/v1/webhooks')); + $this->assertTrue($this->validate('https://hooks.slack.com/services/123')); + $this->assertTrue($this->validate('http://webhook.site/abc123')); + } + + public function testRejectsLocalhostUrls(): void + { + $this->assertFalse($this->validate('http://localhost/admin')); + $this->assertFalse($this->validate('http://localhost:8080/api')); + $this->assertFalse($this->validate('https://localhost/webhook')); + } + + public function testRejectsLoopbackIpUrls(): void + { + $this->assertFalse($this->validate('http://127.0.0.1/admin')); + $this->assertFalse($this->validate('http://127.0.0.1:3000/api')); + $this->assertFalse($this->validate('https://127.0.0.1/webhook')); + } + + public function testRejectsCloudMetadataUrls(): void + { + $this->assertFalse($this->validate('http://169.254.169.254/latest/meta-data/')); + $this->assertFalse($this->validate('http://169.254.169.254/latest/meta-data/iam/security-credentials/')); + $this->assertFalse($this->validate('http://metadata.google.internal/computeMetadata/v1/')); + } + + public function testRejectsPrivateIpAddresses(): void + { + $this->assertFalse($this->validate('http://10.0.0.1/internal')); + $this->assertFalse($this->validate('http://10.255.255.255/api')); + $this->assertFalse($this->validate('http://172.16.0.1/webhook')); + $this->assertFalse($this->validate('http://172.31.255.255/api')); + $this->assertFalse($this->validate('http://192.168.0.1/admin')); + $this->assertFalse($this->validate('http://192.168.255.255/api')); + } + + public function testRejectsZeroIpAddress(): void + { + $this->assertFalse($this->validate('http://0.0.0.0/')); + $this->assertFalse($this->validate('http://0.0.0.0:8080/webhook')); + } + + public function testRejectsLinkLocalAddresses(): void + { + $this->assertFalse($this->validate('http://169.254.0.1/')); + $this->assertFalse($this->validate('http://169.254.255.254/')); + } + + public function testRejectsInvalidUrls(): void + { + $this->assertFalse($this->validate('not-a-url')); + $this->assertFalse($this->validate('')); + } + + public function testRejectsIpv6Localhost(): void + { + $this->assertFalse($this->validate('http://[::1]/webhook')); + } + + public function testRejectsNonHttpSchemes(): void + { + $this->assertFalse($this->validate('file:///etc/passwd')); + $this->assertFalse($this->validate('gopher://localhost/')); + $this->assertFalse($this->validate('ftp://example.com/')); + $this->assertFalse($this->validate('dict://localhost/')); + } + + public function testRejectsLocalhostTld(): void + { + $this->assertFalse($this->validate('http://app.localhost/webhook')); + $this->assertFalse($this->validate('https://api.localhost/')); + $this->assertFalse($this->validate('http://anything.localhost/')); + } + + public function testAcceptsHttpAndHttpsSchemes(): void + { + $this->assertTrue($this->validate('http://example.com/webhook')); + $this->assertTrue($this->validate('https://example.com/webhook')); + } +}