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
+42 -4
View File
@@ -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_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. | | `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: Example Docker override fragment:
```yaml ```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 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`. - 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. - 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. - 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. - 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 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 ## 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 ### Player loads the master playlist but fails on media/segments
- Check browser developer tools for `403`, `429`, CORS, mixed-content, or proxy-cache errors. - 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. - 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. - 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 ### Sessions remain at the limit
+39 -1
View File
@@ -3,16 +3,53 @@
declare(strict_types=1); declare(strict_types=1);
use App\CallableEventDispatcherInterface; use App\CallableEventDispatcherInterface;
use App\Event\BuildConsoleCommands;
use App\Event\BuildDoctrineMappingPaths;
use App\Event\BuildMigrationConfigurationArray;
use App\Enums\StationPermissions; use App\Enums\StationPermissions;
use App\Enums\StationFeatures; use App\Enums\StationFeatures;
use App\Event; use App\Event;
use App\Middleware; 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\CreatePlaybackAction;
use Plugin\AzuraCastOnDemandHls\Controller\ServePlaybackAssetAction; use Plugin\AzuraCastOnDemandHls\Controller\ServePlaybackAssetAction;
use Plugin\AzuraCastOnDemandHls\EventHandler\NginxConfiguration; use Plugin\AzuraCastOnDemandHls\EventHandler\NginxConfiguration;
use Plugin\AzuraCastOnDemandHls\Service\ScheduledCleanupTask; use Plugin\AzuraCastOnDemandHls\Service\ScheduledCleanupTask;
return static function (CallableEventDispatcherInterface $dispatcher): void { 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( $dispatcher->addListener(
Event\BuildRoutes::class, Event\BuildRoutes::class,
static function (Event\BuildRoutes $event): void { static function (Event\BuildRoutes $event): void {
@@ -32,7 +69,8 @@ return static function (CallableEventDispatcherInterface $dispatcher): void {
->add(Middleware\Auth\ApiAuth::class) ->add(Middleware\Auth\ApiAuth::class)
->add(Middleware\InjectSession::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._-]+}', '/api/station/{station_id}/ondemand-hls/playback/{token:[A-Za-z0-9_-]+}/{resource:[A-Za-z0-9._-]+}',
ServePlaybackAssetAction::class ServePlaybackAssetAction::class
) )
+3
View File
@@ -3,7 +3,10 @@
declare(strict_types=1); declare(strict_types=1);
use Plugin\AzuraCastOnDemandHls\Config; use Plugin\AzuraCastOnDemandHls\Config;
use Plugin\AzuraCastOnDemandHls\Cors\CorsConfigurationProvider;
use Plugin\AzuraCastOnDemandHls\Repository\CorsConfigurationRepository;
return [ return [
Config::class => static fn(): Config => Config::fromEnvironment(), Config::class => static fn(): Config => Config::fromEnvironment(),
CorsConfigurationProvider::class => static fn(CorsConfigurationRepository $repository): CorsConfigurationProvider => $repository,
]; ];
+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\Response;
use App\Http\ServerRequest; use App\Http\ServerRequest;
use Plugin\AzuraCastOnDemandHls\Config; use Plugin\AzuraCastOnDemandHls\Config;
use Plugin\AzuraCastOnDemandHls\Cors\CorsConfigurationProvider;
use Plugin\AzuraCastOnDemandHls\Cors\CorsPolicy;
use Plugin\AzuraCastOnDemandHls\Service\AssetAuthorizer; use Plugin\AzuraCastOnDemandHls\Service\AssetAuthorizer;
use Plugin\AzuraCastOnDemandHls\Service\CleanupService; use Plugin\AzuraCastOnDemandHls\Service\CleanupService;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
@@ -20,6 +22,7 @@ final readonly class ServePlaybackAssetAction implements SingleActionInterface
private Config $config, private Config $config,
private AssetAuthorizer $authorizer, private AssetAuthorizer $authorizer,
private CleanupService $cleanup, private CleanupService $cleanup,
private CorsConfigurationProvider $corsConfiguration,
private LoggerInterface $logger, private LoggerInterface $logger,
) { ) {
} }
@@ -34,6 +37,8 @@ final readonly class ServePlaybackAssetAction implements SingleActionInterface
} }
$station = $request->getStation(); $station = $request->getStation();
$cors = new CorsPolicy($this->corsConfiguration->getAllowedOrigins($station->id));
$origin = $request->getHeaderLine('Origin');
$token = (string)($params['token'] ?? ''); $token = (string)($params['token'] ?? '');
$resource = (string)($params['resource'] ?? ''); $resource = (string)($params['resource'] ?? '');
$authorized = $this->authorizer->authorize( $authorized = $this->authorizer->authorize(
@@ -49,7 +54,14 @@ final readonly class ServePlaybackAssetAction implements SingleActionInterface
'resource' => $resource, 'resource' => $resource,
'token_fingerprint' => '' !== $token ? substr(hash('sha256', $token), 0, 12) : null, '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. // 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); ->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 private function denied(Response $response): ResponseInterface
{ {
return $response 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 App\Event\Nginx\WriteNginxConfiguration;
use Plugin\AzuraCastOnDemandHls\Config; use Plugin\AzuraCastOnDemandHls\Config;
use Plugin\AzuraCastOnDemandHls\Cors\CorsConfigurationProvider;
use Plugin\AzuraCastOnDemandHls\Cors\NginxRules;
final readonly class NginxConfiguration 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(), '/') $assetDirectory = rtrim($station->getRadioTempDir(), '/')
. '/' . $this->config->cacheDirectory . '/assets/'; . '/' . $this->config->cacheDirectory . '/assets/';
$transcodeTimeout = $this->config->transcodeTimeout; $transcodeTimeout = $this->config->transcodeTimeout;
$corsOriginRules = $this->corsOriginRules($station->id);
$event->appendBlock(<<<NGINX $event->appendBlock(<<<NGINX
# Protected on-demand HLS. Handle this route directly in PHP-FPM so denied # 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}/ { location ^~ /internal/ondemand-hls/{$stationId}/ {
internal; internal;
access_log off; 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 Cache-Control "private, no-store, max-age=0" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
alias {$assetDirectory}; alias {$assetDirectory};
} }
NGINX); 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));
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Tests\Cors;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Plugin\AzuraCastOnDemandHls\Cors\CorsPolicy;
#[CoversClass(CorsPolicy::class)]
final class CorsPolicyTest extends TestCase
{
private CorsPolicy $policy;
protected function setUp(): void
{
$this->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));
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Tests\Cors;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Plugin\AzuraCastOnDemandHls\Cors\NginxRules;
#[CoversClass(NginxRules::class)]
final class NginxRulesTest extends TestCase
{
public function testItWritesExactOriginRulesForTheInternalHlsLocation(): void
{
self::assertSame(
" set \$ondemand_hls_cors_origin \"\";\n"
. " set \$ondemand_hls_cors_vary \"\";\n"
. " if (\$http_origin != \"\") { set \$ondemand_hls_cors_vary \"Origin\"; }\n"
. " if (\$http_origin = \"https://aifrequency.org\") { set \$ondemand_hls_cors_origin \$http_origin; }\n"
. " if (\$http_origin = \"https://www.aifrequency.org\") { set \$ondemand_hls_cors_origin \$http_origin; }\n",
NginxRules::forOrigins(['https://aifrequency.org', 'https://www.aifrequency.org'])
);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Tests\Cors;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Plugin\AzuraCastOnDemandHls\Cors\Origin;
#[CoversClass(Origin::class)]
final class OriginTest extends TestCase
{
public function testItCanonicalizesAWebOrigin(): void
{
self::assertSame('https://aifrequency.org', Origin::normalize('HTTPS://AIFrequency.org/'));
self::assertSame('https://www.aifrequency.org', Origin::normalize('https://www.aifrequency.org'));
self::assertSame('https://aifrequency.org:8443', Origin::normalize('https://aifrequency.org:8443'));
}
public function testItRejectsPathsAndUnsafeOriginForms(): void
{
foreach (['*', 'null', 'https://aifrequency.org/path', 'https://[email protected]', 'file:///tmp/a'] as $origin) {
try {
Origin::normalize($origin);
self::fail(sprintf('%s should be rejected.', $origin));
} catch (InvalidArgumentException) {
}
}
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Tests\Entity;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Plugin\AzuraCastOnDemandHls\Entity\StationCorsConfiguration;
#[CoversClass(StationCorsConfiguration::class)]
final class StationCorsConfigurationTest extends TestCase
{
public function testItKeepsStationScopedConfiguredOrigins(): void
{
$configuration = new StationCorsConfiguration(11, ['https://aifrequency.org']);
$configuration->setAllowedOrigins([
'https://aifrequency.org',
'https://www.aifrequency.org',
]);
self::assertSame(11, $configuration->getStationId());
self::assertSame([
'https://aifrequency.org',
'https://www.aifrequency.org',
], $configuration->getAllowedOrigins());
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Plugin\AzuraCastOnDemandHls\Tests\EventHandler;
use App\Entity\Station;
use App\Event\Nginx\WriteNginxConfiguration;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Plugin\AzuraCastOnDemandHls\Config;
use Plugin\AzuraCastOnDemandHls\Cors\CorsConfigurationProvider;
use Plugin\AzuraCastOnDemandHls\EventHandler\NginxConfiguration;
#[CoversClass(NginxConfiguration::class)]
final class NginxConfigurationTest extends TestCase
{
public function testItGeneratesExactCorsRulesForTheProtectedHlsAssetLocation(): void
{
$configuration = new NginxConfiguration(
new Config(),
new class implements CorsConfigurationProvider {
public function getAllowedOrigins(int $stationId): array
{
if (11 !== $stationId) {
throw new \LogicException('Unexpected station ID.');
}
return ['https://aifrequency.org', 'https://www.aifrequency.org'];
}
},
);
$event = new WriteNginxConfiguration(new Station(11, '/var/azuracast/stations/test/config'));
$configuration($event);
$nginx = $event->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);
}
}
+29
View File
@@ -42,3 +42,32 @@ final class StationFilesystems
return $this->filesystem; return $this->filesystem;
} }
} }
namespace App\Event\Nginx;
use App\Entity\Station;
final class WriteNginxConfiguration
{
/** @var list<string> */
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);
}
}