feat: add configurable HLS CORS allowlists

This commit is contained in:
root
2026-09-04 22:28:33 +02:00
parent 6fae6a3a43
commit 9710e8d228
24 changed files with 879 additions and 7 deletions
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Command;
use App\Console\Command\CommandAbstract;
use App\Entity\Repository\StationRepository;
use App\Entity\Station;
use App\Nginx\Nginx;
use Plugin\AzuraCastOnDemandHls\Repository\CorsConfigurationRepository;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
abstract class AbstractCorsCommand extends CommandAbstract
{
public function __construct(
protected readonly StationRepository $stationRepository,
protected readonly CorsConfigurationRepository $corsConfiguration,
private readonly Nginx $nginx,
) {
parent::__construct();
}
protected function configureStationArgument(): void
{
$this->addArgument('station', InputArgument::REQUIRED, 'Station ID or short name.');
}
protected function findStation(InputInterface $input, SymfonyStyle $io): ?Station
{
$station = $this->stationRepository->findByIdentifier((string)$input->getArgument('station'));
if (!$station instanceof Station) {
$io->error('Station not found.');
return null;
}
return $station;
}
protected function reloadNginx(Station $station, SymfonyStyle $io): void
{
$this->nginx->writeConfiguration($station, reloadIfChanged: true);
$io->note('Station Nginx configuration was regenerated; Nginx reloads only when it changed.');
}
/** @param list<string> $origins */
protected function renderOrigins(SymfonyStyle $io, array $origins): void
{
if ([] === $origins) {
$io->writeln('No cross-site origins are allowed. Same-origin playback remains available.');
return;
}
$io->listing($origins);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Command;
use InvalidArgumentException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'azuracast:ondemand-hls:cors:add', description: 'Add one allowed cross-site HLS origin for a station.')]
final class AddCorsOriginCommand extends AbstractCorsCommand
{
protected function configure(): void
{
$this->configureStationArgument();
$this->addArgument('origin', InputArgument::REQUIRED, 'An HTTP(S) origin.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$station = $this->findStation($input, $io);
if (null === $station) {
return self::FAILURE;
}
try {
$origins = $this->corsConfiguration->addAllowedOrigin($station->id, (string)$input->getArgument('origin'));
$this->reloadNginx($station, $io);
$io->success('Allowed HLS CORS origin added.');
$this->renderOrigins($io, $origins);
return self::SUCCESS;
} catch (InvalidArgumentException $exception) {
$io->error($exception->getMessage());
return self::INVALID;
}
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'azuracast:ondemand-hls:cors:clear', description: 'Clear all allowed cross-site HLS origins for a station.')]
final class ClearCorsOriginsCommand extends AbstractCorsCommand
{
protected function configure(): void
{
$this->configureStationArgument();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$station = $this->findStation($input, $io);
if (null === $station) {
return self::FAILURE;
}
$this->corsConfiguration->clearAllowedOrigins($station->id);
$this->reloadNginx($station, $io);
$io->success('All allowed HLS CORS origins were cleared.');
return self::SUCCESS;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'azuracast:ondemand-hls:cors:list', description: 'List allowed cross-site HLS origins for a station.')]
final class ListCorsOriginsCommand extends AbstractCorsCommand
{
protected function configure(): void
{
$this->configureStationArgument();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$station = $this->findStation($input, $io);
if (null === $station) {
return self::FAILURE;
}
$this->renderOrigins($io, $this->corsConfiguration->getAllowedOrigins($station->id));
return self::SUCCESS;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Command;
use InvalidArgumentException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'azuracast:ondemand-hls:cors:remove', description: 'Remove one allowed cross-site HLS origin for a station.')]
final class RemoveCorsOriginCommand extends AbstractCorsCommand
{
protected function configure(): void
{
$this->configureStationArgument();
$this->addArgument('origin', InputArgument::REQUIRED, 'An HTTP(S) origin.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$station = $this->findStation($input, $io);
if (null === $station) {
return self::FAILURE;
}
try {
$origins = $this->corsConfiguration->removeAllowedOrigin($station->id, (string)$input->getArgument('origin'));
$this->reloadNginx($station, $io);
$io->success('Allowed HLS CORS origin removed.');
$this->renderOrigins($io, $origins);
return self::SUCCESS;
} catch (InvalidArgumentException $exception) {
$io->error($exception->getMessage());
return self::INVALID;
}
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Command;
use InvalidArgumentException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'azuracast:ondemand-hls:cors:set', description: 'Replace allowed cross-site HLS origins for a station.')]
final class SetCorsOriginsCommand extends AbstractCorsCommand
{
protected function configure(): void
{
$this->configureStationArgument();
$this->addArgument('origin', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'One or more HTTP(S) origins.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$station = $this->findStation($input, $io);
if (null === $station) {
return self::FAILURE;
}
try {
$origins = $this->corsConfiguration->replaceAllowedOrigins($station->id, $input->getArgument('origin'));
$this->reloadNginx($station, $io);
$io->success('Allowed HLS CORS origins replaced.');
$this->renderOrigins($io, $origins);
return self::SUCCESS;
} catch (InvalidArgumentException $exception) {
$io->error($exception->getMessage());
return self::INVALID;
}
}
}
+22 -1
View File
@@ -8,6 +8,8 @@ use App\Controller\SingleActionInterface;
use App\Http\Response;
use App\Http\ServerRequest;
use Plugin\AzuraCastOnDemandHls\Config;
use Plugin\AzuraCastOnDemandHls\Cors\CorsConfigurationProvider;
use Plugin\AzuraCastOnDemandHls\Cors\CorsPolicy;
use Plugin\AzuraCastOnDemandHls\Service\AssetAuthorizer;
use Plugin\AzuraCastOnDemandHls\Service\CleanupService;
use Psr\Http\Message\ResponseInterface;
@@ -20,6 +22,7 @@ final readonly class ServePlaybackAssetAction implements SingleActionInterface
private Config $config,
private AssetAuthorizer $authorizer,
private CleanupService $cleanup,
private CorsConfigurationProvider $corsConfiguration,
private LoggerInterface $logger,
) {
}
@@ -34,6 +37,8 @@ final readonly class ServePlaybackAssetAction implements SingleActionInterface
}
$station = $request->getStation();
$cors = new CorsPolicy($this->corsConfiguration->getAllowedOrigins($station->id));
$origin = $request->getHeaderLine('Origin');
$token = (string)($params['token'] ?? '');
$resource = (string)($params['resource'] ?? '');
$authorized = $this->authorizer->authorize(
@@ -49,7 +54,14 @@ final readonly class ServePlaybackAssetAction implements SingleActionInterface
'resource' => $resource,
'token_fingerprint' => '' !== $token ? substr(hash('sha256', $token), 0, 12) : null,
]);
return $this->denied($response);
return $this->withHeaders($this->denied($response), $cors->headersFor($origin));
}
if ('OPTIONS' === strtoupper($request->getMethod())) {
return $this->withHeaders(
$response->withStatus(204)->withHeader('Content-Length', '0'),
$cors->headersFor($origin, true),
);
}
// Keep cleanup request-driven without adding latency to every segment request.
@@ -82,6 +94,15 @@ final readonly class ServePlaybackAssetAction implements SingleActionInterface
->withHeader('X-Accel-Redirect', $accelPath);
}
/** @param array<string, string> $headers */
private function withHeaders(ResponseInterface $response, array $headers): ResponseInterface
{
foreach ($headers as $name => $value) {
$response = $response->withHeader($name, $value);
}
return $response;
}
private function denied(Response $response): ResponseInterface
{
return $response
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Cors;
interface CorsConfigurationProvider
{
/** @return list<string> */
public function getAllowedOrigins(int $stationId): array;
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Cors;
/** Builds response headers without ever combining multiple origins into one header. */
final class CorsPolicy
{
/** @param list<string> $allowedOrigins */
public function __construct(private array $allowedOrigins)
{
}
/** @return array<string, string> */
public function headersFor(?string $requestOrigin, bool $preflight = false): array
{
if (null === $requestOrigin || '' === trim($requestOrigin)) {
return [];
}
try {
$origin = Origin::normalize($requestOrigin);
} catch (\InvalidArgumentException) {
return ['Vary' => 'Origin'];
}
if (!in_array($origin, $this->allowedOrigins, true)) {
return ['Vary' => 'Origin'];
}
$headers = [
'Access-Control-Allow-Origin' => $origin,
'Vary' => 'Origin',
];
if ($preflight) {
$headers += [
'Access-Control-Allow-Methods' => 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers' => 'Range',
'Access-Control-Max-Age' => '600',
];
}
return $headers;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Cors;
final class NginxRules
{
/** @param list<string> $origins */
public static function forOrigins(array $origins): string
{
$rules = ' set $ondemand_hls_cors_origin "";' . "\n";
$rules .= ' set $ondemand_hls_cors_vary "";' . "\n";
$rules .= ' if ($http_origin != "") { set $ondemand_hls_cors_vary "Origin"; }' . "\n";
foreach ($origins as $origin) {
$rules .= ' if ($http_origin = "' . Origin::normalize($origin)
. '") { set $ondemand_hls_cors_origin $http_origin; }' . "\n";
}
return $rules;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Cors;
use InvalidArgumentException;
/** Validates and canonicalizes web origins used by the HLS CORS allowlist. */
final class Origin
{
public static function normalize(string $origin): string
{
$origin = trim($origin);
if ('' === $origin || 'null' === strtolower($origin)) {
throw new InvalidArgumentException('A CORS origin must be an absolute HTTP or HTTPS origin.');
}
$parts = parse_url($origin);
if (false === $parts
|| !isset($parts['scheme'], $parts['host'])
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])
|| (isset($parts['path']) && '/' !== $parts['path'])
) {
throw new InvalidArgumentException(sprintf('Invalid CORS origin: %s', $origin));
}
$scheme = strtolower($parts['scheme']);
$host = strtolower($parts['host']);
if (!in_array($scheme, ['http', 'https'], true)
|| '' === $host
|| !preg_match('/^[a-z0-9.-]+$/D', $host)
) {
throw new InvalidArgumentException(sprintf('Invalid CORS origin: %s', $origin));
}
$port = $parts['port'] ?? null;
if (null !== $port && ($port < 1 || $port > 65535)) {
throw new InvalidArgumentException(sprintf('Invalid CORS origin: %s', $origin));
}
if (('https' === $scheme && 443 === $port) || ('http' === $scheme && 80 === $port)) {
$port = null;
}
return $scheme . '://' . $host . (null === $port ? '' : ':' . $port);
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260904223000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Persist allowed CORS origins for each on-demand HLS station.';
}
public function up(Schema $schema): void
{
$this->addSql(
'CREATE TABLE ondemand_hls_station_cors ('
. 'id INT AUTO_INCREMENT NOT NULL, '
. 'station_id INT NOT NULL, '
. 'allowed_origins JSON NOT NULL, '
. 'UNIQUE INDEX uniq_ondemand_hls_station_cors_station (station_id), '
. 'PRIMARY KEY(id)'
. ') DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'
);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE ondemand_hls_station_cors');
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'ondemand_hls_station_cors', uniqueConstraints: [new ORM\UniqueConstraint(name: 'uniq_ondemand_hls_station_cors_station', columns: ['station_id'])])]
final class StationCorsConfiguration
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
/** @var list<string> */
#[ORM\Column(type: 'json')]
private array $allowedOrigins = [];
/** @param list<string> $allowedOrigins */
public function __construct(
#[ORM\Column(name: 'station_id', type: 'integer')]
private readonly int $stationId,
array $allowedOrigins = [],
) {
$this->allowedOrigins = array_values($allowedOrigins);
}
public function getStationId(): int
{
return $this->stationId;
}
/** @return list<string> */
public function getAllowedOrigins(): array
{
return $this->allowedOrigins;
}
/** @param list<string> $allowedOrigins */
public function setAllowedOrigins(array $allowedOrigins): void
{
$this->allowedOrigins = array_values($allowedOrigins);
}
}
+17 -1
View File
@@ -6,10 +6,15 @@ namespace Plugin\AzuraCastOnDemandHls\EventHandler;
use App\Event\Nginx\WriteNginxConfiguration;
use Plugin\AzuraCastOnDemandHls\Config;
use Plugin\AzuraCastOnDemandHls\Cors\CorsConfigurationProvider;
use Plugin\AzuraCastOnDemandHls\Cors\NginxRules;
final readonly class NginxConfiguration
{
public function __construct(private Config $config)
public function __construct(
private Config $config,
private CorsConfigurationProvider $corsConfiguration,
)
{
}
@@ -24,6 +29,7 @@ final readonly class NginxConfiguration
$assetDirectory = rtrim($station->getRadioTempDir(), '/')
. '/' . $this->config->cacheDirectory . '/assets/';
$transcodeTimeout = $this->config->transcodeTimeout;
$corsOriginRules = $this->corsOriginRules($station->id);
$event->appendBlock(<<<NGINX
# Protected on-demand HLS. Handle this route directly in PHP-FPM so denied
@@ -47,10 +53,20 @@ final readonly class NginxConfiguration
location ^~ /internal/ondemand-hls/{$stationId}/ {
internal;
access_log off;
# The allowlist is emitted as exact Origin matches. Nginx omits an
# add_header with an empty value, so missing/disallowed Origins do not
# receive Access-Control-Allow-Origin. This applies after X-Accel.
{$corsOriginRules} add_header Access-Control-Allow-Origin $ondemand_hls_cors_origin always;
add_header Vary $ondemand_hls_cors_vary always;
add_header Cache-Control "private, no-store, max-age=0" always;
add_header X-Content-Type-Options "nosniff" always;
alias {$assetDirectory};
}
NGINX);
}
private function corsOriginRules(int $stationId): string
{
return NginxRules::forOrigins($this->corsConfiguration->getAllowedOrigins($stationId));
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Repository;
use Doctrine\ORM\EntityManagerInterface;
use Plugin\AzuraCastOnDemandHls\Cors\CorsConfigurationProvider;
use Plugin\AzuraCastOnDemandHls\Cors\Origin;
use Plugin\AzuraCastOnDemandHls\Entity\StationCorsConfiguration;
final readonly class CorsConfigurationRepository implements CorsConfigurationProvider
{
public function __construct(private EntityManagerInterface $entityManager)
{
}
/** @return list<string> */
public function getAllowedOrigins(int $stationId): array
{
return $this->find($stationId)?->getAllowedOrigins() ?? [];
}
/** @param list<string> $origins @return list<string> */
public function replaceAllowedOrigins(int $stationId, array $origins): array
{
$origins = $this->normalizeAll($origins);
$configuration = $this->find($stationId);
// There is no reason to retain a database row for an empty allowlist.
// In particular, removing the final origin should have the same stored
// state as the explicit `cors:clear` command.
if ([] === $origins) {
if (null !== $configuration) {
$this->entityManager->remove($configuration);
$this->entityManager->flush();
}
return [];
}
if (null === $configuration) {
$configuration = new StationCorsConfiguration($stationId, $origins);
$this->entityManager->persist($configuration);
} else {
$configuration->setAllowedOrigins($origins);
}
$this->entityManager->flush();
return $origins;
}
/** @return list<string> */
public function addAllowedOrigin(int $stationId, string $origin): array
{
$origins = $this->getAllowedOrigins($stationId);
$origins[] = Origin::normalize($origin);
return $this->replaceAllowedOrigins($stationId, $origins);
}
/** @return list<string> */
public function removeAllowedOrigin(int $stationId, string $origin): array
{
$origin = Origin::normalize($origin);
return $this->replaceAllowedOrigins(
$stationId,
array_values(array_filter($this->getAllowedOrigins($stationId), static fn(string $item): bool => $item !== $origin))
);
}
public function clearAllowedOrigins(int $stationId): void
{
$configuration = $this->find($stationId);
if (null === $configuration) {
return;
}
$this->entityManager->remove($configuration);
$this->entityManager->flush();
}
private function find(int $stationId): ?StationCorsConfiguration
{
$configuration = $this->entityManager->getRepository(StationCorsConfiguration::class)
->findOneBy(['stationId' => $stationId]);
return $configuration instanceof StationCorsConfiguration ? $configuration : null;
}
/** @param list<string> $origins @return list<string> */
private function normalizeAll(array $origins): array
{
$normalized = array_map(Origin::normalize(...), $origins);
sort($normalized, SORT_STRING);
return array_values(array_unique($normalized));
}
}