Skip to content

Commit 5e03d3c

Browse files
empiricompanysreichelHanmac
authored
Content Security Policy (CSP) Implementation a new approach (#4776)
* wip * remove config_csp * docs: add workaround for prototypeJS bug with disabled inputs in array row handling * allowing for specific directive handling admin / frontend * fix: serialized backend xml load default * Add Content Security Policy (CSP) support directives global, adminhtml and frontend * feat: add report-uri support * system config admin only global * fix: phpcs * make $_arrayRowsCache protected: used by public getArrayRows() * feat: add area config info to inputs * chroe: phpstan ignore * chore: phpstan ignore * chore: rector * chore * chore * use Reporting-Endpoints for report URI * trim report uri * report uri not dependent fro report_only mode * add support for <meta> directives * Apply @sreichel suggestions: improve method docs and type hints Co-authored-by: Sven Reichel <[email protected]> * fix suggestion * fix suggestion * docs and type hints * feat: add support to split headers for each directive * fix: disable split headers in frontend CSP configuration by default * php-cs-fixer * Unify Csp Hosts * ~ check CS Fixer * ~ fix abtract * refactor: extract node path parsing logic into a separate method * fix: remove unnecessary whitespace in _parseNodePath method * refactor: use short array syntax for node path extraction * ~ use config path instead of NodePath * refactor: update CSP classes and methods for improved structure and clarity * refactor: remove unused renderer logic in _renderCellTemplate method * feat: add support for merging CSP <meta /> directives into HTTP headers * docs: fix correct return type annotation in getDirectives method * Update app/code/core/Mage/Csp/Model/Observer/Abstract.php Co-authored-by: Sven Reichel <[email protected]> --------- Co-authored-by: Sven Reichel <[email protected]> Co-authored-by: Hans Mackowiak <[email protected]>
1 parent bce3b6a commit 5e03d3c

File tree

17 files changed

+1452
-4
lines changed

17 files changed

+1452
-4
lines changed

.phpstan.dist.baseline.neon

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5496,6 +5496,30 @@ parameters:
54965496
count: 1
54975497
path: app/design/adminhtml/default/default/template/system/config/form/field/array.phtml
54985498

5499+
-
5500+
message: '#^Access to protected property Mage_Adminhtml_Block_System_Config_Form_Field_Csp_Hosts\:\:\$_addAfter\.$#'
5501+
identifier: property.protected
5502+
count: 4
5503+
path: app/design/adminhtml/default/default/template/system/config/form/field/csp.phtml
5504+
5505+
-
5506+
message: '#^Access to protected property Mage_Adminhtml_Block_System_Config_Form_Field_Csp_Hosts\:\:\$_addButtonLabel\.$#'
5507+
identifier: property.protected
5508+
count: 2
5509+
path: app/design/adminhtml/default/default/template/system/config/form/field/csp.phtml
5510+
5511+
-
5512+
message: '#^Access to protected property Mage_Adminhtml_Block_System_Config_Form_Field_Csp_Hosts\:\:\$_columns\.$#'
5513+
identifier: property.protected
5514+
count: 5
5515+
path: app/design/adminhtml/default/default/template/system/config/form/field/csp.phtml
5516+
5517+
-
5518+
message: '#^Call to protected method _renderCellTemplate\(\) of class Mage_Adminhtml_Block_System_Config_Form_Field_Csp_Hosts\.$#'
5519+
identifier: method.protected
5520+
count: 2
5521+
path: app/design/adminhtml/default/default/template/system/config/form/field/csp.phtml
5522+
54995523
-
55005524
message: '#^Unreachable statement \- code above always terminates\.$#'
55015525
identifier: deadCode.unreachable

app/code/core/Mage/Adminhtml/Block/System/Config/Form/Field/Array/Abstract.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ abstract class Mage_Adminhtml_Block_System_Config_Form_Field_Array_Abstract exte
1717
/**
1818
* Grid columns
1919
*
20-
* @var array
20+
* @var array<string, array{label: string, size: string|false, style: ?string, class: ?string, renderer: Mage_Core_Block_Abstract|false}>
2121
*/
2222
protected $_columns = [];
2323

@@ -38,9 +38,9 @@ abstract class Mage_Adminhtml_Block_System_Config_Form_Field_Array_Abstract exte
3838
/**
3939
* Rows cache
4040
*
41-
* @var array|null
41+
* @var array<string, Varien_Object>|null
4242
*/
43-
private $_arrayRowsCache;
43+
protected $_arrayRowsCache;
4444

4545
/**
4646
* Indication whether block is prepared to render or no
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* @copyright For copyright and license information, read the COPYING.txt file.
7+
* @link /COPYING.txt
8+
* @license Open Software License (OSL 3.0)
9+
* @package Mage_Csp
10+
*/
11+
12+
/**
13+
* Base class for CSP hosts field renderer
14+
*/
15+
class Mage_Adminhtml_Block_System_Config_Form_Field_Csp_Hosts extends Mage_Adminhtml_Block_System_Config_Form_Field_Array_Abstract
16+
{
17+
protected Mage_Csp_Helper_Data $helper;
18+
19+
/**
20+
* Constructor
21+
*/
22+
public function __construct()
23+
{
24+
/** @var Mage_Csp_Helper_Data $helper */
25+
$helper = Mage::helper('csp');
26+
$this->helper = $helper;
27+
$this->addColumn('host', [
28+
'label' => Mage::helper('csp')->__('Host'),
29+
]);
30+
31+
$this->_addAfter = false;
32+
$this->_addButtonLabel = Mage::helper('csp')->__('Add Host');
33+
$this->setTemplate('system/config/form/field/csp.phtml');
34+
35+
parent::__construct();
36+
}
37+
38+
/**
39+
* Obtain existing data from form element
40+
*
41+
* Each row will be instance of Varien_Object
42+
* @return array<string, Varien_Object> Array of rows
43+
* @throws Exception
44+
*/
45+
public function getArrayRows(): array
46+
{
47+
if ($this->_arrayRowsCache !== null) {
48+
return $this->_arrayRowsCache;
49+
}
50+
51+
$result = [];
52+
53+
[$area, $directiveName] = $this->_parseNodePath();
54+
55+
$globalPolicy = $this->helper->getGlobalPolicy($directiveName);
56+
if ($globalPolicy) {
57+
foreach ($globalPolicy as $key => $host) {
58+
$rowId = $directiveName . '_xml_' . $area . '_' . $key;
59+
$result[$rowId] = new Varien_Object([
60+
'host' => $host,
61+
'readonly' => 'readonly="readonly"',
62+
'_id' => $rowId,
63+
'area' => 'global',
64+
]);
65+
$this->_prepareArrayRow($result[$rowId]);
66+
}
67+
}
68+
69+
$areaPolicy = $this->helper->getAreaPolicy($area, $directiveName);
70+
if ($areaPolicy) {
71+
foreach ($areaPolicy as $key => $host) {
72+
$rowId = $directiveName . '_xml_' . $area . '_' . $key;
73+
$result[$rowId] = new Varien_Object([
74+
'host' => $host,
75+
'readonly' => 'readonly="readonly"',
76+
'_id' => $rowId,
77+
'area' => $area,
78+
]);
79+
$this->_prepareArrayRow($result[$rowId]);
80+
}
81+
}
82+
83+
$configPolicy = $this->helper->getStoreConfigPolicy($area, $directiveName);
84+
if ($configPolicy) {
85+
foreach ($configPolicy as $key => $value) {
86+
$rowId = $directiveName . '_' . $area . '_' . $key;
87+
$result[$rowId] = new Varien_Object([
88+
'host' => $this->escapeHtml($value),
89+
'_id' => $rowId,
90+
]);
91+
92+
$this->_prepareArrayRow($result[$rowId]);
93+
}
94+
}
95+
96+
$this->_arrayRowsCache = $result;
97+
return $this->_arrayRowsCache;
98+
}
99+
100+
/**
101+
* Extract and validate area and directive name from the node path
102+
*
103+
* @return array{Mage_Core_Model_App_Area::AREA_FRONTEND|Mage_Core_Model_App_Area::AREA_ADMINHTML, value-of<Mage_Csp_Helper_Data::CSP_DIRECTIVES>} Array containing area and directiveName
104+
* @throws Exception If path format is invalid or contains disallowed values
105+
*/
106+
private function _parseNodePath(): array
107+
{
108+
/** @var Varien_Data_Form_Element_Abstract $element */
109+
$element = $this->getElement();
110+
$configPath = $element->getData('config_path');
111+
112+
$allowedDirectives = implode('|', Mage_Csp_Helper_Data::CSP_DIRECTIVES);
113+
$allowedAreas = Mage_Core_Model_App_Area::AREA_FRONTEND . '|' . Mage_Core_Model_App_Area::AREA_ADMINHTML;
114+
115+
$pattern = "#csp/({$allowedAreas})/({$allowedDirectives})#";
116+
117+
if (!$configPath || !preg_match($pattern, $configPath, $matches)) {
118+
throw new Exception('Invalid node path format or disallowed area/directive');
119+
}
120+
121+
$area = $matches[1];
122+
$directiveName = $matches[2];
123+
124+
return [$area, $directiveName];
125+
}
126+
127+
/**
128+
* Render array cell for prototypeJS template
129+
*
130+
* @param string $columnName
131+
* @return string
132+
* @throws Exception
133+
*/
134+
protected function _renderCellTemplate($columnName)
135+
{
136+
if (empty($this->_columns[$columnName])) {
137+
throw new Exception('Wrong column name specified.');
138+
}
139+
140+
$column = $this->_columns[$columnName];
141+
/** @var Varien_Data_Form_Element_Text $element */
142+
$element = $this->getElement();
143+
$elementName = $element->getName();
144+
$inputName = $elementName . '[#{_id}][' . $columnName . ']';
145+
146+
return '<input type="text" name="' . $inputName . '" value="#{' . $columnName . '}" ' .
147+
'#{readonly}' .
148+
($column['size'] ? 'size="' . $column['size'] . '"' : '') . ' class="' .
149+
($column['class'] ?? 'input-text') . '"' .
150+
(isset($column['style']) ? ' style="' . $column['style'] . '"' : '') . '/>';
151+
}
152+
}

app/code/core/Mage/Adminhtml/Model/System/Config/Backend/Serialized.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ protected function _afterLoad()
2020
if (!is_array($this->getValue())) {
2121
$serializedValue = $this->getValue();
2222
$unserializedValue = false;
23-
if (!empty($serializedValue)) {
23+
if (!empty($serializedValue) && is_string($serializedValue)) {
2424
try {
2525
$unserializedValue = Mage::helper('core/unserializeArray')
2626
->unserialize((string) $serializedValue);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* @copyright For copyright and license information, read the COPYING.txt file.
7+
* @link /COPYING.txt
8+
* @license Open Software License (OSL 3.0)
9+
* @package Mage_Csp
10+
*/
11+
12+
/**
13+
* CSP Meta Block
14+
*
15+
* @package Mage_Csp
16+
*/
17+
class Mage_Csp_Block_Adminhtml_Meta extends Mage_Csp_Block_Meta
18+
{
19+
protected string $area = Mage_Core_Model_App_Area::AREA_ADMINHTML;
20+
}

app/code/core/Mage/Csp/Block/Meta.php

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* @copyright For copyright and license information, read the COPYING.txt file.
7+
* @link /COPYING.txt
8+
* @license Open Software License (OSL 3.0)
9+
* @package Mage_Csp
10+
*/
11+
12+
/**
13+
* CSP Meta Block
14+
*
15+
* @package Mage_Csp
16+
*/
17+
class Mage_Csp_Block_Meta extends Mage_Core_Block_Template
18+
{
19+
/**
20+
* CSP directives
21+
* @var array<value-of<Mage_Csp_Helper_Data::CSP_DIRECTIVES>, array<string>>
22+
*/
23+
protected array $directives = [];
24+
25+
/**
26+
* CSP meta tag area
27+
* @var Mage_Core_Model_App_Area::AREA_FRONTEND|Mage_Core_Model_App_Area::AREA_ADMINHTML
28+
*/
29+
protected string $area = Mage_Core_Model_App_Area::AREA_FRONTEND;
30+
31+
/**
32+
* Add CSP directive
33+
*
34+
* @param value-of<Mage_Csp_Helper_Data::CSP_DIRECTIVES> $directive
35+
*/
36+
public function addDirective(string $directive, string $value): static
37+
{
38+
if (!in_array($directive, Mage_Csp_Helper_Data::CSP_DIRECTIVES)) {
39+
return $this;
40+
}
41+
42+
if (!isset($this->directives[$directive])) {
43+
$this->directives[$directive] = [];
44+
}
45+
46+
$this->directives[$directive][] = $value;
47+
48+
return $this;
49+
}
50+
51+
/**
52+
* Get CSP directives
53+
* @return array<value-of<Mage_Csp_Helper_Data::CSP_DIRECTIVES>, array<string>>
54+
*/
55+
public function getDirectives(): array
56+
{
57+
return $this->directives;
58+
}
59+
60+
/**
61+
* Get CSP policy content
62+
*/
63+
public function getContents(): string
64+
{
65+
$content = [];
66+
foreach ($this->directives as $directive => $values) {
67+
if (!empty($values)) {
68+
$content[] = $directive . ' ' . implode(' ', $values);
69+
}
70+
}
71+
$content = implode('; ', $content);
72+
return trim($content);
73+
}
74+
75+
/**
76+
* Render CSP meta tag if enabled
77+
*/
78+
protected function _toHtml(): string
79+
{
80+
if (empty($this->directives)) {
81+
return '';
82+
}
83+
84+
/** @var Mage_Csp_Helper_Data $helper */
85+
$helper = Mage::helper('csp');
86+
if (!$helper->isEnabled($this->area) || $helper->shouldMergeMeta($this->area)) {
87+
return '';
88+
}
89+
90+
$headerValue = $this->getContents();
91+
if (!empty($helper->getReportUri($this->area))) {
92+
$reportUriEndpoint = trim($helper->getReportUri($this->area));
93+
$headerValue .= '; report-uri ' . $reportUriEndpoint;
94+
}
95+
$headerName = $helper->getReportOnly($this->area)
96+
? Mage_Csp_Helper_Data::HEADER_CONTENT_SECURITY_POLICY_REPORT_ONLY
97+
: Mage_Csp_Helper_Data::HEADER_CONTENT_SECURITY_POLICY;
98+
99+
return sprintf(
100+
'<meta http-equiv="%s" content="%s" />' . PHP_EOL,
101+
$headerName,
102+
$headerValue,
103+
);
104+
}
105+
}

0 commit comments

Comments
 (0)