Skip to content

Commit 51eb0e5

Browse files
committed
Initial commit
0 parents  commit 51eb0e5

File tree

18 files changed

+479
-0
lines changed

18 files changed

+479
-0
lines changed

Api/Data/ConfigInterface.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Basecom\CspSplitHeader\Api\Data;
4+
5+
/**
6+
* @api
7+
* @since 1.0.0
8+
*/
9+
interface ConfigInterface
10+
{
11+
/**
12+
* Check if header splitting is enabled
13+
*
14+
* @return bool
15+
* @since 1.0.0
16+
*/
17+
public function isHeaderSplittingEnabled();
18+
19+
/**
20+
* Get max header size
21+
*
22+
* @return int
23+
* @since 1.0.0
24+
*/
25+
public function getMaxHeaderSize();
26+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Basecom\CspSplitHeader\Block\Adminhtml\Form\Field;
4+
5+
use Basecom\CspSplitHeader\Api\Data\ConfigInterface;
6+
use Magento\Backend\Block\Template;
7+
use Magento\Backend\Block\Template\Context;
8+
use Magento\Csp\Api\PolicyCollectorInterface;
9+
use Magento\Framework\Data\Form\Element\AbstractElement;
10+
use Magento\Framework\Data\Form\Element\Renderer\RendererInterface;
11+
12+
class CspHeader extends Template implements RendererInterface
13+
{
14+
private string $header = '';
15+
16+
public function __construct(
17+
private readonly PolicyCollectorInterface $policyCollector,
18+
private readonly ConfigInterface $config,
19+
Context $context,
20+
array $data = [],
21+
) {
22+
$this->_template = 'Basecom_CspSplitHeader::cspHeader.phtml';
23+
parent::__construct($context, $data);
24+
}
25+
26+
public function render(AbstractElement $element): string
27+
{
28+
$this->setData('html_id', $element->getHtmlId());
29+
$this->setData('label', $element->getData('label'));
30+
31+
return $this->_toHtml();
32+
}
33+
34+
public function getCurrentHeaderSize(): int
35+
{
36+
return strlen($this->getCspHeader());
37+
}
38+
39+
public function getCspHeader(): string
40+
{
41+
if (empty($this->header)) {
42+
$cspHeader = '';
43+
$policies = $this->policyCollector->collect();
44+
45+
foreach ($policies as $policy) {
46+
$value = $policy->getValue();
47+
$cspHeader .= $policy->getId().': '.$value.';'.PHP_EOL;
48+
}
49+
$this->header = $cspHeader;
50+
}
51+
return $this->header ;
52+
}
53+
54+
public function isHeaderIsTooBig(): bool
55+
{
56+
$header = $this->getCspHeader();
57+
$currentHeaderSize = strlen($header);
58+
$maxHeaderSize = $this->config->getMaxHeaderSize();
59+
60+
$isHeaderSplittingEnabled = $this->config->isHeaderSplittingEnabled();
61+
$headerIsTooBig = false;
62+
if ($isHeaderSplittingEnabled) {
63+
$headerParts = explode(PHP_EOL, $header);
64+
foreach ($headerParts as $headerPart) {
65+
$headerPartsSize = strlen($headerPart);
66+
if ($headerPartsSize > $maxHeaderSize) {
67+
$headerIsTooBig = true;
68+
break;
69+
}
70+
}
71+
} else {
72+
$headerIsTooBig = $currentHeaderSize > $maxHeaderSize;
73+
}
74+
return $headerIsTooBig;
75+
}
76+
77+
public function getConfig(): ConfigInterface
78+
{
79+
return $this->config;
80+
}
81+
}

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Changelog
2+
All notable changes to this project will be documented in this file.
3+
4+
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.1.0/)
5+
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
6+
7+
## [Unreleased]
8+
### Added
9+
### Changed
10+
### Removed
11+
### Fixed

CONTRIBUTING.md

