Skip to content

Commit

Permalink
Add Resource Isolation Policy using Fetch Metadata (#371)
Browse files Browse the repository at this point in the history
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
spaze authored Jul 26, 2024
2 parents 1635055 + f6cbb67 commit 65d9e48
Show file tree
Hide file tree
Showing 8 changed files with 256 additions and 0 deletions.
2 changes: 2 additions & 0 deletions site/app/Api/Presenters/CompanyPresenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,6 +20,7 @@ public function __construct(
}


#[ResourceIsolationPolicyCrossSite]
public function actionDefault(?string $country, ?string $companyId): void
{
if ($country === null || $companyId === null) {
Expand Down
3 changes: 3 additions & 0 deletions site/app/Application/WebApplication.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,6 +20,7 @@ public function __construct(
private SecurityHeaders $securityHeaders,
private Application $application,
private CrLfUrlInjections $crLfUrlInjections,
private ResourceIsolationPolicy $resourceIsolationPolicy,
private string $fqdn,
) {
}
Expand All @@ -28,6 +30,7 @@ public function run(): void
{
$this->detectCrLfUrlInjectionAttempt();
$this->redirectToSecure();
$this->resourceIsolationPolicy->install();
$this->application->onResponse[] = function (): void {
$this->securityHeaders->sendHeaders();
};
Expand Down
97 changes: 97 additions & 0 deletions site/app/Http/FetchMetadata/ResourceIsolationPolicy.php
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 site/app/Http/FetchMetadata/ResourceIsolationPolicyCrossSite.php
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
{
}
1 change: 1 addition & 0 deletions site/app/lang/messages.cs_CZ.neon
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ error:
410: "Požadované URL již není na tomto serveru k dispozici, ani není k dispozici žádná adresa k přesměrování."
forbidden:
spam: "Přístup zakázán kvůli opakovanému spamování."
crossSite: "Cross-site požadavek odmítnut."
interview:
detail: "Detail rozhovoru na webu"
talks:
Expand Down
1 change: 1 addition & 0 deletions site/app/lang/messages.en_US.neon
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ error:
410: "The requested URL is no longer available on this server and there's nowhere to be redirected to."
forbidden:
spam: "Access forbidden for repeated spammy crap."
crossSite: "Cross-site request forbidden."
interview:
detail: "Interview detail on the web"
talks:
Expand Down
1 change: 1 addition & 0 deletions site/config/services.neon
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ services:
- MichalSpacekCz\Http\Cookies\CookieDescriptions
- MichalSpacekCz\Http\Cookies\Cookies
- MichalSpacekCz\Http\FetchMetadata\FetchMetadata
- MichalSpacekCz\Http\FetchMetadata\ResourceIsolationPolicy(reportOnly: true)
- MichalSpacekCz\Http\HttpInput
- MichalSpacekCz\Http\Redirections
- MichalSpacekCz\Http\SecurityHeaders(permissionsPolicy: %permissionsPolicy%)
Expand Down
140 changes: 140 additions & 0 deletions site/tests/Http/FetchMetadata/ResourceIsolationPolicyTest.phpt
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);

0 comments on commit 65d9e48

Please sign in to comment.