Fixes #6350 -- Tighten security of webhook test dispatching via log level, not IANA IP check.

This commit is contained in:
Buster Neece 2023-08-20 19:17:23 -05:00
parent 26b79e7509
commit 13d1e5c216
No known key found for this signature in database
11 changed files with 118 additions and 69 deletions

View File

@ -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);

View File

@ -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;

View File

@ -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
);
}
}

View File

@ -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
);
}

View File

@ -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
);
}
}

View File

@ -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
);
}
}

View File

@ -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
);
}
}

View File

@ -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
);
}
}

View File

@ -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
);
}
}

View File

@ -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
);
}
}

View File

@ -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(