Skip to content

Commit e88d507

Browse files
author
Oleksandr Gorkun
committed
MC-19927: Implement hash-whitelisting, dynamic CSP
1 parent 40edf5c commit e88d507

File tree

9 files changed

+414
-21
lines changed

9 files changed

+414
-21
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
/**
3+
* Copyright © Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Csp\Api;
9+
10+
use Magento\Framework\App\ActionInterface;
11+
12+
/**
13+
* Interface for controllers that can provide route-specific CSPs.
14+
*/
15+
interface CspAwareActionInterface extends ActionInterface
16+
{
17+
/**
18+
* Return CSPs that will be applied to current route (page).
19+
*
20+
* The array returned will be used as is so if you need to keep policies that have been already applied they need
21+
* to be included in the resulting array.
22+
*
23+
* @param \Magento\Csp\Api\Data\PolicyInterface[] $appliedPolicies
24+
* @return \Magento\Csp\Api\Data\PolicyInterface[]
25+
*/
26+
public function modifyCsp(array $appliedPolicies): array;
27+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
/**
3+
* Copyright © Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Csp\Api;
9+
10+
use Magento\Csp\Api\Data\PolicyInterface;
11+
12+
/**
13+
* Utility for classes responsible for rendering and templates that allows whitelist inline sources.
14+
*/
15+
interface InlineUtilInterface
16+
{
17+
/**
18+
* Render HTML tag and whitelist it as trusted source.
19+
*
20+
* Use this method to whitelist remote static resources and inline styles/scripts.
21+
* Do not use user-provided as any of the parameters.
22+
*
23+
* @param string $tagName
24+
* @param string[] $attributes
25+
* @param string|null $content
26+
* @return string
27+
*/
28+
public function renderTag(string $tagName, array $attributes, ?string $content = null): string;
29+
30+
/**
31+
* Render event listener as an HTML attribute and whitelist it as trusted source.
32+
*
33+
* Do not use user-provided as any of the parameters.
34+
*
35+
* @param string $eventName
36+
* @param string $javascript
37+
* @return string
38+
*/
39+
public function renderEventListener(string $eventName, string $javascript): string;
40+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
/**
3+
* Copyright © Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Csp\Helper;
9+
10+
use Magento\Csp\Api\InlineUtilInterface;
11+
use Magento\Csp\Model\Collector\DynamicCollector;
12+
use Magento\Csp\Model\Policy\FetchPolicy;
13+
14+
/**
15+
* Helper for classes responsible for rendering and templates.
16+
*
17+
* Allows to whitelist dynamic sources specific to a certain page.
18+
*/
19+
class InlineUtil implements InlineUtilInterface
20+
{
21+
/**
22+
* @var DynamicCollector
23+
*/
24+
private $dynamicCollector;
25+
26+
/**
27+
* @var bool
28+
*/
29+
private $eventHandlersEnabled = false;
30+
31+
/**
32+
* @param DynamicCollector $dynamicCollector
33+
*/
34+
public function __construct(DynamicCollector $dynamicCollector)
35+
{
36+
$this->dynamicCollector = $dynamicCollector;
37+
}
38+
39+
/**
40+
* Generate fetch policy hash for some content.
41+
*
42+
* @param string $content
43+
* @return string
44+
*/
45+
private function generateHash(string $content): string
46+
{
47+
return 'sha256-' .base64_encode(hash('sha256', $content, true));
48+
}
49+
50+
/**
51+
* @inheritDoc
52+
*/
53+
public function renderTag(string $tagName, array $attributes, ?string $content = null): string
54+
{
55+
$remote = !empty($attributes['src'])
56+
? $attributes['src'] : (!empty($attributes['href']) ? $attributes['href'] : null);
57+
if (!$remote && !$content) {
58+
throw new \InvalidArgumentException('Either remote URL or hashable content is required to whitelist');
59+
}
60+
switch ($tagName) {
61+
case 'script':
62+
$policyId = 'script-src';
63+
break;
64+
case 'style':
65+
$policyId = 'style-src';
66+
break;
67+
case 'img':
68+
$policyId = 'img-src';
69+
break;
70+
case 'audio':
71+
case 'video':
72+
case 'track':
73+
$policyId = 'media-src';
74+
break;
75+
case 'object':
76+
case 'embed':
77+
case 'applet':
78+
$policyId = 'object-src';
79+
break;
80+
case 'link':
81+
if (empty($attributes['rel']) || $attributes['rel'] !== 'stylesheet') {
82+
throw new \InvalidArgumentException('Only remote styles can be whitelisted via "link" tag');
83+
}
84+
$policyId = 'style-src';
85+
break;
86+
default:
87+
throw new \InvalidArgumentException('Unknown source type - ' .$tagName);
88+
}
89+
90+
if ($remote) {
91+
$urlData = parse_url($remote);
92+
$this->dynamicCollector->add(
93+
new FetchPolicy($policyId, false, [$urlData['scheme'] .'://' .$urlData['host']])
94+
);
95+
} elseif ($policyId === 'style-src' || $policyId === 'script-src') {
96+
$this->dynamicCollector->add(
97+
new FetchPolicy($policyId, false, [], [], false, false, false, [], [$this->generateHash($content)])
98+
);
99+
} else {
100+
throw new \InvalidArgumentException('Only inline scripts and styles can be whitelisted');
101+
}
102+
103+
$html = '<' .$tagName;
104+
foreach ($attributes as $attribute => $value) {
105+
$html .= ' ' .$attribute .'="' .$value .'"';
106+
}
107+
if ($content) {
108+
$html .= '>' .$content .'</' .$tagName .'>';
109+
} else {
110+
$html .= ' />';
111+
}
112+
113+
return $html;
114+
}
115+
116+
/**
117+
* @inheritDoc
118+
*/
119+
public function renderEventListener(string $eventName, string $javascript): string
120+
{
121+
if (!$this->eventHandlersEnabled) {
122+
$this->dynamicCollector->add(
123+
new FetchPolicy('default-src', false, [], [], false, false, false, [], [], false, true)
124+
);
125+
$this->eventHandlersEnabled = true;
126+
}
127+
128+
$this->dynamicCollector->add(
129+
new FetchPolicy('script-src', false, [], [], false, false, false, [], [$this->generateHash($javascript)])
130+
);
131+
132+
return $eventName .'="' .$javascript .'"';
133+
}
134+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
/**
3+
* Copyright © Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Csp\Model\Collector;
9+
10+
use Magento\Csp\Api\CspAwareActionInterface;
11+
use Magento\Csp\Api\PolicyCollectorInterface;
12+
13+
/**
14+
* Asks for route-specific policies from a compatible controller.
15+
*/
16+
class ControllerCollector implements PolicyCollectorInterface
17+
{
18+
/**
19+
* @var CspAwareActionInterface|null
20+
*/
21+
private $controller;
22+
23+
/**
24+
* Set the action interface that is responsible for processing current HTTP request.
25+
*
26+
* @param CspAwareActionInterface $cspAwareAction
27+
* @return void
28+
*/
29+
public function setCurrentActionInstance(CspAwareActionInterface $cspAwareAction): void
30+
{
31+
$this->controller = $cspAwareAction;
32+
}
33+
34+
/**
35+
* @inheritDoc
36+
*/
37+
public function collect(array $defaultPolicies = []): array
38+
{
39+
if ($this->controller) {
40+
return $this->controller->modifyCsp($defaultPolicies);
41+
}
42+
43+
return $defaultPolicies;
44+
}
45+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
/**
3+
* Copyright © Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Csp\Model\Collector;
9+
10+
use Magento\Csp\Api\Data\PolicyInterface;
11+
use Magento\Csp\Api\PolicyCollectorInterface;
12+
13+
/**
14+
* CSPs dynamically added during the rendering of current page (from .phtml templates for instance).
15+
*/
16+
class DynamicCollector implements PolicyCollectorInterface
17+
{
18+
/**
19+
* @var PolicyInterface[]
20+
*/
21+
private $added = [];
22+
23+
/**
24+
* Add a policy for current page.
25+
*
26+
* @param PolicyInterface $policy
27+
* @return void
28+
*/
29+
public function add(PolicyInterface $policy): void
30+
{
31+
$this->added[] = $policy;
32+
}
33+
34+
/**
35+
* @inheritDoc
36+
*/
37+
public function collect(array $defaultPolicies = []): array
38+
{
39+
return array_merge($defaultPolicies, $this->added);
40+
}
41+
}

app/code/Magento/Csp/Model/CompositePolicyCollector.php

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -32,27 +32,39 @@ class CompositePolicyCollector implements PolicyCollectorInterface
3232
*/
3333
public function __construct(array $collectors, array $mergers)
3434
{
35+
ksort($collectors);
3536
$this->collectors = $collectors;
3637
$this->mergers = $mergers;
3738
}
3839

3940
/**
40-
* Merge 2 policies with the same ID.
41+
* Merge policies with same IDs and return a list of policies with 1 DTO per policy ID.
4142
*
42-
* @param PolicyInterface $policy1
43-
* @param PolicyInterface $policy2
44-
* @return PolicyInterface
43+
* @param PolicyInterface[] $collected
44+
* @return PolicyInterface[]
4545
* @throws \RuntimeException When failed to merge.
4646
*/
47-
private function merge(PolicyInterface $policy1, PolicyInterface $policy2): PolicyInterface
47+
private function merge(array $collected): array
4848
{
49-
foreach ($this->mergers as $merger) {
50-
if ($merger->canMerge($policy1, $policy2)) {
51-
return $merger->merge($policy1, $policy2);
49+
/** @var PolicyInterface[] $merged */
50+
$merged = [];
51+
52+
foreach ($collected as $policy) {
53+
if (array_key_exists($policy->getId(), $merged)) {
54+
foreach ($this->mergers as $merger) {
55+
if ($merger->canMerge($merged[$policy->getId()], $policy)) {
56+
$result[$policy->getId()] = $merger->merge($merged[$policy->getId()], $policy);
57+
continue 2;
58+
}
59+
}
60+
61+
throw new \RuntimeException(sprintf('Merge for policies #%s was not found', $policy->getId()));
62+
} else {
63+
$merged[$policy->getId()] = $policy;
5264
}
5365
}
5466

55-
throw new \RuntimeException(sprintf('Merge for policies #%s was not found', $policy1->getId()));
67+
return $merged;
5668
}
5769

5870
/**
@@ -62,19 +74,9 @@ public function collect(array $defaultPolicies = []): array
6274
{
6375
$collected = $defaultPolicies;
6476
foreach ($this->collectors as $collector) {
65-
$collected = $collector->collect($collected);
66-
}
67-
//Merging policies.
68-
/** @var PolicyInterface[] $result */
69-
$result = [];
70-
foreach ($collected as $policy) {
71-
if (array_key_exists($policy->getId(), $result)) {
72-
$result[$policy->getId()] = $this->merge($result[$policy->getId()], $policy);
73-
} else {
74-
$result[$policy->getId()] = $policy;
75-
}
77+
$collected = $this->merge($collector->collect($collected));
7678
}
7779

78-
return array_values($result);
80+
return array_values($collected);
7981
}
8082
}

0 commit comments

Comments
 (0)