From 9710e8d228ddda28ebbbfe41d1829e1e380d0404 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 4 Sep 2026 22:28:33 +0200 Subject: [PATCH] feat: add configurable HLS CORS allowlists --- README.md | 46 ++++++++- events.php | 40 +++++++- services.php | 3 + src/Command/AbstractCorsCommand.php | 57 +++++++++++ src/Command/AddCorsOriginCommand.php | 41 ++++++++ src/Command/ClearCorsOriginsCommand.php | 32 +++++++ src/Command/ListCorsOriginsCommand.php | 30 ++++++ src/Command/RemoveCorsOriginCommand.php | 41 ++++++++ src/Command/SetCorsOriginsCommand.php | 41 ++++++++ src/Controller/ServePlaybackAssetAction.php | 23 ++++- src/Cors/CorsConfigurationProvider.php | 11 +++ src/Cors/CorsPolicy.php | 47 +++++++++ src/Cors/NginxRules.php | 21 ++++ src/Cors/Origin.php | 48 ++++++++++ .../Migration/Version20260904223000.php | 34 +++++++ src/Entity/StationCorsConfiguration.php | 47 +++++++++ src/EventHandler/NginxConfiguration.php | 18 +++- .../CorsConfigurationRepository.php | 95 +++++++++++++++++++ tests/Cors/CorsPolicyTest.php | 54 +++++++++++ tests/Cors/NginxRulesTest.php | 25 +++++ tests/Cors/OriginTest.php | 32 +++++++ tests/Entity/StationCorsConfigurationTest.php | 28 ++++++ tests/EventHandler/NginxConfigurationTest.php | 43 +++++++++ tests/Support/AzuraCastStubs.php | 29 ++++++ 24 files changed, 879 insertions(+), 7 deletions(-) create mode 100644 src/Command/AbstractCorsCommand.php create mode 100644 src/Command/AddCorsOriginCommand.php create mode 100644 src/Command/ClearCorsOriginsCommand.php create mode 100644 src/Command/ListCorsOriginsCommand.php create mode 100644 src/Command/RemoveCorsOriginCommand.php create mode 100644 src/Command/SetCorsOriginsCommand.php create mode 100644 src/Cors/CorsConfigurationProvider.php create mode 100644 src/Cors/CorsPolicy.php create mode 100644 src/Cors/NginxRules.php create mode 100644 src/Cors/Origin.php create mode 100644 src/Entity/Migration/Version20260904223000.php create mode 100644 src/Entity/StationCorsConfiguration.php create mode 100644 src/Repository/CorsConfigurationRepository.php create mode 100644 tests/Cors/CorsPolicyTest.php create mode 100644 tests/Cors/NginxRulesTest.php create mode 100644 tests/Cors/OriginTest.php create mode 100644 tests/Entity/StationCorsConfigurationTest.php create mode 100644 tests/EventHandler/NginxConfigurationTest.php diff --git a/README.md b/README.md index 79b67b0..d84992c 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,40 @@ Configuration is read from the process environment when AzuraCast builds its con | `AZURACAST_ONDEMAND_HLS_AUDIO_BITRATE` | `128k` | `10k`–`9999k` in the exact form `[1-9][0-9]{1,3}k` | AAC target bitrate and part of the rendition cache key. Use a sensible audio bitrate despite the broad validation range. | | `AZURACAST_ONDEMAND_HLS_TRANSCODE_TIMEOUT` | `600` | `30`–`3600` seconds | Maximum synchronous FFmpeg run time. | +### Cross-origin HLS playback (CORS) + +Cross-origin playback is **disabled by default**. Configure an explicit allowlist per station with the plugin commands; do not edit generated Nginx files. The setting is stored in the AzuraCast database by the plugin, and every modifying command rewrites that station's generated Nginx configuration and reloads Nginx only when the file changed. + +First, run the plugin migration once after installing/upgrading the plugin: + +```bash +cd /var/azuracast +./docker.sh cli doctrine:migrations:migrate --no-interaction +``` + +Then configure the station (the station argument accepts its ID or short name): + +```bash +# Replace the entire allowlist with the two approved web origins. +./docker.sh cli azuracast:ondemand-hls:cors:set 11 \ + https://aifrequency.org \ + https://www.aifrequency.org + +# Inspect the current allowlist. +./docker.sh cli azuracast:ondemand-hls:cors:list 11 + +# Add or remove one origin without changing the others. +./docker.sh cli azuracast:ondemand-hls:cors:add 11 https://player.example.org +./docker.sh cli azuracast:ondemand-hls:cors:remove 11 https://player.example.org + +# Disable cross-origin playback for this station again. +./docker.sh cli azuracast:ondemand-hls:cors:clear 11 +``` + +Origins must be absolute `http://` or `https://` origins without paths, credentials, query strings, or fragments. They are normalized before being stored. The plugin emits an exact `Access-Control-Allow-Origin` value for the requesting allowed origin; it never joins multiple origins and never substitutes `*`. It also emits `Vary: Origin`, supports `GET`, `HEAD`, and token-authorized `OPTIONS` preflight requests (including the `Range` request header), and leaves requests with no `Origin` header unchanged. + +The allowlist is generated into the plugin's protected internal Nginx HLS location, after the authorized `X-Accel-Redirect`. It therefore covers `master.m3u8`, `media.m3u8`, and HLS `.ts` segments rather than only the initial PHP authorization response. The bearer URL remains required for every resource request; allowing an origin does not make a resource public or extend its expiry. + Example Docker override fragment: ```yaml @@ -247,7 +281,7 @@ The safest deployment is same-origin playback through AzuraCast without a CDN in - Preserve the complete path and do not normalize or rewrite token/resource components. - Preserve AzuraCast's `X-Accel-Redirect` processing at its internal Nginx layer. An external proxy should forward the resulting body; it must not expose or independently interpret `/internal/stations`. - Redact bearer URLs from proxy/CDN/WAF logs as described above. -- The plugin does not emit `Access-Control-Allow-Origin`. Cross-origin players will fail unless CORS is added deliberately at a trusted proxy. Prefer same-origin; if enabling CORS, allow only required origins and still disable caching/logging. +- Cross-origin playback is disabled until an explicit station allowlist is configured with `azuracast:ondemand-hls:cors:*`. Prefer same-origin; if enabling CORS, allow only required HTTPS origins and still disable caching/logging. Do not add wildcard headers at a proxy, because that would override the plugin's explicit-origin policy. - Avoid redirects to another hostname: the full token appears in the `Location` request path and may be logged. Set AzuraCast's external/base URL correctly so the API returns the final HTTPS origin. - Keep proxy read timeouts above the expected synchronous first-generation time, up to `TRANSCODE_TIMEOUT`, or create/warm sessions from a backend job before giving the URL to a client. @@ -277,7 +311,7 @@ composer lint composer test ``` -The test suite covers configuration safety, opaque-token format/mutation, resource allowlisting and traversal/symlink rejection, station/expiry authorization, session limits, and cleanup behavior. +The test suite covers configuration safety, opaque-token format/mutation, resource allowlisting and traversal/symlink rejection, station/expiry authorization, session limits, cleanup behavior, CORS origin validation, exact allowlist matching for both approved origins, rejected/missing origins, preflight headers, and generated Nginx CORS rules. ## Troubleshooting @@ -311,10 +345,14 @@ The authorized cache file is outside a path recognized by `App\Nginx\CustomUrls: ### Player loads the master playlist but fails on media/segments - Check browser developer tools for `403`, `429`, CORS, mixed-content, or proxy-cache errors. +- For a browser hosted at `https://aifrequency.org`, configure both required origins if applicable and verify them with: + ```bash + ./docker.sh cli azuracast:ondemand-hls:cors:list 11 + ``` + The output must list `https://aifrequency.org` and, for the `www` site, `https://www.aifrequency.org` exactly. +- Run the CORS command again after plugin installation/upgrades if a station's generated Nginx configuration was removed; each modifying command regenerates it automatically. - The asset rate limit is 150 requests per 5 seconds; investigate retry loops or unusually short segments if it is reached. -- Use same-origin URLs unless CORS is explicitly configured. - Ensure proxies preserve relative playlist resolution and do not cache assets. -- Confirm the station Nginx config was regenerated after installation. ### Sessions remain at the limit diff --git a/events.php b/events.php index 0f19a8e..c464927 100644 --- a/events.php +++ b/events.php @@ -3,16 +3,53 @@ declare(strict_types=1); use App\CallableEventDispatcherInterface; +use App\Event\BuildConsoleCommands; +use App\Event\BuildDoctrineMappingPaths; +use App\Event\BuildMigrationConfigurationArray; use App\Enums\StationPermissions; use App\Enums\StationFeatures; use App\Event; use App\Middleware; +use Plugin\AzuraCastOnDemandHls\Command\AddCorsOriginCommand; +use Plugin\AzuraCastOnDemandHls\Command\ClearCorsOriginsCommand; +use Plugin\AzuraCastOnDemandHls\Command\ListCorsOriginsCommand; +use Plugin\AzuraCastOnDemandHls\Command\RemoveCorsOriginCommand; +use Plugin\AzuraCastOnDemandHls\Command\SetCorsOriginsCommand; use Plugin\AzuraCastOnDemandHls\Controller\CreatePlaybackAction; use Plugin\AzuraCastOnDemandHls\Controller\ServePlaybackAssetAction; use Plugin\AzuraCastOnDemandHls\EventHandler\NginxConfiguration; use Plugin\AzuraCastOnDemandHls\Service\ScheduledCleanupTask; return static function (CallableEventDispatcherInterface $dispatcher): void { + $dispatcher->addListener( + BuildConsoleCommands::class, + static fn(BuildConsoleCommands $event) => $event->addAliases([ + 'azuracast:ondemand-hls:cors:list' => ListCorsOriginsCommand::class, + 'azuracast:ondemand-hls:cors:set' => SetCorsOriginsCommand::class, + 'azuracast:ondemand-hls:cors:add' => AddCorsOriginCommand::class, + 'azuracast:ondemand-hls:cors:remove' => RemoveCorsOriginCommand::class, + 'azuracast:ondemand-hls:cors:clear' => ClearCorsOriginsCommand::class, + ]) + ); + + $dispatcher->addListener( + BuildDoctrineMappingPaths::class, + static function (BuildDoctrineMappingPaths $event): void { + $paths = $event->getMappingClassesPaths(); + $paths[] = __DIR__ . '/src/Entity'; + $event->setMappingClassesPaths(array_values(array_unique($paths))); + } + ); + + $dispatcher->addListener( + BuildMigrationConfigurationArray::class, + static function (BuildMigrationConfigurationArray $event): void { + $configuration = $event->getMigrationConfigurations(); + $configuration['migrations_paths'] ??= []; + $configuration['migrations_paths']['Plugin\AzuraCastOnDemandHls\Entity\Migration'] = __DIR__ . '/src/Entity/Migration'; + $event->setMigrationConfigurations($configuration); + } + ); $dispatcher->addListener( Event\BuildRoutes::class, static function (Event\BuildRoutes $event): void { @@ -32,7 +69,8 @@ return static function (CallableEventDispatcherInterface $dispatcher): void { ->add(Middleware\Auth\ApiAuth::class) ->add(Middleware\InjectSession::class); - $app->get( + $app->map( + ['GET', 'HEAD', 'OPTIONS'], '/api/station/{station_id}/ondemand-hls/playback/{token:[A-Za-z0-9_-]+}/{resource:[A-Za-z0-9._-]+}', ServePlaybackAssetAction::class ) diff --git a/services.php b/services.php index f9bf972..d951828 100644 --- a/services.php +++ b/services.php @@ -3,7 +3,10 @@ declare(strict_types=1); use Plugin\AzuraCastOnDemandHls\Config; +use Plugin\AzuraCastOnDemandHls\Cors\CorsConfigurationProvider; +use Plugin\AzuraCastOnDemandHls\Repository\CorsConfigurationRepository; return [ Config::class => static fn(): Config => Config::fromEnvironment(), + CorsConfigurationProvider::class => static fn(CorsConfigurationRepository $repository): CorsConfigurationProvider => $repository, ]; diff --git a/src/Command/AbstractCorsCommand.php b/src/Command/AbstractCorsCommand.php new file mode 100644 index 0000000..6b6a7de --- /dev/null +++ b/src/Command/AbstractCorsCommand.php @@ -0,0 +1,57 @@ +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 $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); + } +} diff --git a/src/Command/AddCorsOriginCommand.php b/src/Command/AddCorsOriginCommand.php new file mode 100644 index 0000000..4c742ab --- /dev/null +++ b/src/Command/AddCorsOriginCommand.php @@ -0,0 +1,41 @@ +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; + } + } +} diff --git a/src/Command/ClearCorsOriginsCommand.php b/src/Command/ClearCorsOriginsCommand.php new file mode 100644 index 0000000..432adf7 --- /dev/null +++ b/src/Command/ClearCorsOriginsCommand.php @@ -0,0 +1,32 @@ +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; + } +} diff --git a/src/Command/ListCorsOriginsCommand.php b/src/Command/ListCorsOriginsCommand.php new file mode 100644 index 0000000..11eb08c --- /dev/null +++ b/src/Command/ListCorsOriginsCommand.php @@ -0,0 +1,30 @@ +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; + } +} diff --git a/src/Command/RemoveCorsOriginCommand.php b/src/Command/RemoveCorsOriginCommand.php new file mode 100644 index 0000000..da98640 --- /dev/null +++ b/src/Command/RemoveCorsOriginCommand.php @@ -0,0 +1,41 @@ +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; + } + } +} diff --git a/src/Command/SetCorsOriginsCommand.php b/src/Command/SetCorsOriginsCommand.php new file mode 100644 index 0000000..6998864 --- /dev/null +++ b/src/Command/SetCorsOriginsCommand.php @@ -0,0 +1,41 @@ +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; + } + } +} diff --git a/src/Controller/ServePlaybackAssetAction.php b/src/Controller/ServePlaybackAssetAction.php index d8408bb..5ea0552 100644 --- a/src/Controller/ServePlaybackAssetAction.php +++ b/src/Controller/ServePlaybackAssetAction.php @@ -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 $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 diff --git a/src/Cors/CorsConfigurationProvider.php b/src/Cors/CorsConfigurationProvider.php new file mode 100644 index 0000000..a7c7b8f --- /dev/null +++ b/src/Cors/CorsConfigurationProvider.php @@ -0,0 +1,11 @@ + */ + public function getAllowedOrigins(int $stationId): array; +} diff --git a/src/Cors/CorsPolicy.php b/src/Cors/CorsPolicy.php new file mode 100644 index 0000000..de243d9 --- /dev/null +++ b/src/Cors/CorsPolicy.php @@ -0,0 +1,47 @@ + $allowedOrigins */ + public function __construct(private array $allowedOrigins) + { + } + + /** @return array */ + 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; + } +} diff --git a/src/Cors/NginxRules.php b/src/Cors/NginxRules.php new file mode 100644 index 0000000..4284a4e --- /dev/null +++ b/src/Cors/NginxRules.php @@ -0,0 +1,21 @@ + $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; + } +} diff --git a/src/Cors/Origin.php b/src/Cors/Origin.php new file mode 100644 index 0000000..edba0bf --- /dev/null +++ b/src/Cors/Origin.php @@ -0,0 +1,48 @@ + 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); + } +} diff --git a/src/Entity/Migration/Version20260904223000.php b/src/Entity/Migration/Version20260904223000.php new file mode 100644 index 0000000..0618d54 --- /dev/null +++ b/src/Entity/Migration/Version20260904223000.php @@ -0,0 +1,34 @@ +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'); + } +} diff --git a/src/Entity/StationCorsConfiguration.php b/src/Entity/StationCorsConfiguration.php new file mode 100644 index 0000000..93c94d0 --- /dev/null +++ b/src/Entity/StationCorsConfiguration.php @@ -0,0 +1,47 @@ + */ + #[ORM\Column(type: 'json')] + private array $allowedOrigins = []; + + /** @param list $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 */ + public function getAllowedOrigins(): array + { + return $this->allowedOrigins; + } + + /** @param list $allowedOrigins */ + public function setAllowedOrigins(array $allowedOrigins): void + { + $this->allowedOrigins = array_values($allowedOrigins); + } +} diff --git a/src/EventHandler/NginxConfiguration.php b/src/EventHandler/NginxConfiguration.php index f95811d..a410b72 100644 --- a/src/EventHandler/NginxConfiguration.php +++ b/src/EventHandler/NginxConfiguration.php @@ -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(<<corsConfiguration->getAllowedOrigins($stationId)); + } } diff --git a/src/Repository/CorsConfigurationRepository.php b/src/Repository/CorsConfigurationRepository.php new file mode 100644 index 0000000..4dd1755 --- /dev/null +++ b/src/Repository/CorsConfigurationRepository.php @@ -0,0 +1,95 @@ + */ + public function getAllowedOrigins(int $stationId): array + { + return $this->find($stationId)?->getAllowedOrigins() ?? []; + } + + /** @param list $origins @return list */ + 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 */ + public function addAllowedOrigin(int $stationId, string $origin): array + { + $origins = $this->getAllowedOrigins($stationId); + $origins[] = Origin::normalize($origin); + return $this->replaceAllowedOrigins($stationId, $origins); + } + + /** @return list */ + 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 $origins @return list */ + private function normalizeAll(array $origins): array + { + $normalized = array_map(Origin::normalize(...), $origins); + sort($normalized, SORT_STRING); + return array_values(array_unique($normalized)); + } +} diff --git a/tests/Cors/CorsPolicyTest.php b/tests/Cors/CorsPolicyTest.php new file mode 100644 index 0000000..02fed08 --- /dev/null +++ b/tests/Cors/CorsPolicyTest.php @@ -0,0 +1,54 @@ +policy = new CorsPolicy(['https://aifrequency.org', 'https://www.aifrequency.org']); + } + + public function testItAllowsTheFirstExplicitOrigin(): void + { + self::assertSame([ + 'Access-Control-Allow-Origin' => 'https://aifrequency.org', + 'Vary' => 'Origin', + ], $this->policy->headersFor('https://aifrequency.org')); + } + + public function testItAllowsTheSecondExplicitOrigin(): void + { + self::assertSame('https://www.aifrequency.org', $this->policy->headersFor('https://www.aifrequency.org')['Access-Control-Allow-Origin']); + } + + public function testItDoesNotAllowAnUnapprovedOrigin(): void + { + self::assertSame(['Vary' => 'Origin'], $this->policy->headersFor('https://evil.example')); + } + + public function testItDoesNotAddCorsHeadersWithoutAnOrigin(): void + { + self::assertSame([], $this->policy->headersFor(null)); + } + + public function testItBuildsAConstrainedHlsPreflightResponse(): void + { + self::assertSame([ + 'Access-Control-Allow-Origin' => 'https://aifrequency.org', + 'Vary' => 'Origin', + 'Access-Control-Allow-Methods' => 'GET, HEAD, OPTIONS', + 'Access-Control-Allow-Headers' => 'Range', + 'Access-Control-Max-Age' => '600', + ], $this->policy->headersFor('https://aifrequency.org', true)); + } +} diff --git a/tests/Cors/NginxRulesTest.php b/tests/Cors/NginxRulesTest.php new file mode 100644 index 0000000..9b77a26 --- /dev/null +++ b/tests/Cors/NginxRulesTest.php @@ -0,0 +1,25 @@ +setAllowedOrigins([ + 'https://aifrequency.org', + 'https://www.aifrequency.org', + ]); + + self::assertSame(11, $configuration->getStationId()); + self::assertSame([ + 'https://aifrequency.org', + 'https://www.aifrequency.org', + ], $configuration->getAllowedOrigins()); + } +} diff --git a/tests/EventHandler/NginxConfigurationTest.php b/tests/EventHandler/NginxConfigurationTest.php new file mode 100644 index 0000000..84bc837 --- /dev/null +++ b/tests/EventHandler/NginxConfigurationTest.php @@ -0,0 +1,43 @@ +buildConfiguration(); + + self::assertStringContainsString('location ^~ /internal/ondemand-hls/11/', $nginx); + self::assertStringContainsString('if ($http_origin = "https://aifrequency.org")', $nginx); + self::assertStringContainsString('if ($http_origin = "https://www.aifrequency.org")', $nginx); + self::assertStringContainsString('add_header Access-Control-Allow-Origin $ondemand_hls_cors_origin always;', $nginx); + self::assertStringContainsString('add_header Vary $ondemand_hls_cors_vary always;', $nginx); + self::assertStringNotContainsString('Access-Control-Allow-Origin *', $nginx); + } +} diff --git a/tests/Support/AzuraCastStubs.php b/tests/Support/AzuraCastStubs.php index 7fa2150..f883a82 100644 --- a/tests/Support/AzuraCastStubs.php +++ b/tests/Support/AzuraCastStubs.php @@ -42,3 +42,32 @@ final class StationFilesystems return $this->filesystem; } } + +namespace App\Event\Nginx; + +use App\Entity\Station; + +final class WriteNginxConfiguration +{ + /** @var list */ + private array $blocks = []; + + public function __construct(private Station $station) + { + } + + public function getStation(): Station + { + return $this->station; + } + + public function appendBlock(string $block): void + { + $this->blocks[] = $block; + } + + public function buildConfiguration(): string + { + return implode("\n", $this->blocks); + } +}