fix: use plain password in configuration for MailJet webhook url

This commit is contained in:
Björn Fromme
2026-04-30 11:44:17 +02:00
parent c0c8ad515c
commit 68900dd4ea
8 changed files with 63 additions and 14 deletions
+2 -2
View File
@@ -44,8 +44,8 @@ MAILJET_API_KEY=
MAILJET_API_SECRET= MAILJET_API_SECRET=
MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST
MAILJET_DEFAULT_LIST_ID= MAILJET_DEFAULT_LIST_ID=
# Development default for HTTP Basic Auth password "secret"; override in production secrets. # Development default for HTTP Basic Auth password; override in production secrets.
MAILJET_WEBHOOK_BASIC_PASSWORD_HASH='$2y$10$uCeNsfVsTkGhylniiZTtGuzs3TDhFr7k/V36w6kD4LWHWbHuleDpa' MAILJET_WEBHOOK_BASIC_PASSWORD=secret
APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1 APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1
APP_BPN_USER= APP_BPN_USER=
+1 -1
View File
@@ -4,4 +4,4 @@ APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999 SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther PANTHER_APP_ENV=panther
PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots
MAILJET_WEBHOOK_BASIC_PASSWORD_HASH='$2y$10$uCeNsfVsTkGhylniiZTtGuzs3TDhFr7k/V36w6kD4LWHWbHuleDpa' MAILJET_WEBHOOK_BASIC_PASSWORD=secret
+3 -1
View File
@@ -1,6 +1,7 @@
security: security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers: password_hashers:
Symfony\Component\Security\Core\User\InMemoryUser: 'plaintext'
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers: providers:
@@ -12,7 +13,7 @@ security:
memory: memory:
users: users:
mailjet: mailjet:
password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD_HASH)%' password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%'
roles: ['ROLE_MAILJET_WEBHOOK'] roles: ['ROLE_MAILJET_WEBHOOK']
firewalls: firewalls:
dev: dev:
@@ -58,6 +59,7 @@ when@test:
# important to generate secure password hashes. In tests however, secure hashes # important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following # are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values. # reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\InMemoryUser: 'plaintext'
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto algorithm: auto
cost: 4 # Lowest possible value for bcrypt cost: 4 # Lowest possible value for bcrypt
+2
View File
@@ -1169,6 +1169,8 @@ php bin/console app:mailjet:newsletter-webhook deactivate
Creates or deletes the Mailjet `unsub` callback for `POST /webhooks/mailjet/newsletter`. Creates or deletes the Mailjet `unsub` callback for `POST /webhooks/mailjet/newsletter`.
- Uses `APP_BASE_URL` by default and appends the webhook path. - Uses `APP_BASE_URL` by default and appends the webhook path.
- The command prepends `mailjet:<password>@` to the URL before registering it with Mailjet.
- Configure the plain password in `MAILJET_WEBHOOK_BASIC_PASSWORD`; Symfony uses it for the webhook firewall.
- Pass `--url` to target a different full webhook URL. - Pass `--url` to target a different full webhook URL.
- `register` prints the API response payload so you can note the callback ID. - `register` prints the API response payload so you can note the callback ID.
- `remove` and `deactivate` require the callback ID returned by Mailjet. - `remove` and `deactivate` require the callback ID returned by Mailjet.
@@ -26,6 +26,7 @@ final class MailjetNewsletterWebhookCommand extends Command
private const string ACTION_REMOVE = 'remove'; private const string ACTION_REMOVE = 'remove';
private const string ACTION_DEACTIVATE = 'deactivate'; private const string ACTION_DEACTIVATE = 'deactivate';
private const string EVENT_TYPE_UNSUBSCRIBE = 'unsub'; private const string EVENT_TYPE_UNSUBSCRIBE = 'unsub';
private const string WEBHOOK_BASIC_AUTH_USER = 'mailjet';
private const string WEBHOOK_PATH = '/webhooks/mailjet/newsletter'; private const string WEBHOOK_PATH = '/webhooks/mailjet/newsletter';
private const int WEBHOOK_VERSION = 2; private const int WEBHOOK_VERSION = 2;
@@ -33,6 +34,8 @@ final class MailjetNewsletterWebhookCommand extends Command
private readonly MailjetApiClient $mailjetApiClient, private readonly MailjetApiClient $mailjetApiClient,
#[Autowire('%env(APP_BASE_URL)%')] #[Autowire('%env(APP_BASE_URL)%')]
private readonly string $defaultBaseUrl, private readonly string $defaultBaseUrl,
#[Autowire('%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%')]
private readonly string $basicAuthPassword,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
parent::__construct(); parent::__construct();
@@ -43,7 +46,7 @@ final class MailjetNewsletterWebhookCommand extends Command
$this $this
->addArgument('action', InputArgument::REQUIRED, 'One of: register, remove, deactivate') ->addArgument('action', InputArgument::REQUIRED, 'One of: register, remove, deactivate')
->addArgument('callback_id', InputArgument::OPTIONAL, 'Callback ID required for remove/deactivate') ->addArgument('callback_id', InputArgument::OPTIONAL, 'Callback ID required for remove/deactivate')
->addOption('url', null, InputOption::VALUE_REQUIRED, 'Override the webhook URL to register'); ->addOption('url', null, InputOption::VALUE_REQUIRED, 'Override the webhook URL to register before Basic Auth credentials are added');
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
@@ -72,9 +75,10 @@ final class MailjetNewsletterWebhookCommand extends Command
private function register(SymfonyStyle $io, mixed $overrideUrl): int private function register(SymfonyStyle $io, mixed $overrideUrl): int
{ {
$webhookUrl = $this->resolveWebhookUrl($overrideUrl); $webhookUrl = $this->resolveWebhookUrl($overrideUrl);
$payload = $this->mailjetApiClient->createEventCallbackUrl(self::EVENT_TYPE_UNSUBSCRIBE, $webhookUrl, self::WEBHOOK_VERSION); $authenticatedWebhookUrl = $this->prependBasicAuth($webhookUrl);
$payload = $this->mailjetApiClient->createEventCallbackUrl(self::EVENT_TYPE_UNSUBSCRIBE, $authenticatedWebhookUrl, self::WEBHOOK_VERSION);
$io->success(sprintf('Created Mailjet newsletter webhook at %s.', $webhookUrl)); $io->success(sprintf('Created Mailjet newsletter webhook at %s.', $authenticatedWebhookUrl));
$io->writeln($this->formatPayload($payload)); $io->writeln($this->formatPayload($payload));
return Command::SUCCESS; return Command::SUCCESS;
@@ -115,6 +119,47 @@ final class MailjetNewsletterWebhookCommand extends Command
return rtrim(trim($this->defaultBaseUrl), '/').self::WEBHOOK_PATH; return rtrim(trim($this->defaultBaseUrl), '/').self::WEBHOOK_PATH;
} }
private function prependBasicAuth(string $url): string
{
if ('' === trim($this->basicAuthPassword)) {
throw new \InvalidArgumentException('MAILJET_WEBHOOK_BASIC_PASSWORD is not configured.');
}
$parts = parse_url($url);
if (false === is_array($parts) || false === isset($parts['scheme'], $parts['host'])) {
throw new \InvalidArgumentException('The webhook URL is not valid.');
}
$host = $parts['host'];
if (false === str_starts_with($host, '[') && true === str_contains($host, ':')) {
$host = sprintf('[%s]', $host);
}
$authenticatedUrl = sprintf(
'%s://%s:%s@%s',
$parts['scheme'],
rawurlencode(self::WEBHOOK_BASIC_AUTH_USER),
rawurlencode($this->basicAuthPassword),
$host,
);
if (isset($parts['port'])) {
$authenticatedUrl .= sprintf(':%d', $parts['port']);
}
$authenticatedUrl .= $parts['path'] ?? '';
if (isset($parts['query'])) {
$authenticatedUrl .= '?'.$parts['query'];
}
if (isset($parts['fragment'])) {
$authenticatedUrl .= '#'.$parts['fragment'];
}
return $authenticatedUrl;
}
private function resolveCallbackId(mixed $callbackId): int private function resolveCallbackId(mixed $callbackId): int
{ {
if (true === is_int($callbackId) && $callbackId > 0) { if (true === is_int($callbackId) && $callbackId > 0) {
@@ -22,7 +22,7 @@ final class MailjetNewsletterWebhookController extends AbstractController
} }
#[Route('/webhooks/mailjet/newsletter', name: 'app_webhook_mailjet_newsletter', methods: ['POST'])] #[Route('/webhooks/mailjet/newsletter', name: 'app_webhook_mailjet_newsletter', methods: ['POST'])]
public function __invoke(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$payload = json_decode($request->getContent(), true); $payload = json_decode($request->getContent(), true);
if (JSON_ERROR_NONE !== json_last_error() || false === is_array($payload)) { if (JSON_ERROR_NONE !== json_last_error() || false === is_array($payload)) {
@@ -51,8 +51,7 @@ final class MailjetNewsletterWebhookController extends AbstractController
} }
/** /**
* @param array<mixed> $payload * @param array<string, mixed> $payload
*
* @return list<array<string, mixed>> * @return list<array<string, mixed>>
*/ */
private function normalizeEvents(array $payload): array private function normalizeEvents(array $payload): array
@@ -20,7 +20,7 @@ class MailjetNewsletterWebhookCommandTest extends TestCase
->method('createEventCallbackUrl') ->method('createEventCallbackUrl')
->with( ->with(
'unsub', 'unsub',
'https://my.ep-reisen.de/webhooks/mailjet/newsletter', 'https://mailjet:secret@my.ep-reisen.de/webhooks/mailjet/newsletter',
2, 2,
false, false,
) )
@@ -89,6 +89,7 @@ class MailjetNewsletterWebhookCommandTest extends TestCase
return new MailjetNewsletterWebhookCommand( return new MailjetNewsletterWebhookCommand(
$mailjetApiClient, $mailjetApiClient,
'https://my.ep-reisen.de', 'https://my.ep-reisen.de',
'secret',
$this->createMock(LoggerInterface::class), $this->createMock(LoggerInterface::class),
); );
} }
@@ -29,7 +29,7 @@ class MailjetNewsletterWebhookControllerTest extends TestCase
->willReturnCallback(static fn (object $message): Envelope => new Envelope($message)); ->willReturnCallback(static fn (object $message): Envelope => new Envelope($message));
$controller = new MailjetNewsletterWebhookController($messageBus, new NullLogger()); $controller = new MailjetNewsletterWebhookController($messageBus, new NullLogger());
$response = $controller(Request::create( $response = $controller->index(Request::create(
'/webhooks/mailjet/newsletter', '/webhooks/mailjet/newsletter',
'POST', 'POST',
content: json_encode([ content: json_encode([
@@ -61,7 +61,7 @@ class MailjetNewsletterWebhookControllerTest extends TestCase
$messageBus->expects(self::never())->method('dispatch'); $messageBus->expects(self::never())->method('dispatch');
$controller = new MailjetNewsletterWebhookController($messageBus, new NullLogger()); $controller = new MailjetNewsletterWebhookController($messageBus, new NullLogger());
$response = $controller(Request::create('/webhooks/mailjet/newsletter', 'POST', content: '{')); $response = $controller->index(Request::create('/webhooks/mailjet/newsletter', 'POST', content: '{'));
self::assertSame(400, $response->getStatusCode()); self::assertSame(400, $response->getStatusCode());
} }
@@ -72,7 +72,7 @@ class MailjetNewsletterWebhookControllerTest extends TestCase
$messageBus->expects(self::never())->method('dispatch'); $messageBus->expects(self::never())->method('dispatch');
$controller = new MailjetNewsletterWebhookController($messageBus, new NullLogger()); $controller = new MailjetNewsletterWebhookController($messageBus, new NullLogger());
$response = $controller(Request::create( $response = $controller->index(Request::create(
'/webhooks/mailjet/newsletter', '/webhooks/mailjet/newsletter',
'POST', 'POST',
content: json_encode([ content: json_encode([