Whitespace-only changes.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2024 basecom GmbH & Co. KG
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Model/Config.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Basecom\CspSplitHeader\Model;
4+
5+
use Basecom\CspSplitHeader\Api\Data\ConfigInterface;
6+
use Magento\Framework\App\Config\ScopeConfigInterface;
7+
8+
class Config implements ConfigInterface
9+
{
10+
public const XML_PATH_HEADER_SPLITTING_ENABLE = 'basecom_csp_split_header/settings/header_splitting_enable';
11+
public const XML_PATH_MAX_HEADER_SIZE = 'basecom_csp_split_header/settings/max_header_size';
12+
13+
public function __construct(
14+
private readonly ScopeConfigInterface $scopeConfig,
15+
) {
16+
}
17+
18+
/**
19+
* Check if header splitting is enabled
20+
*
21+
* @return bool
22+
*/
23+
public function isHeaderSplittingEnabled(): bool
24+
{
25+
return (bool) $this->scopeConfig->getValue(self::XML_PATH_HEADER_SPLITTING_ENABLE);
26+
}
27+
28+
/**
29+
* Get max header size
30+
*
31+
* @return int
32+
*/
33+
public function getMaxHeaderSize(): int
34+
{
35+
return (int) $this->scopeConfig->getValue(self::XML_PATH_MAX_HEADER_SIZE);
36+
}
37+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Basecom\CspSplitHeader\Plugin\Model\Policy;
4+
5+
class PolicyHeader
6+
{
7+
public const HEADER_NAMES = ['Content-Security-Policy-Report-Only', 'Content-Security-Policy'];
8+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Basecom\CspSplitHeader\Plugin\Model\Policy\Renderer;
4+
5+
use Basecom\CspSplitHeader\Model\Config;
6+
use Basecom\CspSplitHeader\Plugin\Model\Policy\PolicyHeader;
7+
use Laminas\Http\AbstractMessage;
8+
use Laminas\Http\Header\ContentSecurityPolicy;
9+
use Laminas\Http\Header\ContentSecurityPolicyReportOnly;
10+
use Laminas\Http\Header\HeaderInterface;
11+
use Laminas\Loader\PluginClassLoader;
12+
use Magento\Csp\Api\Data\PolicyInterface;
13+
use Magento\Csp\Model\Policy\Renderer\SimplePolicyHeaderRenderer;
14+
use Magento\Framework\App\Response\HttpInterface as HttpResponse;
15+
use Psr\Log\LoggerInterface;
16+
17+
/**
18+
* Plugin for Simple Policy Header
19+
*/
20+
class CspHeaderSplitter
21+
{
22+
public function __construct(
23+
private readonly LoggerInterface $logger,
24+
private readonly Config $config,
25+
) {
26+
}
27+
28+
private const PLUGINS = [
29+
'contentsecuritypolicyreportonly' => ContentSecurityPolicyReportOnly::class,
30+
'contentsecuritypolicy' => ContentSecurityPolicy::class,
31+
];
32+
33+
private array $contentHeaders = [];
34+
35+
/**
36+
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
37+
* @param null $result
38+
*/
39+
public function afterRender(
40+
SimplePolicyHeaderRenderer $subject,
41+
$result,
42+
PolicyInterface $policy,
43+
HttpResponse $response
44+
): void {
45+
$headerName = $this->getHeaderName($response);
46+
/** @var HeaderInterface $header */
47+
$header = $response->getHeader($headerName);
48+
$policyValue = $header->getFieldValue();
49+
$isHeaderSplittingEnabled = $this->config->isHeaderSplittingEnabled();
50+
51+
$maxHeaderSize = $this->config->getMaxHeaderSize();
52+
$currentHeaderSize = strlen($policyValue);
53+
54+
if ($isHeaderSplittingEnabled) {
55+
$this->registerCspHeaderPlugins($response);
56+
$this->splitUpCspHeaders($response, $policy->getId(), $policyValue);
57+
} else {
58+
if ($maxHeaderSize >= $currentHeaderSize) {
59+
$response->setHeader($headerName, $policyValue, true);
60+
} else {
61+
$this->logger->error(
62+
sprintf(
63+
'Unable to set the CSP header. The header size of %d bytes exceeds the '.
64+
'maximum size of %d bytes.',
65+
$currentHeaderSize,
66+
$maxHeaderSize
67+
)
68+
);
69+
}
70+
}
71+
}
72+
73+
/**
74+
* The CSP headers normally use the GenericHeader class, which does not support multi-header values.
75+
* The Laminas framework includes multi-value supported special classes for CSP headers.
76+
* With this registration we enable the usage of the special classes by registering the definitions to the
77+
* plugin loader class.
78+
*/
79+
private function registerCspHeaderPlugins(HttpResponse $response): void
80+
{
81+
/** @var AbstractMessage $response */
82+
/** @var PluginClassLoader $pluginClassLoader */
83+
$pluginClassLoader = $response->getHeaders()->getPluginClassLoader();
84+
$pluginClassLoader->registerPlugins(self::PLUGINS);
85+
}
86+
87+
/**
88+
* Make sure that the CSP headers are handled as several headers ("multi-header")
89+
*/
90+
private function splitUpCspHeaders(HttpResponse $response, string $policyId, string $policyValue): void
91+
{
92+
$headerName = $this->getHeaderName($response);
93+
94+
if (!$headerName) {
95+
return;
96+
}
97+
98+
$newHeader = $policyId.' '.$policyValue.';';
99+
$maxHeaderSize = $this->config->getMaxHeaderSize();
100+
$newHeaderSize = strlen($policyValue);
101+
102+
if ($newHeaderSize <= $maxHeaderSize) {
103+
$this->contentHeaders[] = $newHeader;
104+
} else {
105+
$this->logger->error(
106+
sprintf(
107+
'Unable to set the CSP header. The header size of %d bytes exceeds the '.
108+
'maximum size of %d bytes.',
109+
$newHeaderSize,
110+
$maxHeaderSize
111+
)
112+
);
113+
}
114+
115+
foreach ($this->contentHeaders as $i => $headerPart) {
116+
$isFirstEntry = ($i === 0);
117+
$response->setHeader($headerName, $headerPart.';', $isFirstEntry);
118+
}
119+
}
120+
121+
private function getHeaderName(HttpResponse $response): string
122+
{
123+
$headerName = '';
124+
foreach (PolicyHeader::HEADER_NAMES as $name) {
125+
$headerContent = $response->getHeader($name);
126+
if ($headerContent) {
127+
$headerName = $name;
128+
}
129+
}
130+
return $headerName;
131+
}
132+
}

README.md

Whitespace-only changes.

composer.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "basecom/magento2-csp-split-header",
3+
"version": "1.0.0",
4+
"description": "N/A",
5+
"type": "magento2-module",
6+
"license": [
7+
"MIT"
8+
],
9+
"authors": [
10+
{
11+
"name": "Team Magento @basecom",
12+
"email": "[email protected]"
13+
}
14+
],
15+
"require": {
16+
"php": "~8.1",
17+
"magento/framework": "*",
18+
"magento/module-csp": "*",
19+
"magento/module-config": "*",
20+
"magento/module-backend": "*"
21+
},
22+
"autoload": {
23+
"files": [
24+
"registration.php"
25+
],
26+
"psr-4": {
27+
"Basecom\\CspSplitHeader\\": ""
28+
}
29+
}
30+
}

0 commit comments

Comments
 (0)