Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions backend/app/Exceptions/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Sentry\Laravel\Facade as Sentry;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Exception\ResourceNotFoundException as SymfonyResourceNotFoundException;
use Throwable;

class Handler extends ExceptionHandler
Expand All @@ -15,7 +15,8 @@ class Handler extends ExceptionHandler
* @var array
*/
protected $dontReport = [
// Add exceptions that shouldn't be reported
ResourceNotFoundException::class,
SymfonyResourceNotFoundException::class,
];

/**
Expand Down Expand Up @@ -54,7 +55,7 @@ public function report(Throwable $e)
*/
public function render($request, Throwable $exception)
{
if ($exception instanceof ResourceNotFoundException) {
if ($exception instanceof ResourceNotFoundException || $exception instanceof SymfonyResourceNotFoundException) {
return response()->json([
'message' => $exception->getMessage() ?: 'Resource not found',
], 404);
Expand Down
16 changes: 14 additions & 2 deletions backend/app/Http/Actions/PromoCodes/GetPromoCodeAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace HiEvents\Http\Actions\PromoCodes;

use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract;
use HiEvents\Exceptions\ResourceNotFoundException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
use HiEvents\Resources\PromoCode\PromoCodeResource;
Expand All @@ -18,12 +20,22 @@ public function __construct(PromoCodeRepositoryInterface $promoCodeRepository)
$this->promoCodeRepository = $promoCodeRepository;
}

/**
* @throws ResourceNotFoundException
*/
public function __invoke(Request $request, int $eventId, int $promoCodeId): JsonResponse
{
$this->isActionAuthorized($eventId, EventDomainObject::class);

$codes = $this->promoCodeRepository->findById($promoCodeId);
$promoCode = $this->promoCodeRepository->findFirstWhere([
PromoCodeDomainObjectAbstract::ID => $promoCodeId,
PromoCodeDomainObjectAbstract::EVENT_ID => $eventId,
]);

return $this->resourceResponse(PromoCodeResource::class, $codes);
if ($promoCode === null) {
throw new ResourceNotFoundException(__('Promo code not found'));
}

return $this->resourceResponse(PromoCodeResource::class, $promoCode);
}
}
3 changes: 2 additions & 1 deletion backend/app/Http/Request/Webhook/UpsertWebhookRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
use HiEvents\DomainObjects\Status\WebhookStatus;
use HiEvents\Http\Request\BaseRequest;
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
use HiEvents\Validators\Rules\NoInternalUrlRule;
use Illuminate\Validation\Rule;

class UpsertWebhookRequest extends BaseRequest
{
public function rules(): array
{
return [
'url' => 'required|url',
'url' => ['required', 'url', new NoInternalUrlRule()],
'event_types.*' => ['required', Rule::in(DomainEventType::valuesArray())],
'status' => ['nullable', Rule::in(WebhookStatus::valuesArray())],
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract;
use HiEvents\DomainObjects\PromoCodeDomainObject;
use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Exceptions\ResourceNotFoundException;
use HiEvents\Helper\DateHelper;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
Expand All @@ -26,9 +27,19 @@ public function __construct(
/**
* @throws ResourceConflictException
* @throws UnrecognizedProductIdException
* @throws ResourceNotFoundException
*/
public function handle(int $promoCodeId, UpsertPromoCodeDTO $promoCodeDTO): PromoCodeDomainObject
{
$promoCode = $this->promoCodeRepository->findFirstWhere([
PromoCodeDomainObjectAbstract::ID => $promoCodeId,
PromoCodeDomainObjectAbstract::EVENT_ID => $promoCodeDTO->event_id,
]);

if ($promoCode === null) {
throw new ResourceNotFoundException(__('Promo code not found'));
}

$this->eventProductValidationService->validateProductIds(
productIds: $promoCodeDTO->applicable_product_ids,
eventId: $promoCodeDTO->event_id
Expand Down
123 changes: 123 additions & 0 deletions backend/app/Validators/Rules/NoInternalUrlRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

namespace HiEvents\Validators\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

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 validate(string $attribute, mixed $value, Closure $fail): void
{
if (!is_string($value)) {
$fail(__('The :attribute must be a valid URL.'));
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);
}

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 = gethostbyname($host);

if ($ip === $host && !filter_var($host, FILTER_VALIDATE_IP)) {
return false;
}

if (!filter_var($ip, FILTER_VALIDATE_IP)) {
return false;
}

if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
return true;
}

if (str_starts_with($ip, '169.254.')) {
return true;
}

return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<?php

namespace Tests\Unit\Services\Application\Handlers\PromoCode;

use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\PromoCodeDomainObject;
use HiEvents\Exceptions\ResourceNotFoundException;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
use HiEvents\Services\Application\Handlers\PromoCode\DTO\UpsertPromoCodeDTO;
use HiEvents\Services\Application\Handlers\PromoCode\UpdatePromoCodeHandler;
use HiEvents\Services\Domain\Product\EventProductValidationService;
use Mockery as m;
use Tests\TestCase;

class UpdatePromoCodeHandlerTest extends TestCase
{
private PromoCodeRepositoryInterface $promoCodeRepository;
private EventProductValidationService $eventProductValidationService;
private EventRepositoryInterface $eventRepository;
private UpdatePromoCodeHandler $handler;

protected function setUp(): void
{
parent::setUp();

$this->promoCodeRepository = m::mock(PromoCodeRepositoryInterface::class);
$this->eventProductValidationService = m::mock(EventProductValidationService::class);
$this->eventRepository = m::mock(EventRepositoryInterface::class);

$this->handler = new UpdatePromoCodeHandler(
$this->promoCodeRepository,
$this->eventProductValidationService,
$this->eventRepository
);
}

public function testHandleThrowsExceptionWhenPromoCodeNotFoundForEvent(): void
{
$promoCodeId = 1;
$eventId = 2;
$dto = new UpsertPromoCodeDTO(
code: 'testcode',
event_id: $eventId,
applicable_product_ids: [],
discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE,
discount: 10.0,
expiry_date: null,
max_allowed_usages: null
);

$this->promoCodeRepository
->shouldReceive('findFirstWhere')
->once()
->with([
'id' => $promoCodeId,
'event_id' => $eventId,
])
->andReturn(null);

$this->expectException(ResourceNotFoundException::class);
$this->expectExceptionMessage('Promo code not found');

$this->handler->handle($promoCodeId, $dto);
}

public function testHandleVerifiesPromoCodeBelongsToEvent(): void
{
$promoCodeId = 1;
$eventIdFromRequest = 2;
$attackerEventId = 999;

$dto = new UpsertPromoCodeDTO(
code: 'testcode',
event_id: $attackerEventId,
applicable_product_ids: [],
discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE,
discount: 10.0,
expiry_date: null,
max_allowed_usages: null
);

$this->promoCodeRepository
->shouldReceive('findFirstWhere')
->once()
->with([
'id' => $promoCodeId,
'event_id' => $attackerEventId,
])
->andReturn(null);

$this->promoCodeRepository
->shouldNotReceive('updateFromArray');

$this->expectException(ResourceNotFoundException::class);

$this->handler->handle($promoCodeId, $dto);
}

public function testHandleSuccessfullyUpdatesPromoCodeWhenOwnershipVerified(): void
{
$promoCodeId = 1;
$eventId = 2;
$dto = new UpsertPromoCodeDTO(
code: 'testcode',
event_id: $eventId,
applicable_product_ids: [],
discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE,
discount: 10.0,
expiry_date: null,
max_allowed_usages: null
);

$existingPromoCode = m::mock(PromoCodeDomainObject::class);
$existingPromoCode->shouldReceive('getId')->andReturn($promoCodeId);

$event = m::mock(EventDomainObject::class);
$event->shouldReceive('getTimezone')->andReturn('UTC');

$updatedPromoCode = m::mock(PromoCodeDomainObject::class);

$this->promoCodeRepository
->shouldReceive('findFirstWhere')
->once()
->with([
'id' => $promoCodeId,
'event_id' => $eventId,
])
->andReturn($existingPromoCode);

$this->eventProductValidationService
->shouldReceive('validateProductIds')
->once();

$this->promoCodeRepository
->shouldReceive('findFirstWhere')
->once()
->with([
'event_id' => $eventId,
'code' => 'testcode',
])
->andReturn($existingPromoCode);

$this->eventRepository
->shouldReceive('findById')
->once()
->with($eventId)
->andReturn($event);

$this->promoCodeRepository
->shouldReceive('updateFromArray')
->once()
->andReturn($updatedPromoCode);

$result = $this->handler->handle($promoCodeId, $dto);

$this->assertSame($updatedPromoCode, $result);
}

protected function tearDown(): void
{
m::close();
parent::tearDown();
}
}
Loading
Loading