Fixes #6350 -- Tighten security of webhook test dispatching via log level, not IANA IP check.
This commit is contained in:
parent
26b79e7509
commit
13d1e5c216
|
@ -4,17 +4,22 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Controller\Api\Stations\Webhooks;
|
||||
|
||||
use App\Container\EnvironmentAwareTrait;
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Entity\Repository\StationWebhookRepository;
|
||||
use App\Enums\GlobalPermissions;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use App\Message\TestWebhookMessage;
|
||||
use App\Utilities\File;
|
||||
use Monolog\Level;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Symfony\Component\Messenger\MessageBus;
|
||||
|
||||
final class TestAction implements SingleActionInterface
|
||||
{
|
||||
use EnvironmentAwareTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly StationWebhookRepository $webhookRepo,
|
||||
private readonly MessageBus $messageBus
|
||||
|
@ -29,7 +34,14 @@ final class TestAction implements SingleActionInterface
|
|||
/** @var string $id */
|
||||
$id = $params['id'];
|
||||
|
||||
$webhook = $this->webhookRepo->requireForStation($id, $request->getStation());
|
||||
$station = $request->getStation();
|
||||
$acl = $request->getAcl();
|
||||
|
||||
$webhook = $this->webhookRepo->requireForStation($id, $station);
|
||||
|
||||
$logLevel = ($this->environment->isDevelopment() || $acl->isAllowed(GlobalPermissions::View))
|
||||
? Level::Debug
|
||||
: Level::Info;
|
||||
|
||||
$tempFile = File::generateTempPath('webhook_test_' . $id . '.log');
|
||||
touch($tempFile);
|
||||
|
@ -37,6 +49,7 @@ final class TestAction implements SingleActionInterface
|
|||
$message = new TestWebhookMessage();
|
||||
$message->webhookId = $webhook->getIdRequired();
|
||||
$message->outputPath = $tempFile;
|
||||
$message->logLevel = $logLevel->value;
|
||||
|
||||
$this->messageBus->dispatch($message);
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace App\Message;
|
||||
|
||||
use App\Environment;
|
||||
use Monolog\Level;
|
||||
|
||||
final class TestWebhookMessage extends AbstractUniqueMessage
|
||||
{
|
||||
|
@ -13,6 +14,9 @@ final class TestWebhookMessage extends AbstractUniqueMessage
|
|||
/** @var string|null The path to log output of the Backup command to. */
|
||||
public ?string $outputPath = null;
|
||||
|
||||
/** @var value-of<Level::VALUES> */
|
||||
public int $logLevel = Level::Info->value;
|
||||
|
||||
public function getIdentifier(): string
|
||||
{
|
||||
return 'TestWebHook_' . $this->webhookId;
|
||||
|
|
|
@ -10,8 +10,7 @@ use App\Entity\StationWebhook;
|
|||
use App\Utilities;
|
||||
use GuzzleHttp\Client;
|
||||
use InvalidArgumentException;
|
||||
use PhpIP\IP;
|
||||
use RuntimeException;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
abstract class AbstractConnector implements ConnectorInterface
|
||||
{
|
||||
|
@ -122,16 +121,6 @@ abstract class AbstractConnector implements ConnectorInterface
|
|||
return null;
|
||||
}
|
||||
|
||||
// Check for IP addresses that shouldn't be used in user-provided URLs.
|
||||
try {
|
||||
$ip = IP::create($uri->getHost());
|
||||
if ($ip->isReserved()) {
|
||||
throw new RuntimeException('URL references an IANA reserved block.');
|
||||
}
|
||||
} catch (InvalidArgumentException) {
|
||||
// Noop, URL is not an IP
|
||||
}
|
||||
|
||||
return (string)$uri;
|
||||
}
|
||||
|
||||
|
@ -145,4 +134,35 @@ abstract class AbstractConnector implements ConnectorInterface
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
protected function logHttpResponse(
|
||||
StationWebhook $webhook,
|
||||
ResponseInterface $response,
|
||||
mixed $requestBody = null
|
||||
): void {
|
||||
if (204 !== $response->getStatusCode()) {
|
||||
$this->logger->error(
|
||||
sprintf(
|
||||
'Webhook "%s" returned unsuccessful response code %d.',
|
||||
$webhook->getName(),
|
||||
$response->getStatusCode()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$debugLogInfo = [];
|
||||
if ($requestBody) {
|
||||
$debugLogInfo['message_sent'] = $requestBody;
|
||||
}
|
||||
$debugLogInfo['response_body'] = $response->getBody()->getContents();
|
||||
|
||||
$this->logger->debug(
|
||||
sprintf(
|
||||
'Webhook "%s" returned response code %d',
|
||||
$webhook->getName(),
|
||||
$response->getStatusCode()
|
||||
),
|
||||
$debugLogInfo
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ namespace App\Webhook\Connector;
|
|||
use App\Entity\Api\NowPlaying\NowPlaying;
|
||||
use App\Entity\Station;
|
||||
use App\Entity\StationWebhook;
|
||||
use Monolog\Level;
|
||||
|
||||
/*
|
||||
* https://discordapp.com/developers/docs/resources/webhook#execute-webhook
|
||||
|
@ -140,14 +139,10 @@ final class Discord extends AbstractConnector
|
|||
]
|
||||
);
|
||||
|
||||
$this->logger->addRecord(
|
||||
($response->getStatusCode() !== 204 ? Level::Error : Level::Debug),
|
||||
sprintf(
|
||||
'Webhook "%s" returned code %d',
|
||||
$webhook->getName(),
|
||||
$response->getStatusCode()
|
||||
),
|
||||
['message_sent' => $webhookBody, 'response_body' => $response->getBody()->getContents()]
|
||||
$this->logHttpResponse(
|
||||
$webhook,
|
||||
$response,
|
||||
$webhookBody
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -44,9 +44,9 @@ final class Generic extends AbstractConnector
|
|||
|
||||
$response = $this->httpClient->request('POST', $webhookUrl, $requestOptions);
|
||||
|
||||
$this->logger->debug(
|
||||
sprintf('Generic webhook returned code %d', $response->getStatusCode()),
|
||||
['response_body' => $response->getBody()->getContents()]
|
||||
$this->logHttpResponse(
|
||||
$webhook,
|
||||
$response
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,6 +48,11 @@ final class Mastodon extends AbstractSocialConnector
|
|||
);
|
||||
|
||||
foreach ($this->getMessages($webhook, $np, $triggers) as $message) {
|
||||
$messageBody = [
|
||||
'status' => $message,
|
||||
'visibility' => $visibility,
|
||||
];
|
||||
|
||||
$response = $this->httpClient->request(
|
||||
'POST',
|
||||
$instanceUri->withPath('/api/v1/statuses'),
|
||||
|
@ -56,19 +61,14 @@ final class Mastodon extends AbstractSocialConnector
|
|||
'Authorization' => 'Bearer ' . $accessToken,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'status' => $message,
|
||||
'visibility' => $visibility,
|
||||
],
|
||||
'json' => $messageBody,
|
||||
]
|
||||
);
|
||||
|
||||
$this->logger->debug(
|
||||
sprintf('Webhook "%s" returned code %d', $webhook->getName(), $response->getStatusCode()),
|
||||
[
|
||||
'instanceUri' => (string)$instanceUri,
|
||||
'response' => $response->getBody()->getContents(),
|
||||
]
|
||||
$this->logHttpResponse(
|
||||
$webhook,
|
||||
$response,
|
||||
$messageBody
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -121,17 +121,21 @@ final class MatomoAnalytics extends AbstractConnector
|
|||
$i++;
|
||||
|
||||
if (100 === $i) {
|
||||
$this->sendBatch($apiUrl, $apiToken, $entries);
|
||||
$this->sendBatch($webhook, $apiUrl, $apiToken, $entries);
|
||||
$entries = [];
|
||||
$i = 0;
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendBatch($apiUrl, $apiToken, $entries);
|
||||
$this->sendBatch($webhook, $apiUrl, $apiToken, $entries);
|
||||
}
|
||||
|
||||
private function sendBatch(UriInterface $apiUrl, ?string $apiToken, array $entries): void
|
||||
{
|
||||
private function sendBatch(
|
||||
StationWebhook $webhook,
|
||||
UriInterface $apiUrl,
|
||||
?string $apiToken,
|
||||
array $entries
|
||||
): void {
|
||||
if (empty($entries)) {
|
||||
return;
|
||||
}
|
||||
|
@ -146,15 +150,14 @@ final class MatomoAnalytics extends AbstractConnector
|
|||
$jsonBody['token_auth'] = $apiToken;
|
||||
}
|
||||
|
||||
$this->logger->debug('Message body for Matomo API Query', ['body' => $jsonBody]);
|
||||
|
||||
$response = $this->httpClient->post($apiUrl, [
|
||||
'json' => $jsonBody,
|
||||
]);
|
||||
|
||||
$this->logger->debug(
|
||||
sprintf('Matomo returned code %d', $response->getStatusCode()),
|
||||
['response_body' => $response->getBody()->getContents()]
|
||||
$this->logHttpResponse(
|
||||
$webhook,
|
||||
$response,
|
||||
$jsonBody
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,13 +60,10 @@ final class Telegram extends AbstractConnector
|
|||
]
|
||||
);
|
||||
|
||||
$this->logger->debug(
|
||||
sprintf('Webhook "%s" returned code %d', $webhook->getName(), $response->getStatusCode()),
|
||||
[
|
||||
'request_url' => $webhookUrl,
|
||||
'request_params' => $requestParams,
|
||||
'response_body' => $response->getBody()->getContents(),
|
||||
]
|
||||
$this->logHttpResponse(
|
||||
$webhook,
|
||||
$response,
|
||||
$requestParams
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,23 +33,26 @@ final class TuneIn extends AbstractConnector
|
|||
|
||||
$this->logger->debug('Dispatching TuneIn AIR API call...');
|
||||
|
||||
$messageQuery = [
|
||||
'partnerId' => $config['partner_id'],
|
||||
'partnerKey' => $config['partner_key'],
|
||||
'id' => $config['station_id'],
|
||||
'title' => $np->now_playing?->song?->title,
|
||||
'artist' => $np->now_playing?->song?->artist,
|
||||
'album' => $np->now_playing?->song?->album,
|
||||
];
|
||||
|
||||
$response = $this->httpClient->get(
|
||||
'https://air.radiotime.com/Playing.ashx',
|
||||
[
|
||||
'query' => [
|
||||
'partnerId' => $config['partner_id'],
|
||||
'partnerKey' => $config['partner_key'],
|
||||
'id' => $config['station_id'],
|
||||
'title' => $np->now_playing?->song?->title,
|
||||
'artist' => $np->now_playing?->song?->artist,
|
||||
'album' => $np->now_playing?->song?->album,
|
||||
],
|
||||
'query' => $messageQuery,
|
||||
]
|
||||
);
|
||||
|
||||
$this->logger->debug(
|
||||
sprintf('TuneIn returned code %d', $response->getStatusCode()),
|
||||
['response_body' => $response->getBody()->getContents()]
|
||||
$this->logHttpResponse(
|
||||
$webhook,
|
||||
$response,
|
||||
$messageQuery
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,21 +65,24 @@ final class Twitter extends AbstractSocialConnector
|
|||
$this->logger->debug('Posting to Twitter...');
|
||||
|
||||
foreach ($this->getMessages($webhook, $np, $triggers) as $message) {
|
||||
$messageBody = [
|
||||
'status' => $message,
|
||||
];
|
||||
|
||||
$response = $this->httpClient->request(
|
||||
'POST',
|
||||
'https://api.twitter.com/1.1/statuses/update.json',
|
||||
[
|
||||
'auth' => 'oauth',
|
||||
'handler' => $stack,
|
||||
'form_params' => [
|
||||
'status' => $message,
|
||||
],
|
||||
'form_params' => $messageBody,
|
||||
]
|
||||
);
|
||||
|
||||
$this->logger->debug(
|
||||
sprintf('Twitter returned code %d', $response->getStatusCode()),
|
||||
['response_body' => $response->getBody()->getContents()]
|
||||
$this->logHttpResponse(
|
||||
$webhook,
|
||||
$response,
|
||||
$messageBody
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -126,16 +126,25 @@ final class Dispatcher
|
|||
$outputPath = $message->outputPath;
|
||||
|
||||
if (null !== $outputPath) {
|
||||
$logHandler = new StreamHandler($outputPath, Level::Debug, true);
|
||||
$logHandler = new StreamHandler(
|
||||
$outputPath,
|
||||
Level::fromValue($message->logLevel),
|
||||
true
|
||||
);
|
||||
$this->logger->pushHandler($logHandler);
|
||||
}
|
||||
|
||||
try {
|
||||
$webhook = $this->em->find(StationWebhook::class, $message->webhookId);
|
||||
if (!($webhook instanceof StationWebhook)) {
|
||||
$this->logger->error(
|
||||
sprintf('Webhook ID %d not found.', $message->webhookId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->info(sprintf('Dispatching test web hook "%s"...', $webhook->getName()));
|
||||
|
||||
$station = $webhook->getStation();
|
||||
$np = $this->nowPlayingApiGen->currentOrEmpty($station);
|
||||
$np->resolveUrls($this->router->getBaseUrl());
|
||||
|
@ -151,6 +160,8 @@ final class Dispatcher
|
|||
$webhookObj->dispatch($station, $webhook, $np, [
|
||||
WebhookTriggers::SongChanged->value,
|
||||
]);
|
||||
|
||||
$this->logger->info(sprintf('Web hook "%s" completed.', $webhook->getName()));
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error(
|
||||
sprintf(
|
||||
|
|
Loading…
Reference in New Issue