Skip to content
This repository was archived by the owner on Dec 19, 2019. It is now read-only.

Commit bfc854e

Browse files
Merge pull request #5111 from magento-qwerty/MC-19927
[CIA] Implement hash-whitelisting, dynamic CSP
2 parents 9dfc765 + 6dbc756 commit bfc854e

23 files changed

+1124
-23
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 values as any of the parameters.
34+
*
35+
* @param string $eventName Full attribute name like "onclick".
36+
* @param string $javascript
37+
* @return string
38+
*/
39+
public function renderEventListener(string $eventName, string $javascript): string;
40+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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 $useUnsafeHashes;
30+
31+
private static $tagMeta = [
32+
'script' => ['id' => 'script-src', 'remote' => ['src'], 'hash' => true],
33+
'style' => ['id' => 'style-src', 'remote' => [], 'hash' => true],
34+
'img' => ['id' => 'img-src', 'remote' => ['src']],
35+
'audio' => ['id' => 'media-src', 'remote' => ['src']],
36+
'video' => ['id' => 'media-src', 'remote' => ['src']],
37+
'track' => ['id' => 'media-src', 'remote' => ['src']],
38+
'source' => ['id' => 'media-src', 'remote' => ['src']],
39+
'object' => ['id' => 'object-src', 'remote' => ['data', 'archive']],
40+
'embed' => ['id' => 'object-src', 'remote' => ['src']],
41+
'applet' => ['id' => 'object-src', 'remote' => ['code', 'archive']],
42+
'link' => ['id' => 'style-src', 'remote' => ['href']],
43+
'form' => ['id' => 'form-action', 'remote' => ['action']],
44+
'iframe' => ['id' => 'frame-src', 'remote' => ['src']],
45+
'frame' => ['id' => 'frame-src', 'remote' => ['src']]
46+
];
47+
48+
/**
49+
* @param DynamicCollector $dynamicCollector
50+
* @param bool $useUnsafeHashes Use 'unsafe-hashes' policy (not supported by CSP v2).
51+
*/
52+
public function __construct(DynamicCollector $dynamicCollector, bool $useUnsafeHashes = false)
53+
{
54+
$this->dynamicCollector = $dynamicCollector;
55+
$this->useUnsafeHashes = $useUnsafeHashes;
56+
}
57+
58+
/**
59+
* Generate fetch policy hash for some content.
60+
*
61+
* @param string $content
62+
* @return array Hash data to insert into a FetchPolicy.
63+
*/
64+
private function generateHashValue(string $content): array
65+
{
66+
return [base64_encode(hash('sha256', $content, true)) => 'sha256'];
67+
}
68+
69+
/**
70+
* Extract host for a fetch policy from a URL.
71+
*
72+
* @param string $url
73+
* @return string|null Null is returned when URL does not point to a remote host.
74+
*/
75+
private function extractHost(string $url): ?string
76+
{
77+
// phpcs:ignore Magento2.Functions.DiscouragedFunction
78+
$urlData = parse_url($url);
79+
if (!$urlData
80+
|| empty($urlData['scheme'])
81+
|| ($urlData['scheme'] !== 'http' && $urlData['scheme'] !== 'https')
82+
) {
83+
return null;
84+
}
85+
86+
return $urlData['scheme'] .'://' .$urlData['host'];
87+
}
88+
89+
/**
90+
* Extract remote hosts used to get fonts.
91+
*
92+
* @param string $styleContent
93+
* @return string[]
94+
*/
95+
private function extractRemoteFonts(string $styleContent): array
96+
{
97+
$urlsFound = [[]];
98+
preg_match_all('/\@font\-face\s*?\{([^\}]*)[^\}]*?\}/im', $styleContent, $fontFaces);
99+
foreach ($fontFaces[1] as $fontFaceContent) {
100+
preg_match_all('/url\([\'\"]?(http(s)?\:[^\)]+)[\'\"]?\)/i', $fontFaceContent, $urls);
101+
$urlsFound[] = $urls[1];
102+
}
103+
104+
return array_map([$this, 'extractHost'], array_merge(...$urlsFound));
105+
}
106+
107+
/**
108+
* Extract remote hosts utilized.
109+
*
110+
* @param string $tag
111+
* @param string[] $attributes
112+
* @param string|null $content
113+
* @return string[]
114+
*/
115+
private function extractRemoteHosts(string $tag, array $attributes, ?string $content): array
116+
{
117+
/** @var string[] $remotes */
118+
$remotes = [];
119+
foreach (self::$tagMeta[$tag]['remote'] as $remoteAttr) {
120+
if (!empty($attributes[$remoteAttr]) && $host = $this->extractHost($attributes[$remoteAttr])) {
121+
$remotes[] = $host;
122+
break;
123+
}
124+
}
125+
if ($tag === 'style' && $content) {
126+
$remotes += $this->extractRemoteFonts($content);
127+
}
128+
129+
return $remotes;
130+
}
131+
132+
/**
133+
* Render tag.
134+
*
135+
* @param string $tag
136+
* @param string[] $attributes
137+
* @param string|null $content
138+
* @return string
139+
*/
140+
private function render(string $tag, array $attributes, ?string $content): string
141+
{
142+
$html = '<' .$tag;
143+
foreach ($attributes as $attribute => $value) {
144+
$html .= ' ' .$attribute .'="' .$value .'"';
145+
}
146+
if ($content) {
147+
$html .= '>' .$content .'</' .$tag .'>';
148+
} else {
149+
$html .= ' />';
150+
}
151+
152+
return $html;
153+
}
154+
155+
/**
156+
* @inheritDoc
157+
*/
158+
public function renderTag(string $tagName, array $attributes, ?string $content = null): string
159+
{
160+
//Processing tag data
161+
if (!array_key_exists($tagName, self::$tagMeta)) {
162+
throw new \InvalidArgumentException('Unknown source type - ' .$tagName);
163+
}
164+
/** @var string $policyId */
165+
$policyId = self::$tagMeta[$tagName]['id'];
166+
$remotes = $this->extractRemoteHosts($tagName, $attributes, $content);
167+
if (empty($remotes) && !$content) {
168+
throw new \InvalidArgumentException('Either remote URL or hashable content is required to whitelist');
169+
}
170+
171+
//Adding required policies.
172+
if ($remotes) {
173+
$this->dynamicCollector->add(
174+
new FetchPolicy($policyId, false, $remotes)
175+
);
176+
}
177+
if ($content && !empty(self::$tagMeta[$tagName]['hash'])) {
178+
$this->dynamicCollector->add(
179+
new FetchPolicy($policyId, false, [], [], false, false, false, [], $this->generateHashValue($content))
180+
);
181+
}
182+
183+
return $this->render($tagName, $attributes, $content);
184+
}
185+
186+
/**
187+
* @inheritDoc
188+
*/
189+
public function renderEventListener(string $eventName, string $javascript): string
190+
{
191+
if ($this->useUnsafeHashes) {
192+
$policy = new FetchPolicy(
193+
'script-src',
194+
false,
195+
[],
196+
[],
197+
false,
198+
false,
199+
false,
200+
[],
201+
$this->generateHashValue($javascript),
202+
false,
203+
true
204+
);
205+
} else {
206+
$policy = new FetchPolicy('script-src', false, [], [], false, true);
207+
}
208+
$this->dynamicCollector->add($policy);
209+
210+
return $eventName .'="' .$javascript .'"';
211+
}
212+
}
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+
}

0 commit comments

Comments
 (0)