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/Orders/GetOrderAction.php b/backend/app/Http/Actions/Orders/GetOrderAction.php index 0d399cacae..6947037904 100644 --- a/backend/app/Http/Actions/Orders/GetOrderAction.php +++ b/backend/app/Http/Actions/Orders/GetOrderAction.php @@ -4,8 +4,10 @@ use HiEvents\DomainObjects\AttendeeDomainObject; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract; use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject; +use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Eloquent\Value\OrderAndDirection; use HiEvents\Repository\Eloquent\Value\Relationship; @@ -22,6 +24,9 @@ public function __construct(OrderRepositoryInterface $orderRepository) $this->orderRepository = $orderRepository; } + /** + * @throws ResourceNotFoundException + */ public function __invoke(int $eventId, int $orderId): JsonResponse { $this->isActionAuthorized($eventId, EventDomainObject::class); @@ -32,7 +37,14 @@ public function __invoke(int $eventId, int $orderId): JsonResponse ->loadRelation(new Relationship(domainObject: QuestionAndAnswerViewDomainObject::class, orderAndDirections: [ new OrderAndDirection(order: 'question_id'), ])) - ->findById($orderId); + ->findFirstWhere([ + OrderDomainObjectAbstract::ID => $orderId, + OrderDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if ($order === null) { + throw new ResourceNotFoundException(__('Order not found')); + } return $this->resourceResponse(OrderResource::class, $order); } 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/Actions/Questions/GetQuestionAction.php b/backend/app/Http/Actions/Questions/GetQuestionAction.php index 6701e7ce3f..6859f9549e 100644 --- a/backend/app/Http/Actions/Questions/GetQuestionAction.php +++ b/backend/app/Http/Actions/Questions/GetQuestionAction.php @@ -3,7 +3,9 @@ namespace HiEvents\Http\Actions\Questions; use HiEvents\DomainObjects\EventDomainObject; +use HiEvents\DomainObjects\Generated\QuestionDomainObjectAbstract; use HiEvents\DomainObjects\ProductDomainObject; +use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Http\Actions\BaseAction; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; use HiEvents\Resources\Question\QuestionResource; @@ -19,14 +21,24 @@ public function __construct(QuestionRepositoryInterface $questionRepository) $this->questionRepository = $questionRepository; } + /** + * @throws ResourceNotFoundException + */ public function __invoke(Request $request, int $eventId, int $questionId): JsonResponse { $this->isActionAuthorized($eventId, EventDomainObject::class); - $questions = $this->questionRepository + $question = $this->questionRepository ->loadRelation(ProductDomainObject::class) - ->findById($questionId); + ->findFirstWhere([ + QuestionDomainObjectAbstract::ID => $questionId, + QuestionDomainObjectAbstract::EVENT_ID => $eventId, + ]); - return $this->resourceResponse(QuestionResource::class, $questions); + if ($question === null) { + throw new ResourceNotFoundException(__('Question not found')); + } + + return $this->resourceResponse(QuestionResource::class, $question); } } 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/Attendee/PartialEditAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php index 0256ccd6bc..393dd0ecce 100644 --- a/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php +++ b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php @@ -102,7 +102,14 @@ private function adjustEventStatistics(PartialEditAttendeeDTO $data, AttendeeDom { if ($data->status === AttendeeStatus::CANCELLED->name) { // Get the order to access the creation date for daily statistics - $order = $this->orderRepository->findById($attendee->getOrderId()); + $order = $this->orderRepository->findFirstWhere([ + 'id' => $attendee->getOrderId(), + 'event_id' => $attendee->getEventId(), + ]); + + if ($order === null) { + return; + } $this->eventStatisticsCancellationService->decrementForCancelledAttendee( eventId: $attendee->getEventId(), diff --git a/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php index 49c7bd46be..175e1e3045 100644 --- a/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php +++ b/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php @@ -49,7 +49,10 @@ private function updateEventStatus(UpdateEventStatusDTO $updateEventStatusDTO): $this->eventRepository->updateWhere( attributes: ['status' => $updateEventStatusDTO->status], - where: ['id' => $updateEventStatusDTO->eventId] + where: [ + 'id' => $updateEventStatusDTO->eventId, + 'account_id' => $updateEventStatusDTO->accountId, + ] ); $this->logger->info('Event status updated', [ @@ -57,6 +60,9 @@ private function updateEventStatus(UpdateEventStatusDTO $updateEventStatusDTO): 'status' => $updateEventStatusDTO->status ]); - return $this->eventRepository->findById($updateEventStatusDTO->eventId); + return $this->eventRepository->findFirstWhere([ + 'id' => $updateEventStatusDTO->eventId, + 'account_id' => $updateEventStatusDTO->accountId, + ]); } } diff --git a/backend/app/Services/Application/Handlers/Organizer/EditOrganizerHandler.php b/backend/app/Services/Application/Handlers/Organizer/EditOrganizerHandler.php index 7cf4927556..62e5c8fe50 100644 --- a/backend/app/Services/Application/Handlers/Organizer/EditOrganizerHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/EditOrganizerHandler.php @@ -51,6 +51,9 @@ private function editOrganizer(EditOrganizerDTO $organizerData): OrganizerDomain return $this->organizerRepository ->loadRelation(ImageDomainObject::class) - ->findById($organizerData->id); + ->findFirstWhere([ + 'id' => $organizerData->id, + 'account_id' => $organizerData->account_id, + ]); } } diff --git a/backend/app/Services/Application/Handlers/Organizer/UpdateOrganizerStatusHandler.php b/backend/app/Services/Application/Handlers/Organizer/UpdateOrganizerStatusHandler.php index d82c628452..9042838bf7 100644 --- a/backend/app/Services/Application/Handlers/Organizer/UpdateOrganizerStatusHandler.php +++ b/backend/app/Services/Application/Handlers/Organizer/UpdateOrganizerStatusHandler.php @@ -48,7 +48,10 @@ private function updateOrganizerStatus(UpdateOrganizerStatusDTO $updateOrganizer $this->organizerRepository->updateWhere( attributes: ['status' => $updateOrganizerStatusDTO->status], - where: ['id' => $updateOrganizerStatusDTO->organizerId] + where: [ + 'id' => $updateOrganizerStatusDTO->organizerId, + 'account_id' => $updateOrganizerStatusDTO->accountId, + ] ); $this->logger->info('Organizer status updated', [ @@ -56,6 +59,9 @@ private function updateOrganizerStatus(UpdateOrganizerStatusDTO $updateOrganizer 'status' => $updateOrganizerStatusDTO->status ]); - return $this->organizerRepository->findById($updateOrganizerStatusDTO->organizerId); + return $this->organizerRepository->findFirstWhere([ + 'id' => $updateOrganizerStatusDTO->organizerId, + 'account_id' => $updateOrganizerStatusDTO->accountId, + ]); } } diff --git a/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php index 105adde141..574183a576 100644 --- a/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php +++ b/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php @@ -29,6 +29,9 @@ public function handle(UpsertProductCategoryDTO $dto): ProductCategoryDomainObje ], ); - return $this->productCategoryRepository->findById($dto->product_category_id); + return $this->productCategoryRepository->findFirstWhere([ + 'id' => $dto->product_category_id, + 'event_id' => $dto->event_id, + ]); } } 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/Services/Application/Handlers/TaxAndFee/EditTaxHandler.php b/backend/app/Services/Application/Handlers/TaxAndFee/EditTaxHandler.php index d3a65a7888..50e1163a2f 100644 --- a/backend/app/Services/Application/Handlers/TaxAndFee/EditTaxHandler.php +++ b/backend/app/Services/Application/Handlers/TaxAndFee/EditTaxHandler.php @@ -61,7 +61,10 @@ public function handle(UpsertTaxDTO $data): TaxAndFeesDomainObject ); /** @var TaxAndFeesDomainObject $tax */ - $tax = $this->taxRepository->findById($data->id); + $tax = $this->taxRepository->findFirstWhere([ + 'id' => $data->id, + 'account_id' => $data->account_id, + ]); $this->logger->info('Updated tax', [ 'id' => $tax->getId(), diff --git a/backend/app/Services/Application/Handlers/User/CancelEmailChangeHandler.php b/backend/app/Services/Application/Handlers/User/CancelEmailChangeHandler.php index 330c8c4974..f4c086b20a 100644 --- a/backend/app/Services/Application/Handlers/User/CancelEmailChangeHandler.php +++ b/backend/app/Services/Application/Handlers/User/CancelEmailChangeHandler.php @@ -3,6 +3,7 @@ namespace HiEvents\Services\Application\Handlers\User; use HiEvents\DomainObjects\UserDomainObject; +use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Repository\Interfaces\UserRepositoryInterface; use HiEvents\Services\Application\Handlers\User\DTO\CancelEmailChangeDTO; use Psr\Log\LoggerInterface; @@ -24,6 +25,12 @@ public function __construct( public function handle(CancelEmailChangeDTO $data): UserDomainObject { + $user = $this->userRepository->findByIdAndAccountId($data->userId, $data->accountId); + + if ($user === null) { + throw new ResourceNotFoundException(__('User not found')); + } + $this->userRepository->updateWhere( attributes: [ 'pending_email' => null, diff --git a/backend/app/Services/Application/Handlers/User/UpdateMeHandler.php b/backend/app/Services/Application/Handlers/User/UpdateMeHandler.php index 0deba3033b..965a9257b6 100644 --- a/backend/app/Services/Application/Handlers/User/UpdateMeHandler.php +++ b/backend/app/Services/Application/Handlers/User/UpdateMeHandler.php @@ -88,15 +88,10 @@ private function isChangingEmail(UpdateMeDTO $updateUserData, UserDomainObject $ private function getExistingUser(UpdateMeDTO $updateUserData): UserDomainObject { - $existingUser = $this->userRepository->findFirstWhere([ - 'id' => $updateUserData->id, - ]); - - if ($existingUser === null) { - throw new ResourceNotFoundException(); - } - - return $existingUser; + return $this->userRepository->findByIdAndAccountId( + $updateUserData->id, + $updateUserData->account_id + ); } private function sendEmailChangeConfirmation(UserDomainObject $existingUser): void 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')); + } +}