-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Resource Isolation Policy using Fetch Metadata (#371)
AKA block most cross-site requests to block some attacks For now in report-only mode as configured in `services.neon`. See https://web.dev/articles/fetch-metadata#implementing_a_resource_isolation_policy and my upcoming article about Fetch Metadata.
- Loading branch information
Showing
8 changed files
with
256 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
<?php | ||
declare(strict_types = 1); | ||
|
||
namespace MichalSpacekCz\Http\FetchMetadata; | ||
|
||
use Nette\Application\Application; | ||
use Nette\Application\IPresenter; | ||
use Nette\Application\Request as AppRequest; | ||
use Nette\Application\UI\Presenter; | ||
use Nette\Http\IRequest; | ||
use Nette\Utils\Arrays; | ||
use ReflectionException; | ||
use ReflectionMethod; | ||
use Tracy\Debugger; | ||
|
||
readonly class ResourceIsolationPolicy | ||
{ | ||
|
||
public function __construct( | ||
private FetchMetadata $fetchMetadata, | ||
private IRequest $httpRequest, | ||
private Application $application, | ||
private bool $reportOnly, | ||
) { | ||
} | ||
|
||
|
||
public function install(): void | ||
{ | ||
$this->application->onPresenter[] = function (Application $application, IPresenter $presenter): void { | ||
if ($presenter instanceof Presenter) { | ||
$presenter->onStartup[] = function () use ($presenter): void { | ||
if (!$this->isRequestAllowed($presenter)) { | ||
if ($this->reportOnly) { | ||
$message = sprintf('%s %s %s', $this->httpRequest->getMethod(), $presenter->getAction(true), implode(', ', array_keys($presenter->getParameters()))); | ||
Debugger::log($message, 'cross-site'); | ||
} else { | ||
$presenter->forward(':Www:Forbidden:', ['message' => 'messages.forbidden.crossSite']); | ||
} | ||
} | ||
}; | ||
} | ||
}; | ||
} | ||
|
||
|
||
/** | ||
* Inspired by https://web.dev/articles/fetch-metadata#implementing_a_resource_isolation_policy | ||
*/ | ||
public function isRequestAllowed(Presenter $presenter): bool | ||
{ | ||
if ($presenter->getRequest()?->getMethod() === AppRequest::FORWARD) { | ||
return true; | ||
} | ||
// Allow requests from browsers which don't send Fetch Metadata | ||
if ($this->fetchMetadata->getHeader(FetchMetadataHeader::Site) === null) { | ||
return true; | ||
} | ||
// Allow same-site and browser-initiated requests | ||
if (Arrays::contains(['same-origin', 'same-site', 'none'], $this->fetchMetadata->getHeader(FetchMetadataHeader::Site))) { | ||
return true; | ||
} | ||
// Allow simple top-level navigations except <object> and <embed> | ||
if ( | ||
$this->fetchMetadata->getHeader(FetchMetadataHeader::Mode) === 'navigate' | ||
&& $this->httpRequest->isMethod(IRequest::Get) | ||
&& !Arrays::contains(['object', 'embed'], $this->fetchMetadata->getHeader(FetchMetadataHeader::Dest)) | ||
) { | ||
return true; | ||
} | ||
|
||
// [OPTIONAL] Exempt paths/endpoints meant to be served cross-origin | ||
// In this app, presenter's action or render methods with the ResourceIsolationPolicyCrossSite attribute are allowed to be called cross-site | ||
if ( | ||
$this->isCallableCrossSite($presenter, Presenter::formatActionMethod($presenter->action)) | ||
|| $this->isCallableCrossSite($presenter, Presenter::formatRenderMethod($presenter->action)) | ||
) { | ||
return true; | ||
} | ||
|
||
// Reject all other requests that are cross-site and not navigational | ||
return false; | ||
} | ||
|
||
|
||
private function isCallableCrossSite(Presenter $presenter, string $method): bool | ||
{ | ||
try { | ||
$method = new ReflectionMethod($presenter, $method); | ||
} catch (ReflectionException) { | ||
return false; | ||
} | ||
$attributes = $method->getAttributes(ResourceIsolationPolicyCrossSite::class); | ||
return $attributes !== []; | ||
} | ||
|
||
} |
11 changes: 11 additions & 0 deletions
11
site/app/Http/FetchMetadata/ResourceIsolationPolicyCrossSite.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
<?php | ||
declare(strict_types = 1); | ||
|
||
namespace MichalSpacekCz\Http\FetchMetadata; | ||
|
||
use Attribute; | ||
|
||
#[Attribute(Attribute::TARGET_METHOD)] | ||
class ResourceIsolationPolicyCrossSite | ||
{ | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
140 changes: 140 additions & 0 deletions
140
site/tests/Http/FetchMetadata/ResourceIsolationPolicyTest.phpt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,140 @@ | ||
<?php | ||
/** @noinspection PhpUnhandledExceptionInspection */ | ||
declare(strict_types = 1); | ||
|
||
namespace MichalSpacekCz\Http\FetchMetadata; | ||
|
||
use MichalSpacekCz\Test\Application\ApplicationPresenter; | ||
use MichalSpacekCz\Test\Http\Request; | ||
use MichalSpacekCz\Test\Http\Response; | ||
use MichalSpacekCz\Test\NullLogger; | ||
use MichalSpacekCz\Test\PrivateProperty; | ||
use MichalSpacekCz\Test\TestCaseRunner; | ||
use Nette\Application\Application; | ||
use Nette\Application\Request as NetteRequest; | ||
use Nette\Http\IRequest; | ||
use Nette\Http\IResponse; | ||
use Nette\Utils\Helpers; | ||
use Override; | ||
use Tester\Assert; | ||
use Tester\TestCase; | ||
|
||
require __DIR__ . '/../../bootstrap.php'; | ||
|
||
/** @testCase */ | ||
class ResourceIsolationPolicyTest extends TestCase | ||
{ | ||
|
||
private const string PRESENTER_NAME = 'Www:Homepage'; | ||
|
||
|
||
public function __construct( | ||
private readonly Application $application, | ||
private readonly Request $httpRequest, | ||
private readonly Response $httpResponse, | ||
private readonly NullLogger $logger, | ||
private readonly FetchMetadata $fetchMetadata, | ||
private readonly ApplicationPresenter $applicationPresenter, | ||
) { | ||
} | ||
|
||
|
||
#[Override] | ||
protected function setUp(): void | ||
{ | ||
$this->httpResponse->setCode(IResponse::S200_OK); | ||
} | ||
|
||
|
||
#[Override] | ||
protected function tearDown(): void | ||
{ | ||
$this->logger->reset(); | ||
$this->application->onPresenter = []; | ||
} | ||
|
||
|
||
public function testNoHeader(): void | ||
{ | ||
$this->installPolicy(true); | ||
$this->callPresenterAction(); | ||
Assert::same([], $this->logger->getLogged()); | ||
Assert::same(IResponse::S200_OK, $this->httpResponse->getCode()); | ||
} | ||
|
||
|
||
public function testCrossSite(): void | ||
{ | ||
$this->installPolicy(true); | ||
$this->httpRequest->setHeader(FetchMetadataHeader::Site->value, 'cross-site'); | ||
$this->callPresenterAction(); | ||
Assert::same(['GET :Www:Homepage:default foo, waldo'], $this->logger->getLogged()); | ||
Assert::same(IResponse::S200_OK, $this->httpResponse->getCode()); | ||
} | ||
|
||
|
||
public function testSameSite(): void | ||
{ | ||
$this->installPolicy(true); | ||
$this->httpRequest->setHeader(FetchMetadataHeader::Site->value, 'same-site'); | ||
$this->callPresenterAction(); | ||
Assert::same([], $this->logger->getLogged()); | ||
Assert::same(IResponse::S200_OK, $this->httpResponse->getCode()); | ||
} | ||
|
||
|
||
public function testNoHeaderEnforcingPolicy(): void | ||
{ | ||
$this->installPolicy(false); | ||
$content = $this->callPresenterAction(); | ||
Assert::contains('messages.homepage.aboutme', $content); | ||
Assert::notContains('messages.forbidden.crossSite', $content); | ||
Assert::same([], $this->logger->getLogged()); | ||
Assert::same(IResponse::S200_OK, $this->httpResponse->getCode()); | ||
} | ||
|
||
|
||
public function testCrossSiteEnforcingPolicy(): void | ||
{ | ||
$this->installPolicy(false); | ||
$this->httpRequest->setHeader(FetchMetadataHeader::Site->value, 'cross-site'); | ||
$content = $this->callPresenterAction(); | ||
Assert::notContains('messages.homepage.aboutme', $content); | ||
Assert::contains('messages.forbidden.crossSite', $content); | ||
Assert::same([], $this->logger->getLogged()); | ||
Assert::same(IResponse::S403_Forbidden, $this->httpResponse->getCode()); | ||
} | ||
|
||
|
||
public function testSameSiteEnforcingPolicy(): void | ||
{ | ||
$this->installPolicy(false); | ||
$this->httpRequest->setHeader(FetchMetadataHeader::Site->value, 'same-site'); | ||
$content = $this->callPresenterAction(); | ||
Assert::contains('messages.homepage.aboutme', $content); | ||
Assert::notContains('messages.forbidden.crossSite', $content); | ||
Assert::same([], $this->logger->getLogged()); | ||
Assert::same(IResponse::S200_OK, $this->httpResponse->getCode()); | ||
} | ||
|
||
|
||
private function installPolicy(bool $readOnly): void | ||
{ | ||
$this->httpRequest->setMethod(IRequest::Get); | ||
$presenter = $this->applicationPresenter->createUiPresenter(self::PRESENTER_NAME, 'Foo', 'bar'); | ||
PrivateProperty::setValue($this->application, 'presenter', $presenter); | ||
$resourceIsolationPolicy = new ResourceIsolationPolicy($this->fetchMetadata, $this->httpRequest, $this->application, $readOnly); | ||
$resourceIsolationPolicy->install(); | ||
} | ||
|
||
|
||
private function callPresenterAction(): string | ||
{ | ||
return Helpers::capture(function (): void { | ||
$this->application->processRequest(new NetteRequest(self::PRESENTER_NAME, params: ['foo' => 'bar', 'waldo' => 'fred'])); | ||
}); | ||
} | ||
|
||
} | ||
|
||
TestCaseRunner::run(ResourceIsolationPolicyTest::class); |