Skip to content

Commit 9f14f36

Browse files
feat: canonical host service (#47)
1 parent 5932df7 commit 9f14f36

8 files changed

Lines changed: 219 additions & 48 deletions

File tree

‎composer.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
],
1212
"require": {
1313
"php": ">=8.1 <8.5.0",
14+
"atoolo/resource-bundle": "^1.7",
1415
"lexik/jwt-authentication-bundle": "^2.21.0 || ^3.1.1",
1516
"symfony/config": "^6.3 || ^7.0",
1617
"symfony/dependency-injection": "^6.3 || ^7.0",
@@ -82,4 +83,4 @@
8283
"test:infection": "vendor/bin/infection --threads=8 --no-progress --only-covered -s || exit 0",
8384
"test:phpunit": "./tools/phpunit.phar -c phpunit.xml --coverage-text"
8485
}
85-
}
86+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Atoolo\Security\Service;
6+
7+
interface CanonicalHostProvider
8+
{
9+
public function getCanonicalHost(): ?string;
10+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Atoolo\Security\Service;
6+
7+
use Atoolo\Resource\ResourceChannel;
8+
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
9+
use Symfony\Component\DependencyInjection\Attribute\Autowire;
10+
11+
/**
12+
* Provides a secure mechanism to retrieve the canonical host from multiple host providers.
13+
*
14+
* This service is crucial for preventing host-based security vulnerabilities by:
15+
* - Protecting against HTTP Host header attacks
16+
* - Ensuring only trusted hosts are used
17+
* - Preventing potential domain spoofing
18+
*
19+
* Typical attack vectors mitigated:
20+
* - Manipulated Host headers
21+
* - Cross-Site Request Forgery (CSRF) via host manipulation
22+
* - Unauthorized domain access
23+
*
24+
* The service is part of the security bundle because:
25+
* - It validates and sanitizes host information
26+
* - Provides a critical layer of input validation
27+
* - Prevents potential security exploits at the application entry point
28+
*
29+
* Use cases:
30+
* - Secure routing
31+
* - Application firewall protection
32+
* - Trusted host verification
33+
*
34+
* @package Atoolo\Security\Service
35+
* @see CanonicalHostProvider
36+
*/
37+
#[AsAlias(id: 'atoolo_security.canonical_host_service')]
38+
class CanonicalHostService
39+
{
40+
/**
41+
* @param iterable<CanonicalHostProvider> $hostProviders
42+
*/
43+
public function __construct(
44+
#[Autowire(service: 'atoolo_resource.resource_channel')]
45+
private readonly ResourceChannel $resourceChannel,
46+
private readonly iterable $hostProviders,
47+
) {}
48+
49+
/**
50+
* Securely retrieves the canonical host by iterating through registered providers.
51+
*
52+
* Implements a defense-in-depth approach by:
53+
* - Checking multiple provider sources
54+
* - Returning only validated hosts
55+
* - Preventing null or unsafe host exposures
56+
*/
57+
public function getCanonicalHost(): string
58+
{
59+
foreach ($this->hostProviders as $provider) {
60+
$host = $provider->getCanonicalHost();
61+
if ($host !== null) {
62+
return $host;
63+
}
64+
}
65+
return $this->resourceChannel->serverName;
66+
}
67+
}

‎src/SiteKit/AccessMapFactory.php‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

55
namespace Atoolo\Security\SiteKit;
66

7+
use Atoolo\Resource\ResourceChannel;
78
use Psr\Log\LoggerInterface;
9+
use Symfony\Component\DependencyInjection\Attribute\Autowire;
810
use Symfony\Component\Finder\Finder;
911
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
1012
use Symfony\Component\HttpFoundation\RequestMatcher\IpsRequestMatcher;
@@ -25,8 +27,11 @@ class AccessMapFactory
2527
*/
2628
private $accessMap;
2729

28-
public function __construct(LoggerInterface $logger)
29-
{
30+
public function __construct(
31+
#[Autowire(service: 'atoolo_resource.resource_channel')]
32+
private readonly ResourceChannel $resourceChannel,
33+
LoggerInterface $logger,
34+
) {
3035
$this->logger = $logger;
3136
}
3237

@@ -35,7 +40,7 @@ public function create(): AccessMapInterface
3540
$this->accessMap = new AccessMap();
3641

3742
/** @var string $resourceRoot */
38-
$resourceRoot = $_SERVER['RESOURCE_ROOT'];
43+
$resourceRoot = $this->resourceChannel->resourceDir;
3944
$baseDir = $resourceRoot . '/security';
4045

4146
if (!is_dir($baseDir)) {

‎src/SiteKit/UserLoader.php‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
namespace Atoolo\Security\SiteKit;
66

7+
use Atoolo\Resource\ResourceChannel;
78
use Atoolo\Security\Entity\User as UserEntity;
89
use Atoolo\Security\UserLoader as UserLoaderInterface;
910
use Psr\Log\LoggerInterface;
11+
use Symfony\Component\DependencyInjection\Attribute\Autowire;
1012
use Symfony\Component\Finder\Finder;
1113
use Symfony\Component\Security\Core\User\UserInterface;
1214

@@ -17,8 +19,11 @@ class UserLoader implements UserLoaderInterface
1719
*/
1820
private $logger;
1921

20-
public function __construct(LoggerInterface $logger)
21-
{
22+
public function __construct(
23+
#[Autowire(service: 'atoolo_resource.resource_channel')]
24+
private readonly ResourceChannel $resourceChannel,
25+
LoggerInterface $logger,
26+
) {
2227
$this->logger = $logger;
2328
}
2429

@@ -31,7 +36,7 @@ public function load(): array
3136
$userList = [];
3237

3338
/** @var string $resourceRoot */
34-
$resourceRoot = $_SERVER['RESOURCE_ROOT'];
39+
$resourceRoot = $this->resourceChannel->resourceDir;
3540
$baseDir = $resourceRoot . '/security';
3641

3742
if (!is_dir($baseDir)) {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Atoolo\Security\Test\Service;
6+
7+
use Atoolo\Resource\DataBag;
8+
use Atoolo\Resource\ResourceChannel;
9+
use Atoolo\Resource\ResourceTenant;
10+
use Atoolo\Security\Service\CanonicalHostProvider;
11+
use Atoolo\Security\Service\CanonicalHostService;
12+
use PHPUnit\Framework\Attributes\CoversClass;
13+
use PHPUnit\Framework\TestCase;
14+
15+
#[CoversClass(CanonicalHostService::class)]
16+
class CanonicalHostServiceTest extends TestCase
17+
{
18+
private CanonicalHostService $canonicalHostService;
19+
20+
private CanonicalHostProvider $canonicalHostProvider;
21+
22+
protected function setUp(): void
23+
{
24+
$this->canonicalHostProvider = $this->createMock(CanonicalHostProvider::class);
25+
$resourceTanent = $this->createMock(ResourceTenant::class);
26+
$resourceChannel = new ResourceChannel(
27+
'',
28+
'',
29+
'',
30+
'www.test.com',
31+
false,
32+
'',
33+
'',
34+
'',
35+
'',
36+
'',
37+
'',
38+
[],
39+
new DataBag([]),
40+
$resourceTanent,
41+
);
42+
$this->canonicalHostService = new CanonicalHostService($resourceChannel, [$this->canonicalHostProvider]);
43+
}
44+
45+
public function testGetCanonicalHostFromProvider(): void
46+
{
47+
$this->canonicalHostProvider->expects($this->once())
48+
->method('getCanonicalHost')
49+
->willReturn('canonical.test.com');
50+
51+
$canonicalHost = $this->canonicalHostService->getCanonicalHost();
52+
53+
$this->assertEquals('canonical.test.com', $canonicalHost, 'Canonical host should be returned from provider');
54+
}
55+
56+
public function testGetCanonicalHostFromResourceChannel(): void
57+
{
58+
$this->canonicalHostProvider->expects($this->once())
59+
->method('getCanonicalHost')
60+
->willReturn(null);
61+
62+
$canonicalHost = $this->canonicalHostService->getCanonicalHost();
63+
64+
$this->assertEquals('www.test.com', $canonicalHost, 'Canonical host should be returned from provider');
65+
}
66+
}

‎test/SiteKit/AccessMapFactoryTest.php‎

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
namespace Atoolo\Security\Test\SiteKit;
66

7+
use Atoolo\Resource\DataBag;
8+
use Atoolo\Resource\ResourceChannel;
9+
use Atoolo\Resource\ResourceTenant;
710
use Atoolo\Security\SiteKit\AccessMapFactory;
811
use PHPUnit\Framework\TestCase;
912
use Psr\Log\LoggerInterface;
@@ -15,31 +18,14 @@
1518
*/
1619
class AccessMapFactoryTest extends TestCase
1720
{
18-
/**
19-
* @var array<string,string>
20-
*/
21-
private array $saveServerState;
22-
23-
public function setUp(): void
24-
{
25-
$this->saveServerState = $_SERVER;
26-
}
27-
28-
public function tearDown(): void
29-
{
30-
$_SERVER = $this->saveServerState;
31-
}
32-
3321
/*+
3422
* @covers AccessMapFactory
3523
*/
3624
public function testDirNotExists(): void
3725
{
38-
3926
$mockLogger = $this->createMock(LoggerInterface::class);
40-
41-
$_SERVER['RESOURCE_ROOT'] = __DIR__ . '/security-not-exists';
42-
$factory = new AccessMapFactory($mockLogger);
27+
$resourceChannel = $this->createResourceChannel(__DIR__ . '/security-not-exists');
28+
$factory = new AccessMapFactory($resourceChannel, $mockLogger);
4329
$accessMap = $factory->create();
4430

4531
$this->assertInstanceOf(AccessMapInterface::class, $accessMap);
@@ -54,10 +40,9 @@ public function testLoadDir(): void
5440
$mockLogger = $this->createMock(LoggerInterface::class);
5541
$mockLogger->expects($this->once())
5642
->method('error');
43+
$resourceChannel = $this->createResourceChannel(__DIR__);
5744

58-
$_SERVER['RESOURCE_ROOT'] = __DIR__;
59-
60-
$factory = new AccessMapFactory($mockLogger);
45+
$factory = new AccessMapFactory($resourceChannel, $mockLogger);
6146
$accessMap = $factory->create();
6247

6348
$this->assertInstanceOf(AccessMapInterface::class, $accessMap);
@@ -86,4 +71,25 @@ public function testLoadDir(): void
8671
$roles = $accessMap->getPatterns($request)[0];
8772
$this->assertNull($roles);
8873
}
74+
75+
private function createResourceChannel(string $resourceDir): ResourceChannel
76+
{
77+
$resourceTanent = $this->createMock(ResourceTenant::class);
78+
return new ResourceChannel(
79+
'',
80+
'',
81+
'',
82+
'',
83+
false,
84+
'',
85+
'',
86+
'',
87+
$resourceDir,
88+
'',
89+
'',
90+
[],
91+
new DataBag([]),
92+
$resourceTanent,
93+
);
94+
}
8995
}

‎test/SiteKit/UserLoaderTest.php‎

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
namespace Atoolo\Security\Test\SiteKit;
66

7+
use Atoolo\Resource\DataBag;
8+
use Atoolo\Resource\ResourceChannel;
9+
use Atoolo\Resource\ResourceTenant;
710
use Atoolo\Security\SiteKit\UserLoader;
811
use PHPUnit\Framework\TestCase;
912
use Psr\Log\LoggerInterface;
@@ -13,31 +16,16 @@
1316
*/
1417
class UserLoaderTest extends TestCase
1518
{
16-
/**
17-
* @var array<string,string>
18-
*/
19-
private array $saveServerState;
20-
21-
public function setUp(): void
22-
{
23-
$this->saveServerState = $_SERVER;
24-
}
25-
26-
public function tearDown(): void
27-
{
28-
$_SERVER = $this->saveServerState;
29-
}
30-
3119
/*+
3220
* @covers UserLoader
3321
*/
3422
public function testDirNotExists(): void
3523
{
3624

3725
$mockLogger = $this->createMock(LoggerInterface::class);
38-
$_SERVER['RESOURCE_ROOT'] = __DIR__ . '/security-not-exists';
26+
$resourceChannel = $this->createResourceChannel(__DIR__ . '/security-not-exists');
3927

40-
$loader = new UserLoader($mockLogger);
28+
$loader = new UserLoader($resourceChannel, $mockLogger);
4129
$users = $loader->load();
4230

4331
$this->assertIsArray($users);
@@ -54,8 +42,9 @@ public function testLoadDir(): void
5442
$mockLogger->expects($this->once())
5543
->method('error');
5644

57-
$_SERVER['RESOURCE_ROOT'] = __DIR__;
58-
$loader = new UserLoader($mockLogger);
45+
$resourceChannel = $this->createResourceChannel(__DIR__);
46+
47+
$loader = new UserLoader($resourceChannel, $mockLogger);
5948
$users = $loader->load();
6049

6150
$this->assertIsArray($users);
@@ -76,4 +65,26 @@ public function testLoadDir(): void
7665
$this->assertEquals("hash:x", $users['x']->getPassword());
7766
$this->assertEquals(['ROLE_XER'], $users['x']->getRoles());
7867
}
68+
69+
private function createResourceChannel(string $resourceDir): ResourceChannel
70+
{
71+
$resourceTanent = $this->createMock(ResourceTenant::class);
72+
return new ResourceChannel(
73+
'',
74+
'',
75+
'',
76+
'',
77+
false,
78+
'',
79+
'',
80+
'',
81+
$resourceDir,
82+
'',
83+
'',
84+
[],
85+
new DataBag([]),
86+
$resourceTanent,
87+
);
88+
}
89+
7990
}

0 commit comments

Comments
 (0)