Commit 430d6b57 authored by Oliver Hader's avatar Oliver Hader Committed by Oliver Hader
Browse files

[TASK] Introduce resource Content-Security-Policy check

Introduces Content-Security-Policy HTTP header check on
fileadmin/ resources.

This can be seen as follow-up up to TYPO3-CORE-SA-2020-006
and TYPO3-PSA-2019-010 now actively analyzing this HTTP
header and letting users know in reports module and
system environment check of the Install Tool.

Resolves: #92835
Releases: master, 10.4, 9.5
Change-Id: I53028ae36c9195082993ee89d630efa7b555c547
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/66627


Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Markus Klein's avatarMarkus Klein <markus.klein@typo3.org>
Tested-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Reviewed-by: Markus Klein's avatarMarkus Klein <markus.klein@typo3.org>
Reviewed-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
parent 7af1bf4f
......@@ -281,6 +281,12 @@ class DefaultFactory
'type' => DirectoryNode::class,
'targetPermission' => $directoryPermission,
'children' => [
[
'name' => '.htaccess',
'type' => FileNode::class,
'targetPermission' => $filePermission,
'targetContentFile' => Environment::getFrameworkBasePath() . '/install/Resources/Private/FolderStructureTemplateFiles/resources-root-htaccess',
],
[
'name' => '_temp_',
'type' => DirectoryNode::class,
......
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\SystemEnvironment\ServerResponse;
/**
* Evaluates a Content-Security-Policy HTTP header.
*
* @internal should only be used from within TYPO3 Core
*/
class ContentSecurityPolicyDirective
{
protected const RULE_PATTERN = '#(?:\'(?<instruction>[^\']+)\')|(?<source>[^\s]+)#';
/**
* @var string
*/
protected $name;
/**
* @var string[]
*/
protected $instructions = [];
/**
* @var string[]
*/
protected $sources = [];
public function __construct(string $name, string $rule)
{
$this->name = $name;
if (preg_match_all(self::RULE_PATTERN, $rule, $matches)) {
foreach (array_keys($matches[0]) as $index) {
if ($matches['instruction'][$index] !== '') {
$this->instructions[] = $matches['instruction'][$index];
} elseif ($matches['source'][$index] !== '') {
$this->sources[] = $matches['source'][$index];
}
}
}
}
public function getName(): string
{
return $this->name;
}
/**
* @return string[]
*/
public function getInstructions(): array
{
return $this->instructions;
}
/**
* @return string[]
*/
public function getSources(): array
{
return $this->sources;
}
public function hasInstructions(string ...$instructions): bool
{
return array_intersect($this->instructions, $instructions) !== [];
}
}
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\SystemEnvironment\ServerResponse;
/**
* Evaluates a Content-Security-Policy HTTP header.
*
* @internal should only be used from within TYPO3 Core
*/
class ContentSecurityPolicyHeader
{
protected const HEADER_PATTERN = '#(?<directive>default-src|script-src|style-src|object-src)\h+(?<rule>[^;]+)(?:\s*;\s*|$)#';
/**
* @var ContentSecurityPolicyDirective[]
*/
protected $directives = [];
public function __construct(string $header)
{
if (preg_match_all(self::HEADER_PATTERN, $header, $matches)) {
foreach ($matches['directive'] as $index => $name) {
$this->directives[$name] = new ContentSecurityPolicyDirective(
$name,
$matches['rule'][$index]
);
}
}
}
public function isEmpty(): bool
{
return empty($this->directives);
}
public function mitigatesCrossSiteScripting(): bool
{
$defaultSrc = isset($this->directives['default-src'])
? $this->directiveMitigatesCrossSiteScripting($this->directives['default-src'])
: null;
$scriptSrc = isset($this->directives['script-src'])
? $this->directiveMitigatesCrossSiteScripting($this->directives['script-src'])
: null;
$styleSrc = isset($this->directives['style-src'])
? $this->directiveMitigatesCrossSiteScripting($this->directives['style-src'])
: null;
$objectSrc = isset($this->directives['object-src'])
? $this->directiveMitigatesCrossSiteScripting($this->directives['object-src'])
: null;
return ($scriptSrc ?? $defaultSrc ?? false)
&& ($styleSrc ?? $defaultSrc ?? false)
&& ($objectSrc ?? $defaultSrc ?? false);
}
protected function directiveMitigatesCrossSiteScripting(ContentSecurityPolicyDirective $directive): bool
{
return $directive->hasInstructions('none')
&& !$directive->hasInstructions('unsafe-eval', 'unsafe-inline');
}
}
......@@ -67,6 +67,11 @@ class FileDeclaration
*/
protected $unexpectedContent;
/**
* @var \Closure
*/
protected $handler;
/**
* @var int
*/
......@@ -116,6 +121,14 @@ class FileDeclaration
public function getMismatches(ResponseInterface $response): array
{
$mismatches = [];
if ($this->handler instanceof \Closure) {
$result = $this->handler->call($this, $response);
if ($result !== null) {
$mismatches[] = $result;
}
return $mismatches;
}
$body = (string)$response->getBody();
$contentType = $response->getHeaderLine('content-type');
if ($this->expectedContent !== null && strpos($body, $this->expectedContent) === false) {
......@@ -179,6 +192,13 @@ class FileDeclaration
return $target;
}
public function withHandler(\Closure $handler): self
{
$target = clone $this;
$target->handler = $handler;
return $target;
}
public function withBuildFlags(int $buildFlags): self
{
$target = clone $this;
......
......@@ -53,6 +53,11 @@ class ServerResponseCheck implements CheckInterface
*/
protected $assetLocation;
/**
* @var FileLocation
*/
protected $fileadminLocation;
/**
* @var FileDeclaration[]
*/
......@@ -65,6 +70,8 @@ class ServerResponseCheck implements CheckInterface
$fileName = bin2hex(random_bytes(4));
$folderName = bin2hex(random_bytes(4));
$this->assetLocation = new FileLocation(sprintf('/typo3temp/assets/%s.tmp/', $folderName));
$fileadminDir = rtrim($GLOBALS['TYPO3_CONF_VARS']['BE']['fileadminDir'] ?? 'fileadmin', '/');
$this->fileadminLocation = new FileLocation(sprintf('/%s/%s.tmp/', $fileadminDir, $folderName));
$this->fileDeclarations = $this->initializeFileDeclarations($fileName);
}
......@@ -75,17 +82,24 @@ class ServerResponseCheck implements CheckInterface
foreach ($messageQueue->getAllMessages() as $flashMessage) {
$messages[] = $flashMessage->getMessage();
}
$detailsLink = sprintf(
'<p><a href="%s" rel="noreferrer" target="_blank">%s</a></p>',
'https://docs.typo3.org/c/typo3/cms-core/master/en-us/Changelog/9.5.x/Feature-91354-IntegrateServerResponseSecurityChecks.html',
'Please see documentation for further details...'
);
if ($messageQueue->getAllMessages(FlashMessage::ERROR) !== []) {
$title = 'Potential vulnerabilities';
$label = $detailsLink;
$severity = Status::ERROR;
} elseif ($messageQueue->getAllMessages(FlashMessage::WARNING) !== []) {
$title = 'Warnings';
$label = $detailsLink;
$severity = Status::WARNING;
}
return new Status(
'Server Response on static files',
$title ?? 'OK',
$this->wrapList($messages, '', self::WRAP_NESTED),
$this->wrapList($messages, $label ?? '', self::WRAP_NESTED),
$severity ?? Status::OK
);
}
......@@ -115,6 +129,25 @@ class ServerResponseCheck implements CheckInterface
protected function initializeFileDeclarations(string $fileName): array
{
$cspClosure = function (ResponseInterface $response): ?StatusMessage {
$cspHeader = new ContentSecurityPolicyHeader(
$response->getHeaderLine('content-security-policy')
);
if ($cspHeader->isEmpty()) {
return new StatusMessage(
'missing Content-Security-Policy for this location'
);
}
if (!$cspHeader->mitigatesCrossSiteScripting()) {
return new StatusMessage(
'weak Content-Security-Policy for this location "%s"',
$response->getHeaderLine('content-security-policy')
);
}
return null;
};
return [
(new FileDeclaration($this->assetLocation, $fileName . '.html'))
->withExpectedContentType('text/html')
......@@ -143,6 +176,12 @@ class ServerResponseCheck implements CheckInterface
(new FileDeclaration($this->assetLocation, $fileName . '.php.txt', true))
->withBuildFlags(FileDeclaration::FLAG_BUILD_PHP | FileDeclaration::FLAG_BUILD_HTML_DOCUMENT)
->withUnexpectedContent('PHP content'),
(new FileDeclaration($this->fileadminLocation, $fileName . '.html'))
->withBuildFlags(FileDeclaration::FLAG_BUILD_HTML_DOCUMENT)
->withHandler($cspClosure),
(new FileDeclaration($this->fileadminLocation, $fileName . '.svg'))
->withBuildFlags(FileDeclaration::FLAG_BUILD_SVG | FileDeclaration::FLAG_BUILD_SVG_DOCUMENT)
->withHandler($cspClosure),
];
}
......@@ -163,6 +202,7 @@ class ServerResponseCheck implements CheckInterface
protected function purgeFileDeclarations(): void
{
GeneralUtility::rmdir($this->assetLocation->getFilePath(), true);
GeneralUtility::rmdir($this->fileadminLocation->getFilePath(), true);
}
protected function processFileDeclarations(FlashMessageQueue $messageQueue): void
......
# This file applies Content-Security-Policy (CSP) HTTP headers
# to directories containing (user uploaded) resources like
# /fileadmin/ or /uploads/
<IfModule mod_headers.c>
Header set Content-Security-Policy "default-src 'self'; script-src 'none'; style-src 'none'; object-src 'none';"
# // comment previous line and use the following two lines instead
# // in order to only set the header when it has not be set before
# // (known as `setifempty` in Apache v2.4.7 - v2.2 fallback below)
# Header append Content-Security-Policy ""
# Header edit Content-Security-Policy "^$" "default-src 'self'; script-src 'none'; style-src 'none'; object-src 'none';"
</IfModule>
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Tests\Unit\SystemEnvironment\ServerResponse;
use PHPUnit\Framework\TestCase;
use TYPO3\CMS\Install\SystemEnvironment\ServerResponse\ContentSecurityPolicyHeader;
class ContentSecurityPolicyHeaderTest extends TestCase
{
public function mitigatesCrossSiteScriptingDataProvider(): array
{
return [
'#1' => [
'',
false,
],
'#2' => [
"default-src 'none'",
true,
],
'#3' => [
"script-src 'none'",
false,
],
'#4' => [
"style-src 'none'",
false,
],
'#5' => [
"default-src 'none'; script-src 'none'",
true,
],
'#6' => [
"default-src 'none'; style-src 'none'",
true,
],
'#7' => [
"default-src 'none'; object-src 'none'",
true,
],
'#8' => [
"default-src 'none'; script-src 'self'; style-src 'self'; object-src 'self'",
false,
],
'#9' => [
"script-src 'none'; style-src 'none'; object-src 'none'",
true,
],
'#10' => [
"default-src 'none'; script-src 'unsafe-eval'; style-src 'none'; object-src 'none'",
false,
],
'#11' => [
"default-src 'none'; script-src 'unsafe-inline'; style-src 'none'; object-src 'none'",
false,
],
];
}
/**
* @param string $header
* @param bool $expectation
*
* @test
* @dataProvider mitigatesCrossSiteScriptingDataProvider
*/
public function mitigatesCrossSiteScripting(string $header, bool $expectation)
{
$subject = new ContentSecurityPolicyHeader($header);
self::assertSame($expectation, $subject->mitigatesCrossSiteScripting());
}
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment