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; namespace App\Controller\Api\Stations\Webhooks;
use App\Container\EnvironmentAwareTrait;
use App\Controller\SingleActionInterface; use App\Controller\SingleActionInterface;
use App\Entity\Repository\StationWebhookRepository; use App\Entity\Repository\StationWebhookRepository;
use App\Enums\GlobalPermissions;
use App\Http\Response; use App\Http\Response;
use App\Http\ServerRequest; use App\Http\ServerRequest;
use App\Message\TestWebhookMessage; use App\Message\TestWebhookMessage;
use App\Utilities\File; use App\Utilities\File;
use Monolog\Level;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Messenger\MessageBus; use Symfony\Component\Messenger\MessageBus;
final class TestAction implements SingleActionInterface final class TestAction implements SingleActionInterface
{ {
use EnvironmentAwareTrait;
public function __construct( public function __construct(
private readonly StationWebhookRepository $webhookRepo, private readonly StationWebhookRepository $webhookRepo,
private readonly MessageBus $messageBus private readonly MessageBus $messageBus
@ -29,7 +34,14 @@ final class TestAction implements SingleActionInterface
/** @var string $id */ /** @var string $id */
$id = $params['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'); $tempFile = File::generateTempPath('webhook_test_' . $id . '.log');
touch($tempFile); touch($tempFile);
@ -37,6 +49,7 @@ final class TestAction implements SingleActionInterface
$message = new TestWebhookMessage(); $message = new TestWebhookMessage();
$message->webhookId = $webhook->getIdRequired(); $message->webhookId = $webhook->getIdRequired();
$message->outputPath = $tempFile; $message->outputPath = $tempFile;
$message->logLevel = $logLevel->value;
$this->messageBus->dispatch($message); $this->messageBus->dispatch($message);

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Message; namespace App\Message;
use App\Environment; use App\Environment;
use Monolog\Level;
final class TestWebhookMessage extends AbstractUniqueMessage 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. */ /** @var string|null The path to log output of the Backup command to. */
public ?string $outputPath = null; public ?string $outputPath = null;
/** @var value-of<Level::VALUES> */
public int $logLevel = Level::Info->value;
public function getIdentifier(): string public function getIdentifier(): string
{ {
return 'TestWebHook_' . $this->webhookId; return 'TestWebHook_' . $this->webhookId;

View File

@ -10,8 +10,7 @@ use App\Entity\StationWebhook;
use App\Utilities; use App\Utilities;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use InvalidArgumentException; use InvalidArgumentException;
use PhpIP\IP; use Psr\Http\Message\ResponseInterface;
use RuntimeException;
abstract class AbstractConnector implements ConnectorInterface abstract class AbstractConnector implements ConnectorInterface
{ {
@ -122,16 +121,6 @@ abstract class AbstractConnector implements ConnectorInterface
return null; 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; 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\Api\NowPlaying\NowPlaying;
use App\Entity\Station; use App\Entity\Station;
use App\Entity\StationWebhook; use App\Entity\StationWebhook;
use Monolog\Level;
/* /*
* https://discordapp.com/developers/docs/resources/webhook#execute-webhook * https://discordapp.com/developers/docs/resources/webhook#execute-webhook
@ -140,14 +139,10 @@ final class Discord extends AbstractConnector
] ]
); );
$this->logger->addRecord( $this->logHttpResponse(
($response->getStatusCode() !== 204 ? Level::Error : Level::Debug), $webhook,
sprintf( $response,
'Webhook "%s" returned code %d', $webhookBody
$webhook->getName(),
$response->getStatusCode()
),
['message_sent' => $webhookBody, 'response_body' => $response->getBody()->getContents()]
); );
} }

View File

@ -44,9 +44,9 @@ final class Generic extends AbstractConnector
$response = $this->httpClient->request('POST', $webhookUrl, $requestOptions); $response = $this->httpClient->request('POST', $webhookUrl, $requestOptions);
$this->logger->debug( $this->logHttpResponse(
sprintf('Generic webhook returned code %d', $response->getStatusCode()), $webhook,
['response_body' => $response->getBody()->getContents()] $response
); );
} }
} }

View File

@ -48,6 +48,11 @@ final class Mastodon extends AbstractSocialConnector
); );
foreach ($this->getMessages($webhook, $np, $triggers) as $message) { foreach ($this->getMessages($webhook, $np, $triggers) as $message) {
$messageBody = [
'status' => $message,
'visibility' => $visibility,
];
$response = $this->httpClient->request( $response = $this->httpClient->request(
'POST', 'POST',
$instanceUri->withPath('/api/v1/statuses'), $instanceUri->withPath('/api/v1/statuses'),
@ -56,19 +61,14 @@ final class Mastodon extends AbstractSocialConnector
'Authorization' => 'Bearer ' . $accessToken, 'Authorization' => 'Bearer ' . $accessToken,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
], ],
'json' => [ 'json' => $messageBody,
'status' => $message,
'visibility' => $visibility,
],
] ]
); );
$this->logger->debug( $this->logHttpResponse(
sprintf('Webhook "%s" returned code %d', $webhook->getName(), $response->getStatusCode()), $webhook,
[ $response,
'instanceUri' => (string)$instanceUri, $messageBody
'response' => $response->getBody()->getContents(),
]
); );
} }
} }

View File

@ -121,17 +121,21 @@ final class MatomoAnalytics extends AbstractConnector
$i++; $i++;
if (100 === $i) { if (100 === $i) {
$this->sendBatch($apiUrl, $apiToken, $entries); $this->sendBatch($webhook, $apiUrl, $apiToken, $entries);
$entries = []; $entries = [];
$i = 0; $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)) { if (empty($entries)) {
return; return;
} }
@ -146,15 +150,14 @@ final class MatomoAnalytics extends AbstractConnector
$jsonBody['token_auth'] = $apiToken; $jsonBody['token_auth'] = $apiToken;
} }
$this->logger->debug('Message body for Matomo API Query', ['body' => $jsonBody]);
$response = $this->httpClient->post($apiUrl, [ $response = $this->httpClient->post($apiUrl, [
'json' => $jsonBody, 'json' => $jsonBody,
]); ]);
$this->logger->debug( $this->logHttpResponse(
sprintf('Matomo returned code %d', $response->getStatusCode()), $webhook,
['response_body' => $response->getBody()->getContents()] $response,
$jsonBody
); );
} }
} }

View File

@ -60,13 +60,10 @@ final class Telegram extends AbstractConnector
] ]
); );
$this->logger->debug( $this->logHttpResponse(
sprintf('Webhook "%s" returned code %d', $webhook->getName(), $response->getStatusCode()), $webhook,
[ $response,
'request_url' => $webhookUrl, $requestParams
'request_params' => $requestParams,
'response_body' => $response->getBody()->getContents(),
]
); );
} }
} }

View File

@ -33,23 +33,26 @@ final class TuneIn extends AbstractConnector
$this->logger->debug('Dispatching TuneIn AIR API call...'); $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( $response = $this->httpClient->get(
'https://air.radiotime.com/Playing.ashx', 'https://air.radiotime.com/Playing.ashx',
[ [
'query' => [ 'query' => $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,
],
] ]
); );
$this->logger->debug( $this->logHttpResponse(
sprintf('TuneIn returned code %d', $response->getStatusCode()), $webhook,
['response_body' => $response->getBody()->getContents()] $response,
$messageQuery
); );
} }
} }

View File

@ -65,21 +65,24 @@ final class Twitter extends AbstractSocialConnector
$this->logger->debug('Posting to Twitter...'); $this->logger->debug('Posting to Twitter...');
foreach ($this->getMessages($webhook, $np, $triggers) as $message) { foreach ($this->getMessages($webhook, $np, $triggers) as $message) {
$messageBody = [
'status' => $message,
];
$response = $this->httpClient->request( $response = $this->httpClient->request(
'POST', 'POST',
'https://api.twitter.com/1.1/statuses/update.json', 'https://api.twitter.com/1.1/statuses/update.json',
[ [
'auth' => 'oauth', 'auth' => 'oauth',
'handler' => $stack, 'handler' => $stack,
'form_params' => [ 'form_params' => $messageBody,
'status' => $message,
],
] ]
); );
$this->logger->debug( $this->logHttpResponse(
sprintf('Twitter returned code %d', $response->getStatusCode()), $webhook,
['response_body' => $response->getBody()->getContents()] $response,
$messageBody
); );
} }
} }

View File

@ -126,16 +126,25 @@ final class Dispatcher
$outputPath = $message->outputPath; $outputPath = $message->outputPath;
if (null !== $outputPath) { if (null !== $outputPath) {
$logHandler = new StreamHandler($outputPath, Level::Debug, true); $logHandler = new StreamHandler(
$outputPath,
Level::fromValue($message->logLevel),
true
);
$this->logger->pushHandler($logHandler); $this->logger->pushHandler($logHandler);
} }
try { try {
$webhook = $this->em->find(StationWebhook::class, $message->webhookId); $webhook = $this->em->find(StationWebhook::class, $message->webhookId);
if (!($webhook instanceof StationWebhook)) { if (!($webhook instanceof StationWebhook)) {
$this->logger->error(
sprintf('Webhook ID %d not found.', $message->webhookId),
);
return; return;
} }
$this->logger->info(sprintf('Dispatching test web hook "%s"...', $webhook->getName()));
$station = $webhook->getStation(); $station = $webhook->getStation();
$np = $this->nowPlayingApiGen->currentOrEmpty($station); $np = $this->nowPlayingApiGen->currentOrEmpty($station);
$np->resolveUrls($this->router->getBaseUrl()); $np->resolveUrls($this->router->getBaseUrl());
@ -151,6 +160,8 @@ final class Dispatcher
$webhookObj->dispatch($station, $webhook, $np, [ $webhookObj->dispatch($station, $webhook, $np, [
WebhookTriggers::SongChanged->value, WebhookTriggers::SongChanged->value,
]); ]);
$this->logger->info(sprintf('Web hook "%s" completed.', $webhook->getName()));
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error( $this->logger->error(
sprintf( sprintf(