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