diff --git a/site/app/Api/Presenters/CompanyPresenter.php b/site/app/Api/Presenters/CompanyPresenter.php index 261b5c815..d031f7cbd 100644 --- a/site/app/Api/Presenters/CompanyPresenter.php +++ b/site/app/Api/Presenters/CompanyPresenter.php @@ -4,6 +4,7 @@ namespace MichalSpacekCz\Api\Presenters; use MichalSpacekCz\CompanyInfo\CompanyInfo; +use MichalSpacekCz\Http\FetchMetadata\ResourceIsolationPolicyCrossSite; use MichalSpacekCz\Http\SecurityHeaders; use MichalSpacekCz\Www\Presenters\BasePresenter; use Nette\Application\BadRequestException; @@ -19,6 +20,7 @@ public function __construct( } + #[ResourceIsolationPolicyCrossSite] public function actionDefault(?string $country, ?string $companyId): void { if ($country === null || $companyId === null) { diff --git a/site/app/Application/WebApplication.php b/site/app/Application/WebApplication.php index 144d25542..c2e896f70 100644 --- a/site/app/Application/WebApplication.php +++ b/site/app/Application/WebApplication.php @@ -5,6 +5,7 @@ use MichalSpacekCz\EasterEgg\CrLfUrlInjections; use MichalSpacekCz\Http\ContentSecurityPolicy\CspValues; +use MichalSpacekCz\Http\FetchMetadata\ResourceIsolationPolicy; use MichalSpacekCz\Http\SecurityHeaders; use Nette\Application\Application; use Nette\Http\IRequest; @@ -19,6 +20,7 @@ public function __construct( private SecurityHeaders $securityHeaders, private Application $application, private CrLfUrlInjections $crLfUrlInjections, + private ResourceIsolationPolicy $resourceIsolationPolicy, private string $fqdn, ) { } @@ -28,6 +30,7 @@ public function run(): void { $this->detectCrLfUrlInjectionAttempt(); $this->redirectToSecure(); + $this->resourceIsolationPolicy->install(); $this->application->onResponse[] = function (): void { $this->securityHeaders->sendHeaders(); }; diff --git a/site/app/Http/FetchMetadata/ResourceIsolationPolicy.php b/site/app/Http/FetchMetadata/ResourceIsolationPolicy.php new file mode 100644 index 000000000..605aa1ea7 --- /dev/null +++ b/site/app/Http/FetchMetadata/ResourceIsolationPolicy.php @@ -0,0 +1,97 @@ +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 and + 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 !== []; + } + +} diff --git a/site/app/Http/FetchMetadata/ResourceIsolationPolicyCrossSite.php b/site/app/Http/FetchMetadata/ResourceIsolationPolicyCrossSite.php new file mode 100644 index 000000000..1485e1954 --- /dev/null +++ b/site/app/Http/FetchMetadata/ResourceIsolationPolicyCrossSite.php @@ -0,0 +1,11 @@ +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);